Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ root = true
[*.cs]
indent_style = space
indent_size = 4
end_of_line = lf
dotnet_diagnostic.IDE0005.severity = warning
csharp_style_namespace_declarations = file_scoped:warning

Expand All @@ -15,6 +16,85 @@ dotnet_diagnostic.CA2007.severity = none
# LageBuch.App.Shared namespace root cannot be sanely renamed. Suppress app-wide.
dotnet_diagnostic.CA1716.severity = none

# StyleCop.Analyzers: curated for this codebase. Rules disabled below either
# conflict with an established convention or don't fit a project that doesn't
# mandate XML documentation (CS1591 is already suppressed above). Everything
# else keeps StyleCop's shipped default severity, which becomes a build error
# via TreatWarningsAsErrors.

# SA1101: this. prefix required on every member access - fights the _camelCase
# private-field convention already used throughout (see below).
dotnet_diagnostic.SA1101.severity = none

# SA1201/SA1202/SA1204: member-kind/access/static ordering. No code-fix
# support exists for any of these - every violation needs manual reordering.
# The codebase organizes members by domain/lifecycle grouping (e.g. a
# registration factory next to the validation it calls) rather than by kind
# or access level, and there's no functional benefit to forcing the churn.
dotnet_diagnostic.SA1201.severity = none
dotnet_diagnostic.SA1202.severity = none
dotnet_diagnostic.SA1204.severity = none

# SA1402: one type per file. Several files (SyncCommand.cs, IncidentSnapshot.cs,
# MasterDataSet.cs, ...) deliberately group a closed set of small related
# record types together (a DU-style command/DTO set) so the whole set reads
# in one place - splitting each into its own file would scatter that and add
# dozens of one-line files for no benefit.
dotnet_diagnostic.SA1402.severity = none

# SA1312: local variables must begin with a lower-case letter. Its walker doesn't recognize
# `using var _ = ...` / `await using var _ = ...` as the discard idiom it is - the test suite
# uses this pattern throughout for scope-based disposal without a named variable.
dotnet_diagnostic.SA1312.severity = none

# SA1309: private fields must not begin with an underscore - the whole
# codebase already uses _camelCase for private fields.
dotnet_diagnostic.SA1309.severity = none

# SA1600-SA1649: "documentation rules" family (elements/parameters/return
# values/generic type params must be documented, file headers, etc.).
# Documentation is optional here, not mandated - keep SA1649
# (FileNameMustMatchTypeName) enabled, it's an unrelated naming check.
dotnet_diagnostic.SA1600.severity = none
dotnet_diagnostic.SA1601.severity = none
dotnet_diagnostic.SA1602.severity = none
dotnet_diagnostic.SA1604.severity = none
dotnet_diagnostic.SA1605.severity = none
dotnet_diagnostic.SA1606.severity = none
dotnet_diagnostic.SA1607.severity = none
dotnet_diagnostic.SA1608.severity = none
dotnet_diagnostic.SA1609.severity = none
dotnet_diagnostic.SA1610.severity = none
dotnet_diagnostic.SA1611.severity = none
dotnet_diagnostic.SA1612.severity = none
dotnet_diagnostic.SA1613.severity = none
dotnet_diagnostic.SA1614.severity = none
dotnet_diagnostic.SA1615.severity = none
dotnet_diagnostic.SA1616.severity = none
dotnet_diagnostic.SA1617.severity = none
dotnet_diagnostic.SA1618.severity = none
dotnet_diagnostic.SA1619.severity = none
dotnet_diagnostic.SA1620.severity = none
dotnet_diagnostic.SA1621.severity = none
dotnet_diagnostic.SA1622.severity = none
dotnet_diagnostic.SA1623.severity = none
dotnet_diagnostic.SA1624.severity = none
dotnet_diagnostic.SA1625.severity = none
dotnet_diagnostic.SA1627.severity = none
dotnet_diagnostic.SA1629.severity = none
dotnet_diagnostic.SA1633.severity = none
dotnet_diagnostic.SA1634.severity = none
dotnet_diagnostic.SA1635.severity = none
dotnet_diagnostic.SA1636.severity = none
dotnet_diagnostic.SA1637.severity = none
dotnet_diagnostic.SA1638.severity = none
dotnet_diagnostic.SA1639.severity = none
dotnet_diagnostic.SA1640.severity = none
dotnet_diagnostic.SA1641.severity = none
dotnet_diagnostic.SA1642.severity = none
dotnet_diagnostic.SA1643.severity = none
dotnet_diagnostic.SA1648.severity = none

[tests/**/*.cs]
# xUnit method names with underscores are idiomatic.
dotnet_diagnostic.CA1707.severity = none
Expand Down
5 changes: 5 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="all" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)stylecop.json" Link="stylecop.json" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="QuestPDF" Version="2026.8.0" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3" />
Expand Down
4 changes: 4 additions & 0 deletions src/LageBuch.App.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using LageBuch.App.Shared;
using LageBuch.AppLogic.Services;
using LageBuch.Domain.Time;

// Inside the LageBuch.App.Android namespace the bare name "App" binds to the LageBuch.App
// namespace, not LageBuch.App.Shared.App — alias it so the shared Application type is reachable.
using SharedApp = LageBuch.App.Shared.App;
Expand Down Expand Up @@ -35,6 +36,7 @@ protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
_importLauncher = RegisterForActivityResult(
new ActivityResultContracts.GetContent(),
new ImportCallback(uri => _dialogs?.CompleteImport(uri)));

// OpenDocument (rather than GetContent) accepts multiple MIME types on Launch, needed
// since an attachment can be any of several image types or a PDF.
_attachmentLauncher = RegisterForActivityResult(
Expand All @@ -48,7 +50,9 @@ protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
private sealed class ImportCallback : Java.Lang.Object, IActivityResultCallback
{
private readonly Action<global::Android.Net.Uri?> _onResult;

public ImportCallback(Action<global::Android.Net.Uri?> onResult) => _onResult = onResult;

public void OnActivityResult(Java.Lang.Object? result) => _onResult(result as global::Android.Net.Uri);
}

Expand Down
3 changes: 2 additions & 1 deletion src/LageBuch.App.Android/MainApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ namespace LageBuch.App.Android;
[Application]
public class MainApplication : Application
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership)
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
}
4 changes: 3 additions & 1 deletion src/LageBuch.App.Android/Services/AndroidAlarmService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ namespace LageBuch.App.Android.Services;
/// </summary>
public sealed class AndroidAlarmService : IAlarmService
{
public void Play(AlarmSound sound) { }
public void Play(AlarmSound sound)
{
}
}
22 changes: 21 additions & 1 deletion src/LageBuch.App.Android/Services/AndroidFileDialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed class AndroidFileDialogService : IFileDialogService
var ext = System.IO.Path.GetExtension(suggestedFileName);
path = System.IO.Path.Combine(dir, $"{stem} ({count++}){ext}");
}

return Task.FromResult<string?>(path);
}

Expand Down Expand Up @@ -65,16 +66,23 @@ public void CompleteImport(global::Android.Net.Uri? uri)
var pending = _pendingImport;
_pendingImport = null;
if (pending is null)
{
return;
}

if (uri is null)
{
pending.SetResult(null);
return;
}

var destPath = System.IO.Path.Combine(AndroidAppPaths.CacheDir(_activity), "import.json");
using (var input = _activity.ContentResolver!.OpenInputStream(uri)!)
using (var output = System.IO.File.Create(destPath))
{
input.CopyTo(output);
}

pending.SetResult(destPath);
}

Expand Down Expand Up @@ -102,16 +110,23 @@ public void CompleteAttachment(global::Android.Net.Uri? uri)
var pending = _pendingAttachment;
_pendingAttachment = null;
if (pending is null)
{
return;
}

if (uri is null)
{
pending.SetResult(null);
return;
}

var destPath = System.IO.Path.Combine(AndroidAppPaths.CacheDir(_activity), DisplayNameOf(uri));
using (var input = _activity.ContentResolver!.OpenInputStream(uri)!)
using (var output = System.IO.File.Create(destPath))
{
input.CopyTo(output);
}

pending.SetResult(destPath);
}

Expand All @@ -125,9 +140,12 @@ private string DisplayNameOf(global::Android.Net.Uri uri)
{
var name = cursor.GetString(index);
if (!string.IsNullOrWhiteSpace(name))
{
return name;
}
}
}

return "anhang";
}

Expand Down Expand Up @@ -166,7 +184,9 @@ public Task OpenUrlAsync(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return Task.CompletedTask;
}

var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(uri.AbsoluteUri));
_activity.StartActivity(intent);
Expand All @@ -180,6 +200,6 @@ public Task OpenUrlAsync(string url)
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => "*/*"
_ => "*/*",
};
}
3 changes: 2 additions & 1 deletion src/LageBuch.App.Shared/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LageBuch.App.Shared.Views;
using LageBuch.AppLogic.ViewModels;
using System.Diagnostics.CodeAnalysis;

namespace LageBuch.App.Shared;

Expand Down Expand Up @@ -35,6 +35,7 @@ public override void OnFrameworkInitializationCompleted()
mainView.AttachViewModel(CreateMainViewModel!());
singleView.MainView = mainView;
}

base.OnFrameworkInitializationCompleted();
}
}
5 changes: 5 additions & 0 deletions src/LageBuch.App.Shared/Behaviors/EnterSubmit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,19 @@ static EnterSubmit()
{
box.RemoveHandler(InputElement.KeyDownEvent, OnPreviewKeyDown);
if (e.NewValue is ICommand)
{
box.AddHandler(InputElement.KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
}
});
}

private static void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter || sender is not AutoCompleteBox box || box.IsDropDownOpen)
{
return;
}

var command = GetCommand(box);
if (command?.CanExecute(null) == true)
{
Expand Down
4 changes: 4 additions & 0 deletions src/LageBuch.App.Shared/Behaviors/IntegerOnly.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,18 @@ static IntegerOnly()
{
box.RemoveHandler(InputElement.TextInputEvent, OnTextInput);
if (e.NewValue is true)
{
// Tunneling: must see the input before the TextBox inserts it.
box.AddHandler(InputElement.TextInputEvent, OnTextInput, RoutingStrategies.Tunnel);
}
});
}

private static void OnTextInput(object? sender, TextInputEventArgs e)
{
if (sender is TextBox && e.Text is not null && !e.Text.All(char.IsAsciiDigit))
{
e.Handled = true;
}
}
}
4 changes: 4 additions & 0 deletions src/LageBuch.App.Shared/Services/AvaloniaUiDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ public void Post(Action action)
{
ArgumentNullException.ThrowIfNull(action);
if (Dispatcher.UIThread.CheckAccess())
{
action();
}
else
{
Dispatcher.UIThread.Post(action);
}
}

public Task<T> InvokeAsync<T>(Func<T> func) => Dispatcher.UIThread.InvokeAsync(func).GetTask();
Expand Down
21 changes: 19 additions & 2 deletions src/LageBuch.App.Shared/Services/DispatcherTimerTicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,49 @@ public IDisposable Subscribe(Action onTick)
{
_subscribers.Add(onTick);
if (!_timer.IsEnabled)
{
_timer.Start();
}

return new Subscription(this, onTick);
}

private void Notify()
{
foreach (var s in _subscribers.ToArray())
{
s();
}
}

private void Unsubscribe(Action onTick)
{
_subscribers.Remove(onTick);
if (_subscribers.Count == 0)
{
_timer.Stop();
}
}

private sealed class Subscription : IDisposable
{
private readonly DispatcherTimerTicker _owner;
private readonly Action _onTick;
private bool _disposed;
public Subscription(DispatcherTimerTicker owner, Action onTick) { _owner = owner; _onTick = onTick; }

public Subscription(DispatcherTimerTicker owner, Action onTick)
{
_owner = owner;
_onTick = onTick;
}

public void Dispose()
{
if (_disposed) return;
if (_disposed)
{
return;
}

_disposed = true;
_owner.Unsubscribe(_onTick);
}
Expand Down
2 changes: 2 additions & 0 deletions src/LageBuch.App.Shared/ViewLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ public sealed class ViewLocator : IDataTemplate
public Control Build(object? data)
{
if (data is null)
{
return new TextBlock { Text = "—" };
}

var shortName = data.GetType().Name.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType($"LageBuch.App.Shared.Views.{shortName}, LageBuch.App.Shared");
Expand Down
1 change: 1 addition & 0 deletions src/LageBuch.App.Shared/Views/AboutView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public partial class AboutView : UserControl
public AboutView()
{
InitializeComponent();

// Default focus on Close so a stray Enter just dismisses the dialog. Posted rather than
// called inline: realized as an overlay, the subtree is not yet laid out at
// AttachedToVisualTree time, so a synchronous Focus() is dropped (see OperatorPromptView).
Expand Down
Loading
Loading