diff --git a/.editorconfig b/.editorconfig
index 697191c..d1a0e92 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -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
@@ -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
diff --git a/Directory.Build.props b/Directory.Build.props
index 7f9446a..5bf6f2c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,4 +12,9 @@
true
$(NoWarn);CS1591
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1a2ab7b..4dfad62 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -25,6 +25,7 @@
+
diff --git a/src/LageBuch.App.Android/MainActivity.cs b/src/LageBuch.App.Android/MainActivity.cs
index eb928bc..014400a 100644
--- a/src/LageBuch.App.Android/MainActivity.cs
+++ b/src/LageBuch.App.Android/MainActivity.cs
@@ -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;
@@ -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(
@@ -48,7 +50,9 @@ protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
private sealed class ImportCallback : Java.Lang.Object, IActivityResultCallback
{
private readonly Action _onResult;
+
public ImportCallback(Action onResult) => _onResult = onResult;
+
public void OnActivityResult(Java.Lang.Object? result) => _onResult(result as global::Android.Net.Uri);
}
diff --git a/src/LageBuch.App.Android/MainApplication.cs b/src/LageBuch.App.Android/MainApplication.cs
index ff3d704..afb023a 100644
--- a/src/LageBuch.App.Android/MainApplication.cs
+++ b/src/LageBuch.App.Android/MainApplication.cs
@@ -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)
{
}
}
diff --git a/src/LageBuch.App.Android/Services/AndroidAlarmService.cs b/src/LageBuch.App.Android/Services/AndroidAlarmService.cs
index ceafce8..02ff58c 100644
--- a/src/LageBuch.App.Android/Services/AndroidAlarmService.cs
+++ b/src/LageBuch.App.Android/Services/AndroidAlarmService.cs
@@ -9,5 +9,7 @@ namespace LageBuch.App.Android.Services;
///
public sealed class AndroidAlarmService : IAlarmService
{
- public void Play(AlarmSound sound) { }
+ public void Play(AlarmSound sound)
+ {
+ }
}
diff --git a/src/LageBuch.App.Android/Services/AndroidFileDialogService.cs b/src/LageBuch.App.Android/Services/AndroidFileDialogService.cs
index b57444d..5b8e9ac 100644
--- a/src/LageBuch.App.Android/Services/AndroidFileDialogService.cs
+++ b/src/LageBuch.App.Android/Services/AndroidFileDialogService.cs
@@ -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(path);
}
@@ -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);
}
@@ -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);
}
@@ -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";
}
@@ -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);
@@ -180,6 +200,6 @@ public Task OpenUrlAsync(string url)
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
- _ => "*/*"
+ _ => "*/*",
};
}
diff --git a/src/LageBuch.App.Shared/App.axaml.cs b/src/LageBuch.App.Shared/App.axaml.cs
index e113673..f64c5f4 100644
--- a/src/LageBuch.App.Shared/App.axaml.cs
+++ b/src/LageBuch.App.Shared/App.axaml.cs
@@ -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;
@@ -35,6 +35,7 @@ public override void OnFrameworkInitializationCompleted()
mainView.AttachViewModel(CreateMainViewModel!());
singleView.MainView = mainView;
}
+
base.OnFrameworkInitializationCompleted();
}
}
diff --git a/src/LageBuch.App.Shared/Behaviors/EnterSubmit.cs b/src/LageBuch.App.Shared/Behaviors/EnterSubmit.cs
index 82170bc..1f4e664 100644
--- a/src/LageBuch.App.Shared/Behaviors/EnterSubmit.cs
+++ b/src/LageBuch.App.Shared/Behaviors/EnterSubmit.cs
@@ -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)
{
diff --git a/src/LageBuch.App.Shared/Behaviors/IntegerOnly.cs b/src/LageBuch.App.Shared/Behaviors/IntegerOnly.cs
index 028c403..d548854 100644
--- a/src/LageBuch.App.Shared/Behaviors/IntegerOnly.cs
+++ b/src/LageBuch.App.Shared/Behaviors/IntegerOnly.cs
@@ -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;
+ }
}
}
diff --git a/src/LageBuch.App.Shared/Services/AvaloniaUiDispatcher.cs b/src/LageBuch.App.Shared/Services/AvaloniaUiDispatcher.cs
index daf366c..a057c66 100644
--- a/src/LageBuch.App.Shared/Services/AvaloniaUiDispatcher.cs
+++ b/src/LageBuch.App.Shared/Services/AvaloniaUiDispatcher.cs
@@ -16,9 +16,13 @@ public void Post(Action action)
{
ArgumentNullException.ThrowIfNull(action);
if (Dispatcher.UIThread.CheckAccess())
+ {
action();
+ }
else
+ {
Dispatcher.UIThread.Post(action);
+ }
}
public Task InvokeAsync(Func func) => Dispatcher.UIThread.InvokeAsync(func).GetTask();
diff --git a/src/LageBuch.App.Shared/Services/DispatcherTimerTicker.cs b/src/LageBuch.App.Shared/Services/DispatcherTimerTicker.cs
index 91dac2f..685b138 100644
--- a/src/LageBuch.App.Shared/Services/DispatcherTimerTicker.cs
+++ b/src/LageBuch.App.Shared/Services/DispatcherTimerTicker.cs
@@ -19,21 +19,28 @@ 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
@@ -41,10 +48,20 @@ 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);
}
diff --git a/src/LageBuch.App.Shared/ViewLocator.cs b/src/LageBuch.App.Shared/ViewLocator.cs
index 598b65c..1db1418 100644
--- a/src/LageBuch.App.Shared/ViewLocator.cs
+++ b/src/LageBuch.App.Shared/ViewLocator.cs
@@ -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");
diff --git a/src/LageBuch.App.Shared/Views/AboutView.axaml.cs b/src/LageBuch.App.Shared/Views/AboutView.axaml.cs
index 6ff7fda..2484bb2 100644
--- a/src/LageBuch.App.Shared/Views/AboutView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/AboutView.axaml.cs
@@ -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).
diff --git a/src/LageBuch.App.Shared/Views/ConfirmDialogView.axaml.cs b/src/LageBuch.App.Shared/Views/ConfirmDialogView.axaml.cs
index b514882..c821ea6 100644
--- a/src/LageBuch.App.Shared/Views/ConfirmDialogView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/ConfirmDialogView.axaml.cs
@@ -9,6 +9,7 @@ public partial class ConfirmDialogView : UserControl
public ConfirmDialogView()
{
InitializeComponent();
+
// Default focus on Cancel so a stray Enter doesn't blindly confirm a destructive action.
AttachedToVisualTree += (_, _) => CancelButton.Focus();
}
@@ -17,7 +18,10 @@ public ConfirmDialogView()
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not ConfirmDialogViewModel vm)
+ {
return;
+ }
+
if (e.Key == Key.Escape)
{
vm.CancelCommand.Execute(null);
diff --git a/src/LageBuch.App.Shared/Views/EtbView.axaml.cs b/src/LageBuch.App.Shared/Views/EtbView.axaml.cs
index 82a3587..992e01e 100644
--- a/src/LageBuch.App.Shared/Views/EtbView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/EtbView.axaml.cs
@@ -7,6 +7,7 @@ public partial class EtbView : UserControl
public EtbView()
{
InitializeComponent();
+
// Land the cursor in the entry field so the operator can log radio traffic
// without first reaching for the mouse.
AttachedToVisualTree += (_, _) => EtbTextBox.Focus();
diff --git a/src/LageBuch.App.Shared/Views/HomeView.axaml.cs b/src/LageBuch.App.Shared/Views/HomeView.axaml.cs
index 011ffff..28af088 100644
--- a/src/LageBuch.App.Shared/Views/HomeView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/HomeView.axaml.cs
@@ -11,7 +11,9 @@ public HomeView()
RecentList.DoubleTapped += (_, _) =>
{
if (DataContext is HomeViewModel vm && RecentList.SelectedItem is RecentFileItem item)
+ {
vm.OpenRecentCommand.Execute(item.Path);
+ }
};
}
}
diff --git a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml.cs b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml.cs
index ebc68ec..f238359 100644
--- a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml.cs
@@ -17,10 +17,15 @@ public IncidentWorkspaceView()
private void OnDataContextChanged(object? sender, System.EventArgs e)
{
if (_vm is not null)
+ {
_vm.PropertyChanged -= OnViewModelPropertyChanged;
+ }
+
_vm = DataContext as IncidentWorkspaceViewModel;
if (_vm is not null)
+ {
_vm.PropertyChanged += OnViewModelPropertyChanged;
+ }
}
// When the continue-editing prompt appears, watch it for confirmation (Result set),
@@ -33,7 +38,9 @@ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs
prompt.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(OperatorPromptViewModel.Result) && prompt.Result is not null)
+ {
_vm.ConfirmContinueEditing();
+ }
};
prompt.Cancelled += (_, _) => _vm.CancelContinueEditing();
}
diff --git a/src/LageBuch.App.Shared/Views/MainView.axaml.cs b/src/LageBuch.App.Shared/Views/MainView.axaml.cs
index 98169d0..b93b554 100644
--- a/src/LageBuch.App.Shared/Views/MainView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/MainView.axaml.cs
@@ -23,7 +23,9 @@ public void AttachViewModel(MainWindowViewModel viewModel)
prompt.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(OperatorPromptViewModel.Result) && prompt.Result is not null)
+ {
viewModel.ConfirmOperatorCommand.Execute(null);
+ }
};
prompt.Cancelled += (_, _) => viewModel.CancelOperatorCommand.Execute(null);
}
diff --git a/src/LageBuch.App.Shared/Views/MainWindow.axaml.cs b/src/LageBuch.App.Shared/Views/MainWindow.axaml.cs
index d35730b..f092f36 100644
--- a/src/LageBuch.App.Shared/Views/MainWindow.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/MainWindow.axaml.cs
@@ -7,6 +7,7 @@ public partial class MainWindow : Window
{
public MainWindow() => InitializeComponent();
- public MainWindow(MainWindowViewModel viewModel) : this() =>
+ public MainWindow(MainWindowViewModel viewModel)
+ : this() =>
((MainView)Content!).AttachViewModel(viewModel);
}
diff --git a/src/LageBuch.App.Shared/Views/OperatorPromptView.axaml.cs b/src/LageBuch.App.Shared/Views/OperatorPromptView.axaml.cs
index ea539e4..99ab75d 100644
--- a/src/LageBuch.App.Shared/Views/OperatorPromptView.axaml.cs
+++ b/src/LageBuch.App.Shared/Views/OperatorPromptView.axaml.cs
@@ -10,6 +10,7 @@ public partial class OperatorPromptView : UserControl
public OperatorPromptView()
{
InitializeComponent();
+
// Cursor straight into the name field — confirming the operator gates incident start.
// Posted rather than called inline: when this prompt is realized as an overlay, its
// subtree is not yet laid out at AttachedToVisualTree time, so a synchronous Focus() is
diff --git a/src/LageBuch.App/Services/IncidentHostController.cs b/src/LageBuch.App/Services/IncidentHostController.cs
index a18cee3..da774e0 100644
--- a/src/LageBuch.App/Services/IncidentHostController.cs
+++ b/src/LageBuch.App/Services/IncidentHostController.cs
@@ -29,14 +29,20 @@ public IncidentHostController(IClock clock, string appVersion, IUiDispatcher ui)
}
public bool CanHost => true;
+
public bool IsHosting => _host?.IsRunning ?? false;
+
public string? ShareHint { get; private set; }
+
public string? SharePin { get; private set; }
public async Task StartAsync(LocalIncidentSession session)
{
if (_host is not null)
+ {
return;
+ }
+
// A fresh 4-digit PIN per share session: the host reads it out, joiners type it (§ #64).
// Cryptographic RNG so the PIN isn't predictable from a seeded/observed sequence — cheap
// hardening even though a 4-digit space is small (brute-force is the accepted, documented risk).
@@ -45,6 +51,7 @@ public async Task StartAsync(LocalIncidentSession session)
await host.StartAsync(IPAddress.Any);
_host = host;
SharePin = pin;
+
// Bound on every interface; show the nicest address to dial plus the same-machine shortcut.
ShareHint = $"Erreichbar unter {LocalNetwork.DisplayAddress()}:{SyncProtocol.Port} · "
+ $"auf diesem Gerät: localhost:{SyncProtocol.Port}";
@@ -53,7 +60,10 @@ public async Task StartAsync(LocalIncidentSession session)
public async Task StopAsync()
{
if (_host is null)
+ {
return;
+ }
+
await _host.DisposeAsync();
_host = null;
ShareHint = null;
diff --git a/src/LageBuch.App/Services/SerialAudioQueue.cs b/src/LageBuch.App/Services/SerialAudioQueue.cs
index 44f520c..6b4ac6e 100644
--- a/src/LageBuch.App/Services/SerialAudioQueue.cs
+++ b/src/LageBuch.App/Services/SerialAudioQueue.cs
@@ -12,7 +12,9 @@ namespace LageBuch.App.Services;
/// audio player process that never exits), the queue moves on to the next item anyway so one
/// stuck cue can't permanently silence later ones.
///
-[SuppressMessage("Design", "CA1001",
+[SuppressMessage(
+ "Design",
+ "CA1001",
Justification = "App-lifetime singleton: its worker thread drains until process shutdown, so the owning BlockingCollection is intentionally never disposed.")]
internal sealed class SerialAudioQueue
{
@@ -29,7 +31,9 @@ public SerialAudioQueue(TimeSpan? perItemTimeout = null)
/// Queues to run after everything already queued.
public void Enqueue(Action play) => _queue.Add(play);
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "A misbehaving or hanging cue must not stop the worker from serving the next one (the per-item timeout has already elapsed).")]
private void Run()
{
diff --git a/src/LageBuch.App/Services/StorageProviderFileDialogService.cs b/src/LageBuch.App/Services/StorageProviderFileDialogService.cs
index 1b0c480..2879c0e 100644
--- a/src/LageBuch.App/Services/StorageProviderFileDialogService.cs
+++ b/src/LageBuch.App/Services/StorageProviderFileDialogService.cs
@@ -10,10 +10,13 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
{
private static readonly FilePickerFileType Incident =
new("Einsatzdokumentation") { Patterns = new[] { "*.fwincident" } };
+
private static readonly FilePickerFileType Pdf =
new("PDF-Dokument") { Patterns = new[] { "*.pdf" } };
+
private static readonly FilePickerFileType Json =
new("Stammdaten (JSON)") { Patterns = new[] { "*.json" } };
+
private static readonly FilePickerFileType Attachment =
new("Bild oder PDF") { Patterns = new[] { "*.jpg", "*.jpeg", "*.png", "*.gif", "*.webp", "*.pdf" } };
@@ -24,26 +27,35 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
public async Task PickSaveAsync(string suggestedFileName, string? initialFolder = null)
{
var top = _topLevel();
- if (top is null) return null;
+ if (top is null)
+ {
+ return null;
+ }
+
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Einsatz speichern",
SuggestedFileName = suggestedFileName,
DefaultExtension = "fwincident",
FileTypeChoices = new[] { Incident },
- SuggestedStartLocation = await ResolveStartLocation(top, initialFolder)
+ SuggestedStartLocation = await ResolveStartLocation(top, initialFolder),
});
return file?.TryGetLocalPath();
}
// A missing/moved/inaccessible folder just means no start-location hint — the OS picker falls
// back to wherever it last remembered, exactly like today's behavior with no hint at all.
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Missing/moved/inaccessible folder means no start hint, by design (see comment).")]
private static async Task ResolveStartLocation(TopLevel top, string? folder)
{
if (string.IsNullOrWhiteSpace(folder))
+ {
return null;
+ }
+
try
{
return await top.StorageProvider.TryGetFolderFromPathAsync(folder);
@@ -57,12 +69,16 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
public async Task PickOpenAsync()
{
var top = _topLevel();
- if (top is null) return null;
+ if (top is null)
+ {
+ return null;
+ }
+
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Einsatz öffnen",
AllowMultiple = false,
- FileTypeFilter = new[] { Incident }
+ FileTypeFilter = new[] { Incident },
});
return files.Count > 0 ? files[0].TryGetLocalPath() : null;
}
@@ -70,13 +86,17 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
public async Task PickExportPdfAsync(string suggestedFileName)
{
var top = _topLevel();
- if (top is null) return null;
+ if (top is null)
+ {
+ return null;
+ }
+
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "PDF exportieren",
SuggestedFileName = suggestedFileName,
DefaultExtension = "pdf",
- FileTypeChoices = new[] { Pdf }
+ FileTypeChoices = new[] { Pdf },
});
return file?.TryGetLocalPath();
}
@@ -84,12 +104,16 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
public async Task PickImportJsonAsync()
{
var top = _topLevel();
- if (top is null) return null;
+ if (top is null)
+ {
+ return null;
+ }
+
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Stammdaten importieren",
AllowMultiple = false,
- FileTypeFilter = new[] { Json }
+ FileTypeFilter = new[] { Json },
});
return files.Count > 0 ? files[0].TryGetLocalPath() : null;
}
@@ -97,13 +121,17 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
public async Task PickExportJsonAsync(string suggestedFileName)
{
var top = _topLevel();
- if (top is null) return null;
+ if (top is null)
+ {
+ return null;
+ }
+
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Stammdaten exportieren",
SuggestedFileName = suggestedFileName,
DefaultExtension = "json",
- FileTypeChoices = new[] { Json }
+ FileTypeChoices = new[] { Json },
});
return file?.TryGetLocalPath();
}
@@ -111,12 +139,16 @@ internal sealed class StorageProviderFileDialogService : IFileDialogService
public async Task PickAttachmentAsync()
{
var top = _topLevel();
- if (top is null) return null;
+ if (top is null)
+ {
+ return null;
+ }
+
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Datei anhängen",
AllowMultiple = false,
- FileTypeFilter = new[] { Attachment }
+ FileTypeFilter = new[] { Attachment },
});
return files.Count > 0 ? files[0].TryGetLocalPath() : null;
}
@@ -134,7 +166,9 @@ public Task OpenUrlAsync(string url)
// anything but http(s) regardless of what a caller passes in.
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
return Task.CompletedTask;
+ }
LaunchWithOsDefault(uri.AbsoluteUri);
return Task.CompletedTask;
@@ -146,9 +180,13 @@ public Task OpenUrlAsync(string url)
private static void LaunchWithOsDefault(string target)
{
if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
+ {
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
+ }
else
+ {
Process.Start(new ProcessStartInfo("xdg-open", $"\"{target}\"") { UseShellExecute = false });
+ }
}
// The user already chose the exact destination via the native save dialog above — there is
diff --git a/src/LageBuch.App/Services/SystemAlarmService.cs b/src/LageBuch.App/Services/SystemAlarmService.cs
index 0498592..09c2d1d 100644
--- a/src/LageBuch.App/Services/SystemAlarmService.cs
+++ b/src/LageBuch.App/Services/SystemAlarmService.cs
@@ -17,6 +17,7 @@ internal sealed class SystemAlarmService : IAlarmService
{
private const uint SndNodefault = 0x0002; // no default beep if it fails
private const uint SndMemory = 0x0004; // pszSound points to in-memory WAV
+
// No SND_ASYNC: playback must block the queue's worker thread until the clip finishes,
// so cues play one after another instead of overlapping (see SerialAudioQueue).
@@ -25,6 +26,7 @@ internal sealed class SystemAlarmService : IAlarmService
new Dictionary
{
[AlarmSound.IlsReminderDue] = "voice-rueckmeldung-ils.wav",
+
// Generic tone (already bundled) — a task falling due is frequent enough that a spoken
// sentence would be more noise than signal.
[AlarmSound.TaskDue] = "alarm.wav",
@@ -40,22 +42,32 @@ public SystemAlarmService()
{
// Preload the voice clips (all platforms). Absent files are simply skipped.
foreach (var (sound, file) in VoiceAssets)
+ {
if (TryLoad(new Uri($"avares://LageBuch.App/Assets/{file}")) is { } bytes)
+ {
_voiceBytes[sound] = bytes;
+ }
+ }
}
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "A missing player binary must stay silent (see comment); a failed alarm never crashes the app.")]
public void Play(AlarmSound sound)
{
if (!_voiceBytes.TryGetValue(sound, out var bytes))
+ {
return; // no clip bundled for this cue yet
+ }
_queue.Enqueue(() => PlayBlocking(sound, bytes));
}
// Runs on the SerialAudioQueue's worker thread; blocks until the clip finishes playing.
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Player binary missing (e.g. a headless Linux host without ALSA) — stay silent.")]
private void PlayBlocking(AlarmSound sound, byte[] bytes)
{
@@ -67,7 +79,9 @@ private void PlayBlocking(AlarmSound sound, byte[] bytes)
var path = TempFileFor(sound, bytes);
if (path is null)
+ {
return;
+ }
var player = OperatingSystem.IsMacOS() ? "afplay" : "aplay";
try
@@ -86,12 +100,17 @@ private void PlayBlocking(AlarmSound sound, byte[] bytes)
}
// afplay/aplay need a file path, so materialize the embedded WAV to a temp file once and cache it.
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Best-effort temp cache: a failure falls back to silent alarm.")]
private string? TempFileFor(AlarmSound sound, byte[] bytes)
{
if (_voiceTempFiles.TryGetValue(sound, out var cached))
+ {
return cached;
+ }
+
try
{
var path = Path.Combine(Path.GetTempPath(), $"lagebuch-{sound}.wav");
@@ -105,7 +124,9 @@ private void PlayBlocking(AlarmSound sound, byte[] bytes)
}
}
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "A missing bundled asset must stay silent (see comment).")]
private static byte[]? TryLoad(Uri asset)
{
diff --git a/src/LageBuch.AppLogic/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs
index 761a06e..aba8b24 100644
--- a/src/LageBuch.AppLogic/LocalIncidentSession.cs
+++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs
@@ -5,8 +5,8 @@
using LageBuch.Domain.CoMeasurement;
using LageBuch.Domain.Etb;
using LageBuch.Domain.Files;
-using LageBuch.Domain.Time;
using LageBuch.Domain.Tasks;
+using LageBuch.Domain.Time;
using LageBuch.Domain.ValueObjects;
using LageBuch.Sync;
@@ -33,7 +33,9 @@ private LocalIncidentSession(IIncidentStore store, IClock clock, Incident incide
}
public Incident Incident { get; }
+
public string Path { get; }
+
public SessionOperator? Operator { get; private set; }
public event Action? Changed;
@@ -58,6 +60,7 @@ public static LocalIncidentSession StartNew(
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(op);
+
// The Einsatznummer goes through the factory rather than SetIncidentNumber afterwards, so the
// automatic "Einsatz begonnen" entry can name it.
var incident = Incident.Start(clock, op, keyword: keyword, incidentNumber: incidentNumber);
@@ -72,7 +75,10 @@ public static LocalIncidentSession Open(IIncidentStore store, IClock clock, stri
ArgumentNullException.ThrowIfNull(store);
var incident = store.Load(path);
if (incident.State == IncidentState.Open && op is null)
+ {
throw new InvalidOperationException("Ein offener Einsatz erfordert einen Bearbeiter.");
+ }
+
var effectiveOperator = incident.State == IncidentState.Closed ? null : op;
return new LocalIncidentSession(store, clock, incident, path, effectiveOperator);
}
@@ -90,9 +96,15 @@ public void ContinueEditing(SessionOperator op)
{
ArgumentNullException.ThrowIfNull(op);
if (Incident.State == IncidentState.Closed)
+ {
throw new InvalidOperationException("Ein abgeschlossener Einsatz kann nicht weiter bearbeitet werden.");
+ }
+
if (Operator is not null)
+ {
return; // already editable
+ }
+
Operator = op;
Incident.ResumeEditing(_clock, op);
Save();
@@ -110,13 +122,15 @@ public Task ExportPdfAsync()
{
var bytes = _store.TryReadFileBytes(Path, IncidentFile.StorageFileName(file.Id, file.FileName));
if (bytes is not null)
+ {
fileBytes[file.Id] = bytes;
+ }
}
+
return Task.FromResult(IncidentPdf.Generate(Incident, fileBytes));
}
// --- IIncidentSession mutation surface: apply → persist → notify. ---
-
public void AddJournalEntry(EtbDirection direction, string text, string? from = null, string? to = null) =>
Mutate(() => Incident.AddJournalEntry(_clock, RequireOperator(), direction, text, from, to));
@@ -125,8 +139,14 @@ public void EditJournalEntry(Guid entryId, string text) =>
public void ToggleChecklistItem(Guid itemId) => Mutate(() => Incident.ToggleChecklistItem(_clock, RequireOperator(), itemId));
- public void AssignRole(string role, string personName, string? callSign = null,
- DateTimeOffset? from = null, DateTimeOffset? to = null, string? section = null, string? phone = null) =>
+ public void AssignRole(
+ string role,
+ string personName,
+ string? callSign = null,
+ DateTimeOffset? from = null,
+ DateTimeOffset? to = null,
+ string? section = null,
+ string? phone = null) =>
Mutate(() => Incident.AssignRole(_clock, RequireOperator(), role, personName, callSign, from, to, section, phone));
public void TransferRole(Guid assignmentId, string newPersonName, string? newCallSign = null, string? newPhone = null) =>
@@ -135,8 +155,14 @@ public void TransferRole(Guid assignmentId, string newPersonName, string? newCal
public void EditRolePhone(Guid assignmentId, string? phone) =>
Mutate(() => Incident.EditRolePhone(_clock, RequireOperator(), assignmentId, phone));
- public void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
- string? status = null, string? notes = null, int scbaCount = 0, int officerCount = 0) =>
+ public void AddForceUnit(
+ string brigade,
+ int personnelCount,
+ string? callSign = null,
+ string? status = null,
+ string? notes = null,
+ int scbaCount = 0,
+ int officerCount = 0) =>
Mutate(() => Incident.AddForceUnit(_clock, RequireOperator(), brigade, personnelCount, callSign, status, notes, scbaCount, officerCount));
public void UpdateForceUnit(Guid unitId, string? status, string? notes) =>
@@ -154,15 +180,27 @@ public void AddTask(string text, string? assignee, TaskImportance importance, Ta
public void SetTaskCompleted(Guid taskId, bool isDone) =>
Mutate(() => Incident.SetTaskCompleted(taskId, isDone, _clock, RequireOperator()));
- public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
+ public void AddScbaTrupp(
+ string designation,
+ IEnumerable members,
+ int entryPressure,
int? truppNumber = null,
string? callSign = null,
string? task = null,
int maxDurationMinutes = AtemschutzTrupp.DefaultMaxDurationMinutes,
int returnPressureBar = AtemschutzTrupp.DefaultReturnPressureBar,
int pressureControlIntervalMinutes = AtemschutzTrupp.DefaultPressureControlIntervalMinutes) =>
- Mutate(() => Incident.AddScbaTrupp(_clock, designation, members, entryPressure, truppNumber, callSign, task,
- maxDurationMinutes, returnPressureBar, pressureControlIntervalMinutes));
+ Mutate(() => Incident.AddScbaTrupp(
+ _clock,
+ designation,
+ members,
+ entryPressure,
+ truppNumber,
+ callSign,
+ task,
+ maxDurationMinutes,
+ returnPressureBar,
+ pressureControlIntervalMinutes));
public void StartScbaTrupp(Guid truppId) =>
Mutate(() => Incident.StartScbaTrupp(_clock, truppId));
@@ -177,8 +215,11 @@ public void MarkScbaRemoved(Guid truppId) =>
Mutate(() => Incident.MarkScbaRemoved(_clock, truppId));
public void SetIncidentNumber(IncidentNumber? number) => Mutate(() => Incident.SetIncidentNumber(number));
+
public void SetKeyword(string? keyword) => Mutate(() => Incident.SetKeyword(keyword));
+
public void SetAddress(string? street, string? district) => Mutate(() => Incident.SetAddress(street, district));
+
public void SetStatus(string? status) => Mutate(() => Incident.SetStatus(status));
public void UpsertTimer(string key, DateTimeOffset cycleAnchor, int intervalMinutes, int recurringIntervalMinutes, bool isRunning) =>
@@ -238,7 +279,10 @@ public void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? labe
public void Close()
{
if (IsReadOnly)
+ {
throw new InvalidOperationException("Der Einsatz ist bereits abgeschlossen.");
+ }
+
Incident.Close(_clock, RequireOperator());
Save();
Changed?.Invoke();
@@ -247,7 +291,10 @@ public void Close()
private void Mutate(Action apply)
{
if (IsReadOnly)
+ {
throw new InvalidOperationException("Der Einsatz ist bereits abgeschlossen.");
+ }
+
apply();
Save();
Changed?.Invoke();
diff --git a/src/LageBuch.AppLogic/Services/IFileDialogService.cs b/src/LageBuch.AppLogic/Services/IFileDialogService.cs
index aa2f86a..efcc4f8 100644
--- a/src/LageBuch.AppLogic/Services/IFileDialogService.cs
+++ b/src/LageBuch.AppLogic/Services/IFileDialogService.cs
@@ -10,9 +10,13 @@ public interface IFileDialogService
/// ignore it.
///
Task PickSaveAsync(string suggestedFileName, string? initialFolder = null);
+
Task PickOpenAsync();
+
Task PickExportPdfAsync(string suggestedFileName);
+
Task PickImportJsonAsync();
+
Task PickExportJsonAsync(string suggestedFileName);
///
diff --git a/src/LageBuch.AppLogic/Services/IIncidentHostController.cs b/src/LageBuch.AppLogic/Services/IIncidentHostController.cs
index ab1a69a..6bec9b4 100644
--- a/src/LageBuch.AppLogic/Services/IIncidentHostController.cs
+++ b/src/LageBuch.AppLogic/Services/IIncidentHostController.cs
@@ -28,9 +28,14 @@ public interface IIncidentHostController
public sealed class NoopIncidentHostController : IIncidentHostController
{
public bool CanHost => false;
+
public bool IsHosting => false;
+
public string? ShareHint => null;
+
public string? SharePin => null;
+
public Task StartAsync(LocalIncidentSession session) => Task.CompletedTask;
+
public Task StopAsync() => Task.CompletedTask;
}
diff --git a/src/LageBuch.AppLogic/Services/IIncidentStore.cs b/src/LageBuch.AppLogic/Services/IIncidentStore.cs
index b45ab51..c4dc1b9 100644
--- a/src/LageBuch.AppLogic/Services/IIncidentStore.cs
+++ b/src/LageBuch.AppLogic/Services/IIncidentStore.cs
@@ -5,6 +5,7 @@ namespace LageBuch.AppLogic.Services;
public interface IIncidentStore
{
void Save(string path, Incident incident);
+
Incident Load(string path);
///
diff --git a/src/LageBuch.AppLogic/Services/ILastSaveFolderStore.cs b/src/LageBuch.AppLogic/Services/ILastSaveFolderStore.cs
index 88c195e..ef34947 100644
--- a/src/LageBuch.AppLogic/Services/ILastSaveFolderStore.cs
+++ b/src/LageBuch.AppLogic/Services/ILastSaveFolderStore.cs
@@ -8,5 +8,6 @@ namespace LageBuch.AppLogic.Services;
public interface ILastSaveFolderStore
{
string? GetLastFolder();
+
void SetLastFolder(string folder);
}
diff --git a/src/LageBuch.AppLogic/Services/IMasterDataFileService.cs b/src/LageBuch.AppLogic/Services/IMasterDataFileService.cs
index b71f2cb..6ec0b18 100644
--- a/src/LageBuch.AppLogic/Services/IMasterDataFileService.cs
+++ b/src/LageBuch.AppLogic/Services/IMasterDataFileService.cs
@@ -10,5 +10,6 @@ namespace LageBuch.AppLogic.Services;
public interface IMasterDataFileService
{
MasterDataSet Read(string path);
+
void Write(string path, MasterDataSet set);
}
diff --git a/src/LageBuch.AppLogic/Services/IRecentFilesStore.cs b/src/LageBuch.AppLogic/Services/IRecentFilesStore.cs
index 387babe..00b20a8 100644
--- a/src/LageBuch.AppLogic/Services/IRecentFilesStore.cs
+++ b/src/LageBuch.AppLogic/Services/IRecentFilesStore.cs
@@ -3,5 +3,6 @@ namespace LageBuch.AppLogic.Services;
public interface IRecentFilesStore
{
IReadOnlyList GetRecent();
+
void Add(string path);
}
diff --git a/src/LageBuch.AppLogic/Services/IncidentStore.cs b/src/LageBuch.AppLogic/Services/IncidentStore.cs
index 470acb8..b85d695 100644
--- a/src/LageBuch.AppLogic/Services/IncidentStore.cs
+++ b/src/LageBuch.AppLogic/Services/IncidentStore.cs
@@ -5,7 +5,7 @@ namespace LageBuch.AppLogic.Services;
public sealed class IncidentStore : IIncidentStore
{
-private readonly IncidentFileStore _fileStore = new IncidentFileStore();
+ private readonly IncidentFileStore _fileStore = new IncidentFileStore();
public void Save(string path, Incident incident) => IncidentRepository.Save(path, incident);
diff --git a/src/LageBuch.AppLogic/Services/JsonLastSaveFolderStore.cs b/src/LageBuch.AppLogic/Services/JsonLastSaveFolderStore.cs
index ab34d4f..b0d950f 100644
--- a/src/LageBuch.AppLogic/Services/JsonLastSaveFolderStore.cs
+++ b/src/LageBuch.AppLogic/Services/JsonLastSaveFolderStore.cs
@@ -11,7 +11,9 @@ public sealed class JsonLastSaveFolderStore : ILastSaveFolderStore
public string? GetLastFolder()
{
if (!File.Exists(_path))
+ {
return null;
+ }
try
{
diff --git a/src/LageBuch.AppLogic/Services/JsonRecentFilesStore.cs b/src/LageBuch.AppLogic/Services/JsonRecentFilesStore.cs
index d07aa60..915d14a 100644
--- a/src/LageBuch.AppLogic/Services/JsonRecentFilesStore.cs
+++ b/src/LageBuch.AppLogic/Services/JsonRecentFilesStore.cs
@@ -12,7 +12,9 @@ public sealed class JsonRecentFilesStore : IRecentFilesStore
public IReadOnlyList GetRecent()
{
if (!File.Exists(_path))
+ {
return Array.Empty();
+ }
try
{
@@ -31,7 +33,9 @@ public void Add(string path)
list.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase));
list.Insert(0, path);
if (list.Count > MaxEntries)
+ {
list.RemoveRange(MaxEntries, list.Count - MaxEntries);
+ }
File.WriteAllText(_path, JsonSerializer.Serialize(list));
}
diff --git a/src/LageBuch.AppLogic/Services/MasterDataProvider.cs b/src/LageBuch.AppLogic/Services/MasterDataProvider.cs
index 61db4a4..271c469 100644
--- a/src/LageBuch.AppLogic/Services/MasterDataProvider.cs
+++ b/src/LageBuch.AppLogic/Services/MasterDataProvider.cs
@@ -14,6 +14,7 @@ public sealed class MasterDataProvider : IMasterDataProvider
public void Save(MasterDataSet set)
{
MasterDataStore.Save(_path, set);
+
// Re-read rather than trust the in-memory copy: the store is the canonical shape
// (e.g. personnel comes back name-sorted), so callers see exactly what a fresh start would.
_cached = MasterDataStore.GetOrCreate(_path);
diff --git a/src/LageBuch.AppLogic/ViewModels/AboutViewModel.cs b/src/LageBuch.AppLogic/ViewModels/AboutViewModel.cs
index aa1d33a..0a8c4a1 100644
--- a/src/LageBuch.AppLogic/ViewModels/AboutViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/AboutViewModel.cs
@@ -25,16 +25,20 @@ public AboutViewModel(IFileDialogService dialogs, string version)
[SuppressMessage("Performance", "CA1822", Justification = "XAML {Binding} target in AboutView; binding requires an instance property.")]
public string AppName => "Lagebuch";
+
[SuppressMessage("Performance", "CA1822", Justification = "XAML {Binding} target in AboutView; binding requires an instance property.")]
public string Descriptor => "Einsatzdokumentation";
+
public string Version { get; }
-[SuppressMessage("Design", "CA1056", Justification = "RepositoryUrl is a display/launch string handed to IFileDialogService.OpenUrlAsync; System.Uri would add parse/validation behavior with no benefit here.")]
+
+ [SuppressMessage("Design", "CA1056", Justification = "RepositoryUrl is a display/launch string handed to IFileDialogService.OpenUrlAsync; System.Uri would add parse/validation behavior with no benefit here.")]
[SuppressMessage("Performance", "CA1822", Justification = "XAML {Binding} target in AboutView; binding requires an instance property.")]
public string RepositoryUrl => RepoUrl;
// Kept in sync with the LICENSE file in the repo root.
[SuppressMessage("Performance", "CA1822", Justification = "XAML {Binding} target in AboutView; binding requires an instance property.")]
public string LicenseLine => "Veröffentlicht unter der MIT-Lizenz.";
+
[SuppressMessage("Performance", "CA1822", Justification = "XAML {Binding} target in AboutView; binding requires an instance property.")]
public string CopyrightLine => "Copyright © 2026 Thomas Müller";
@@ -48,7 +52,9 @@ public AboutViewModel(IFileDialogService dialogs, string version)
private void Close() => Closed?.Invoke(this, EventArgs.Empty);
[RelayCommand]
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Deliberately broad: any launcher failure surfaces in the dialog instead of crashing it.")]
private async Task OpenRepositoryAsync()
{
diff --git a/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateRow.cs b/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateRow.cs
index 8229d1d..d1bc6c0 100644
--- a/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateRow.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateRow.cs
@@ -14,9 +14,12 @@ public ChecklistTemplateRow(string text, bool isMandatory, Action onChanged)
_isMandatory = isMandatory;
}
- [ObservableProperty] private string _text;
- [ObservableProperty] private bool _isMandatory;
+ [ObservableProperty]
+ private string _text;
+ [ObservableProperty]
+ private bool _isMandatory;
partial void OnTextChanged(string value) => _onChanged();
+
partial void OnIsMandatoryChanged(bool value) => _onChanged();
}
diff --git a/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateSection.cs b/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateSection.cs
index 59b1de8..b15fd6b 100644
--- a/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ChecklistTemplateSection.cs
@@ -12,7 +12,8 @@ public sealed partial class ChecklistTemplateSection : EditorSection
{
private readonly Action _onChanged;
- public ChecklistTemplateSection(string title, IEnumerable items, Action onChanged) : base(title)
+ public ChecklistTemplateSection(string title, IEnumerable items, Action onChanged)
+ : base(title)
{
_onChanged = onChanged;
Rows = new ObservableCollection(
@@ -31,21 +32,32 @@ private void Add()
[RelayCommand]
private void Remove(ChecklistTemplateRow row)
{
- if (Rows.Remove(row)) _onChanged();
+ if (Rows.Remove(row))
+ {
+ _onChanged();
+ }
}
[RelayCommand]
private void MoveUp(ChecklistTemplateRow row)
{
var i = Rows.IndexOf(row);
- if (i > 0) { Rows.Move(i, i - 1); _onChanged(); }
+ if (i > 0)
+ {
+ Rows.Move(i, i - 1);
+ _onChanged();
+ }
}
[RelayCommand]
private void MoveDown(ChecklistTemplateRow row)
{
var i = Rows.IndexOf(row);
- if (i >= 0 && i < Rows.Count - 1) { Rows.Move(i, i + 1); _onChanged(); }
+ if (i >= 0 && i < Rows.Count - 1)
+ {
+ Rows.Move(i, i + 1);
+ _onChanged();
+ }
}
///
@@ -59,8 +71,12 @@ public IReadOnlyList ToValues()
foreach (var row in Rows)
{
var text = row.Text?.Trim() ?? string.Empty;
- if (text.Length > 0) result.Add(new ChecklistTemplateItem(text, row.IsMandatory));
+ if (text.Length > 0)
+ {
+ result.Add(new ChecklistTemplateItem(text, row.IsMandatory));
+ }
}
+
return result;
}
}
diff --git a/src/LageBuch.AppLogic/ViewModels/ChecklistViewModel.cs b/src/LageBuch.AppLogic/ViewModels/ChecklistViewModel.cs
index a6612fd..0296a00 100644
--- a/src/LageBuch.AppLogic/ViewModels/ChecklistViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ChecklistViewModel.cs
@@ -21,12 +21,14 @@ public ChecklistViewModel(IIncidentSession session, ChecklistKind kind, Action o
session, kind, item.Id, item.Text, item.IsDone, item.Note, item.IsMandatory, IsReadOnly, onChanged))
.ToList();
_allMandatoryDone = ComputeAllMandatoryDone();
+
// Recomputed after every change to this incident (local toggle, or a remote broadcast),
// mirroring ScbaViewModel.UpdateAlarm — this is what the AUFBAU/ABBAU tab header dot binds to.
session.Changed += Recompute;
}
public bool IsReadOnly { get; }
+
public IReadOnlyList Items { get; }
[ObservableProperty]
@@ -50,8 +52,15 @@ public sealed partial class ChecklistItemViewModel : ObservableObject
private bool _suppressWriteback;
public ChecklistItemViewModel(
- IIncidentSession session, ChecklistKind kind, Guid id, string text, bool isDone, string? note,
- bool isMandatory, bool isReadOnly, Action onChanged)
+ IIncidentSession session,
+ ChecklistKind kind,
+ Guid id,
+ string text,
+ bool isDone,
+ string? note,
+ bool isMandatory,
+ bool isReadOnly,
+ Action onChanged)
{
_session = session;
_kind = kind;
@@ -62,6 +71,7 @@ public ChecklistItemViewModel(
_isDone = isDone;
_note = note;
IsReadOnly = isReadOnly;
+
// Reflect toggles made elsewhere (another tab, or another device once joined).
_session.Changed += SyncFromIncident;
}
@@ -70,7 +80,10 @@ private void SyncFromIncident()
{
var item = ChecklistViewModel.ItemsFor(_session.Incident, _kind).FirstOrDefault(c => c.Id == _id);
if (item is null)
+ {
return;
+ }
+
_suppressWriteback = true; // this is a state pull, not a user toggle — don't write it back
IsDone = item.IsDone;
Note = item.Note;
@@ -78,7 +91,9 @@ private void SyncFromIncident()
}
public string Text { get; }
+
public bool IsMandatory { get; }
+
public bool IsReadOnly { get; }
[ObservableProperty]
diff --git a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs
index 7a540c8..3bb6755 100644
--- a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs
@@ -14,7 +14,9 @@ public sealed partial class DwellingCellViewModel : ObservableObject
private readonly Action _onOpenEditor;
public DwellingCellViewModel(
- Dwelling dwelling, Building building, bool isReadOnly,
+ Dwelling dwelling,
+ Building building,
+ bool isReadOnly,
Action onStatusChanged,
Action onCoValueChanged,
Action onOpenEditor)
@@ -36,9 +38,13 @@ public DwellingCellViewModel(
}
public Guid Id { get; }
+
public Guid BuildingId { get; }
+
public int FloorOrdinal { get; }
+
public int ApartmentNumber { get; }
+
public bool IsReadOnly { get; }
[ObservableProperty]
@@ -64,7 +70,7 @@ public DwellingCellViewModel(
{
true => "\uD83D\uDD11",
false => "\u2716",
- _ => ""
+ _ => string.Empty,
};
// Mirrors the spray-marked "X-code" convention search teams already use on doors: a single
@@ -75,7 +81,7 @@ public DwellingCellViewModel(
DwellingStatus.NotSearched => "\u2571",
DwellingStatus.Searched => "\u2715",
DwellingStatus.Affected => "\u2297",
- _ => "\u2571"
+ _ => "\u2571",
};
private static string GetStatusBrush(DwellingStatus status) => status switch
@@ -83,7 +89,7 @@ public DwellingCellViewModel(
DwellingStatus.NotSearched => "#FFC000",
DwellingStatus.Searched => "#92D050",
DwellingStatus.Affected => "#FF0000",
- _ => "#FFC000"
+ _ => "#FFC000",
};
partial void OnStatusChanged(DwellingStatus value)
@@ -120,6 +126,7 @@ public ApartmentColumnViewModel(int apartmentNumber, string label, bool isReadOn
}
public int ApartmentNumber { get; }
+
public bool IsReadOnly { get; }
[ObservableProperty]
@@ -143,8 +150,11 @@ public FloorRowViewModel(int ordinal, string label, IReadOnlyList Cells { get; }
+
public string? Description { get; }
}
@@ -192,10 +202,14 @@ private void Refresh()
{
BuildingOptions.Clear();
foreach (var b in _session.Incident.Buildings)
+ {
BuildingOptions.Add(b);
+ }
if (SelectedBuilding is null || !_session.Incident.Buildings.Contains(SelectedBuilding))
+ {
SelectedBuilding = BuildingOptions.FirstOrDefault();
+ }
BuildMatrix();
OnPropertyChanged(nameof(IsReadOnly));
@@ -258,7 +272,11 @@ private void OnCoValueChanged(Guid buildingId, int floorOrdinal, int apartmentNu
private void OnApartmentLabelChanged(int apartmentNumber, string? label)
{
- if (SelectedBuilding is null) return;
+ if (SelectedBuilding is null)
+ {
+ return;
+ }
+
_session.SetApartmentLabel(SelectedBuilding.Id, apartmentNumber, label);
_onChanged();
}
@@ -306,7 +324,11 @@ private void ConfirmAddBuilding()
[RelayCommand(CanExecute = nameof(CanRemoveBuilding))]
private void RemoveBuilding()
{
- if (SelectedBuilding is null) return;
+ if (SelectedBuilding is null)
+ {
+ return;
+ }
+
IsRemoveBuildingConfirmOpen = true;
}
@@ -318,7 +340,11 @@ private void RemoveBuilding()
[RelayCommand]
private void ConfirmRemoveBuilding()
{
- if (SelectedBuilding is null) return;
+ if (SelectedBuilding is null)
+ {
+ return;
+ }
+
_session.RemoveCoBuilding(SelectedBuilding.Id);
IsRemoveBuildingConfirmOpen = false;
_onChanged();
@@ -335,7 +361,6 @@ private void CloseEditor()
IsEditorOpen = false;
}
-
[RelayCommand]
private void SetEditorStatusNotSearched() => SetEditorStatus(DwellingStatus.NotSearched);
@@ -347,7 +372,11 @@ private void CloseEditor()
private void SetEditorStatus(DwellingStatus status)
{
- if (SelectedCell is null) return;
+ if (SelectedCell is null)
+ {
+ return;
+ }
+
SelectedCell.Status = status;
}
@@ -361,9 +390,16 @@ private void ConfirmEditor()
private void PersistSelectedCellDetails()
{
- if (SelectedCell is null) return;
- _session.SetDwellingDetails(SelectedCell.BuildingId, SelectedCell.FloorOrdinal,
- SelectedCell.ApartmentNumber, SelectedCell.ResidentName, SelectedCell.KeyAvailable);
- }
+ if (SelectedCell is null)
+ {
+ return;
+ }
+ _session.SetDwellingDetails(
+ SelectedCell.BuildingId,
+ SelectedCell.FloorOrdinal,
+ SelectedCell.ApartmentNumber,
+ SelectedCell.ResidentName,
+ SelectedCell.KeyAvailable);
+ }
}
diff --git a/src/LageBuch.AppLogic/ViewModels/ConfirmDialogViewModel.cs b/src/LageBuch.AppLogic/ViewModels/ConfirmDialogViewModel.cs
index 605d16a..2be1c26 100644
--- a/src/LageBuch.AppLogic/ViewModels/ConfirmDialogViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ConfirmDialogViewModel.cs
@@ -21,7 +21,9 @@ public ConfirmDialogViewModel(string title, string message, string confirmLabel,
}
public string Title { get; }
+
public string Message { get; }
+
public string ConfirmLabel { get; }
/// Raised after Confirm or Cancel so the host removes the overlay.
diff --git a/src/LageBuch.AppLogic/ViewModels/EditableListSection.cs b/src/LageBuch.AppLogic/ViewModels/EditableListSection.cs
index 77077cb..22629d6 100644
--- a/src/LageBuch.AppLogic/ViewModels/EditableListSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/EditableListSection.cs
@@ -8,7 +8,8 @@ public sealed partial class EditableListSection : EditorSection
{
private readonly Action _onChanged;
- public EditableListSection(string title, IEnumerable values, Action onChanged) : base(title)
+ public EditableListSection(string title, IEnumerable values, Action onChanged)
+ : base(title)
{
_onChanged = onChanged;
Items = new ObservableCollection(values.Select(v => new MasterDataItem(v, onChanged)));
@@ -26,21 +27,32 @@ private void Add()
[RelayCommand]
private void Remove(MasterDataItem item)
{
- if (Items.Remove(item)) _onChanged();
+ if (Items.Remove(item))
+ {
+ _onChanged();
+ }
}
[RelayCommand]
private void MoveUp(MasterDataItem item)
{
var i = Items.IndexOf(item);
- if (i > 0) { Items.Move(i, i - 1); _onChanged(); }
+ if (i > 0)
+ {
+ Items.Move(i, i - 1);
+ _onChanged();
+ }
}
[RelayCommand]
private void MoveDown(MasterDataItem item)
{
var i = Items.IndexOf(item);
- if (i >= 0 && i < Items.Count - 1) { Items.Move(i, i + 1); _onChanged(); }
+ if (i >= 0 && i < Items.Count - 1)
+ {
+ Items.Move(i, i + 1);
+ _onChanged();
+ }
}
/// Trimmed, non-empty, de-duplicated (ordinal, first wins), in current order.
@@ -51,8 +63,12 @@ public IReadOnlyList ToValues()
foreach (var item in Items)
{
var v = item.Value?.Trim() ?? string.Empty;
- if (v.Length > 0 && seen.Add(v)) result.Add(v);
+ if (v.Length > 0 && seen.Add(v))
+ {
+ result.Add(v);
+ }
}
+
return result;
}
}
diff --git a/src/LageBuch.AppLogic/ViewModels/EtbViewModel.cs b/src/LageBuch.AppLogic/ViewModels/EtbViewModel.cs
index 52c00d9..075d6a0 100644
--- a/src/LageBuch.AppLogic/ViewModels/EtbViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/EtbViewModel.cs
@@ -11,64 +11,12 @@
namespace LageBuch.AppLogic.ViewModels;
-///
-/// One rendered ETB row. Carries its own (rather than the view
-/// reaching back up to via a $parent binding, mirroring
-/// 's reasoning) but is otherwise plain data: an edit happens through
-/// 's edit panel, not a per-keystroke two-way binding on the row itself,
-/// so the row is replaced wholesale (not mutated in place) whenever its entry changes.
-///
-public sealed class EtbEntryRow
-{
- public EtbEntryRow(
- EtbEntry entry, Action beginEdit, Func canEdit, Action showHistory)
- {
- ArgumentNullException.ThrowIfNull(entry);
- Id = entry.Id;
- Time = Formatting.Timestamp(entry.Timestamp);
- Direction = Formatting.Direction(entry.Direction);
- From = entry.From;
- To = entry.To;
- Text = entry.Text;
- EnteredBy = entry.EnteredBy;
- DirectionValue = entry.Direction;
- WasEdited = entry.Edits.Count > 0;
- Edits = entry.Edits;
- IsEditable = entry.Direction != EtbDirection.System;
- BeginEditCommand = new RelayCommand(() => beginEdit(this), () => canEdit(this));
- // Deliberately not gated on IsReadOnly/IsEditable like BeginEditCommand: a closed or
- // remotely-joined-read-only incident must still let its history be read, since that is the
- // one thing that makes an edit acceptable in the first place.
- ShowHistoryCommand = new RelayCommand(() => showHistory(this), () => WasEdited);
- }
-
- public Guid Id { get; }
- public string Time { get; }
- public string Direction { get; }
- public string? From { get; }
- public string? To { get; }
- public string Text { get; }
- public string EnteredBy { get; }
- public EtbDirection DirectionValue { get; }
- public bool WasEdited { get; }
- public IReadOnlyList Edits { get; }
- public bool IsEditable { get; }
- public ICommand BeginEditCommand { get; }
- public ICommand ShowHistoryCommand { get; }
-}
-
-///
-/// An paired with its German label, so the picker shows the same
-/// wording as the grid and the PDF. Binding the raw enum makes Avalonia fall back to
-/// , which leaks the English identifiers into the UI.
-///
-public sealed record EtbDirectionOption(EtbDirection Value, string Label);
-
public sealed partial class EtbViewModel : ObservableObject
{
private readonly IIncidentSession _session;
private readonly IClock _clock;
private readonly Action _onChanged;
+
// Opens the create-task overlay pre-filled with an entry's text (#88); null where the host
// offers no task feature, which disables the "add & create task" dock button too.
private readonly Action? _createTaskFromEntry;
@@ -81,7 +29,11 @@ public sealed partial class EtbViewModel : ObservableObject
// instead of a linear scan of _all for every journal entry.
private readonly Dictionary _byId = new();
- public EtbViewModel(IIncidentSession session, IClock clock, MasterDataSet masterData, Action onChanged,
+ public EtbViewModel(
+ IIncidentSession session,
+ IClock clock,
+ MasterDataSet masterData,
+ Action onChanged,
Action? createTaskFromEntry = null)
{
ArgumentNullException.ThrowIfNull(session);
@@ -93,6 +45,7 @@ public EtbViewModel(IIncidentSession session, IClock clock, MasterDataSet master
IsReadOnly = session.IsReadOnly;
CallSignOptions = masterData.RadioCallSigns;
Entries = new ObservableCollection();
+
// Any change to the incident — from this tab, another tab, or (when joined) another device —
// brings the journal up to date through the same path.
_session.Changed += Sync;
@@ -124,28 +77,38 @@ public void Sync()
_all.Insert(0, row);
_byId[row.Id] = row;
if (IsVisible(row))
+ {
Entries.Insert(0, row);
+ }
}
foreach (var entry in journal)
{
if (!_byId.TryGetValue(entry.Id, out var current) || current.Edits.Count == entry.Edits.Count)
+ {
continue;
+ }
var updated = ToRow(entry);
_all[_all.IndexOf(current)] = updated;
_byId[entry.Id] = updated;
var entriesIndex = Entries.IndexOf(current);
if (entriesIndex >= 0)
+ {
Entries[entriesIndex] = updated;
+ }
if (EditingEntry?.Id == entry.Id)
+ {
CancelEdit(); // the entry being edited changed underneath us (another device saved first)
+ }
}
}
public bool IsReadOnly { get; }
+
public IReadOnlyList CallSignOptions { get; }
+
public ObservableCollection Entries { get; }
// System-generated lines (Kräfte, Atemschutz, Einsatz-Lebenszyklus) are usually less important
@@ -210,7 +173,6 @@ private void AddEntryAndCreateTask()
}
// --- Edit an existing manual entry: a small panel below the grid, not inline cell editing. ---
-
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsEditing))]
private EtbEntryRow? _editingEntry;
@@ -250,7 +212,6 @@ private void CancelEdit()
// --- View an edited entry's history: available whenever WasEdited, independent of IsReadOnly
// and of the edit panel above -- a closed incident must still let its history be read. ---
-
[ObservableProperty]
private EtbEntryRow? _historyEntry;
@@ -266,3 +227,69 @@ private void ShowHistory(EtbEntryRow row)
private EtbEntryRow ToRow(EtbEntry e) =>
new(e, BeginEdit, CanEdit, ShowHistory);
}
+
+///
+/// One rendered ETB row. Carries its own (rather than the view
+/// reaching back up to via a $parent binding, mirroring
+/// 's reasoning) but is otherwise plain data: an edit happens through
+/// 's edit panel, not a per-keystroke two-way binding on the row itself,
+/// so the row is replaced wholesale (not mutated in place) whenever its entry changes.
+///
+public sealed class EtbEntryRow
+{
+ public EtbEntryRow(
+ EtbEntry entry, Action beginEdit, Func canEdit, Action showHistory)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ Id = entry.Id;
+ Time = Formatting.Timestamp(entry.Timestamp);
+ Direction = Formatting.Direction(entry.Direction);
+ From = entry.From;
+ To = entry.To;
+ Text = entry.Text;
+ EnteredBy = entry.EnteredBy;
+ DirectionValue = entry.Direction;
+ WasEdited = entry.Edits.Count > 0;
+ Edits = entry.Edits;
+ IsEditable = entry.Direction != EtbDirection.System;
+ BeginEditCommand = new RelayCommand(() => beginEdit(this), () => canEdit(this));
+
+ // Deliberately not gated on IsReadOnly/IsEditable like BeginEditCommand: a closed or
+ // remotely-joined-read-only incident must still let its history be read, since that is the
+ // one thing that makes an edit acceptable in the first place.
+ ShowHistoryCommand = new RelayCommand(() => showHistory(this), () => WasEdited);
+ }
+
+ public Guid Id { get; }
+
+ public string Time { get; }
+
+ public string Direction { get; }
+
+ public string? From { get; }
+
+ public string? To { get; }
+
+ public string Text { get; }
+
+ public string EnteredBy { get; }
+
+ public EtbDirection DirectionValue { get; }
+
+ public bool WasEdited { get; }
+
+ public IReadOnlyList Edits { get; }
+
+ public bool IsEditable { get; }
+
+ public ICommand BeginEditCommand { get; }
+
+ public ICommand ShowHistoryCommand { get; }
+}
+
+///
+/// An paired with its German label, so the picker shows the same
+/// wording as the grid and the PDF. Binding the raw enum makes Avalonia fall back to
+/// , which leaks the English identifiers into the UI.
+///
+public sealed record EtbDirectionOption(EtbDirection Value, string Label);
diff --git a/src/LageBuch.AppLogic/ViewModels/FilesViewModel.cs b/src/LageBuch.AppLogic/ViewModels/FilesViewModel.cs
index fcc9249..0339ff6 100644
--- a/src/LageBuch.AppLogic/ViewModels/FilesViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/FilesViewModel.cs
@@ -20,8 +20,15 @@ public sealed partial class IncidentFileRow : ObservableObject
private readonly Action _onRenamed;
public IncidentFileRow(
- Guid id, string fileName, string displayName, string sizeDisplay, string addedAtDisplay,
- string addedBy, bool isImage, bool isReadOnly, Action onRenamed)
+ Guid id,
+ string fileName,
+ string displayName,
+ string sizeDisplay,
+ string addedAtDisplay,
+ string addedBy,
+ bool isImage,
+ bool isReadOnly,
+ Action onRenamed)
{
Id = id;
FileName = fileName;
@@ -35,11 +42,17 @@ public IncidentFileRow(
}
public Guid Id { get; }
+
public string FileName { get; }
+
public string SizeDisplay { get; }
+
public string AddedAtDisplay { get; }
+
public string AddedBy { get; }
+
public bool IsImage { get; }
+
public bool IsReadOnly { get; }
[ObservableProperty]
@@ -77,6 +90,7 @@ public FilesViewModel(IIncidentSession session, IFileDialogService dialogs, Acti
}
public bool IsReadOnly { get; }
+
public ObservableCollection Files { get; }
[ObservableProperty]
@@ -90,20 +104,27 @@ public void Sync()
{
var files = _session.Incident.Files;
for (var i = _rendered; i < files.Count; i++)
+ {
Files.Insert(0, ToRow(files[i]));
+ }
+
_rendered = files.Count;
}
private bool CanAddFile => !IsReadOnly && !IsUploading;
[RelayCommand(CanExecute = nameof(CanAddFile))]
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Domain guards, IO and network failures are heterogeneous; all surface as one error line.")]
private async Task AddFileAsync()
{
var path = await _dialogs.PickAttachmentAsync();
if (string.IsNullOrWhiteSpace(path))
+ {
return;
+ }
ErrorMessage = null;
IsUploading = true;
@@ -135,21 +156,28 @@ private async Task OpenFileAsync(IncidentFileRow row)
ErrorMessage = $"„{row.DisplayName}“ ist nicht verfügbar.";
return;
}
+
var tempPath = Path.Combine(Path.GetTempPath(), row.FileName);
await File.WriteAllBytesAsync(tempPath, bytes);
await _dialogs.OpenFileAsync(tempPath);
}
private IncidentFileRow ToRow(IncidentFile f) => new(
- f.Id, f.FileName, f.DisplayName, FormatSize(f.SizeBytes), Formatting.Timestamp(f.AddedAt), f.AddedBy,
- f.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase), IsReadOnly,
+ f.Id,
+ f.FileName,
+ f.DisplayName,
+ FormatSize(f.SizeBytes),
+ Formatting.Timestamp(f.AddedAt),
+ f.AddedBy,
+ f.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase),
+ IsReadOnly,
displayName => _session.RenameFile(f.Id, displayName));
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:0.#} KB",
- _ => $"{bytes / (1024.0 * 1024.0):0.#} MB"
+ _ => $"{bytes / (1024.0 * 1024.0):0.#} MB",
};
// Mirrors IncidentFile.AllowedContentTypes' extensions — the picker already restricts choice to
@@ -161,6 +189,6 @@ private async Task OpenFileAsync(IncidentFileRow row)
".GIF" => "image/gif",
".WEBP" => "image/webp",
".PDF" => "application/pdf",
- _ => "application/octet-stream"
+ _ => "application/octet-stream",
};
}
diff --git a/src/LageBuch.AppLogic/ViewModels/ForcesViewModel.cs b/src/LageBuch.AppLogic/ViewModels/ForcesViewModel.cs
index 7ddf4fc..a4886ae 100644
--- a/src/LageBuch.AppLogic/ViewModels/ForcesViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ForcesViewModel.cs
@@ -29,8 +29,11 @@ public sealed partial class ForceRow : ObservableObject
private readonly Action _onRemoved;
public ForceRow(
- Domain.ForceUnit unit, IReadOnlyList statusOptions, bool isReadOnly,
- Action onEdited, Action onStrengthEdited,
+ Domain.ForceUnit unit,
+ IReadOnlyList statusOptions,
+ bool isReadOnly,
+ Action onEdited,
+ Action onStrengthEdited,
Action onRemoved)
{
ArgumentNullException.ThrowIfNull(unit);
@@ -51,8 +54,11 @@ public ForceRow(
}
public Guid Id { get; }
+
public string Brigade { get; }
+
public string? CallSign { get; }
+
public bool IsReadOnly { get; }
/// Wert-Historie der Stärke (#76), für die Verlauf-Anzeige.
@@ -111,7 +117,10 @@ public ForceRow(
private void PushStatusNotes()
{
if (IsReadOnly)
+ {
return;
+ }
+
_onEdited(Status, Notes);
}
@@ -120,7 +129,10 @@ private void PushStatusNotes()
public void CommitStrength()
{
if (IsReadOnly)
+ {
return;
+ }
+
_onStrengthEdited(OfficerCount ?? 0, MannschaftCount ?? 0, ScbaCount ?? 0);
}
@@ -135,7 +147,10 @@ public void CommitStrength()
private void Remove()
{
if (!CanRemove)
+ {
return;
+ }
+
_onRemoved();
}
@@ -191,7 +206,10 @@ private void RefreshForces()
{
Forces.Clear();
foreach (var f in _session.Incident.Forces)
+ {
Forces.Add(ToRow(f));
+ }
+
TotalPersonnel = _session.Incident.TotalPersonnel;
TotalOfficer = _session.Incident.TotalOfficer;
TotalScba = _session.Incident.TotalScba;
@@ -203,7 +221,9 @@ private void RefreshForces()
}
public bool IsReadOnly { get; }
+
public IReadOnlyList BrigadeOptions { get; }
+
public IReadOnlyList CallSignOptions { get; }
///
@@ -305,6 +325,7 @@ partial void OnSelectedVehicleChanged(Vehicle? value)
if (value is null)
return; // also fires when the form resets -- must not re-prefill then
NewCallSign = value.CallSign;
+
// Sitzplätze-Vorbelegung: 9 Sitze ergeben 1 Führungskraft + 8 Mannschaft (#76).
NewOfficerCount = Math.Min(1, value.Seats);
NewMannschaftCount = Math.Max(value.Seats - 1, 0);
@@ -313,10 +334,13 @@ partial void OnSelectedVehicleChanged(Vehicle? value)
private bool CanAddForce =>
!IsReadOnly && !string.IsNullOrWhiteSpace(NewBrigade)
+
// Lifted comparisons: null >= 0 is false, so every operand coalesces first.
&& (NewOfficerCount ?? 0) >= 0 && (NewMannschaftCount ?? 0) >= 0 && (NewScbaCount ?? 0) >= 0
+
// Mirrors the domain rule, so an over-count disables the button instead of throwing on click.
&& (NewScbaCount ?? 0) <= (NewOfficerCount ?? 0) + (NewMannschaftCount ?? 0)
+
// Ein Fahrzeug ist einzig — sein Funkrufname darf nicht schon in der Liste stehen.
&& !IsDuplicateCallSign;
@@ -324,8 +348,13 @@ partial void OnSelectedVehicleChanged(Vehicle? value)
private void AddForce()
{
_session.AddForceUnit(
- NewBrigade, (NewOfficerCount ?? 0) + (NewMannschaftCount ?? 0), NewCallSign, NewStatus, NewNotes,
- NewScbaCount ?? 0, NewOfficerCount ?? 0); // Changed → RefreshForces
+ NewBrigade,
+ (NewOfficerCount ?? 0) + (NewMannschaftCount ?? 0),
+ NewCallSign,
+ NewStatus,
+ NewNotes,
+ NewScbaCount ?? 0,
+ NewOfficerCount ?? 0); // Changed → RefreshForces
NewBrigade = string.Empty;
NewCallSign = null;
NewOfficerCount = null;
@@ -337,7 +366,10 @@ private void AddForce()
}
private ForceRow ToRow(Domain.ForceUnit f) =>
- new(f, StatusOptions, IsReadOnly,
+ new(
+ f,
+ StatusOptions,
+ IsReadOnly,
(status, notes) =>
{
_session.UpdateForceUnit(f.Id, status, notes);
diff --git a/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs b/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
index 9f6e57b..8d6ee13 100644
--- a/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
@@ -23,14 +23,17 @@ public sealed partial class HomeViewModel : ObservableObject
private readonly IAlarmService _alarm;
private readonly IIncidentHostController _hostController;
private readonly string _appVersion;
+
// Marshals a joined client's host broadcasts onto the UI thread (see IUiDispatcher). Production
// wires the real dispatcher via CompositionRoot; the immediate default keeps the many non-join
// HomeViewModel tests (which never open a RemoteIncidentSession) construction-noise free.
private readonly IUiDispatcher _uiDispatcher;
+
// Where the last new-incident save landed, so the next one opens the picker there instead of
// wherever the OS last remembered. Null when not supplied (e.g. most tests) -- every use site
// is null-guarded, so the feature is simply inert rather than required.
private readonly ILastSaveFolderStore? _lastSaveFolder;
+
// Where a joined client caches pulled attachment bytes (see RemoteIncidentSession.GetFileBytesAsync).
// Null (most tests) just means "no caching" -- correct, only not free -- not an error.
private readonly string? _attachmentCacheRoot;
@@ -59,6 +62,7 @@ public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRece
// Passive peek: never migrates or mutates the file. A moved, corrupt, or too-new file just
// shows no marker (TryReadState returns null) rather than blocking the overview.
private bool IsClosed(string path) => _store.TryReadState(path) == IncidentState.Closed;
+
public Action? WorkspaceOpened { get; set; }
/// Radio call signs offered as dropdown suggestions in the new-incident operator prompt.
@@ -90,16 +94,26 @@ private async Task NewIncidentAsync(NewIncidentRequest request)
var suggestedName = $"{stem}.fwincident";
var path = await _dialogs.PickSaveAsync(suggestedName, _lastSaveFolder?.GetLastFolder());
if (string.IsNullOrWhiteSpace(path))
+ {
return;
+ }
+
// Remember where this landed so the next new incident's picker opens there too.
if (Path.GetDirectoryName(path) is { Length: > 0 } dir)
+ {
_lastSaveFolder?.SetLastFolder(dir);
+ }
+
var md = _masterData.Get();
var session = LocalIncidentSession.StartNew(
- _store, _clock, request.Operator, path,
+ _store,
+ _clock,
+ request.Operator,
+ path,
md.ChecklistTemplateAufbau.Select(i => (i.Text, i.IsMandatory)),
md.ChecklistTemplateAbbau.Select(i => (i.Text, i.IsMandatory)),
- incidentNumber: null, keyword: request.Keyword);
+ incidentNumber: null,
+ keyword: request.Keyword);
OpenWorkspace(session, path, md);
}
@@ -122,7 +136,10 @@ private async Task OpenFileAsync()
{
var path = await _dialogs.PickOpenAsync();
if (string.IsNullOrWhiteSpace(path))
+ {
return;
+ }
+
TryOpen(path);
}
@@ -135,7 +152,9 @@ private async Task OpenFileAsync()
/// user which file and why, and leave the app standing. Letting any of them escape kills the
/// process, which during an Einsatz is the worst possible outcome.
///
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Deliberately broad: heterogeneous open failures all get the same user-facing answer.")]
private void TryOpen(string path)
{
@@ -156,7 +175,10 @@ private void OpenWorkspace(LocalIncidentSession session, string path, Persistenc
_recent.Add(path);
var existing = RecentFiles.FirstOrDefault(f => f.Path == path);
if (existing is not null)
+ {
RecentFiles.Remove(existing);
+ }
+
InsertSortedByFileNameDescending(new RecentFileItem(path, session.Incident.State == IncidentState.Closed));
var workspace = new IncidentWorkspaceViewModel(session, _clock, _ticker, md, _dialogs, _alarm, _hostController);
WorkspaceOpened?.Invoke(workspace);
@@ -177,7 +199,6 @@ private void InsertSortedByFileNameDescending(RecentFileItem item)
}
// ===== Multi-device join (#52 §4/§6): connect to another device's hosted incident as a thin client. =====
-
[RelayCommand]
private async Task JoinDeviceAsync(JoinRequest request)
{
@@ -215,7 +236,10 @@ private static (string Host, int Port) ParseHost(string address)
var trimmed = address.Trim();
var colon = trimmed.LastIndexOf(':');
if (colon > 0 && int.TryParse(trimmed[(colon + 1)..], out var port))
+ {
return (trimmed[..colon], port);
+ }
+
return (trimmed, SyncProtocol.Port);
}
diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
index 295f51e..67885b8 100644
--- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
@@ -14,6 +14,7 @@ namespace LageBuch.AppLogic.ViewModels;
public sealed partial class IncidentWorkspaceViewModel : ObservableObject
{
private readonly IIncidentSession _session;
+
// The concrete local session, or null on a joined client. Guards the two capabilities that only
// exist on the device that owns the .fwincident file: PDF export and resuming a read-only file.
private readonly LocalIncidentSession? _local;
@@ -36,8 +37,10 @@ public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicke
_alarm = alarm;
_hostController = hostController;
IsReadOnly = session.IsReadOnly;
+
// Seed the backing field directly so initialization doesn't trigger a write-back/save.
_incidentNumberInput = _session.Incident.IncidentNumber?.Value ?? string.Empty;
+
// The Stichwort is creation-time-only (unlike the Einsatznummer above, it has no write-back
// path), so a plain property seeded once is enough -- no ObservableProperty needed.
KeywordDisplay = _session.Incident.Keyword;
@@ -137,6 +140,7 @@ private void ConfirmIncidentNumber()
{
var number = new IncidentNumber(IncidentNumberEditInput.Trim());
_session.SetIncidentNumber(number);
+
// A local session applies this immediately in-process; a remote/joined session only
// reflects it once the host's broadcast round-trips (OnRemoteLifecycle) -- updating here
// too is harmless, it just gets overwritten with the same value shortly after.
@@ -148,15 +152,25 @@ private void ConfirmIncidentNumber()
private void CancelEditIncidentNumber() => IsEditingIncidentNumber = false;
public CoMessprotokollViewModel CoMessprotokoll { get; private set; } = null!;
+
public ChecklistViewModel ChecklistAufbau { get; private set; } = null!;
+
public ChecklistViewModel ChecklistAbbau { get; private set; } = null!;
+
public EtbViewModel Etb { get; private set; } = null!;
+
public RolesViewModel Roles { get; private set; } = null!;
+
public ForcesViewModel Forces { get; private set; } = null!;
+
public ScbaViewModel Scba { get; private set; } = null!;
+
public FilesViewModel Files { get; private set; } = null!;
+
public LinksViewModel Links { get; private set; } = null!;
+
public TasksViewModel Tasks { get; private set; } = null!;
+
public ReminderViewModel? Reminder { get; private set; }
public string StatusDisplay => Formatting.State(_session.Incident.State);
@@ -169,7 +183,6 @@ private void ConfirmIncidentNumber()
// ===== Joined-client connection state (#52 §7). Always "connected" locally; on a remote session
// it tracks the SignalR link so the view can grey out input while reconnecting. =====
-
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsInputEnabled))]
private bool _isConnected = true;
@@ -190,7 +203,9 @@ private void ConfirmIncidentNumber()
public async ValueTask LeaveAsync()
{
if (_session is IAsyncDisposable disposable)
+ {
await disposable.DisposeAsync();
+ }
}
// A host broadcast can change lifecycle state under a joined client (e.g. the host closes the
@@ -219,7 +234,11 @@ private void BuildChildren()
{
ChecklistAufbau = new ChecklistViewModel(_session, ChecklistKind.Aufbau, OnChanged);
ChecklistAbbau = new ChecklistViewModel(_session, ChecklistKind.Abbau, OnChanged);
- Etb = new EtbViewModel(_session, _clock, _masterData, OnChanged,
+ Etb = new EtbViewModel(
+ _session,
+ _clock,
+ _masterData,
+ OnChanged,
text => OpenTaskDialog(text));
Roles = new RolesViewModel(_session, _clock, _masterData, OnChanged);
Forces = new ForcesViewModel(_session, _clock, _masterData, OnChanged);
@@ -236,11 +255,17 @@ private void BuildChildren()
Tasks = new TasksViewModel(_session, _clock, _ticker, _alarm, _masterData, OnChanged);
Reminder?.Dispose();
+
// The ILS reminder is autonomous, time-driven host-side logging (§ IsRemote) — a joined
// client must not run its own, or the host's journal would be double-logged.
Reminder = _session.IsReadOnly || _session.IsRemote
? null
- : new ReminderViewModel(_session, _clock, _ticker, _alarm, OnChanged,
+ : new ReminderViewModel(
+ _session,
+ _clock,
+ _ticker,
+ _alarm,
+ OnChanged,
_masterData.Settings.IlsReminderIntervalMinutes,
_masterData.Settings.IlsReminderFollowUpIntervalMinutes);
@@ -272,6 +297,7 @@ private void CloseIncident()
: "Der Einsatz wird unwiderruflich abgeschlossen und schreibgeschützt. Fortfahren?";
var dialog = new ConfirmDialogViewModel(
"Einsatz abschließen?", message, "ABSCHLIESSEN", PerformClose);
+
// Clear the overlay on either outcome; PerformClose has already run on confirm.
dialog.Closed += (_, _) => PendingConfirm = null;
PendingConfirm = dialog;
@@ -281,6 +307,7 @@ private void CloseIncident()
private void OpenTaskDialog(string text)
{
var dialog = new TaskDialogViewModel(_session, _masterData, text, OnChanged);
+
// Clear the overlay on either outcome; Save has already added the task on confirm.
dialog.Closed += (_, _) => PendingTaskDialog = null;
PendingTaskDialog = dialog;
@@ -309,7 +336,10 @@ public void ConfirmContinueEditing()
var op = PendingPrompt?.Result;
PendingPrompt = null;
if (op is null)
+ {
return;
+ }
+
_local!.ContinueEditing(op); // CanContinueEditing guarantees _local is not null
IsReadOnly = false; // notifies CanContinueEditing + both commands
LastSavedAt = _clock.Now;
@@ -328,7 +358,10 @@ private async Task ExportPdfAsync()
var suggested = (_session.Incident.IncidentNumber?.Value ?? "Einsatz") + ".pdf";
var path = await _dialogs.PickExportPdfAsync(suggested);
if (string.IsNullOrWhiteSpace(path))
+ {
return;
+ }
+
await File.WriteAllBytesAsync(path, await _local!.ExportPdfAsync());
await _dialogs.ShareFileAsync(path, "application/pdf");
}
@@ -354,14 +387,19 @@ private async Task ExportPdfAsync()
public string ShareButtonText => IsSharing ? "FREIGABE BEENDEN" : "IM NETZWERK FREIGEBEN";
[RelayCommand]
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Host start can fail in several ways (port in use etc.); surfaces in the status line.")]
private async Task ToggleSharing()
{
// Hosting exposes the local .fwincident, so it needs the concrete local session. The toggle is
// only shown when CanHost (a hostable platform with a local session), so _local is non-null here.
if (_local is null)
+ {
return;
+ }
+
if (IsSharing)
{
await _hostController.StopAsync();
@@ -370,6 +408,7 @@ private async Task ToggleSharing()
SharePin = null;
return;
}
+
try
{
// Binds every interface (loopback + LAN + tailnet). Can still fail — most likely the
@@ -382,6 +421,7 @@ private async Task ToggleSharing()
ShareStatus = $"Freigabe fehlgeschlagen: {ex.Message}";
return;
}
+
IsSharing = true;
ShareStatus = _hostController.ShareHint;
SharePin = _hostController.SharePin;
diff --git a/src/LageBuch.AppLogic/ViewModels/LinkRow.cs b/src/LageBuch.AppLogic/ViewModels/LinkRow.cs
index f5bd766..258e0cc 100644
--- a/src/LageBuch.AppLogic/ViewModels/LinkRow.cs
+++ b/src/LageBuch.AppLogic/ViewModels/LinkRow.cs
@@ -1,5 +1,5 @@
-using CommunityToolkit.Mvvm.ComponentModel;
using System.Diagnostics.CodeAnalysis;
+using CommunityToolkit.Mvvm.ComponentModel;
namespace LageBuch.AppLogic.ViewModels;
@@ -16,9 +16,12 @@ public LinkRow(string name, string url, Action onChanged)
_url = url;
}
- [ObservableProperty] private string _name;
- [ObservableProperty] private string _url;
+ [ObservableProperty]
+ private string _name;
+ [ObservableProperty]
+ private string _url;
partial void OnNameChanged(string value) => _onChanged();
+
partial void OnUrlChanged(string value) => _onChanged();
}
diff --git a/src/LageBuch.AppLogic/ViewModels/LinksSection.cs b/src/LageBuch.AppLogic/ViewModels/LinksSection.cs
index e4b6c1d..6949a01 100644
--- a/src/LageBuch.AppLogic/ViewModels/LinksSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/LinksSection.cs
@@ -12,7 +12,8 @@ public sealed partial class LinksSection : EditorSection
{
private readonly Action _onChanged;
- public LinksSection(string title, IEnumerable links, Action onChanged) : base(title)
+ public LinksSection(string title, IEnumerable links, Action onChanged)
+ : base(title)
{
_onChanged = onChanged;
Rows = new ObservableCollection(
@@ -31,21 +32,32 @@ private void Add()
[RelayCommand]
private void Remove(LinkRow row)
{
- if (Rows.Remove(row)) _onChanged();
+ if (Rows.Remove(row))
+ {
+ _onChanged();
+ }
}
[RelayCommand]
private void MoveUp(LinkRow row)
{
var i = Rows.IndexOf(row);
- if (i > 0) { Rows.Move(i, i - 1); _onChanged(); }
+ if (i > 0)
+ {
+ Rows.Move(i, i - 1);
+ _onChanged();
+ }
}
[RelayCommand]
private void MoveDown(LinkRow row)
{
var i = Rows.IndexOf(row);
- if (i >= 0 && i < Rows.Count - 1) { Rows.Move(i, i + 1); _onChanged(); }
+ if (i >= 0 && i < Rows.Count - 1)
+ {
+ Rows.Move(i, i + 1);
+ _onChanged();
+ }
}
/// Rows with both a non-blank name and URL, trimmed, in current order.
@@ -56,8 +68,12 @@ public IReadOnlyList ToValues()
{
var name = row.Name?.Trim() ?? string.Empty;
var url = row.Url?.Trim() ?? string.Empty;
- if (name.Length > 0 && url.Length > 0) result.Add(new Link(name, url));
+ if (name.Length > 0 && url.Length > 0)
+ {
+ result.Add(new Link(name, url));
+ }
}
+
return result;
}
}
diff --git a/src/LageBuch.AppLogic/ViewModels/LinksViewModel.cs b/src/LageBuch.AppLogic/ViewModels/LinksViewModel.cs
index 4759695..6e742d5 100644
--- a/src/LageBuch.AppLogic/ViewModels/LinksViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/LinksViewModel.cs
@@ -34,7 +34,9 @@ public LinksViewModel(IReadOnlyList links, IFileDialogService dialogs)
/// just what the user themselves typed here.
///
[RelayCommand]
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Deliberately broad: any launcher failure surfaces in the view instead of crashing it.")]
private async Task OpenAsync(Link link)
{
diff --git a/src/LageBuch.AppLogic/ViewModels/MainWindowViewModel.cs b/src/LageBuch.AppLogic/ViewModels/MainWindowViewModel.cs
index 2e95471..da27b74 100644
--- a/src/LageBuch.AppLogic/ViewModels/MainWindowViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/MainWindowViewModel.cs
@@ -6,7 +6,12 @@ namespace LageBuch.AppLogic.ViewModels;
public sealed partial class MainWindowViewModel : ObservableObject
{
- private enum PendingAction { None, New, Join }
+ private enum PendingAction
+ {
+ None,
+ New,
+ Join,
+ }
private readonly HomeViewModel _home;
private readonly MasterDataEditorViewModel _editor;
@@ -53,11 +58,16 @@ private void NavigateAway(Action proceed)
if (ReferenceEquals(CurrentView, _editor))
{
if (_editor.PendingConfirm is not null)
+ {
return; // a discard prompt is already up — don't stack a second one
+ }
+
_editor.ConfirmDiscardThen(proceed);
}
else
+ {
proceed();
+ }
}
[RelayCommand]
@@ -95,12 +105,19 @@ private void ConfirmOperator()
var action = _pending;
PendingPrompt = null;
_pending = PendingAction.None;
- if (op is null) return;
+ if (op is null)
+ {
+ return;
+ }
if (action == PendingAction.New)
+ {
_home.NewIncidentCommand.Execute(new NewIncidentRequest(op, prompt!.Keyword));
+ }
else if (action == PendingAction.Join)
+ {
_home.JoinDeviceCommand.Execute(new JoinRequest(op, prompt!.Host, prompt.Pin));
+ }
}
[RelayCommand]
diff --git a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
index 4cc0912..93dbb26 100644
--- a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
@@ -22,9 +22,34 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private bool _originalIsEmpty = true;
// Typed handles kept so BuildSet reads each section without fragile positional casts.
- private EditableListSection _roles = null!, _status = null!, _unitStatus = null!, _equipment = null!,
- _districts = null!, _brigades = null!, _callSigns = null!, _truppTypes = null!, _einsatzarten = null!;
- private ChecklistTemplateSection _checklistAufbau = null!, _checklistAbbau = null!;
+ private EditableListSection _roles = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _status = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _unitStatus = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _equipment = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _districts = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _brigades = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _callSigns = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _truppTypes = null!;
+
+ // Typed handles kept so BuildSet reads each section without fragile positional casts.
+ private EditableListSection _einsatzarten = null!;
+
+ private ChecklistTemplateSection _checklistAufbau = null!;
+ private ChecklistTemplateSection _checklistAbbau = null!;
private LinksSection _links = null!;
private PersonnelSection _personnel = null!;
private VehiclesSection _vehicles = null!;
@@ -141,6 +166,7 @@ private MasterDataSet BuildSet() => _original with
Personnel = _personnel.ToPeople(),
Vehicles = _vehicles.ToValues(),
Settings = _settings.ToSettings(),
+
// Streets are not editable here; _original carries them through unchanged.
};
@@ -167,13 +193,18 @@ private void Save()
/// Offered only while the data is empty, so there is nothing to overwrite.
///
[RelayCommand(CanExecute = nameof(CanImport))]
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Imports read arbitrary user-chosen files; any parse/IO failure is shown as an error.")]
private async Task Import()
{
FileError = null;
var path = await _dialogs.PickImportJsonAsync();
- if (string.IsNullOrWhiteSpace(path)) return;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
MasterDataSet imported;
try
@@ -193,13 +224,18 @@ private async Task Import()
/// Writes the current editor contents (including unsaved edits and carried-through streets) to a JSON file.
[RelayCommand]
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "IO and share failures are shown as an error, never a crash.")]
private async Task Export()
{
FileError = null;
var path = await _dialogs.PickExportJsonAsync("stammdaten.json");
- if (string.IsNullOrWhiteSpace(path)) return;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
try
{
@@ -229,7 +265,11 @@ public void ConfirmDiscardThen(Action proceed)
"Änderungen verwerfen?",
"Die Stammdaten wurden geändert. Beim Verlassen gehen die nicht gespeicherten Änderungen verloren.",
"VERWERFEN",
- () => { Load(); proceed(); });
+ () =>
+ {
+ Load();
+ proceed();
+ });
dialog.Closed += (_, _) => PendingConfirm = null;
PendingConfirm = dialog;
}
diff --git a/src/LageBuch.AppLogic/ViewModels/PersonRow.cs b/src/LageBuch.AppLogic/ViewModels/PersonRow.cs
index eb55ced..a97641f 100644
--- a/src/LageBuch.AppLogic/ViewModels/PersonRow.cs
+++ b/src/LageBuch.AppLogic/ViewModels/PersonRow.cs
@@ -17,15 +17,24 @@ public PersonRow(string lastName, string firstName, string? role, string? callSi
_phone = phone;
}
- [ObservableProperty] private string _lastName;
- [ObservableProperty] private string _firstName;
- [ObservableProperty] private string? _role;
- [ObservableProperty] private string? _callSign;
- [ObservableProperty] private string? _phone;
+ [ObservableProperty]
+ private string _lastName;
+ [ObservableProperty]
+ private string _firstName;
+ [ObservableProperty]
+ private string? _role;
+ [ObservableProperty]
+ private string? _callSign;
+ [ObservableProperty]
+ private string? _phone;
partial void OnLastNameChanged(string value) => _onChanged();
+
partial void OnFirstNameChanged(string value) => _onChanged();
+
partial void OnRoleChanged(string? value) => _onChanged();
+
partial void OnCallSignChanged(string? value) => _onChanged();
+
partial void OnPhoneChanged(string? value) => _onChanged();
}
diff --git a/src/LageBuch.AppLogic/ViewModels/PersonnelSection.cs b/src/LageBuch.AppLogic/ViewModels/PersonnelSection.cs
index 8854d7c..e17f1d0 100644
--- a/src/LageBuch.AppLogic/ViewModels/PersonnelSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/PersonnelSection.cs
@@ -9,7 +9,8 @@ public sealed partial class PersonnelSection : EditorSection
{
private readonly Action _onChanged;
- public PersonnelSection(string title, IEnumerable people, Action onChanged) : base(title)
+ public PersonnelSection(string title, IEnumerable people, Action onChanged)
+ : base(title)
{
_onChanged = onChanged;
Rows = new ObservableCollection(
@@ -28,7 +29,10 @@ private void Add()
[RelayCommand]
private void Remove(PersonRow row)
{
- if (Rows.Remove(row)) _onChanged();
+ if (Rows.Remove(row))
+ {
+ _onChanged();
+ }
}
/// Rows with a non-blank last name; trimmed, with blank optionals collapsed to null.
@@ -38,9 +42,14 @@ public IReadOnlyList ToPeople()
foreach (var r in Rows)
{
var last = r.LastName?.Trim() ?? string.Empty;
- if (last.Length == 0) continue;
+ if (last.Length == 0)
+ {
+ continue;
+ }
+
result.Add(new Person(last, r.FirstName?.Trim() ?? string.Empty, Nz(r.Role), Nz(r.CallSign), Nz(r.Phone)));
}
+
return result;
static string? Nz(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
diff --git a/src/LageBuch.AppLogic/ViewModels/ReminderViewModel.cs b/src/LageBuch.AppLogic/ViewModels/ReminderViewModel.cs
index 089d952..0c24453 100644
--- a/src/LageBuch.AppLogic/ViewModels/ReminderViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ReminderViewModel.cs
@@ -17,6 +17,10 @@ namespace LageBuch.AppLogic.ViewModels;
///
public sealed partial class ReminderViewModel : ObservableObject, IDisposable
{
+ // The key under which this reminder's state is persisted on the incident, so it survives a
+ // close+reopen or a crash instead of restarting a fresh cycle.
+ private const string TimerKey = "ils-reminder";
+
private readonly IIncidentSession _session;
private readonly IClock _clock;
private readonly IAlarmService _alarm;
@@ -24,10 +28,6 @@ public sealed partial class ReminderViewModel : ObservableObject, IDisposable
private readonly ReminderTimer _timer = new();
private readonly IDisposable _subscription;
- // The key under which this reminder's state is persisted on the incident, so it survives a
- // close+reopen or a crash instead of restarting a fresh cycle.
- private const string TimerKey = "ils-reminder";
-
// The spoken cue repeats while a cycle sits unacknowledged, so it stays insistent instead of
// being said once and forgotten; this tracks when it last played so OnTick only re-plays once
// RepeatInterval has passed, and Acknowledge clears it to announce immediately on the next cycle.
@@ -35,8 +35,13 @@ public sealed partial class ReminderViewModel : ObservableObject, IDisposable
private DateTimeOffset? _lastAnnouncedAt;
public ReminderViewModel(
- IIncidentSession session, IClock clock, ITicker ticker, IAlarmService alarm, Action onChanged,
- int firstIntervalMinutes, int recurringIntervalMinutes)
+ IIncidentSession session,
+ IClock clock,
+ ITicker ticker,
+ IAlarmService alarm,
+ Action onChanged,
+ int firstIntervalMinutes,
+ int recurringIntervalMinutes)
{
ArgumentNullException.ThrowIfNull(ticker);
_session = session;
@@ -65,6 +70,7 @@ private void PersistTimer() =>
_session.UpsertTimer(TimerKey, _timer.CycleAnchor, _timer.IntervalMinutes, _timer.RecurringIntervalMinutes, _timer.IsRunning);
public bool IsRunning => _timer.IsRunning;
+
public bool IsDue => _timer.IsDue(_clock.Now);
public string RemainingDisplay
@@ -72,7 +78,10 @@ public string RemainingDisplay
get
{
if (_timer.IsDue(_clock.Now))
+ {
return "fällig";
+ }
+
var remaining = _timer.Remaining(_clock.Now);
return $"{(int)remaining.TotalMinutes:00}:{remaining.Seconds:00}";
}
@@ -102,6 +111,7 @@ private void Acknowledge()
_timer.Acknowledge(_clock);
_lastAnnouncedAt = null;
PersistTimer(); // durable anchor for the new (recurring) cycle
+
// "Von" is us — the logged-in operator's call sign (e.g. the ELW's Funkrufname).
_session.AddJournalEntry(
EtbDirection.Outgoing, "Rückmeldung an ILS", from: _session.Operator?.CallSign, to: "ILS");
diff --git a/src/LageBuch.AppLogic/ViewModels/RolesViewModel.cs b/src/LageBuch.AppLogic/ViewModels/RolesViewModel.cs
index 9a8917f..314f404 100644
--- a/src/LageBuch.AppLogic/ViewModels/RolesViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/RolesViewModel.cs
@@ -20,9 +20,18 @@ public sealed partial class RoleAssignmentRow : ObservableObject
private readonly Action _onTransfer;
private readonly Action _onPhoneEdited;
- public RoleAssignmentRow(Guid id, string role, string personName, string? section,
- string? callSign, string? phone, DateTimeOffset? from, DateTimeOffset? to,
- bool isReadOnly, Action onTransfer, Action onPhoneEdited)
+ public RoleAssignmentRow(
+ Guid id,
+ string role,
+ string personName,
+ string? section,
+ string? callSign,
+ string? phone,
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ bool isReadOnly,
+ Action onTransfer,
+ Action onPhoneEdited)
{
Id = id;
Role = role;
@@ -38,11 +47,17 @@ public RoleAssignmentRow(Guid id, string role, string personName, string? sectio
}
public Guid Id { get; }
+
public string Role { get; }
+
public string PersonName { get; }
+
public string? Section { get; }
+
public string? CallSign { get; }
+
public DateTimeOffset? From { get; }
+
public bool IsReadOnly { get; }
[ObservableProperty]
@@ -64,6 +79,7 @@ partial void OnPhoneChanged(string? value)
private DateTimeOffset? _to;
public string FromDisplay => From is { } f ? Formatting.Timestamp(f) : "—";
+
public string ToDisplay => To is { } t ? Formatting.Timestamp(t) : "—";
/// True while the assignment is still active, i.e. has no Bis stamp yet.
@@ -116,12 +132,18 @@ private void ApplyFilter()
{
Roles.Clear();
foreach (var row in _all)
+ {
if (ShowAllRoles || row.IsRunning)
+ {
Roles.Add(row);
+ }
+ }
}
public bool IsReadOnly { get; }
+
public IReadOnlyList RoleOptions { get; }
+
public IReadOnlyList CallSignOptions { get; }
///
@@ -168,8 +190,13 @@ private void AddRole()
// Von is stamped rather than typed: an assignment is recorded at the moment it happens,
// and every other time in this application comes from the injected clock the same way.
_session.AssignRole(
- NewRole, NewPersonName, NewCallSign, from: _clock.Now, to: null,
- section: NewSection, phone: NewPhone); // Changed → RefreshRoles renders the row
+ NewRole,
+ NewPersonName,
+ NewCallSign,
+ from: _clock.Now,
+ to: null,
+ section: NewSection,
+ phone: NewPhone); // Changed → RefreshRoles renders the row
NewRole = string.Empty;
NewPersonName = string.Empty;
NewSection = null;
@@ -182,7 +209,6 @@ private void AddRole()
// rather than inline DataGrid cell editing — a handover needs its own person/call
// sign/phone, not a single cell. Replaces the old standalone "beenden" action; an
// assignment now only ends as part of a handover, or automatically when the incident closes. ---
-
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTransferring))]
[NotifyCanExecuteChangedFor(nameof(ConfirmTransferCommand))]
@@ -230,17 +256,28 @@ private void ConfirmTransfer()
/// outranks the roster, which may be out of date.
///
private void PrefillFromRoster(
- string personName, Func getPhone, Action setPhone,
- Func getCallSign, Action setCallSign)
+ string personName,
+ Func getPhone,
+ Action setPhone,
+ Func getCallSign,
+ Action setCallSign)
{
var person = _personnel.FirstOrDefault(
p => string.Equals(p.DisplayName, personName, StringComparison.OrdinalIgnoreCase));
if (person is null)
+ {
return;
+ }
+
if (string.IsNullOrWhiteSpace(getPhone()))
+ {
setPhone(person.Phone);
+ }
+
if (string.IsNullOrWhiteSpace(getCallSign()))
+ {
setCallSign(person.CallSign);
+ }
}
private RoleAssignmentRow CreateRow(Domain.RoleAssignment r) =>
diff --git a/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs b/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs
index e536636..aeab73a 100644
--- a/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs
@@ -30,8 +30,13 @@ public sealed partial class ScbaTruppRow : ObservableObject
private readonly Action _onMarkRemoved;
public ScbaTruppRow(
- AtemschutzTrupp trupp, IClock clock, bool isReadOnly,
- Action onStart, Action onRecordPressure, Action onWithdraw, Action onMarkRemoved)
+ AtemschutzTrupp trupp,
+ IClock clock,
+ bool isReadOnly,
+ Action onStart,
+ Action onRecordPressure,
+ Action onWithdraw,
+ Action onMarkRemoved)
{
ArgumentNullException.ThrowIfNull(trupp);
_trupp = trupp;
@@ -45,9 +50,13 @@ public ScbaTruppRow(
}
public Guid Id => _trupp.Id;
+
public int TruppNumber => _trupp.TruppNumber;
+
public string Designation => _trupp.Designation;
+
public string DisplayName => _trupp.DisplayName;
+
public string Members => _trupp.MembersDisplay;
///
@@ -65,13 +74,19 @@ public ScbaTruppRow(
public string? CallSign => _trupp.CallSign;
public bool IsWaiting => _trupp.IsWaiting;
+
public bool IsActive => _trupp.IsActive;
+
public bool IsWithdrawing => _trupp.IsWithdrawing;
+
public bool IsReturned => _trupp.IsReturned;
+
public bool IsAlarm => _trupp.IsAlarm(_clock.Now);
+
public bool IsControlDue => _trupp.IsControlDue(_clock.Now);
public string StartTimeDisplay => _trupp.StartTime is { } s ? s.ToString("HH:mm", CultureInfo.InvariantCulture) : "—";
+
public string? PressureDisplay => _trupp.LatestPressure is { } p ? $"{p} bar" : null;
public string ElapsedDisplay => _trupp.HasStarted ? Clock(_trupp.Elapsed(_clock.Now)) : "—";
@@ -81,7 +96,10 @@ public string RemainingDisplay
get
{
if (!(_trupp.IsActive || _trupp.IsWithdrawing))
+ {
return "—";
+ }
+
var remaining = _trupp.Remaining(_clock.Now);
return remaining <= TimeSpan.Zero ? "überzogen" : Clock(remaining);
}
@@ -92,7 +110,10 @@ public string ControlRemainingDisplay
get
{
if (!(_trupp.IsActive || _trupp.IsWithdrawing))
+ {
return "—";
+ }
+
var remaining = _trupp.ControlRemaining(_clock.Now);
return remaining <= TimeSpan.Zero ? "fällig" : Clock(remaining);
}
@@ -105,7 +126,7 @@ public string ControlRemainingDisplay
_ when IsAlarm => "ALARM",
{ IsWithdrawing: true } => "Rückzug",
_ when IsControlDue => "Druckabfrage",
- _ => "Im Einsatz"
+ _ => "Im Einsatz",
};
[ObservableProperty]
@@ -174,13 +195,13 @@ public sealed partial class ScbaViewModel : ObservableObject, IDisposable
// (so the next cycle announces immediately) and by a newly-tripped alarm (so a second Trupp
// alarming after an ack is heard right away rather than waiting out the window).
private static readonly TimeSpan RetreatRepeatInterval = TimeSpan.FromSeconds(15);
- private DateTimeOffset? _lastAlarmAnnouncedAt;
-
- private readonly IncidentSettings _settings;
// True once the user has hand-edited the Einsatzzeit; after that a Trupp-type switch must not
// overwrite it. Programmatic sets (default application, form reset) are fenced by _applyingDefault
// so they do not count as a user edit.
+ private readonly IncidentSettings _settings;
+ private DateTimeOffset? _lastAlarmAnnouncedAt;
+
private bool _maxDurationUserEdited;
// Same idea for the Abfrage-Intervall, which otherwise defaults to a third of the Einsatzzeit
@@ -203,6 +224,7 @@ public ScbaViewModel(IIncidentSession session, MasterDataSet masterData, IClock
_alarm = alarm;
_onChanged = onChanged;
_settings = masterData.Settings;
+
// Seed the add-Trupp form defaults from the configured settings (empty designation => AGT).
// Direct field writes so no OnChanged fires and the fields do not read as user-edited.
_newMaxDurationMinutes = _settings.AgtMaxDurationMinutes;
@@ -215,21 +237,28 @@ public ScbaViewModel(IIncidentSession session, MasterDataSet masterData, IClock
PersonOptions = masterData.Personnel.Select(p => p.DisplayName).ToArray();
Trupps = new ObservableCollection(session.Incident.ScbaTrupps.Select(CreateRow));
_session.Changed += RefreshTrupps;
+
// The property setter path (below) is what marks an interval as user-edited, so the
// initial derivation from _newMaxDurationMinutes must go through it once here too.
ApplyDefaultControlInterval();
// Suppress re-logging alarms for trupps already alarming when the incident is reopened.
foreach (var t in session.Incident.ScbaTrupps)
+ {
if ((t.IsActive || t.IsWithdrawing) && t.IsAlarm(_clock.Now))
+ {
_alarmLogged.Add(t.Id);
+ }
+ }
// A closed incident is historical: no live ticking, no auto-logging (it cannot mutate).
_subscription = IsReadOnly ? null : ticker.Subscribe(OnTick);
}
public bool IsReadOnly { get; }
+
public IReadOnlyList TruppTypeOptions { get; }
+
public IReadOnlyList CallSignOptions { get; }
///
@@ -237,6 +266,7 @@ public ScbaViewModel(IIncidentSession session, MasterDataSet masterData, IClock
/// the normal state on a fresh clone — the boxes stay free text either way.
///
public IReadOnlyList PersonOptions { get; }
+
public ObservableCollection Trupps { get; }
[ObservableProperty]
@@ -289,23 +319,32 @@ public ScbaViewModel(IIncidentSession session, MasterDataSet masterData, IClock
partial void OnNewTruppNumberChanged(int value)
{
if (!_applyingDefault)
+ {
_truppNumberUserEdited = true;
+ }
}
partial void OnNewMaxDurationMinutesChanged(int value)
{
if (!_applyingDefault)
+ {
_maxDurationUserEdited = true;
+ }
+
// The Abfrage-Intervall tracks the Einsatzzeit (a third of it) unless separately overridden,
// whether this change came from the user or from ApplyDefaultMaxDuration below.
if (!_controlIntervalUserEdited)
+ {
ApplyDefaultControlInterval();
+ }
}
partial void OnNewControlIntervalMinutesChanged(int value)
{
if (!_applyingDefault)
+ {
_controlIntervalUserEdited = true;
+ }
}
// Switching the Trupp type re-suggests its Einsatzzeit (CSA is shorter, LPA is longer than an
@@ -313,7 +352,9 @@ partial void OnNewControlIntervalMinutesChanged(int value)
partial void OnNewDesignationChanged(string value)
{
if (!_maxDurationUserEdited)
+ {
ApplyDefaultMaxDuration();
+ }
}
private void ApplyDefaultMaxDuration()
@@ -325,12 +366,15 @@ private void ApplyDefaultMaxDuration()
: AtemschutzTrupp.IsLpaTrupp(NewDesignation) ? _settings.LpaMaxDurationMinutes
: _settings.AgtMaxDurationMinutes;
_applyingDefault = previous;
+
// Called explicitly rather than left to OnNewMaxDurationMinutesChanged's cascade: the
// generated property setter is a no-op when the value doesn't actually change (e.g. the
// AGT default reapplied on form reset), which would otherwise leave a stale user-edited
// Abfrage-Intervall in place.
if (!_controlIntervalUserEdited)
+ {
ApplyDefaultControlInterval();
+ }
}
// Abfrage-Intervall defaults to a third of the Einsatzzeit -- frequent enough to catch a fast
@@ -363,15 +407,16 @@ public string NextControlDisplay
{
var urgent = MostUrgentActive;
if (urgent is null)
+ {
return "—";
+ }
+
return IsAnyControlDue
? $"Druckabfrage fällig: {urgent.DisplayName}"
: $"Nächste Druckabfrage: {urgent.DisplayName} in {urgent.ControlRemainingDisplay}";
}
}
- // ----- Rückzugsalarm: a trupp has hit its time limit or return pressure (life-safety) -----
-
private IEnumerable AlarmingTrupps =>
_session.Incident.ScbaTrupps.Where(t => (t.IsActive || t.IsWithdrawing) && t.IsAlarm(_clock.Now));
@@ -383,7 +428,10 @@ public string AlarmDisplay
{
var trupps = AlarmingTrupps.ToList();
if (trupps.Count == 0)
+ {
return "—";
+ }
+
var first = $"RÜCKZUGSALARM {trupps[0].DisplayName}: {AlarmReason(trupps[0])}";
return trupps.Count == 1 ? first : $"{first} (+{trupps.Count - 1})";
}
@@ -412,7 +460,9 @@ private string AlarmReason(AtemschutzTrupp trupp) => trupp.IsTimeAlarm(_clock.No
private void UpdateAlarm(bool newAlarmTripped)
{
if (newAlarmTripped)
+ {
IsAlarmAcknowledged = false;
+ }
if (IsAnyAlarm && !IsAlarmAcknowledged &&
(_lastAlarmAnnouncedAt is null || _clock.Now - _lastAlarmAnnouncedAt >= RetreatRepeatInterval))
@@ -434,6 +484,7 @@ private void UpdateAlarm(bool newAlarmTripped)
private bool CanAddTrupp =>
!IsReadOnly && !string.IsNullOrWhiteSpace(NewDesignation)
&& !string.IsNullOrWhiteSpace(NewTruppfuehrer) && !string.IsNullOrWhiteSpace(NewTruppmann)
+
// Mirrors the domain cardinality rule so an incomplete CSA-Trupp disables the button
// rather than throwing on click.
&& (!RequiresThirdMember || !string.IsNullOrWhiteSpace(NewZweiterTruppmann))
@@ -454,13 +505,21 @@ private void AddTrupp()
var truppNumber = NewTruppNumber;
var entryPressure = NewEntryPressure;
var displayName = AtemschutzTrupp.FormatDisplayName(truppNumber, designation);
- _session.AddScbaTrupp(designation, crew, entryPressure, truppNumber, callSign,
- task: null, maxDurationMinutes: NewMaxDurationMinutes, returnPressureBar: NewReturnPressureBar,
+ _session.AddScbaTrupp(
+ designation,
+ crew,
+ entryPressure,
+ truppNumber,
+ callSign,
+ task: null,
+ maxDurationMinutes: NewMaxDurationMinutes,
+ returnPressureBar: NewReturnPressureBar,
pressureControlIntervalMinutes: NewControlIntervalMinutes);
_session.AddJournalEntry(
EtbDirection.System,
$"{displayName} bereitgestellt: {membersDisplay}, Einstiegsdruck {entryPressure} bar",
- from: callSign, to: null);
+ from: callSign,
+ to: null);
_maxDurationUserEdited = false;
_controlIntervalUserEdited = false;
@@ -471,6 +530,7 @@ private void AddTrupp()
NewCallSign = null;
NewEntryPressure = 300;
NewReturnPressureBar = _settings.ReturnPressureBar;
+
// Guarded like RefreshTrupps' own re-suggestion below: this sets up the *next* Trupp's
// auto-suggested number and must not itself read back as a user edit.
_truppNumberUserEdited = false;
@@ -484,7 +544,10 @@ private void AddTrupp()
}
private ScbaTruppRow CreateRow(AtemschutzTrupp trupp) =>
- new(trupp, _clock, IsReadOnly,
+ new(
+ trupp,
+ _clock,
+ IsReadOnly,
() => Start(trupp.Id),
bar => RecordPressure(trupp.Id, bar),
() => Withdraw(trupp.Id),
@@ -505,7 +568,9 @@ private void Start(Guid truppId)
_session.StartScbaTrupp(truppId);
_session.AddJournalEntry(
EtbDirection.System,
- $"{displayName} im Einsatz", from: callSign, to: null);
+ $"{displayName} im Einsatz",
+ from: callSign,
+ to: null);
RefreshHeader();
_onChanged();
}
@@ -516,7 +581,9 @@ private void RecordPressure(Guid truppId, int bar)
_session.RecordScbaPressure(truppId, bar);
_session.AddJournalEntry(
EtbDirection.System,
- $"Druckkontrolle {displayName}: {bar} bar", from: callSign, to: null);
+ $"Druckkontrolle {displayName}: {bar} bar",
+ from: callSign,
+ to: null);
var tripped = LogNewAlarms(); // a low reading may immediately trip the Rückzugsdruck alarm
UpdateAlarm(tripped);
RefreshHeader();
@@ -529,7 +596,9 @@ private void Withdraw(Guid truppId)
_session.WithdrawScbaTrupp(truppId);
_session.AddJournalEntry(
EtbDirection.System,
- $"{displayName} Rückzug", from: callSign, to: null);
+ $"{displayName} Rückzug",
+ from: callSign,
+ to: null);
RefreshHeader();
_onChanged();
}
@@ -540,7 +609,9 @@ private void MarkRemoved(Guid truppId)
_session.MarkScbaRemoved(truppId);
_session.AddJournalEntry(
EtbDirection.System,
- $"{displayName} abgenommen", from: callSign, to: null);
+ $"{displayName} abgenommen",
+ from: callSign,
+ to: null);
UpdateAlarm(newAlarmTripped: false); // a removed trupp may clear the last alarm
RefreshHeader();
_onChanged();
@@ -551,7 +622,10 @@ private void RefreshTrupps()
{
Trupps.Clear();
foreach (var trupp in _session.Incident.ScbaTrupps)
+ {
Trupps.Add(CreateRow(trupp));
+ }
+
// Another device may have just taken the suggested number -- re-suggest, but never
// clobber a number this device's operator already hand-typed into the form.
if (!_truppNumberUserEdited)
@@ -561,6 +635,7 @@ private void RefreshTrupps()
NewTruppNumber = _session.Incident.NextFreeScbaTruppNumber();
_applyingDefault = previous;
}
+
RefreshHeader();
}
@@ -574,13 +649,18 @@ private void RefreshHeader()
private void OnTick()
{
foreach (var row in Trupps)
+ {
row.Refresh();
+ }
+
RefreshHeader();
var tripped = LogNewAlarms();
UpdateAlarm(tripped);
AnnounceControlDue();
if (tripped)
+ {
_onChanged();
+ }
}
/// Plays a cue once per Druckabfrage due-crossing per Trupp. Unlike
@@ -589,13 +669,18 @@ private void OnTick()
private void AnnounceControlDue()
{
if (IsReadOnly)
+ {
return;
+ }
+
foreach (var trupp in _session.Incident.ScbaTrupps)
{
if ((trupp.IsActive || trupp.IsWithdrawing) && trupp.IsControlDue(_clock.Now))
{
if (_controlDueAnnounced.Add(trupp.Id))
+ {
_alarm.Play(AlarmSound.PressureCheckDue);
+ }
}
else
{
@@ -610,18 +695,27 @@ private bool LogNewAlarms()
{
// Only the authoritative device auto-logs alarms; a joined client would double-log (§ IsRemote).
if (IsReadOnly || _session.IsRemote)
+ {
return false;
+ }
+
var logged = false;
foreach (var trupp in _session.Incident.ScbaTrupps)
{
if (!(trupp.IsActive || trupp.IsWithdrawing) || !trupp.IsAlarm(_clock.Now) || !_alarmLogged.Add(trupp.Id))
+ {
continue;
+ }
+
var reason = AlarmReason(trupp);
_session.AddJournalEntry(
EtbDirection.System,
- $"Rückzugsalarm {trupp.DisplayName}: {reason}", from: null, to: trupp.CallSign);
+ $"Rückzugsalarm {trupp.DisplayName}: {reason}",
+ from: null,
+ to: trupp.CallSign);
logged = true;
}
+
return logged;
}
diff --git a/src/LageBuch.AppLogic/ViewModels/SettingsSection.cs b/src/LageBuch.AppLogic/ViewModels/SettingsSection.cs
index feae100..58e0ea6 100644
--- a/src/LageBuch.AppLogic/ViewModels/SettingsSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/SettingsSection.cs
@@ -13,7 +13,8 @@ public sealed partial class SettingsSection : EditorSection
{
private readonly Action _onChanged;
- public SettingsSection(string title, IncidentSettings settings, Action onChanged) : base(title)
+ public SettingsSection(string title, IncidentSettings settings, Action onChanged)
+ : base(title)
{
ArgumentNullException.ThrowIfNull(settings);
_onChanged = onChanged;
@@ -48,11 +49,17 @@ public SettingsSection(string title, IncidentSettings settings, Action onChanged
private int _returnPressureBar;
partial void OnIlsReminderIntervalMinutesChanged(int value) => _onChanged();
+
partial void OnIlsReminderFollowUpIntervalMinutesChanged(int value) => _onChanged();
+
partial void OnAgtMaxDurationMinutesChanged(int value) => _onChanged();
+
partial void OnCsaMaxDurationMinutesChanged(int value) => _onChanged();
+
partial void OnLpaMaxDurationMinutesChanged(int value) => _onChanged();
+
partial void OnPressureControlIntervalMinutesChanged(int value) => _onChanged();
+
partial void OnReturnPressureBarChanged(int value) => _onChanged();
public IncidentSettings ToSettings() => new(
diff --git a/src/LageBuch.AppLogic/ViewModels/TaskDialogViewModel.cs b/src/LageBuch.AppLogic/ViewModels/TaskDialogViewModel.cs
index 05d2671..7a9ebab 100644
--- a/src/LageBuch.AppLogic/ViewModels/TaskDialogViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/TaskDialogViewModel.cs
@@ -62,6 +62,7 @@ partial void OnUrgencyChanged(TaskUrgency value) =>
TimerMinutes = IncidentTask.DefaultTimerMinutes(value);
public IReadOnlyList ImportanceOptions { get; }
+
public IReadOnlyList UrgencyOptions { get; }
private bool CanSave =>
diff --git a/src/LageBuch.AppLogic/ViewModels/TasksViewModel.cs b/src/LageBuch.AppLogic/ViewModels/TasksViewModel.cs
index 68ceaa5..9d5501a 100644
--- a/src/LageBuch.AppLogic/ViewModels/TasksViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/TasksViewModel.cs
@@ -2,27 +2,14 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LageBuch.AppLogic.Services;
+using LageBuch.Documents;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.Time;
-using LageBuch.Documents;
using LageBuch.Persistence.MasterData;
using LageBuch.Sync;
namespace LageBuch.AppLogic.ViewModels;
-public enum TaskFilterKind
-{
- Open,
- Done,
- All,
-}
-
-/// An enum value paired with its German label (EtbDirectionOption precedent). Two
-/// closed records instead of a generic one, so Avalonia compiled-bind templates stay simple.
-public readonly record struct ImportanceOption(TaskImportance Value, string Label);
-
-public readonly record struct UrgencyOption(TaskUrgency Value, string Label);
-
///
/// The AUFGABEN tab (#88). Unlike the journal this list mutates in place (completion reorders,
/// remote broadcasts replace), so Sync() rebuilds the visible rows wholesale — cheap at task
@@ -35,6 +22,7 @@ public sealed partial class TasksViewModel : ObservableObject, IDisposable
private readonly IClock _clock;
private readonly IAlarmService _alarm;
private readonly Action _onChanged;
+
// Null on a read-only workspace: rows are static history there and the due alarm is gated off
// anyway, so holding a live ticker subscription would only keep the clock ticking for nothing
// (ScbaViewModel precedent — keeps Closing_workspace_drops_reminder at zero subscribers).
@@ -42,8 +30,12 @@ public sealed partial class TasksViewModel : ObservableObject, IDisposable
private readonly HashSet _dueAnnounced = new();
public TasksViewModel(
- IIncidentSession session, IClock clock, ITicker ticker, IAlarmService alarm,
- MasterDataSet masterData, Action onChanged)
+ IIncidentSession session,
+ IClock clock,
+ ITicker ticker,
+ IAlarmService alarm,
+ MasterDataSet masterData,
+ Action onChanged)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(ticker);
@@ -53,6 +45,7 @@ public TasksViewModel(
_alarm = alarm;
_onChanged = onChanged;
IsReadOnly = session.IsReadOnly;
+
// Callsigns, Funktionen and personnel names suggest; anything else stays free text.
AssigneeOptions = masterData.RadioCallSigns
.Concat(masterData.Roles)
@@ -66,7 +59,9 @@ public TasksViewModel(
}
public bool IsReadOnly { get; }
+
public ObservableCollection Rows { get; }
+
public IReadOnlyList AssigneeOptions { get; }
// Shared with TaskDialogViewModel (same assembly) so picker wording matches everywhere.
@@ -83,6 +78,7 @@ internal static IReadOnlyList UrgencyLevels() =>
.ToArray();
public IReadOnlyList ImportanceOptions { get; } = ImportanceLevels();
+
public IReadOnlyList UrgencyOptions { get; } = UrgencyLevels();
// Display order (spec §4): open first, then urgency desc -> importance desc -> oldest first.
@@ -94,7 +90,6 @@ internal static IOrderedEnumerable SortForDisplay(IEnumerable t.CreatedAt);
// --- Filter: three-state radio group (ALLE/OFFEN/ERLEDIGT), default OFFEN. ---
-
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOpenFilter))]
[NotifyPropertyChangedFor(nameof(IsDoneFilter))]
@@ -108,19 +103,37 @@ internal static IOrderedEnumerable SortForDisplay(IEnumerable Filter == TaskFilterKind.Open;
- set { if (value) Filter = TaskFilterKind.Open; }
+ set
+ {
+ if (value)
+ {
+ Filter = TaskFilterKind.Open;
+ }
+ }
}
public bool IsDoneFilter
{
get => Filter == TaskFilterKind.Done;
- set { if (value) Filter = TaskFilterKind.Done; }
+ set
+ {
+ if (value)
+ {
+ Filter = TaskFilterKind.Done;
+ }
+ }
}
public bool IsAllFilter
{
get => Filter == TaskFilterKind.All;
- set { if (value) Filter = TaskFilterKind.All; }
+ set
+ {
+ if (value)
+ {
+ Filter = TaskFilterKind.All;
+ }
+ }
}
[RelayCommand]
@@ -140,7 +153,6 @@ public bool IsAllFilter
};
// --- Input dock ---
-
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddTaskCommand))]
private string _newText = string.Empty;
@@ -175,12 +187,13 @@ private void AddTask()
}
// --- Live countdown + one-shot due alarm ---
-
private void OnTick()
{
var now = _clock.Now;
foreach (var row in Rows)
+ {
row.RefreshClock(now);
+ }
// Audible cue on the open->due crossing, exactly once per task per VM lifetime. Runs on
// joined clients too — the sound is local feedback, not logging (IsRemote gates writes).
@@ -189,8 +202,12 @@ private void OnTick()
if (!IsReadOnly)
{
foreach (var task in _session.Incident.Tasks)
+ {
if (!task.IsCompleted && task.DueAt <= now && _dueAnnounced.Add(task.Id))
+ {
_alarm.Play(AlarmSound.TaskDue);
+ }
+ }
}
}
@@ -211,7 +228,9 @@ public void Sync()
Rows.Clear();
foreach (var row in visible)
+ {
Rows.Add(row);
+ }
}
public void Dispose()
@@ -251,6 +270,7 @@ public TaskRow(IIncidentSession session, IncidentTask task, bool isReadOnly, Dat
IsImportanceMedium = task.Importance == TaskImportance.Medium;
IsImportanceLow = task.Importance == TaskImportance.Low;
_isDone = task.IsCompleted;
+
// German short stamp for completed rows; Sync() recreates the row on completion, so a
// static snapshot is enough. Empty while open — the view hides the label then.
CompletedDisplay = task.CompletedAt is { } completedAt
@@ -261,17 +281,29 @@ public TaskRow(IIncidentSession session, IncidentTask task, bool isReadOnly, Dat
}
public Guid Id { get; }
+
public string Text { get; }
+
public string Assignee { get; }
+
public string CreatedDisplay { get; }
+
public string ImportanceLabel { get; }
+
public string UrgencyLabel { get; }
+
public bool IsUrgencyHigh { get; }
+
public bool IsUrgencyMedium { get; }
+
public bool IsUrgencyLow { get; }
+
public bool IsImportanceHigh { get; }
+
public bool IsImportanceMedium { get; }
+
public bool IsImportanceLow { get; }
+
public bool IsReadOnly { get; }
/// "ERLEDIGT · HH:mm" once done, empty while open (completion time from the task).
@@ -301,7 +333,10 @@ public void RefreshClock(DateTimeOffset now)
{
var task = _session.Incident.Tasks.FirstOrDefault(t => t.Id == _id);
if (task is null)
+ {
return;
+ }
+
IsOverdue = ComputeIsOverdue(task, now);
RemainingDisplay = ComputeRemaining(task, now);
OnPropertyChanged(nameof(IsOverdue));
@@ -313,12 +348,34 @@ private static bool ComputeIsOverdue(IncidentTask task, DateTimeOffset now) =>
private static string ComputeRemaining(IncidentTask task, DateTimeOffset now)
{
if (task.IsCompleted)
+ {
return "–";
+ }
+
if (task.DueAt == DateTimeOffset.MaxValue)
+ {
return "–";
+ }
+
if (task.DueAt <= now)
+ {
return "FÄLLIG";
+ }
+
var remaining = task.DueAt - now;
return $"noch {(int)remaining.TotalMinutes:D2}:{remaining.Seconds:D2}";
}
}
+
+public enum TaskFilterKind
+{
+ Open,
+ Done,
+ All,
+}
+
+/// An enum value paired with its German label (EtbDirectionOption precedent). Two
+/// closed records instead of a generic one, so Avalonia compiled-bind templates stay simple.
+public readonly record struct ImportanceOption(TaskImportance Value, string Label);
+
+public readonly record struct UrgencyOption(TaskUrgency Value, string Label);
diff --git a/src/LageBuch.AppLogic/ViewModels/VehicleRow.cs b/src/LageBuch.AppLogic/ViewModels/VehicleRow.cs
index f102f1b..6dc59c8 100644
--- a/src/LageBuch.AppLogic/ViewModels/VehicleRow.cs
+++ b/src/LageBuch.AppLogic/ViewModels/VehicleRow.cs
@@ -12,8 +12,12 @@ public sealed partial class VehicleRow : ObservableObject
private readonly Action _onChanged;
public VehicleRow(
- string wache, string callSign, int seats,
- IReadOnlyList wacheOptions, IReadOnlyList callSignOptions, Action onChanged)
+ string wache,
+ string callSign,
+ int seats,
+ IReadOnlyList wacheOptions,
+ IReadOnlyList callSignOptions,
+ Action onChanged)
{
_onChanged = onChanged;
_wache = wache;
@@ -29,11 +33,16 @@ public VehicleRow(
/// Suggestions from the Stammdaten "Funkrufnamen" list.
public IReadOnlyList CallSignOptions { get; }
- [ObservableProperty] private string _wache;
- [ObservableProperty] private string _callSign;
- [ObservableProperty] private int _seats;
+ [ObservableProperty]
+ private string _wache;
+ [ObservableProperty]
+ private string _callSign;
+ [ObservableProperty]
+ private int _seats;
partial void OnWacheChanged(string value) => _onChanged();
+
partial void OnCallSignChanged(string value) => _onChanged();
+
partial void OnSeatsChanged(int value) => _onChanged();
}
diff --git a/src/LageBuch.AppLogic/ViewModels/VehiclesSection.cs b/src/LageBuch.AppLogic/ViewModels/VehiclesSection.cs
index 4c39a4a..1a28bba 100644
--- a/src/LageBuch.AppLogic/ViewModels/VehiclesSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/VehiclesSection.cs
@@ -16,9 +16,12 @@ public sealed partial class VehiclesSection : EditorSection
private readonly IReadOnlyList _callSignOptions;
public VehiclesSection(
- string title, IEnumerable vehicles,
- IReadOnlyList wacheOptions, IReadOnlyList callSignOptions,
- Action onChanged) : base(title)
+ string title,
+ IEnumerable vehicles,
+ IReadOnlyList wacheOptions,
+ IReadOnlyList callSignOptions,
+ Action onChanged)
+ : base(title)
{
_onChanged = onChanged;
_wacheOptions = wacheOptions;
@@ -42,7 +45,10 @@ private void Add()
[RelayCommand]
private void Remove(VehicleRow row)
{
- if (Rows.Remove(row)) _onChanged();
+ if (Rows.Remove(row))
+ {
+ _onChanged();
+ }
}
/// Rows with a non-blank Wache and Funkrufname; trimmed, seats as entered.
@@ -53,8 +59,12 @@ public IReadOnlyList ToValues()
{
var wache = row.Wache?.Trim() ?? string.Empty;
var callSign = row.CallSign?.Trim() ?? string.Empty;
- if (wache.Length > 0 && callSign.Length > 0) result.Add(new Vehicle(wache, callSign, row.Seats));
+ if (wache.Length > 0 && callSign.Length > 0)
+ {
+ result.Add(new Vehicle(wache, callSign, row.Seats));
+ }
}
+
return result;
}
}
diff --git a/src/LageBuch.Documents/Formatting.cs b/src/LageBuch.Documents/Formatting.cs
index dccb004..836b80f 100644
--- a/src/LageBuch.Documents/Formatting.cs
+++ b/src/LageBuch.Documents/Formatting.cs
@@ -17,14 +17,14 @@ public static class Formatting
EtbDirection.Outgoing => "Ausgang",
EtbDirection.Internal => "Intern",
EtbDirection.System => "System",
- _ => direction.ToString()
+ _ => direction.ToString(),
};
public static string State(IncidentState state) => state switch
{
IncidentState.Open => "Offen",
IncidentState.Closed => "Abgeschlossen",
- _ => state.ToString()
+ _ => state.ToString(),
};
public static string OrDash(string? value) =>
diff --git a/src/LageBuch.Documents/PdfAttachmentMerger.cs b/src/LageBuch.Documents/PdfAttachmentMerger.cs
index 99a0c64..9a125dc 100644
--- a/src/LageBuch.Documents/PdfAttachmentMerger.cs
+++ b/src/LageBuch.Documents/PdfAttachmentMerger.cs
@@ -19,7 +19,9 @@ public static byte[] Append(byte[] baseReport, IReadOnlyList pdfAttachme
ArgumentNullException.ThrowIfNull(baseReport);
ArgumentNullException.ThrowIfNull(pdfAttachments);
if (pdfAttachments.Count == 0)
+ {
return baseReport;
+ }
var workDir = Path.Combine(Path.GetTempPath(), $"lagebuch-pdf-merge-{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);
diff --git a/src/LageBuch.Documents/PdfLicense.cs b/src/LageBuch.Documents/PdfLicense.cs
index 057a1eb..0e5cb2f 100644
--- a/src/LageBuch.Documents/PdfLicense.cs
+++ b/src/LageBuch.Documents/PdfLicense.cs
@@ -13,6 +13,8 @@ public static class PdfLicense
public static void Ensure()
{
if (Interlocked.Exchange(ref _configured, 1) == 0)
+ {
QuestPDF.Settings.License = LicenseType.Community;
+ }
}
}
diff --git a/src/LageBuch.Documents/Sections/AtemschutzSection.cs b/src/LageBuch.Documents/Sections/AtemschutzSection.cs
index 4f3579d..0208303 100644
--- a/src/LageBuch.Documents/Sections/AtemschutzSection.cs
+++ b/src/LageBuch.Documents/Sections/AtemschutzSection.cs
@@ -40,7 +40,9 @@ public static void Compose(IContainer container, Incident incident)
table.Header(header =>
{
foreach (var title in HeaderTitles)
+ {
header.Cell().Element(Cells.Header).Text(title).SemiBold();
+ }
});
foreach (var trupp in incident.ScbaTrupps)
diff --git a/src/LageBuch.Documents/Sections/ChecklistSection.cs b/src/LageBuch.Documents/Sections/ChecklistSection.cs
index f2879f3..0a5a558 100644
--- a/src/LageBuch.Documents/Sections/ChecklistSection.cs
+++ b/src/LageBuch.Documents/Sections/ChecklistSection.cs
@@ -28,7 +28,9 @@ public static void Compose(IContainer container, Incident incident)
private static void ComposeList(QuestPDF.Fluent.ColumnDescriptor column, string title, IReadOnlyList items)
{
if (items.Count == 0)
+ {
return;
+ }
column.Item().Text(title).FontSize(11).SemiBold();
@@ -40,10 +42,15 @@ private static void ComposeList(QuestPDF.Fluent.ColumnDescriptor column, string
row.RelativeItem().Text(t =>
{
if (item.IsMandatory)
+ {
t.Span("Pflicht: ").SemiBold().FontColor(Colors.Red.Darken1);
+ }
+
t.Span(item.Text);
if (!string.IsNullOrWhiteSpace(item.Note))
+ {
t.Span($" ({item.Note})").FontColor(Colors.Grey.Darken1);
+ }
});
});
}
diff --git a/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs b/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs
index 2dd10e0..45e9852 100644
--- a/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs
+++ b/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs
@@ -35,7 +35,10 @@ public static void Compose(IContainer container, Incident incident)
{
columns.ConstantColumn(60);
for (var apt = 1; apt <= building.ApartmentsPerFloor; apt++)
+ {
columns.ConstantColumn(65);
+ }
+
columns.RelativeColumn(2);
});
@@ -43,7 +46,10 @@ public static void Compose(IContainer container, Incident incident)
{
header.Cell().Element(Cells.Header).Text("Geschoss");
for (var apt = 1; apt <= building.ApartmentsPerFloor; apt++)
+ {
header.Cell().Element(Cells.Header).Text(CoMeasurementLabels.ApartmentLabel(building, apt));
+ }
+
header.Cell().Element(Cells.Header).Text("Lage");
});
@@ -85,7 +91,11 @@ public static void Compose(IContainer container, Incident incident)
foreach (var d in affected)
{
var building = incident.Buildings.FirstOrDefault(b => b.Id == d.BuildingId);
- if (building is null) continue;
+ if (building is null)
+ {
+ continue;
+ }
+
var location = CoMeasurementLabels.DwellingLocation(building, d.FloorOrdinal, d.ApartmentNumber);
var resident = d.ResidentName ?? "—";
var key = d.KeyAvailable is true ? "ja" : d.KeyAvailable is false ? "nein" : "—";
@@ -112,7 +122,7 @@ public static void Compose(IContainer container, Incident incident)
DwellingStatus.NotSearched => HexColor("#FFC000"),
DwellingStatus.Searched => HexColor("#92D050"),
DwellingStatus.Affected => HexColor("#FF0000"),
- _ => Colors.White
+ _ => Colors.White,
};
private static string HexColor(string hex) => hex;
diff --git a/src/LageBuch.Documents/Sections/EtbSection.cs b/src/LageBuch.Documents/Sections/EtbSection.cs
index b04bd4c..27b14b3 100644
--- a/src/LageBuch.Documents/Sections/EtbSection.cs
+++ b/src/LageBuch.Documents/Sections/EtbSection.cs
@@ -8,6 +8,7 @@ namespace LageBuch.Documents.Sections;
public static class EtbSection
{
private static readonly string[] HeaderTitles = ["Zeit", "Richtung", "Von", "An", "Eintrag", "Erfasst von"];
+
public static void Compose(IContainer container, Incident incident)
{
container.Column(column =>
@@ -36,7 +37,9 @@ public static void Compose(IContainer container, Incident incident)
table.Header(header =>
{
foreach (var title in HeaderTitles)
+ {
header.Cell().Element(HeaderCell).Text(title).SemiBold();
+ }
});
foreach (var entry in incident.Journal)
@@ -45,6 +48,7 @@ public static void Compose(IContainer container, Incident incident)
table.Cell().Element(BodyCell).Text(Formatting.Direction(entry.Direction));
table.Cell().Element(BodyCell).Text(Formatting.OrDash(entry.From));
table.Cell().Element(BodyCell).Text(Formatting.OrDash(entry.To));
+
// A corrected entry keeps its full edit history visible in the export, not just
// the current text — the ETB is a legal-weight record, so a rewrite must stay
// traceable in the artifact that leaves the app (#73).
@@ -52,8 +56,10 @@ public static void Compose(IContainer container, Incident incident)
{
col.Item().Text(entry.Text);
foreach (var edit in entry.Edits)
+ {
col.Item().Text($"bearbeitet {Formatting.Timestamp(edit.EditedAt)} von {edit.EditedBy}, zuvor: „{edit.PreviousText}“")
.FontSize(8).Italic().FontColor(Colors.Grey.Medium);
+ }
});
table.Cell().Element(BodyCell).Text(entry.EnteredBy);
}
diff --git a/src/LageBuch.Documents/Sections/FilesSection.cs b/src/LageBuch.Documents/Sections/FilesSection.cs
index 65ac52a..8659d74 100644
--- a/src/LageBuch.Documents/Sections/FilesSection.cs
+++ b/src/LageBuch.Documents/Sections/FilesSection.cs
@@ -14,6 +14,7 @@ namespace LageBuch.Documents.Sections;
public static class FilesSection
{
private static readonly string[] HeaderTitles = ["Name", "Hinzugefügt von", "Datum"];
+
public static void Compose(IContainer container, IReadOnlyList files, IReadOnlyDictionary imageBytesById)
{
container.Column(column =>
@@ -39,7 +40,9 @@ public static void Compose(IContainer container, IReadOnlyList fil
table.Header(header =>
{
foreach (var title in HeaderTitles)
+ {
header.Cell().Element(Cells.Header).Text(title).SemiBold();
+ }
});
foreach (var file in files)
@@ -53,7 +56,10 @@ public static void Compose(IContainer container, IReadOnlyList fil
foreach (var file in files)
{
if (!imageBytesById.TryGetValue(file.Id, out var bytes))
+ {
continue;
+ }
+
column.Item().PaddingTop(6).Text(file.DisplayName).SemiBold().FontSize(9);
column.Item().MaxHeight(400).Image(bytes).FitArea();
}
diff --git a/src/LageBuch.Documents/Sections/ForcesSection.cs b/src/LageBuch.Documents/Sections/ForcesSection.cs
index 321559b..d51a1ae 100644
--- a/src/LageBuch.Documents/Sections/ForcesSection.cs
+++ b/src/LageBuch.Documents/Sections/ForcesSection.cs
@@ -9,6 +9,7 @@ namespace LageBuch.Documents.Sections;
public static class ForcesSection
{
private static readonly string[] HeaderTitles = ["Feuerwehr", "Funkrufname", "Stärke", "AGT", "Status", "Bemerkung"];
+
public static void Compose(IContainer container, Incident incident)
{
container.Column(column =>
@@ -33,13 +34,16 @@ public static void Compose(IContainer container, Incident incident)
table.Header(header =>
{
foreach (var title in HeaderTitles)
+ {
header.Cell().Element(Cells.Header).Text(title).SemiBold();
+ }
});
foreach (var unit in incident.Forces)
{
table.Cell().Element(Cells.Body).Text(unit.Brigade);
table.Cell().Element(Cells.Body).Text(Formatting.OrDash(unit.CallSign));
+
// Stärke im 1/1/2-Format: Führungskräfte/Mannschaft/Gesamt (#76).
table.Cell().Element(Cells.Body).Text(unit.StrengthText);
table.Cell().Element(Cells.Body).Text(unit.ScbaCount.ToString(CultureInfo.InvariantCulture));
diff --git a/src/LageBuch.Documents/Sections/RolesSection.cs b/src/LageBuch.Documents/Sections/RolesSection.cs
index 07bf712..b8cff0b 100644
--- a/src/LageBuch.Documents/Sections/RolesSection.cs
+++ b/src/LageBuch.Documents/Sections/RolesSection.cs
@@ -8,6 +8,7 @@ namespace LageBuch.Documents.Sections;
public static class RolesSection
{
private static readonly string[] HeaderTitles = ["Funktion", "Name", "Abschnitt", "Funkrufname", "Handynummer", "Von", "Bis"];
+
public static void Compose(IContainer container, Incident incident)
{
container.Column(column =>
@@ -39,7 +40,9 @@ public static void Compose(IContainer container, Incident incident)
table.Header(header =>
{
foreach (var title in HeaderTitles)
+ {
header.Cell().Element(Cells.Header).Text(title).SemiBold();
+ }
});
foreach (var role in incident.Roles)
diff --git a/src/LageBuch.Documents/Sections/TasksSection.cs b/src/LageBuch.Documents/Sections/TasksSection.cs
index 6f62d7a..ee5b204 100644
--- a/src/LageBuch.Documents/Sections/TasksSection.cs
+++ b/src/LageBuch.Documents/Sections/TasksSection.cs
@@ -7,7 +7,8 @@ namespace LageBuch.Documents.Sections;
public static class TasksSection
{
- private static readonly string[] HeaderTitles = ["", "Wichtig", "Dringlich", "Fällig", "Zugeteilt", "Aufgabe", "Erledigt"];
+ private static readonly string[] HeaderTitles = [string.Empty, "Wichtig", "Dringlich", "Fällig", "Zugeteilt", "Aufgabe", "Erledigt"];
+
public static void Compose(IContainer container, Incident incident)
{
container.Column(column =>
@@ -43,7 +44,9 @@ public static void Compose(IContainer container, Incident incident)
table.Header(header =>
{
foreach (var title in HeaderTitles)
+ {
header.Cell().Element(HeaderCell).Text(title).SemiBold();
+ }
});
foreach (var task in sorted)
@@ -56,8 +59,10 @@ public static void Compose(IContainer container, Incident incident)
{
col.Item().Text(overdue ? "FÄLLIG" : Formatting.Timestamp(task.DueAt));
if (overdue)
+ {
col.Item().Text($"fällig {Formatting.Timestamp(task.DueAt)}")
.FontSize(8).Italic().FontColor(Colors.Grey.Medium);
+ }
});
table.Cell().Element(BodyCell).Text(Formatting.OrDash(task.Assignee));
table.Cell().Element(BodyCell).Column(col =>
diff --git a/src/LageBuch.Domain/Atemschutz/AtemschutzTrupp.cs b/src/LageBuch.Domain/Atemschutz/AtemschutzTrupp.cs
index 509d13f..4f1480f 100644
--- a/src/LageBuch.Domain/Atemschutz/AtemschutzTrupp.cs
+++ b/src/LageBuch.Domain/Atemschutz/AtemschutzTrupp.cs
@@ -55,10 +55,14 @@ public sealed class AtemschutzTrupp
private readonly List _readings = new();
private readonly List _members = new();
- private AtemschutzTrupp() { }
+ private AtemschutzTrupp()
+ {
+ }
public Guid Id { get; private init; }
+
public int TruppNumber { get; private init; }
+
public string Designation { get; private init; } = string.Empty;
/// "Trupp {N} ({Designation})" — the display form used in the grid, ETB text, the
@@ -83,6 +87,7 @@ private AtemschutzTrupp() { }
public string MembersDisplay => string.Join(" / ", _members.Select(m => m.Name));
public string? CallSign { get; private init; }
+
public string? Task { get; private init; }
/// When the Trupp was announced/registered (not yet necessarily under air).
@@ -96,7 +101,9 @@ private AtemschutzTrupp() { }
public int? EntryPressure { get; private set; }
public int MaxDurationMinutes { get; private init; }
+
public int ReturnPressureBar { get; private init; }
+
public int PressureControlIntervalMinutes { get; private init; }
/// When the Trupp began its Rückzug. Null before withdrawing, and while still waiting/active.
@@ -121,12 +128,18 @@ public static AtemschutzTrupp Register(
{
ArgumentNullException.ThrowIfNull(members);
if (string.IsNullOrWhiteSpace(designation))
+ {
throw new ArgumentException("Trupp-Bezeichnung darf nicht leer sein.", nameof(designation));
+ }
+
var crew = members.ToList();
ValidateCrew(designation, crew);
ValidatePressure(entryPressure, nameof(entryPressure));
if (entryPressure <= 0)
+ {
throw new ArgumentOutOfRangeException(nameof(entryPressure), "Einstiegsdruck muss größer als 0 sein.");
+ }
+
ValidatePressure(returnPressureBar, nameof(returnPressureBar));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxDurationMinutes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pressureControlIntervalMinutes);
@@ -142,7 +155,7 @@ public static AtemschutzTrupp Register(
Task = string.IsNullOrWhiteSpace(task) ? null : task.Trim(),
MaxDurationMinutes = maxDurationMinutes,
ReturnPressureBar = returnPressureBar,
- PressureControlIntervalMinutes = pressureControlIntervalMinutes
+ PressureControlIntervalMinutes = pressureControlIntervalMinutes,
};
trupp._members.AddRange(crew);
return trupp;
@@ -167,13 +180,21 @@ private static void ValidateCrew(string designation, List crew)
// rather than in the ViewModel keeps it true for rehydrated and imported data as well.
var required = RequiredMemberCount(designation);
if (crew.Count != required)
+ {
throw new ArgumentException(
$"{designation.Trim()} muss aus genau {required} Personen bestehen (angegeben: {crew.Count}).",
nameof(crew));
+ }
+
if (crew.Any(m => m is null || string.IsNullOrWhiteSpace(m.Name)))
+ {
throw new ArgumentException("Alle Truppmitglieder müssen einen Namen haben.", nameof(crew));
+ }
+
if (crew.Select(m => m.Role).Distinct().Count() != crew.Count)
+ {
throw new ArgumentException("Jede Truppfunktion darf nur einmal besetzt sein.", nameof(crew));
+ }
}
public static AtemschutzTrupp Rehydrate(
@@ -207,8 +228,9 @@ public static AtemschutzTrupp Rehydrate(
MaxDurationMinutes = maxDurationMinutes,
ReturnPressureBar = returnPressureBar,
PressureControlIntervalMinutes = pressureControlIntervalMinutes,
- ExitTime = exitTime
+ ExitTime = exitTime,
};
+
// Rehydrate deliberately does not re-run ValidateCrew: a stored Trupp is history, and
// refusing to open an incident because an old record has the wrong crew size would make
// the file unreadable rather than merely imperfect.
@@ -222,14 +244,20 @@ public static AtemschutzTrupp Rehydrate(
public void Start(DateTimeOffset time)
{
if (HasStarted)
+ {
throw new InvalidOperationException("Trupp ist bereits unter Atemschutz.");
+ }
+
StartTime = time;
}
public void RecordPressure(DateTimeOffset time, int bar)
{
if (!(IsActive || IsWithdrawing))
+ {
throw new InvalidOperationException("Druckkontrolle nur für einen Trupp unter Atemschutz möglich.");
+ }
+
ValidatePressure(bar, nameof(bar));
_readings.Add(new PressureReading(time, bar));
}
@@ -238,7 +266,10 @@ public void RecordPressure(DateTimeOffset time, int bar)
public void Withdraw(DateTimeOffset time)
{
if (!IsActive)
+ {
throw new InvalidOperationException("Rückzug nur für einen Trupp im Einsatz möglich.");
+ }
+
WithdrawTime = time;
}
@@ -246,9 +277,15 @@ public void Withdraw(DateTimeOffset time)
public void MarkRemoved(DateTimeOffset time)
{
if (WithdrawTime is null)
+ {
throw new InvalidOperationException("Trupp muss zuerst den Rückzug antreten.");
+ }
+
if (ExitTime is not null)
+ {
throw new InvalidOperationException("Trupp ist bereits abgenommen.");
+ }
+
ExitTime = time;
}
@@ -308,6 +345,8 @@ public bool IsControlDue(DateTimeOffset now) =>
private static void ValidatePressure(int bar, string paramName)
{
if (bar < 0 || bar > MaxPressureBar)
+ {
throw new ArgumentOutOfRangeException(paramName, $"Druck muss zwischen 0 und {MaxPressureBar} bar liegen.");
+ }
}
}
diff --git a/src/LageBuch.Domain/Atemschutz/TruppMember.cs b/src/LageBuch.Domain/Atemschutz/TruppMember.cs
index c60fb5e..d4b11ec 100644
--- a/src/LageBuch.Domain/Atemschutz/TruppMember.cs
+++ b/src/LageBuch.Domain/Atemschutz/TruppMember.cs
@@ -8,7 +8,7 @@ public enum TruppRole
{
Truppfuehrer = 0,
Truppmann = 1,
- ZweiterTruppmann = 2
+ ZweiterTruppmann = 2,
}
/// One named person in a Trupp, addressable by their position.
@@ -17,7 +17,10 @@ public sealed record TruppMember(TruppRole Role, string Name)
public static TruppMember Create(TruppRole role, string name)
{
if (string.IsNullOrWhiteSpace(name))
+ {
throw new ArgumentException("Name darf nicht leer sein.", nameof(name));
+ }
+
return new TruppMember(role, name.Trim());
}
@@ -34,7 +37,10 @@ public static IReadOnlyList Crew(
Create(TruppRole.Truppmann, truppmann),
};
if (!string.IsNullOrWhiteSpace(zweiterTruppmann))
+ {
crew.Add(Create(TruppRole.ZweiterTruppmann, zweiterTruppmann));
+ }
+
return crew;
}
@@ -43,6 +49,6 @@ public static IReadOnlyList Crew(
TruppRole.Truppfuehrer => "Truppführer",
TruppRole.Truppmann => "Truppmann",
TruppRole.ZweiterTruppmann => "2. Truppmann",
- _ => Role.ToString()
+ _ => Role.ToString(),
};
}
diff --git a/src/LageBuch.Domain/AuditEvent.cs b/src/LageBuch.Domain/AuditEvent.cs
new file mode 100644
index 0000000..283dd83
--- /dev/null
+++ b/src/LageBuch.Domain/AuditEvent.cs
@@ -0,0 +1,3 @@
+namespace LageBuch.Domain;
+
+public sealed record AuditEvent(DateTimeOffset At, string Action, string By);
diff --git a/src/LageBuch.Domain/ChecklistItem.cs b/src/LageBuch.Domain/ChecklistItem.cs
index 3bb4443..d5d6bbe 100644
--- a/src/LageBuch.Domain/ChecklistItem.cs
+++ b/src/LageBuch.Domain/ChecklistItem.cs
@@ -5,7 +5,10 @@ public sealed class ChecklistItem
public ChecklistItem(string text, bool isMandatory)
{
if (string.IsNullOrWhiteSpace(text))
+ {
throw new ArgumentException("Checklistentext darf nicht leer sein.", nameof(text));
+ }
+
Id = Guid.NewGuid();
Text = text.Trim();
IsMandatory = isMandatory;
@@ -24,9 +27,13 @@ public static ChecklistItem Rehydrate(Guid id, string text, bool isDone, string?
=> new(id, text, isDone, note, isMandatory);
public Guid Id { get; }
+
public string Text { get; }
+
public bool IsDone { get; private set; }
+
public string? Note { get; private set; }
+
public bool IsMandatory { get; }
public void Toggle() => IsDone = !IsDone;
diff --git a/src/LageBuch.Domain/ChecklistKind.cs b/src/LageBuch.Domain/ChecklistKind.cs
index d75ed14..c6d35b5 100644
--- a/src/LageBuch.Domain/ChecklistKind.cs
+++ b/src/LageBuch.Domain/ChecklistKind.cs
@@ -4,5 +4,5 @@ namespace LageBuch.Domain;
public enum ChecklistKind
{
Aufbau,
- Abbau
+ Abbau,
}
diff --git a/src/LageBuch.Domain/CoMeasurement/Building.cs b/src/LageBuch.Domain/CoMeasurement/Building.cs
index db99b3c..1f54aff 100644
--- a/src/LageBuch.Domain/CoMeasurement/Building.cs
+++ b/src/LageBuch.Domain/CoMeasurement/Building.cs
@@ -3,25 +3,41 @@ namespace LageBuch.Domain.CoMeasurement;
public sealed record Building
{
public Guid Id { get; private init; }
+
public string Name { get; private init; } = string.Empty;
+
public int FloorCount { get; private init; }
+
public int ApartmentsPerFloor { get; private init; }
+
public IReadOnlyDictionary FloorDescriptions { get; private init; } =
new Dictionary();
+
public IReadOnlyDictionary ApartmentLabels { get; private init; } =
new Dictionary();
+
public int Ordinal { get; private init; }
- private Building() { }
+ private Building()
+ {
+ }
public static Building Create(string name, int floorCount, int apartmentsPerFloor, int ordinal)
{
if (string.IsNullOrWhiteSpace(name))
+ {
throw new ArgumentException("Hausname darf nicht leer sein.", nameof(name));
+ }
+
if (floorCount < 1 || floorCount > 50)
+ {
throw new ArgumentOutOfRangeException(nameof(floorCount), "Obergeschosse müssen zwischen 1 und 50 liegen.");
+ }
+
if (apartmentsPerFloor < 1 || apartmentsPerFloor > 30)
+ {
throw new ArgumentOutOfRangeException(nameof(apartmentsPerFloor), "Wohnungen je Geschoss müssen zwischen 1 und 30 liegen.");
+ }
return new Building
{
@@ -29,13 +45,17 @@ public static Building Create(string name, int floorCount, int apartmentsPerFloo
Name = name.Trim(),
FloorCount = floorCount,
ApartmentsPerFloor = apartmentsPerFloor,
- Ordinal = ordinal
+ Ordinal = ordinal,
};
}
public static Building Rehydrate(
- Guid id, string name, int floorCount, int apartmentsPerFloor,
- IReadOnlyDictionary floorDescriptions, int ordinal,
+ Guid id,
+ string name,
+ int floorCount,
+ int apartmentsPerFloor,
+ IReadOnlyDictionary floorDescriptions,
+ int ordinal,
IReadOnlyDictionary? apartmentLabels = null)
=> new()
{
@@ -45,15 +65,21 @@ public static Building Rehydrate(
ApartmentsPerFloor = apartmentsPerFloor,
FloorDescriptions = floorDescriptions,
ApartmentLabels = apartmentLabels ?? new Dictionary(),
- Ordinal = ordinal
+ Ordinal = ordinal,
};
public Building WithStructure(int floorCount, int apartmentsPerFloor)
{
if (floorCount < 1 || floorCount > 50)
+ {
throw new ArgumentOutOfRangeException(nameof(floorCount));
+ }
+
if (apartmentsPerFloor < 1 || apartmentsPerFloor > 30)
+ {
throw new ArgumentOutOfRangeException(nameof(apartmentsPerFloor));
+ }
+
return this with { FloorCount = floorCount, ApartmentsPerFloor = apartmentsPerFloor };
}
@@ -61,9 +87,14 @@ public Building WithFloorDescription(int ordinal, string? description)
{
var dict = new Dictionary(FloorDescriptions.ToDictionary(kv => kv.Key, kv => kv.Value));
if (string.IsNullOrWhiteSpace(description))
+ {
dict.Remove(ordinal);
+ }
else
+ {
dict[ordinal] = description.Trim();
+ }
+
return this with { FloorDescriptions = dict };
}
@@ -71,9 +102,14 @@ public Building WithApartmentLabel(int apartmentNumber, string? label)
{
var dict = new Dictionary(ApartmentLabels.ToDictionary(kv => kv.Key, kv => kv.Value));
if (string.IsNullOrWhiteSpace(label))
+ {
dict.Remove(apartmentNumber);
+ }
else
+ {
dict[apartmentNumber] = label.Trim();
+ }
+
return this with { ApartmentLabels = dict };
}
}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs
index f05e3fc..c3aefc0 100644
--- a/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs
+++ b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs
@@ -17,7 +17,7 @@ public static string DefaultApartmentLabel(int apartmentNumber, int apartmentsPe
1 => "Links",
2 => "Mitte",
3 => "Rechts",
- _ => ApartmentLabel(apartmentNumber)
+ _ => ApartmentLabel(apartmentNumber),
}
: ApartmentLabel(apartmentNumber);
@@ -40,7 +40,7 @@ public static string DwellingLocation(Building building, int floorOrdinal, int a
DwellingStatus.NotSearched => "noch nicht abgesucht",
DwellingStatus.Searched => "abgesucht – keine Personen betroffen",
DwellingStatus.Affected => "Person(en) betroffen",
- _ => throw new ArgumentOutOfRangeException(nameof(status))
+ _ => throw new ArgumentOutOfRangeException(nameof(status)),
};
public static string StatusChip(DwellingStatus status) => status switch
@@ -48,6 +48,6 @@ public static string DwellingLocation(Building building, int floorOrdinal, int a
DwellingStatus.NotSearched => "GELB",
DwellingStatus.Searched => "GRÜN",
DwellingStatus.Affected => "ROT",
- _ => throw new ArgumentOutOfRangeException(nameof(status))
+ _ => throw new ArgumentOutOfRangeException(nameof(status)),
};
}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/CoMeasurement/Dwelling.cs b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs
index 5484054..aecbc5b 100644
--- a/src/LageBuch.Domain/CoMeasurement/Dwelling.cs
+++ b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs
@@ -3,12 +3,19 @@ namespace LageBuch.Domain.CoMeasurement;
public sealed record Dwelling
{
public Guid Id { get; private init; }
+
public Guid BuildingId { get; private init; }
+
public int FloorOrdinal { get; private init; }
+
public int ApartmentNumber { get; private init; }
+
public string? ResidentName { get; private init; }
+
public DwellingStatus Status { get; private init; }
+
public bool? KeyAvailable { get; private init; }
+
public int? CoValue { get; private init; }
public static Dwelling Create(Guid buildingId, int floorOrdinal, int apartmentNumber)
@@ -18,12 +25,18 @@ public static Dwelling Create(Guid buildingId, int floorOrdinal, int apartmentNu
BuildingId = buildingId,
FloorOrdinal = floorOrdinal,
ApartmentNumber = apartmentNumber,
- Status = DwellingStatus.NotSearched
+ Status = DwellingStatus.NotSearched,
};
public static Dwelling Rehydrate(
- Guid id, Guid buildingId, int floorOrdinal, int apartmentNumber,
- string? residentName, DwellingStatus status, bool? keyAvailable, int? coValue)
+ Guid id,
+ Guid buildingId,
+ int floorOrdinal,
+ int apartmentNumber,
+ string? residentName,
+ DwellingStatus status,
+ bool? keyAvailable,
+ int? coValue)
=> new()
{
Id = id,
@@ -33,7 +46,7 @@ public static Dwelling Rehydrate(
ResidentName = residentName,
Status = status,
KeyAvailable = keyAvailable,
- CoValue = coValue
+ CoValue = coValue,
};
public Dwelling WithCoValue(int? coValue) => this with { CoValue = coValue };
@@ -43,6 +56,6 @@ public static Dwelling Rehydrate(
public Dwelling WithDetails(string? residentName, bool? keyAvailable) => this with
{
ResidentName = string.IsNullOrWhiteSpace(residentName) ? null : residentName.Trim(),
- KeyAvailable = keyAvailable
+ KeyAvailable = keyAvailable,
};
}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs b/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs
index ec5d090..1638855 100644
--- a/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs
+++ b/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs
@@ -4,5 +4,5 @@ public enum DwellingStatus
{
NotSearched = 0, // Gelb
Searched = 1, // Grün
- Affected = 2 // Rot
+ Affected = 2, // Rot
}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/Etb/EtbDirection.cs b/src/LageBuch.Domain/Etb/EtbDirection.cs
index b76dbc6..61be66b 100644
--- a/src/LageBuch.Domain/Etb/EtbDirection.cs
+++ b/src/LageBuch.Domain/Etb/EtbDirection.cs
@@ -5,8 +5,9 @@ public enum EtbDirection
Incoming,
Outgoing,
Internal,
+
// Auto-generated events (Kräfte, Atemschutz, Einsatz-Lebenszyklus). Distinct from Internal,
// which is reserved for human "Intern" notes. Appended last on purpose: the direction is
// persisted by ordinal, so 0/1/2 are a wire contract and System must take 3.
- System
+ System,
}
diff --git a/src/LageBuch.Domain/Etb/EtbEntry.cs b/src/LageBuch.Domain/Etb/EtbEntry.cs
index b9497b5..0ab9731 100644
--- a/src/LageBuch.Domain/Etb/EtbEntry.cs
+++ b/src/LageBuch.Domain/Etb/EtbEntry.cs
@@ -6,15 +6,24 @@ public sealed record EtbEntry
// via WithEditedText, each retained edit) can grow the journal's storage and wire footprint.
public const int MaxTextLength = 4000;
- private EtbEntry() { }
+ private EtbEntry()
+ {
+ }
public Guid Id { get; private init; }
+
public DateTimeOffset Timestamp { get; private init; }
+
public EtbDirection Direction { get; private init; }
+
public string? From { get; private init; }
+
public string? To { get; private init; }
+
public string Text { get; private init; } = string.Empty;
+
public string EnteredBy { get; private init; } = string.Empty;
+
public IReadOnlyList Edits { get; private init; } = Array.Empty();
public static EtbEntry Create(
@@ -26,9 +35,15 @@ public static EtbEntry Create(
string? to = null)
{
if (string.IsNullOrWhiteSpace(text))
+ {
throw new ArgumentException("ETB-Eintrag darf nicht leer sein.", nameof(text));
+ }
+
if (text.Length > MaxTextLength)
+ {
throw new ArgumentException($"ETB-Eintrag ist länger als das Limit von {MaxTextLength} Zeichen.", nameof(text));
+ }
+
ArgumentNullException.ThrowIfNull(@operator);
return new EtbEntry
@@ -39,7 +54,7 @@ public static EtbEntry Create(
Text = text.Trim(),
From = string.IsNullOrWhiteSpace(from) ? null : from.Trim(),
To = string.IsNullOrWhiteSpace(to) ? null : to.Trim(),
- EnteredBy = @operator.Display
+ EnteredBy = @operator.Display,
};
}
@@ -61,7 +76,7 @@ public static EtbEntry Rehydrate(
EnteredBy = enteredBy,
From = from,
To = to,
- Edits = (edits ?? Enumerable.Empty()).ToList()
+ Edits = (edits ?? Enumerable.Empty()).ToList(),
};
///
@@ -78,14 +93,22 @@ public static EtbEntry Rehydrate(
public EtbEntry WithEditedText(string newText, SessionOperator editor, DateTimeOffset editedAt)
{
if (string.IsNullOrWhiteSpace(newText))
+ {
throw new ArgumentException("ETB-Eintrag darf nicht leer sein.", nameof(newText));
+ }
+
if (newText.Length > MaxTextLength)
+ {
throw new ArgumentException($"ETB-Eintrag ist länger als das Limit von {MaxTextLength} Zeichen.", nameof(newText));
+ }
+
ArgumentNullException.ThrowIfNull(editor);
var trimmed = newText.Trim();
if (trimmed == Text)
+ {
return this;
+ }
var edits = new List(Edits) { new(Text, editor.Display, editedAt) };
return this with { Text = trimmed, Edits = edits };
diff --git a/src/LageBuch.Domain/Files/IncidentFile.cs b/src/LageBuch.Domain/Files/IncidentFile.cs
index 916028c..e5e69ee 100644
--- a/src/LageBuch.Domain/Files/IncidentFile.cs
+++ b/src/LageBuch.Domain/Files/IncidentFile.cs
@@ -9,30 +9,50 @@ public sealed record IncidentFile
public static readonly IReadOnlySet AllowedContentTypes = new HashSet(StringComparer.OrdinalIgnoreCase)
{
- "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"
+ "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf",
};
- private IncidentFile() { }
+ private IncidentFile()
+ {
+ }
public Guid Id { get; private init; }
+
public string FileName { get; private init; } = string.Empty;
+
public string DisplayName { get; private init; } = string.Empty;
+
public string ContentType { get; private init; } = string.Empty;
+
public long SizeBytes { get; private init; }
+
public DateTimeOffset AddedAt { get; private init; }
+
public string AddedBy { get; private init; } = string.Empty;
public static IncidentFile Create(
string fileName, string contentType, long sizeBytes, DateTimeOffset addedAt, string addedBy)
{
if (string.IsNullOrWhiteSpace(fileName))
+ {
throw new ArgumentException("Dateiname darf nicht leer sein.", nameof(fileName));
+ }
+
if (!AllowedContentTypes.Contains(contentType))
+ {
throw new ArgumentException($"Dateityp '{contentType}' wird nicht unterstützt.", nameof(contentType));
+ }
+
if (sizeBytes <= 0)
+ {
throw new ArgumentException("Dateigröße muss positiv sein.", nameof(sizeBytes));
+ }
+
if (sizeBytes > MaxSizeBytes)
+ {
throw new ArgumentException($"Datei ist größer als das Limit von {MaxSizeBytes / (1024 * 1024)} MB.", nameof(sizeBytes));
+ }
+
ArgumentException.ThrowIfNullOrWhiteSpace(addedBy);
var trimmedName = fileName.Trim();
@@ -44,7 +64,7 @@ public static IncidentFile Create(
ContentType = contentType,
SizeBytes = sizeBytes,
AddedAt = addedAt,
- AddedBy = addedBy
+ AddedBy = addedBy,
};
}
@@ -58,7 +78,7 @@ public static IncidentFile Rehydrate(
ContentType = contentType,
SizeBytes = sizeBytes,
AddedAt = addedAt,
- AddedBy = addedBy
+ AddedBy = addedBy,
};
///
@@ -69,7 +89,7 @@ public static IncidentFile Rehydrate(
///
public IncidentFile WithDisplayName(string? displayName) => this with
{
- DisplayName = string.IsNullOrWhiteSpace(displayName) ? FileName : displayName.Trim()
+ DisplayName = string.IsNullOrWhiteSpace(displayName) ? FileName : displayName.Trim(),
};
///
diff --git a/src/LageBuch.Domain/ForceUnit.cs b/src/LageBuch.Domain/ForceUnit.cs
index afc3462..1a61d61 100644
--- a/src/LageBuch.Domain/ForceUnit.cs
+++ b/src/LageBuch.Domain/ForceUnit.cs
@@ -8,6 +8,7 @@ public sealed record ForceUnit(
int ScbaCount,
string? Status,
string? Notes,
+
// Appended last with a default so pre-#76 construction sites (repository, snapshot, sync) keep
// compiling — and old rows/payloads read as "keine Führungskraft erfasst" (0/x/x) instead of
// breaking. The total is unchanged by this field: Mannschaft is derived, not stored.
@@ -29,20 +30,32 @@ public static ForceUnit Create(
int officerCount = 0)
{
if (string.IsNullOrWhiteSpace(brigade))
+ {
throw new ArgumentException("Feuerwehr darf nicht leer sein.", nameof(brigade));
+ }
+
ArgumentOutOfRangeException.ThrowIfNegative(personnelCount);
ArgumentOutOfRangeException.ThrowIfNegative(scbaCount);
+
// Atemschutzgeräteträger are a subset of the crew, so they can never outnumber it. Worth
// enforcing rather than merely displaying: this count is what tells the Einsatzleiter how
// many Trupps can actually be formed.
if (scbaCount > personnelCount)
- throw new ArgumentOutOfRangeException(nameof(scbaCount),
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(scbaCount),
"Atemschutzgeräteträger dürfen die Gesamtstärke nicht übersteigen.");
+ }
+
ArgumentOutOfRangeException.ThrowIfNegative(officerCount);
+
// Führungskräfte are likewise a subset of the crew (#76).
if (officerCount > personnelCount)
- throw new ArgumentOutOfRangeException(nameof(officerCount),
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(officerCount),
"Führungskräfte dürfen die Gesamtstärke nicht übersteigen.");
+ }
return new ForceUnit(
Guid.NewGuid(),
@@ -82,7 +95,7 @@ public static ForceUnit Rehydrate(
IEnumerable? edits = null)
=> new(id, brigade, callSign, personnelCount, scbaCount, status, notes, officerCount)
{
- Edits = (edits ?? Enumerable.Empty()).ToList()
+ Edits = (edits ?? Enumerable.Empty()).ToList(),
};
///
@@ -94,24 +107,36 @@ public static ForceUnit Rehydrate(
/// inflate the retained history. Validation mirrors .
///
public ForceUnit WithStrength(
- int officerCount, int personnelCount, int scbaCount,
- SessionOperator editor, DateTimeOffset editedAt)
+ int officerCount,
+ int personnelCount,
+ int scbaCount,
+ SessionOperator editor,
+ DateTimeOffset editedAt)
{
ArgumentNullException.ThrowIfNull(editor);
ArgumentOutOfRangeException.ThrowIfNegative(personnelCount);
if (scbaCount < 0 || scbaCount > personnelCount)
- throw new ArgumentOutOfRangeException(nameof(scbaCount),
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(scbaCount),
"Atemschutzgeräteträger dürfen die Gesamtstärke nicht übersteigen.");
+ }
+
if (officerCount < 0 || officerCount > personnelCount)
- throw new ArgumentOutOfRangeException(nameof(officerCount),
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(officerCount),
"Führungskräfte dürfen die Gesamtstärke nicht übersteigen.");
+ }
if (officerCount == OfficerCount && personnelCount == PersonnelCount && scbaCount == ScbaCount)
+ {
return this;
+ }
var edits = new List(Edits)
{
- new(OfficerCount, PersonnelCount, ScbaCount, editor.Display, editedAt)
+ new(OfficerCount, PersonnelCount, ScbaCount, editor.Display, editedAt),
};
return this with
{
diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs
index 89c6ca4..7c0239a 100644
--- a/src/LageBuch.Domain/Incident.cs
+++ b/src/LageBuch.Domain/Incident.cs
@@ -8,8 +8,6 @@
namespace LageBuch.Domain;
-public sealed record AuditEvent(DateTimeOffset At, string Action, string By);
-
public sealed class Incident
{
private readonly List _checklistAufbau = new();
@@ -25,27 +23,42 @@ public sealed class Incident
private readonly List _buildings = new();
private readonly List _dwellings = new();
- private Incident() { }
+ private Incident()
+ {
+ }
public Guid Id { get; private init; }
+
public DateTimeOffset StartedAt { get; private init; }
+
public IncidentState State { get; private set; }
public IncidentNumber? IncidentNumber { get; private set; }
+
public string? Keyword { get; private set; }
+
public string? Street { get; private set; }
+
public string? District { get; private set; }
+
public string? Status { get; private set; }
public DateTimeOffset? ClosedAt { get; private set; }
+
public string? ClosedBy { get; private set; }
public IReadOnlyList ChecklistAufbau => _checklistAufbau;
+
public IReadOnlyList ChecklistAbbau => _checklistAbbau;
+
public IReadOnlyList Journal => _journal;
+
public IReadOnlyList Roles => _roles;
+
public IReadOnlyList Forces => _forces;
+
public IReadOnlyList ScbaTrupps => _scbaTrupps;
+
public IReadOnlyList Audit => _audit;
/// Persisted incident-level timers, keyed by .
@@ -58,6 +71,7 @@ private Incident() { }
public IReadOnlyList Tasks => _tasks;
public IReadOnlyList Buildings => _buildings;
+
public IReadOnlyList Dwellings => _dwellings;
/// The persisted state of the timer with this key, or null if none has been recorded.
@@ -93,12 +107,13 @@ public static Incident Start(
StartedAt = clock.Now,
State = IncidentState.Open,
Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(),
- IncidentNumber = incidentNumber
+ IncidentNumber = incidentNumber,
};
incident._audit.Add(new AuditEvent(clock.Now, "opened", openedBy.Display));
- incident.AppendSystemEntry(clock, openedBy, incidentNumber is null
+ var openedText = incidentNumber is null
? "Einsatz begonnen"
- : $"Einsatz begonnen (Einsatznummer {incidentNumber.Value})");
+ : $"Einsatz begonnen (Einsatznummer {incidentNumber.Value})";
+ incident.AppendSystemEntry(clock, openedBy, openedText);
return incident;
}
@@ -137,7 +152,7 @@ public static Incident Rehydrate(
District = district,
Status = status,
ClosedAt = closedAt,
- ClosedBy = closedBy
+ ClosedBy = closedBy,
};
incident._checklistAufbau.AddRange(checklistAufbau);
incident._checklistAbbau.AddRange(checklistAbbau);
@@ -164,7 +179,10 @@ public void UpsertTimer(
{
EnsureOpen();
if (string.IsNullOrWhiteSpace(key))
+ {
throw new ArgumentException("Timer key must not be blank.", nameof(key));
+ }
+
_timers.RemoveAll(t => t.Key == key);
_timers.Add(new IncidentTimerState(key.Trim(), cycleAnchor, intervalMinutes, recurringIntervalMinutes, isRunning));
}
@@ -172,7 +190,9 @@ public void UpsertTimer(
private void EnsureOpen()
{
if (State == IncidentState.Closed)
+ {
throw new IncidentClosedException();
+ }
}
// Appends an automatic (system-generated) entry straight to the journal, deliberately
@@ -198,14 +218,21 @@ public void Close(IClock clock, SessionOperator closedBy)
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(closedBy);
EnsureOpen();
+
// Must precede the state flip — a closed incident rejects journal writes.
AppendSystemEntry(clock, closedBy, "Einsatz abgeschlossen");
+
// A closed incident is a historical record: nobody hands over a role after the fact, so any
// still-running assignment is stamped closed right along with it, silently — same as a plain
// AssignRole/EndRoleAssignment, which don't log to the ETB either.
for (var i = 0; i < _roles.Count; i++)
+ {
if (_roles[i].To is null)
+ {
_roles[i] = _roles[i].EndedAt(clock.Now);
+ }
+ }
+
State = IncidentState.Closed;
ClosedAt = clock.Now;
ClosedBy = closedBy.Display;
@@ -278,7 +305,9 @@ public ChecklistItem ToggleChecklistItem(IClock clock, SessionOperator op, Guid
var isComplete = AllMandatoryDone(list);
if (!wasComplete && isComplete)
+ {
AppendSystemEntry(clock, op, $"Checkliste {kind} abgeschlossen: alle Pflichtpunkte erledigt");
+ }
return item;
}
@@ -286,9 +315,15 @@ public ChecklistItem ToggleChecklistItem(IClock clock, SessionOperator op, Guid
private (List List, ChecklistKind Kind) FindChecklistOwning(Guid itemId)
{
if (_checklistAufbau.Any(c => c.Id == itemId))
+ {
return (_checklistAufbau, ChecklistKind.Aufbau);
+ }
+
if (_checklistAbbau.Any(c => c.Id == itemId))
+ {
return (_checklistAbbau, ChecklistKind.Abbau);
+ }
+
throw new KeyNotFoundException($"Checklist item {itemId} not found.");
}
@@ -306,6 +341,7 @@ public EtbEntry AddJournalEntry(
EnsureOpen();
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(op);
+
// Direction rides the wire as a plain integer (AddJournalEntryCommand.Direction), so a
// malformed value (e.g. a forged "direction": 99) must not reach EtbEntry.Create and get
// persisted by an out-of-range ordinal. Note this intentionally still allows System: several
@@ -313,7 +349,10 @@ public EtbEntry AddJournalEntry(
// same method with EtbDirection.System rather than the private AppendSystemEntry helper, so
// rejecting it here would break them, not just a synced command's ability to forge one.
if (!Enum.IsDefined(direction))
+ {
throw new ArgumentException("Ungültige Richtung für einen ETB-Eintrag.", nameof(direction));
+ }
+
var entry = EtbEntry.Create(clock.Now, direction, text, op, from, to);
_journal.Add(entry);
return entry;
@@ -339,18 +378,26 @@ public EtbEntry EditJournalEntry(IClock clock, SessionOperator op, Guid entryId,
var index = _journal.FindIndex(e => e.Id == entryId);
if (index < 0)
+ {
throw new KeyNotFoundException($"ETB-Eintrag {entryId} nicht gefunden.");
+ }
var existing = _journal[index];
if (existing.Direction == EtbDirection.System)
+ {
throw new InvalidOperationException("Systemeinträge können nicht bearbeitet werden.");
+ }
var edited = existing.WithEditedText(text, op, clock.Now);
_journal[index] = edited;
+
// WithEditedText is a no-op (returns the same instance) when the text didn't actually
// change -- nothing to trace in that case.
if (edited.Edits.Count > existing.Edits.Count)
+ {
AppendSystemEntry(clock, op, $"ETB-Eintrag {existing.Timestamp:HH:mm} bearbeitet");
+ }
+
return edited;
}
@@ -391,9 +438,14 @@ public RoleAssignment EndRoleAssignment(Guid assignmentId, DateTimeOffset to)
EnsureOpen();
var index = _roles.FindIndex(r => r.Id == assignmentId);
if (index < 0)
+ {
throw new ArgumentException("Funktionszuweisung nicht gefunden.", nameof(assignmentId));
+ }
+
if (_roles[index].To is not null)
+ {
throw new InvalidOperationException("Funktionszuweisung ist bereits beendet.");
+ }
var ended = _roles[index].EndedAt(to);
_roles[index] = ended;
@@ -407,8 +459,12 @@ public RoleAssignment EndRoleAssignment(Guid assignmentId, DateTimeOffset to)
/// entry: "wer hat wann welche Funktion übernommen" is exactly what the journal exists to answer.
///
public RoleAssignment TransferRole(
- IClock clock, SessionOperator op, Guid assignmentId,
- string newPersonName, string? newCallSign, string? newPhone)
+ IClock clock,
+ SessionOperator op,
+ Guid assignmentId,
+ string newPersonName,
+ string? newCallSign,
+ string? newPhone)
{
EnsureOpen();
ArgumentNullException.ThrowIfNull(clock);
@@ -416,17 +472,27 @@ public RoleAssignment TransferRole(
var index = _roles.FindIndex(r => r.Id == assignmentId);
if (index < 0)
+ {
throw new ArgumentException("Funktionszuweisung nicht gefunden.", nameof(assignmentId));
+ }
+
if (_roles[index].To is not null)
+ {
throw new InvalidOperationException("Funktionszuweisung ist bereits beendet.");
+ }
var previous = _roles[index];
var ended = previous.EndedAt(clock.Now);
_roles[index] = ended;
var next = RoleAssignment.Create(
- ended.Role, newPersonName, newCallSign, from: clock.Now, to: null,
- section: ended.Section, phone: newPhone);
+ ended.Role,
+ newPersonName,
+ newCallSign,
+ from: clock.Now,
+ to: null,
+ section: ended.Section,
+ phone: newPhone);
_roles.Add(next);
AppendSystemEntry(clock, op, $"Funktion {ended.Role} übergeben: {ended.PersonName} → {next.PersonName}");
@@ -445,15 +511,21 @@ public RoleAssignment EditRolePhone(IClock clock, SessionOperator op, Guid assig
var index = _roles.FindIndex(r => r.Id == assignmentId);
if (index < 0)
+ {
throw new ArgumentException("Funktionszuweisung nicht gefunden.", nameof(assignmentId));
+ }
var previous = _roles[index];
var updated = previous.WithPhone(phone);
_roles[index] = updated;
if (!string.Equals(previous.Phone, updated.Phone, StringComparison.Ordinal))
- AppendSystemEntry(clock, op,
+ {
+ AppendSystemEntry(
+ clock,
+ op,
$"Handynummer für {updated.Role} ({updated.PersonName}) geändert: {previous.Phone ?? "—"} → {updated.Phone ?? "—"}");
+ }
return updated;
}
@@ -485,9 +557,14 @@ public ForceUnit AddForceUnit(
// "Einheit aufgenommen: Aich, Stärke 0/6/6" instead of trailing "davon 0 AGT — Status: ".
var text = $"Einheit aufgenommen: {Label(unit)}, Stärke {unit.StrengthText}";
if (unit.ScbaCount > 0)
+ {
text += $", davon {unit.ScbaCount} AGT";
+ }
+
if (unit.Status is not null)
+ {
text += $" — Status: {unit.Status}";
+ }
AppendSystemEntry(clock, op, text, to: unit.CallSign);
return unit;
@@ -511,7 +588,9 @@ public ForceUnit UpdateForceUnit(
var index = _forces.FindIndex(f => f.Id == unitId);
if (index < 0)
+ {
throw new ArgumentException("Einheit nicht gefunden.", nameof(unitId));
+ }
var previous = _forces[index];
var updated = previous.WithStatusAndNotes(status, notes);
@@ -520,7 +599,9 @@ public ForceUnit UpdateForceUnit(
// Compare the normalised values, so re-selecting the same status -- or the same status
// with stray whitespace -- is not a transition.
if (!string.Equals(previous.Status, updated.Status, StringComparison.Ordinal))
+ {
AppendSystemEntry(clock, op, StatusChangeText(previous, updated), from: updated.CallSign);
+ }
return updated;
}
@@ -536,8 +617,12 @@ private static string Label(ForceUnit unit) =>
/// the unit itself (). An unchanged resubmission is neither.
///
public ForceUnit UpdateForceStrength(
- IClock clock, SessionOperator op, Guid unitId,
- int officerCount, int personnelCount, int scbaCount)
+ IClock clock,
+ SessionOperator op,
+ Guid unitId,
+ int officerCount,
+ int personnelCount,
+ int scbaCount)
{
EnsureOpen();
ArgumentNullException.ThrowIfNull(clock);
@@ -545,12 +630,16 @@ public ForceUnit UpdateForceStrength(
var index = _forces.FindIndex(f => f.Id == unitId);
if (index < 0)
+ {
throw new KeyNotFoundException($"Einheit {unitId} nicht gefunden.");
+ }
var previous = _forces[index];
var updated = previous.WithStrength(officerCount, personnelCount, scbaCount, op, clock.Now);
if (ReferenceEquals(updated, previous))
+ {
return previous;
+ }
_forces[index] = updated;
@@ -558,7 +647,10 @@ public ForceUnit UpdateForceStrength(
// AddForceUnit's optional clauses.
var text = $"{Label(updated)}: Stärke {previous.StrengthText} → {updated.StrengthText}";
if (previous.ScbaCount != updated.ScbaCount)
+ {
text += $", davon AGT {previous.ScbaCount} → {updated.ScbaCount}";
+ }
+
AppendSystemEntry(clock, op, text, from: updated.CallSign);
return updated;
@@ -585,7 +677,9 @@ public void RemoveForceUnit(IClock clock, SessionOperator op, Guid unitId)
var index = _forces.FindIndex(f => f.Id == unitId);
if (index < 0)
+ {
throw new KeyNotFoundException($"Einheit {unitId} nicht gefunden.");
+ }
var unit = _forces[index];
_forces.RemoveAt(index);
@@ -614,10 +708,21 @@ public AtemschutzTrupp AddScbaTrupp(
ArgumentNullException.ThrowIfNull(clock);
var number = truppNumber ?? NextFreeScbaTruppNumber();
if (_scbaTrupps.Any(t => t.TruppNumber == number))
+ {
throw new ArgumentException($"Truppnummer {number} ist bereits vergeben.", nameof(truppNumber));
+ }
+
var trupp = AtemschutzTrupp.Register(
- clock.Now, designation, members, entryPressure, number, callSign, task,
- maxDurationMinutes, returnPressureBar, pressureControlIntervalMinutes);
+ clock.Now,
+ designation,
+ members,
+ entryPressure,
+ number,
+ callSign,
+ task,
+ maxDurationMinutes,
+ returnPressureBar,
+ pressureControlIntervalMinutes);
_scbaTrupps.Add(trupp);
return trupp;
}
@@ -685,7 +790,10 @@ public IncidentFile RenameFile(Guid fileId, string? displayName)
EnsureOpen();
var index = _files.FindIndex(f => f.Id == fileId);
if (index < 0)
+ {
throw new KeyNotFoundException($"Datei {fileId} nicht gefunden.");
+ }
+
var renamed = _files[index].WithDisplayName(displayName);
_files[index] = renamed;
return renamed;
@@ -711,9 +819,14 @@ public IncidentTask AddTask(
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(op);
if (!Enum.IsDefined(importance))
+ {
throw new ArgumentException("Unbekannte Wichtigkeit.", nameof(importance));
+ }
+
if (!Enum.IsDefined(urgency))
+ {
throw new ArgumentException("Unbekannte Dringlichkeit.", nameof(urgency));
+ }
var task = IncidentTask.Create(clock.Now, text, assignee, importance, urgency, timerMinutes, op);
_tasks.Add(task);
@@ -733,7 +846,9 @@ public IncidentTask SetTaskCompleted(Guid taskId, bool isDone, IClock clock, Ses
var index = _tasks.FindIndex(t => t.Id == taskId);
if (index < 0)
+ {
throw new KeyNotFoundException($"Aufgabe {taskId} nicht gefunden.");
+ }
var updated = _tasks[index].WithCompletion(isDone, op, clock.Now);
_tasks[index] = updated;
@@ -755,10 +870,16 @@ public void AddCoBuilding(IClock clock, SessionOperator op, string name, int flo
_buildings.Add(building);
for (var floor = 0; floor <= floorCount; floor++)
+ {
for (var apt = 1; apt <= apartmentsPerFloor; apt++)
+ {
_dwellings.Add(Dwelling.Create(building.Id, floor, apt));
+ }
+ }
- AppendSystemEntry(clock, op,
+ AppendSystemEntry(
+ clock,
+ op,
$"CO-Messprotokoll eröffnet: {building.Name} (EG–{FloorLabel(floorCount)}, {apartmentsPerFloor} Wohnungen je Geschoss)");
}
@@ -783,7 +904,9 @@ public void UpdateCoBuildingStructure(IClock clock, SessionOperator op, Guid bui
var text = $"CO-Struktur geändert: {building.Name} jetzt EG–{FloorLabel(floorCount)}, {apartmentsPerFloor} Wohnungen je Geschoss";
if (removed > 0)
+ {
text += $", {removed} Wohnungen entfernt";
+ }
AppendSystemEntry(clock, op, text);
}
@@ -808,13 +931,17 @@ public void RecordCoValue(IClock clock, SessionOperator op, Guid buildingId, int
ArgumentNullException.ThrowIfNull(op);
if (coValue is < 0)
+ {
throw new ArgumentOutOfRangeException(nameof(coValue), "CO-Messwert darf nicht negativ sein.");
+ }
var building = FindBuilding(buildingId);
var dwelling = FindDwelling(buildingId, floorOrdinal, apartmentNumber);
if (dwelling.CoValue == coValue)
+ {
return;
+ }
var index = _dwellings.IndexOf(dwelling);
_dwellings[index] = dwelling.WithCoValue(coValue);
@@ -836,7 +963,9 @@ public void SetDwellingStatus(IClock clock, SessionOperator op, Guid buildingId,
var dwelling = FindDwelling(buildingId, floorOrdinal, apartmentNumber);
if (dwelling.Status == status)
+ {
return;
+ }
var index = _dwellings.IndexOf(dwelling);
_dwellings[index] = dwelling.WithStatus(status);
diff --git a/src/LageBuch.Domain/IncidentClosedException.cs b/src/LageBuch.Domain/IncidentClosedException.cs
index af9c1a3..59e9465 100644
--- a/src/LageBuch.Domain/IncidentClosedException.cs
+++ b/src/LageBuch.Domain/IncidentClosedException.cs
@@ -3,9 +3,17 @@ namespace LageBuch.Domain;
public sealed class IncidentClosedException : InvalidOperationException
{
public IncidentClosedException()
- : base("Der Einsatz ist abgeschlossen und schreibgeschützt.") { }
+ : base("Der Einsatz ist abgeschlossen und schreibgeschützt.")
+ {
+ }
- public IncidentClosedException(string message) : base(message) { }
+ public IncidentClosedException(string message)
+ : base(message)
+ {
+ }
- public IncidentClosedException(string message, Exception innerException) : base(message, innerException) { }
+ public IncidentClosedException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
}
diff --git a/src/LageBuch.Domain/IncidentState.cs b/src/LageBuch.Domain/IncidentState.cs
index c274627..3bea24b 100644
--- a/src/LageBuch.Domain/IncidentState.cs
+++ b/src/LageBuch.Domain/IncidentState.cs
@@ -3,5 +3,5 @@ namespace LageBuch.Domain;
public enum IncidentState
{
Open,
- Closed
+ Closed,
}
diff --git a/src/LageBuch.Domain/RoleAssignment.cs b/src/LageBuch.Domain/RoleAssignment.cs
index a63fcad..d3fe88e 100644
--- a/src/LageBuch.Domain/RoleAssignment.cs
+++ b/src/LageBuch.Domain/RoleAssignment.cs
@@ -20,11 +20,19 @@ public static RoleAssignment Create(
string? phone = null)
{
if (string.IsNullOrWhiteSpace(role))
+ {
throw new ArgumentException("Funktion darf nicht leer sein.", nameof(role));
+ }
+
if (string.IsNullOrWhiteSpace(personName))
+ {
throw new ArgumentException("Name darf nicht leer sein.", nameof(personName));
+ }
+
if (from is { } f && to is { } t && t < f)
+ {
throw new ArgumentException("Bis-Zeitpunkt darf nicht vor dem Von-Zeitpunkt liegen.", nameof(to));
+ }
return new RoleAssignment(
Guid.NewGuid(),
@@ -44,7 +52,10 @@ public static RoleAssignment Create(
public RoleAssignment EndedAt(DateTimeOffset to)
{
if (From is { } f && to < f)
+ {
throw new ArgumentException("Bis-Zeitpunkt darf nicht vor dem Von-Zeitpunkt liegen.", nameof(to));
+ }
+
return this with { To = to };
}
diff --git a/src/LageBuch.Domain/SessionOperator.cs b/src/LageBuch.Domain/SessionOperator.cs
index 187cc17..afb0702 100644
--- a/src/LageBuch.Domain/SessionOperator.cs
+++ b/src/LageBuch.Domain/SessionOperator.cs
@@ -2,16 +2,19 @@ namespace LageBuch.Domain;
public sealed record SessionOperator
{
- public SessionOperator(string Name, string? CallSign = null)
+ public SessionOperator(string name, string? callSign = null)
{
- if (string.IsNullOrWhiteSpace(Name))
- throw new ArgumentException("Operator name must not be blank.", nameof(Name));
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Operator name must not be blank.", nameof(name));
+ }
- this.Name = Name.Trim();
- this.CallSign = string.IsNullOrWhiteSpace(CallSign) ? null : CallSign.Trim();
+ this.Name = name.Trim();
+ this.CallSign = string.IsNullOrWhiteSpace(callSign) ? null : callSign.Trim();
}
public string Name { get; }
+
public string? CallSign { get; }
public string Display => CallSign is null ? Name : $"{Name} ({CallSign})";
diff --git a/src/LageBuch.Domain/Tasks/IncidentTask.cs b/src/LageBuch.Domain/Tasks/IncidentTask.cs
index 5b42190..257f2ce 100644
--- a/src/LageBuch.Domain/Tasks/IncidentTask.cs
+++ b/src/LageBuch.Domain/Tasks/IncidentTask.cs
@@ -30,17 +30,28 @@ public sealed record IncidentTask
// (same rationale as EtbEntry.MaxTextLength).
public const int MaxTextLength = 1000;
- private IncidentTask() { }
+ private IncidentTask()
+ {
+ }
public Guid Id { get; private init; }
+
public string Text { get; private init; } = string.Empty;
+
public string Assignee { get; private init; } = string.Empty;
+
public TaskImportance Importance { get; private init; }
+
public TaskUrgency Urgency { get; private init; }
+
public string CreatedBy { get; private init; } = string.Empty;
+
public DateTimeOffset CreatedAt { get; private init; }
+
public DateTimeOffset DueAt { get; private init; }
+
public DateTimeOffset? CompletedAt { get; private init; }
+
public string? CompletedBy { get; private init; }
public bool IsCompleted => CompletedAt is not null;
@@ -55,11 +66,20 @@ public static IncidentTask Create(
SessionOperator @operator)
{
if (string.IsNullOrWhiteSpace(text))
+ {
throw new ArgumentException("Aufgabe darf nicht leer sein.", nameof(text));
+ }
+
if (text.Length > MaxTextLength)
+ {
throw new ArgumentException($"Aufgabe ist länger als das Limit von {MaxTextLength} Zeichen.", nameof(text));
+ }
+
if (timerMinutes < 0)
+ {
throw new ArgumentException("Der Timer darf nicht negativ sein.", nameof(timerMinutes));
+ }
+
ArgumentNullException.ThrowIfNull(@operator);
return new IncidentTask
diff --git a/src/LageBuch.Domain/Time/ReminderTimer.cs b/src/LageBuch.Domain/Time/ReminderTimer.cs
index a3c6dff..5169852 100644
--- a/src/LageBuch.Domain/Time/ReminderTimer.cs
+++ b/src/LageBuch.Domain/Time/ReminderTimer.cs
@@ -24,9 +24,15 @@ public void Start(IClock clock, int firstIntervalMinutes, int recurringIntervalM
{
ArgumentNullException.ThrowIfNull(clock);
if (firstIntervalMinutes <= 0)
+ {
throw new ArgumentOutOfRangeException(nameof(firstIntervalMinutes), "Interval must be positive.");
+ }
+
if (recurringIntervalMinutes <= 0)
+ {
throw new ArgumentOutOfRangeException(nameof(recurringIntervalMinutes), "Interval must be positive.");
+ }
+
IntervalMinutes = firstIntervalMinutes;
RecurringIntervalMinutes = recurringIntervalMinutes;
CycleAnchor = clock.Now;
@@ -42,9 +48,15 @@ public void Start(IClock clock, int firstIntervalMinutes, int recurringIntervalM
public void Resume(DateTimeOffset anchor, int currentIntervalMinutes, int recurringIntervalMinutes)
{
if (currentIntervalMinutes <= 0)
+ {
throw new ArgumentOutOfRangeException(nameof(currentIntervalMinutes), "Interval must be positive.");
+ }
+
if (recurringIntervalMinutes <= 0)
+ {
throw new ArgumentOutOfRangeException(nameof(recurringIntervalMinutes), "Interval must be positive.");
+ }
+
IntervalMinutes = currentIntervalMinutes;
RecurringIntervalMinutes = recurringIntervalMinutes;
CycleAnchor = anchor;
@@ -57,7 +69,10 @@ public void Acknowledge(IClock clock)
{
ArgumentNullException.ThrowIfNull(clock);
if (!IsRunning)
+ {
return;
+ }
+
// Subsequent cycles run on the recurring interval — the first "after 15 min" gives way to
// the "then every 30 min" cadence once the crew has reported back at least once.
IntervalMinutes = RecurringIntervalMinutes;
diff --git a/src/LageBuch.Domain/ValueObjects/IncidentNumber.cs b/src/LageBuch.Domain/ValueObjects/IncidentNumber.cs
index f8e72e0..98da23f 100644
--- a/src/LageBuch.Domain/ValueObjects/IncidentNumber.cs
+++ b/src/LageBuch.Domain/ValueObjects/IncidentNumber.cs
@@ -5,7 +5,10 @@ public sealed record IncidentNumber
public IncidentNumber(string value)
{
if (string.IsNullOrWhiteSpace(value))
+ {
throw new ArgumentException("Incident number must not be blank.", nameof(value));
+ }
+
Value = value.Trim();
}
diff --git a/src/LageBuch.Persistence/IncidentFileStore.cs b/src/LageBuch.Persistence/IIncidentFileStore.cs
similarity index 97%
rename from src/LageBuch.Persistence/IncidentFileStore.cs
rename to src/LageBuch.Persistence/IIncidentFileStore.cs
index c36bb2a..be82589 100644
--- a/src/LageBuch.Persistence/IncidentFileStore.cs
+++ b/src/LageBuch.Persistence/IIncidentFileStore.cs
@@ -29,7 +29,9 @@ public void SaveBytes(string incidentPath, string storageFileName, byte[] bytes)
File.WriteAllBytes(Path.Combine(folder, storageFileName), bytes);
}
- [SuppressMessage("Design", "CA1031",
+ [SuppressMessage(
+ "Design",
+ "CA1031",
Justification = "Try-read: an unreadable attachment degrades to null, never fails incident load.")]
public byte[]? TryReadBytes(string incidentPath, string storageFileName)
{
diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs
index ab74970..2bea7c0 100644
--- a/src/LageBuch.Persistence/IncidentRepository.cs
+++ b/src/LageBuch.Persistence/IncidentRepository.cs
@@ -18,24 +18,28 @@ public static void Save(string path, Incident incident)
using var tx = cn.BeginTransaction();
foreach (var table in new[]
- { "incident_meta", "checklist_items", "etb_entries", "etb_entry_edits",
- "role_assignments", "force_units", "scba_trupps",
- "scba_trupp_members", "scba_pressure_readings", "audit_events",
- "incident_timers", "incident_files", "incident_tasks",
- "co_buildings", "co_dwellings" })
+ {
+ "incident_meta", "checklist_items", "etb_entries", "etb_entry_edits",
+ "role_assignments", "force_units", "scba_trupps",
+ "scba_trupp_members", "scba_pressure_readings", "audit_events",
+ "incident_timers", "incident_files", "incident_tasks",
+ "co_buildings", "co_dwellings",
+ })
{
Exec(cn, tx, $"DELETE FROM {table};");
}
- Run(cn, tx,
- "INSERT INTO incident_meta (id, started_at, state, incident_number, ils_number, keyword, street, district, status, closed_at, closed_by) " +
- "VALUES ($id,$started,$state,$num,$ils,$kw,$street,$district,$status,$closedAt,$closedBy);",
+ Run(
+ cn,
+ tx,
+ "INSERT INTO incident_meta (id, started_at, state, incident_number, ils_number, keyword, street, district, status, closed_at, closed_by) " + "VALUES ($id,$started,$state,$num,$ils,$kw,$street,$district,$status,$closedAt,$closedBy);",
p =>
{
p("$id", incident.Id.ToString());
p("$started", incident.StartedAt.ToString(Iso));
p("$state", (int)incident.State);
p("$num", (object?)incident.IncidentNumber?.Value ?? DBNull.Value);
+
// ils_number is retired: the complete Einsatznummer lives in incident_number now.
// The column is kept (dormant) so the schema is unchanged; always written null.
p("$ils", DBNull.Value);
@@ -53,24 +57,37 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.Journal.Count; i++)
{
var e = incident.Journal[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO etb_entries (id, ordinal, timestamp, direction, from_party, to_party, text, entered_by) VALUES ($id,$o,$ts,$dir,$from,$to,$txt,$by);",
p =>
{
- p("$id", e.Id.ToString()); p("$o", i); p("$ts", e.Timestamp.ToString(Iso));
- p("$dir", (int)e.Direction); p("$from", (object?)e.From ?? DBNull.Value);
- p("$to", (object?)e.To ?? DBNull.Value); p("$txt", e.Text); p("$by", e.EnteredBy);
+ p("$id", e.Id.ToString());
+ p("$o", i);
+ p("$ts", e.Timestamp.ToString(Iso));
+ p("$dir", (int)e.Direction);
+ p("$from", (object?)e.From ?? DBNull.Value);
+ p("$to", (object?)e.To ?? DBNull.Value);
+ p("$txt", e.Text);
+ p("$by", e.EnteredBy);
});
for (var j = 0; j < e.Edits.Count; j++)
{
var edit = e.Edits[j];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO etb_entry_edits (id, entry_id, ordinal, previous_text, edited_by, edited_at) VALUES ($id,$eid,$o,$txt,$by,$at);",
p =>
{
- p("$id", Guid.NewGuid().ToString()); p("$eid", e.Id.ToString()); p("$o", j);
- p("$txt", edit.PreviousText); p("$by", edit.EditedBy); p("$at", edit.EditedAt.ToString(Iso));
+ p("$id", Guid.NewGuid().ToString());
+ p("$eid", e.Id.ToString());
+ p("$o", j);
+ p("$txt", edit.PreviousText);
+ p("$by", edit.EditedBy);
+ p("$at", edit.EditedAt.ToString(Iso));
});
}
}
@@ -78,11 +95,16 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.Roles.Count; i++)
{
var r = incident.Roles[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO role_assignments (id, ordinal, role, person_name, call_sign, from_time, to_time, section, phone) VALUES ($id,$o,$role,$name,$cs,$from,$to,$sec,$ph);",
p =>
{
- p("$id", r.Id.ToString()); p("$o", i); p("$role", r.Role); p("$name", r.PersonName);
+ p("$id", r.Id.ToString());
+ p("$o", i);
+ p("$role", r.Role);
+ p("$name", r.PersonName);
p("$cs", (object?)r.CallSign ?? DBNull.Value);
p("$from", (object?)r.From?.ToString(Iso) ?? DBNull.Value);
p("$to", (object?)r.To?.ToString(Iso) ?? DBNull.Value);
@@ -94,28 +116,39 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.Forces.Count; i++)
{
var f = incident.Forces[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO force_units (id, ordinal, brigade, call_sign, personnel_count, scba_count, status, notes, officer_count) VALUES ($id,$o,$b,$cs,$pc,$ac,$st,$n,$oc);",
p =>
{
- p("$id", f.Id.ToString()); p("$o", i); p("$b", f.Brigade);
- p("$cs", (object?)f.CallSign ?? DBNull.Value); p("$pc", f.PersonnelCount);
+ p("$id", f.Id.ToString());
+ p("$o", i);
+ p("$b", f.Brigade);
+ p("$cs", (object?)f.CallSign ?? DBNull.Value);
+ p("$pc", f.PersonnelCount);
p("$ac", f.ScbaCount);
- p("$st", (object?)f.Status ?? DBNull.Value); p("$n", (object?)f.Notes ?? DBNull.Value);
+ p("$st", (object?)f.Status ?? DBNull.Value);
+ p("$n", (object?)f.Notes ?? DBNull.Value);
p("$oc", f.OfficerCount);
});
for (var j = 0; j < f.Edits.Count; j++)
{
var edit = f.Edits[j];
- Run(cn, tx,
- "INSERT INTO force_unit_edits (id, unit_id, ordinal, previous_officer_count, previous_personnel_count, previous_scba_count, edited_by, edited_at) " +
- "VALUES ($id,$uid,$o,$poc,$ppc,$psc,$by,$at);",
+ Run(
+ cn,
+ tx,
+ "INSERT INTO force_unit_edits (id, unit_id, ordinal, previous_officer_count, previous_personnel_count, previous_scba_count, edited_by, edited_at) " + "VALUES ($id,$uid,$o,$poc,$ppc,$psc,$by,$at);",
p =>
{
- p("$id", Guid.NewGuid().ToString()); p("$uid", f.Id.ToString()); p("$o", j);
- p("$poc", edit.PreviousOfficerCount); p("$ppc", edit.PreviousPersonnelCount);
- p("$psc", edit.PreviousScbaCount); p("$by", edit.EditedBy);
+ p("$id", Guid.NewGuid().ToString());
+ p("$uid", f.Id.ToString());
+ p("$o", j);
+ p("$poc", edit.PreviousOfficerCount);
+ p("$ppc", edit.PreviousPersonnelCount);
+ p("$psc", edit.PreviousScbaCount);
+ p("$by", edit.EditedBy);
p("$at", edit.EditedAt.ToString(Iso));
});
}
@@ -124,18 +157,24 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.ScbaTrupps.Count; i++)
{
var t = incident.ScbaTrupps[i];
- Run(cn, tx,
- "INSERT INTO scba_trupps (id, ordinal, trupp_number, designation, call_sign, task, registered_at, start_time, entry_pressure, withdraw_time, max_duration_minutes, return_pressure_bar, pressure_control_interval_minutes, exit_time) " +
- "VALUES ($id,$o,$num,$des,$cs,$task,$reg,$start,$ep,$wd,$max,$ret,$interval,$exit);",
+ Run(
+ cn,
+ tx,
+ "INSERT INTO scba_trupps (id, ordinal, trupp_number, designation, call_sign, task, registered_at, start_time, entry_pressure, withdraw_time, max_duration_minutes, return_pressure_bar, pressure_control_interval_minutes, exit_time) " + "VALUES ($id,$o,$num,$des,$cs,$task,$reg,$start,$ep,$wd,$max,$ret,$interval,$exit);",
p =>
{
- p("$id", t.Id.ToString()); p("$o", i); p("$num", t.TruppNumber); p("$des", t.Designation);
- p("$cs", (object?)t.CallSign ?? DBNull.Value); p("$task", (object?)t.Task ?? DBNull.Value);
+ p("$id", t.Id.ToString());
+ p("$o", i);
+ p("$num", t.TruppNumber);
+ p("$des", t.Designation);
+ p("$cs", (object?)t.CallSign ?? DBNull.Value);
+ p("$task", (object?)t.Task ?? DBNull.Value);
p("$reg", t.RegisteredAt.ToString(Iso));
p("$start", (object?)t.StartTime?.ToString(Iso) ?? DBNull.Value);
p("$ep", (object?)t.EntryPressure ?? DBNull.Value);
p("$wd", (object?)t.WithdrawTime?.ToString(Iso) ?? DBNull.Value);
- p("$max", t.MaxDurationMinutes); p("$ret", t.ReturnPressureBar);
+ p("$max", t.MaxDurationMinutes);
+ p("$ret", t.ReturnPressureBar);
p("$interval", t.PressureControlIntervalMinutes);
p("$exit", (object?)t.ExitTime?.ToString(Iso) ?? DBNull.Value);
});
@@ -143,24 +182,33 @@ public static void Save(string path, Incident incident)
for (var j = 0; j < t.Members.Count; j++)
{
var member = t.Members[j];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO scba_trupp_members (trupp_id, ordinal, role, name) VALUES ($tid,$o,$role,$name);",
p =>
{
- p("$tid", t.Id.ToString()); p("$o", j);
- p("$role", (int)member.Role); p("$name", member.Name);
+ p("$tid", t.Id.ToString());
+ p("$o", j);
+ p("$role", (int)member.Role);
+ p("$name", member.Name);
});
}
for (var j = 0; j < t.PressureReadings.Count; j++)
{
var reading = t.PressureReadings[j];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO scba_pressure_readings (id, trupp_id, ordinal, reading_time, bar) VALUES ($id,$tid,$o,$time,$bar);",
p =>
{
- p("$id", Guid.NewGuid().ToString()); p("$tid", t.Id.ToString()); p("$o", j);
- p("$time", reading.Time.ToString(Iso)); p("$bar", reading.Bar);
+ p("$id", Guid.NewGuid().ToString());
+ p("$tid", t.Id.ToString());
+ p("$o", j);
+ p("$time", reading.Time.ToString(Iso));
+ p("$bar", reading.Bar);
});
}
}
@@ -168,19 +216,31 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.Audit.Count; i++)
{
var a = incident.Audit[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO audit_events (ordinal, at, action, by_operator) VALUES ($o,$at,$act,$by);",
- p => { p("$o", i); p("$at", a.At.ToString(Iso)); p("$act", a.Action); p("$by", a.By); });
+ p =>
+ {
+ p("$o", i);
+ p("$at", a.At.ToString(Iso));
+ p("$act", a.Action);
+ p("$by", a.By);
+ });
}
foreach (var t in incident.Timers)
{
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO incident_timers (key, cycle_anchor, interval_minutes, recurring_interval_minutes, is_running) VALUES ($k,$a,$i,$r,$run);",
p =>
{
- p("$k", t.Key); p("$a", t.CycleAnchor.ToString(Iso));
- p("$i", t.IntervalMinutes); p("$r", t.RecurringIntervalMinutes);
+ p("$k", t.Key);
+ p("$a", t.CycleAnchor.ToString(Iso));
+ p("$i", t.IntervalMinutes);
+ p("$r", t.RecurringIntervalMinutes);
p("$run", t.IsRunning ? 1 : 0);
});
}
@@ -188,13 +248,20 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.Files.Count; i++)
{
var f = incident.Files[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO incident_files (id, ordinal, file_name, content_type, size_bytes, added_at, added_by, display_name) VALUES ($id,$o,$fn,$ct,$sz,$at,$by,$dn);",
p =>
{
- p("$id", f.Id.ToString()); p("$o", i); p("$fn", f.FileName);
- p("$ct", f.ContentType); p("$sz", f.SizeBytes);
- p("$at", f.AddedAt.ToString(Iso)); p("$by", f.AddedBy); p("$dn", f.DisplayName);
+ p("$id", f.Id.ToString());
+ p("$o", i);
+ p("$fn", f.FileName);
+ p("$ct", f.ContentType);
+ p("$sz", f.SizeBytes);
+ p("$at", f.AddedAt.ToString(Iso));
+ p("$by", f.AddedBy);
+ p("$dn", f.DisplayName);
});
}
@@ -203,25 +270,35 @@ public static void Save(string path, Incident incident)
var b = incident.Buildings[i];
var descriptionsJson = System.Text.Json.JsonSerializer.Serialize(b.FloorDescriptions);
var apartmentLabelsJson = System.Text.Json.JsonSerializer.Serialize(b.ApartmentLabels);
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO co_buildings (id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal, apartment_labels) VALUES ($id,$name,$fc,$apf,$fd,$o,$al);",
p =>
{
- p("$id", b.Id.ToString()); p("$name", b.Name);
- p("$fc", b.FloorCount); p("$apf", b.ApartmentsPerFloor);
- p("$fd", descriptionsJson); p("$o", i); p("$al", apartmentLabelsJson);
+ p("$id", b.Id.ToString());
+ p("$name", b.Name);
+ p("$fc", b.FloorCount);
+ p("$apf", b.ApartmentsPerFloor);
+ p("$fd", descriptionsJson);
+ p("$o", i);
+ p("$al", apartmentLabelsJson);
});
}
for (var i = 0; i < incident.Dwellings.Count; i++)
{
var d = incident.Dwellings[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO co_dwellings (id, building_id, floor_ordinal, apartment_number, resident_name, status, key_available, co_value) VALUES ($id,$bid,$fo,$an,$rn,$st,$kv,$cv);",
p =>
{
- p("$id", d.Id.ToString()); p("$bid", d.BuildingId.ToString());
- p("$fo", d.FloorOrdinal); p("$an", d.ApartmentNumber);
+ p("$id", d.Id.ToString());
+ p("$bid", d.BuildingId.ToString());
+ p("$fo", d.FloorOrdinal);
+ p("$an", d.ApartmentNumber);
p("$rn", (object?)d.ResidentName ?? DBNull.Value);
p("$st", (int)d.Status);
p("$kv", d.KeyAvailable is { } k ? (object)(k ? 1 : 0) : DBNull.Value);
@@ -232,14 +309,21 @@ public static void Save(string path, Incident incident)
for (var i = 0; i < incident.Tasks.Count; i++)
{
var t = incident.Tasks[i];
- Run(cn, tx,
- "INSERT INTO incident_tasks (id, ordinal, text, assignee, importance, urgency, created_by, created_at, due_at, completed_at, completed_by) " +
- "VALUES ($id,$o,$txt,$asg,$imp,$urg,$by,$cat,$due,$coat,$coby);",
+ Run(
+ cn,
+ tx,
+ "INSERT INTO incident_tasks (id, ordinal, text, assignee, importance, urgency, created_by, created_at, due_at, completed_at, completed_by) " + "VALUES ($id,$o,$txt,$asg,$imp,$urg,$by,$cat,$due,$coat,$coby);",
p =>
{
- p("$id", t.Id.ToString()); p("$o", i); p("$txt", t.Text); p("$asg", t.Assignee);
- p("$imp", (int)t.Importance); p("$urg", (int)t.Urgency);
- p("$by", t.CreatedBy); p("$cat", t.CreatedAt.ToString(Iso)); p("$due", t.DueAt.ToString(Iso));
+ p("$id", t.Id.ToString());
+ p("$o", i);
+ p("$txt", t.Text);
+ p("$asg", t.Assignee);
+ p("$imp", (int)t.Importance);
+ p("$urg", (int)t.Urgency);
+ p("$by", t.CreatedBy);
+ p("$cat", t.CreatedAt.ToString(Iso));
+ p("$due", t.DueAt.ToString(Iso));
p("$coat", (object?)t.CompletedAt?.ToString(Iso) ?? DBNull.Value);
p("$coby", (object?)t.CompletedBy ?? DBNull.Value);
});
@@ -253,12 +337,19 @@ private static void WriteChecklist(SqliteConnection cn, SqliteTransaction tx, IR
for (var i = 0; i < items.Count; i++)
{
var c = items[i];
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO checklist_items (id, ordinal, text, is_done, note, is_mandatory, kind) VALUES ($id,$o,$t,$d,$n,$m,$k);",
p =>
{
- p("$id", c.Id.ToString()); p("$o", i); p("$t", c.Text); p("$d", c.IsDone ? 1 : 0);
- p("$n", (object?)c.Note ?? DBNull.Value); p("$m", c.IsMandatory ? 1 : 0); p("$k", kind);
+ p("$id", c.Id.ToString());
+ p("$o", i);
+ p("$t", c.Text);
+ p("$d", c.IsDone ? 1 : 0);
+ p("$n", (object?)c.Note ?? DBNull.Value);
+ p("$m", c.IsMandatory ? 1 : 0);
+ p("$k", kind);
});
}
}
@@ -269,12 +360,17 @@ private static void WriteChecklist(SqliteConnection cn, SqliteTransaction tx, IR
/// `state` lives in the base schema's incident_meta, so this works across schema versions;
/// any failure (missing, corrupt, locked, too new) returns null so the overview degrades quietly.
///
-[SuppressMessage("Design", "CA1031",
- Justification = "Try-read: missing, corrupt, locked or too-new reads all degrade to null (see comment).")]
+ [SuppressMessage(
+ "Design",
+ "CA1031",
+ Justification = "Intentional: any failure (missing, corrupt, locked, or schema too new) must return null so the overview degrades quietly, per TryReadState's contract.")]
public static IncidentState? TryReadState(string path)
{
if (!File.Exists(path))
+ {
return null;
+ }
+
try
{
using var cn = SqliteConnectionFactory.OpenReadOnly(path);
@@ -294,7 +390,9 @@ public static Incident Load(string path)
// Check before opening: SQLite would otherwise report a missing file as a bare "unable to
// open database file", and the caller cannot tell that apart from a corrupt one.
if (!File.Exists(path))
+ {
throw new FileNotFoundException("Die Datei wurde nicht gefunden.", path);
+ }
// Bring an older file up to the current schema before reading it. A file last written
// by an earlier version sits at its old schema_version; without this, Load would query
@@ -319,49 +417,81 @@ public static Incident Load(string path)
? SqliteConnectionFactory.OpenReadOnly(path)
: SqliteConnectionFactory.OpenExisting(path);
- var meta = ReadRow(cn,
+ var meta = ReadRow(
+ cn,
"SELECT id, started_at, state, incident_number, ils_number, keyword, street, district, status, closed_at, closed_by FROM incident_meta LIMIT 1;");
- var checklistAufbau = ReadAll(cn,
+ var checklistAufbau = ReadAll(
+ cn,
"SELECT id, text, is_done, note, is_mandatory FROM checklist_items WHERE kind = 0 ORDER BY ordinal;",
r => Domain.ChecklistItem.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), r.GetInt32(2) != 0, Str(r, 3), r.GetInt32(4) != 0));
- var checklistAbbau = ReadAll(cn,
+ var checklistAbbau = ReadAll(
+ cn,
"SELECT id, text, is_done, note, is_mandatory FROM checklist_items WHERE kind = 1 ORDER BY ordinal;",
r => Domain.ChecklistItem.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), r.GetInt32(2) != 0, Str(r, 3), r.GetInt32(4) != 0));
- var editsByEntry = ReadAll(cn,
+ var editsByEntry = ReadAll(
+ cn,
"SELECT entry_id, previous_text, edited_by, edited_at FROM etb_entry_edits ORDER BY ordinal;",
r => (EntryId: Guid.Parse(r.GetString(0)),
Edit: new Domain.Etb.EtbEntryEdit(r.GetString(1), r.GetString(2), ParseDate(r.GetString(3)))))
.GroupBy(x => x.EntryId)
.ToDictionary(g => g.Key, g => g.Select(x => x.Edit).ToList());
- var journal = ReadAll(cn, "SELECT id, timestamp, direction, from_party, to_party, text, entered_by FROM etb_entries ORDER BY ordinal;",
+ var journal = ReadAll(
+ cn,
+ "SELECT id, timestamp, direction, from_party, to_party, text, entered_by FROM etb_entries ORDER BY ordinal;",
r =>
{
var id = Guid.Parse(r.GetString(0));
- return Domain.Etb.EtbEntry.Rehydrate(id, ParseDate(r.GetString(1)),
- (Domain.Etb.EtbDirection)r.GetInt32(2), r.GetString(5), r.GetString(6), Str(r, 3), Str(r, 4),
+ return Domain.Etb.EtbEntry.Rehydrate(
+ id,
+ ParseDate(r.GetString(1)),
+ (Domain.Etb.EtbDirection)r.GetInt32(2),
+ r.GetString(5),
+ r.GetString(6),
+ Str(r, 3),
+ Str(r, 4),
editsByEntry.TryGetValue(id, out var eds) ? eds : null);
});
- var roles = ReadAll(cn, "SELECT id, role, person_name, call_sign, from_time, to_time, section, phone FROM role_assignments ORDER BY ordinal;",
- r => new Domain.RoleAssignment(Guid.Parse(r.GetString(0)), r.GetString(1), r.GetString(2),
- Str(r, 3), NullableDate(r, 4), NullableDate(r, 5), Str(r, 6), Str(r, 7)));
-
- var strengthEditsByUnit = ReadAll(cn,
+ var roles = ReadAll(
+ cn,
+ "SELECT id, role, person_name, call_sign, from_time, to_time, section, phone FROM role_assignments ORDER BY ordinal;",
+ r => new Domain.RoleAssignment(
+ Guid.Parse(r.GetString(0)),
+ r.GetString(1),
+ r.GetString(2),
+ Str(r, 3),
+ NullableDate(r, 4),
+ NullableDate(r, 5),
+ Str(r, 6),
+ Str(r, 7)));
+
+ var strengthEditsByUnit = ReadAll(
+ cn,
"SELECT unit_id, previous_officer_count, previous_personnel_count, previous_scba_count, edited_by, edited_at FROM force_unit_edits ORDER BY ordinal;",
r => (UnitId: Guid.Parse(r.GetString(0)),
Edit: new Domain.ForceUnitStrengthEdit(r.GetInt32(1), r.GetInt32(2), r.GetInt32(3), r.GetString(4), ParseDate(r.GetString(5)))))
.GroupBy(x => x.UnitId)
.ToDictionary(g => g.Key, g => g.Select(x => x.Edit).ToList());
- var forces = ReadAll(cn, "SELECT id, brigade, call_sign, personnel_count, scba_count, status, notes, officer_count FROM force_units ORDER BY ordinal;",
- r => Domain.ForceUnit.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), Str(r, 2), r.GetInt32(3),
- r.GetInt32(4), Str(r, 5), Str(r, 6), r.GetInt32(7),
+ var forces = ReadAll(
+ cn,
+ "SELECT id, brigade, call_sign, personnel_count, scba_count, status, notes, officer_count FROM force_units ORDER BY ordinal;",
+ r => Domain.ForceUnit.Rehydrate(
+ Guid.Parse(r.GetString(0)),
+ r.GetString(1),
+ Str(r, 2),
+ r.GetInt32(3),
+ r.GetInt32(4),
+ Str(r, 5),
+ Str(r, 6),
+ r.GetInt32(7),
strengthEditsByUnit.TryGetValue(Guid.Parse(r.GetString(0)), out var eds) ? eds : null));
- var membersByTrupp = ReadAll(cn,
+ var membersByTrupp = ReadAll(
+ cn,
"SELECT trupp_id, role, name FROM scba_trupp_members ORDER BY ordinal;",
r => (TruppId: Guid.Parse(r.GetString(0)),
Member: new Domain.Atemschutz.TruppMember(
@@ -369,42 +499,65 @@ public static Incident Load(string path)
.GroupBy(x => x.TruppId)
.ToDictionary(g => g.Key, g => g.Select(x => x.Member).ToList());
- var readingsByTrupp = ReadAll(cn,
+ var readingsByTrupp = ReadAll(
+ cn,
"SELECT trupp_id, reading_time, bar FROM scba_pressure_readings ORDER BY ordinal;",
r => (TruppId: Guid.Parse(r.GetString(0)),
Reading: new Domain.Atemschutz.PressureReading(ParseDate(r.GetString(1)), r.GetInt32(2))))
.GroupBy(x => x.TruppId)
.ToDictionary(g => g.Key, g => g.Select(x => x.Reading).ToList());
- var scbaTrupps = ReadAll(cn,
+ var scbaTrupps = ReadAll(
+ cn,
"SELECT id, trupp_number, designation, call_sign, task, registered_at, start_time, withdraw_time, entry_pressure, max_duration_minutes, return_pressure_bar, pressure_control_interval_minutes, exit_time FROM scba_trupps ORDER BY ordinal;",
r =>
{
var id = Guid.Parse(r.GetString(0));
return Domain.Atemschutz.AtemschutzTrupp.Rehydrate(
- id, r.GetInt32(1), ParseDate(r.GetString(5)), NullableDate(r, 6), NullableDate(r, 7), r.GetString(2),
+ id,
+ r.GetInt32(1),
+ ParseDate(r.GetString(5)),
+ NullableDate(r, 6),
+ NullableDate(r, 7),
+ r.GetString(2),
membersByTrupp.TryGetValue(id, out var ms) ? ms : Enumerable.Empty(),
- Str(r, 3), Str(r, 4), NullableInt(r, 8), r.GetInt32(9), r.GetInt32(10), r.GetInt32(11),
+ Str(r, 3),
+ Str(r, 4),
+ NullableInt(r, 8),
+ r.GetInt32(9),
+ r.GetInt32(10),
+ r.GetInt32(11),
NullableDate(r, 12),
readingsByTrupp.TryGetValue(id, out var rs) ? rs : Enumerable.Empty());
});
- var audit = ReadAll(cn, "SELECT at, action, by_operator FROM audit_events ORDER BY ordinal;",
+ var audit = ReadAll(
+ cn,
+ "SELECT at, action, by_operator FROM audit_events ORDER BY ordinal;",
r => new Domain.AuditEvent(ParseDate(r.GetString(0)), r.GetString(1), r.GetString(2)));
- var timers = ReadAll(cn,
+ var timers = ReadAll(
+ cn,
"SELECT key, cycle_anchor, interval_minutes, recurring_interval_minutes, is_running FROM incident_timers;",
r => new Domain.Time.IncidentTimerState(
r.GetString(0), ParseDate(r.GetString(1)), r.GetInt32(2), r.GetInt32(3), r.GetInt32(4) != 0));
- var files = ReadAll(cn,
+ // display_name is null on rows written before this column existed -- fall back to
+ // file_name, same idiom as the Einsatznummer legacy fallback just below.
+ var files = ReadAll(
+ cn,
"SELECT id, file_name, content_type, size_bytes, added_at, added_by, display_name FROM incident_files ORDER BY ordinal;",
- // display_name is null on rows written before this column existed -- fall back to
- // file_name, same idiom as the Einsatznummer legacy fallback just below.
- r => Domain.Files.IncidentFile.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), Str(r, 6) ?? r.GetString(1),
- r.GetString(2), r.GetInt64(3), ParseDate(r.GetString(4)), r.GetString(5)));
-
- var buildings = ReadAll(cn,
+ r => Domain.Files.IncidentFile.Rehydrate(
+ Guid.Parse(r.GetString(0)),
+ r.GetString(1),
+ Str(r, 6) ?? r.GetString(1),
+ r.GetString(2),
+ r.GetInt64(3),
+ ParseDate(r.GetString(4)),
+ r.GetString(5)));
+
+ var buildings = ReadAll(
+ cn,
"SELECT id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal, apartment_labels FROM co_buildings ORDER BY ordinal;",
r =>
{
@@ -414,6 +567,7 @@ public static Incident Load(string path)
var fdDict = fd.ToDictionary(
kv => int.Parse(kv.Key, CultureInfo.InvariantCulture),
kv => kv.Value);
+
// apartment_labels is null on rows written before this column existed.
var alJson = Str(r, 6);
var alDict = alJson is null
@@ -421,26 +575,43 @@ public static Incident Load(string path)
: (System.Text.Json.JsonSerializer.Deserialize>(alJson)
?? new Dictionary())
.ToDictionary(kv => int.Parse(kv.Key, CultureInfo.InvariantCulture), kv => kv.Value);
- return Domain.CoMeasurement.Building.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1),
- r.GetInt32(2), r.GetInt32(3), fdDict, r.GetInt32(5), alDict);
+ return Domain.CoMeasurement.Building.Rehydrate(
+ Guid.Parse(r.GetString(0)),
+ r.GetString(1),
+ r.GetInt32(2),
+ r.GetInt32(3),
+ fdDict,
+ r.GetInt32(5),
+ alDict);
});
- var dwellings = ReadAll(cn,
+ var dwellings = ReadAll(
+ cn,
"SELECT id, building_id, floor_ordinal, apartment_number, resident_name, status, key_available, co_value FROM co_dwellings ORDER BY floor_ordinal, apartment_number;",
r => Domain.CoMeasurement.Dwelling.Rehydrate(
- Guid.Parse(r.GetString(0)), Guid.Parse(r.GetString(1)),
- r.GetInt32(2), r.GetInt32(3),
+ Guid.Parse(r.GetString(0)),
+ Guid.Parse(r.GetString(1)),
+ r.GetInt32(2),
+ r.GetInt32(3),
Str(r, 4),
(Domain.CoMeasurement.DwellingStatus)r.GetInt32(5),
r.IsDBNull(6) ? null : r.GetInt32(6) == 1,
NullableInt(r, 7)));
- var tasks = ReadAll(cn,
+ var tasks = ReadAll(
+ cn,
"SELECT id, text, assignee, importance, urgency, created_by, created_at, due_at, completed_at, completed_by FROM incident_tasks ORDER BY ordinal;",
- r => Domain.Tasks.IncidentTask.Rehydrate(Guid.Parse(r.GetString(0)), ParseDate(r.GetString(6)),
- r.GetString(1), r.GetString(2), (Domain.Tasks.TaskImportance)r.GetInt32(3),
- (Domain.Tasks.TaskUrgency)r.GetInt32(4), r.GetString(5), ParseDate(r.GetString(7)),
- NullableDate(r, 8), Str(r, 9)));
+ r => Domain.Tasks.IncidentTask.Rehydrate(
+ Guid.Parse(r.GetString(0)),
+ ParseDate(r.GetString(6)),
+ r.GetString(1),
+ r.GetString(2),
+ (Domain.Tasks.TaskImportance)r.GetInt32(3),
+ (Domain.Tasks.TaskUrgency)r.GetInt32(4),
+ r.GetString(5),
+ ParseDate(r.GetString(7)),
+ NullableDate(r, 8),
+ Str(r, 9)));
// Legacy fallback: files written before the Einsatznummer unification carry the 4-digit
// number in ils_number and nothing in incident_number. Load that old value as the
@@ -461,8 +632,18 @@ public static Incident Load(string path)
meta[8] as string,
meta[9] is string ca ? ParseDate(ca) : null,
meta[10] as string,
- checklistAufbau, checklistAbbau, journal, roles, forces, scbaTrupps, audit, timers, files, tasks,
- buildings, dwellings);
+ checklistAufbau,
+ checklistAbbau,
+ journal,
+ roles,
+ forces,
+ scbaTrupps,
+ audit,
+ timers,
+ files,
+ tasks,
+ buildings,
+ dwellings);
}
private static DateTimeOffset ParseDate(string s) =>
@@ -475,34 +656,51 @@ private static DateTimeOffset ParseDate(string s) =>
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) =>
r.IsDBNull(i) ? null : ParseDate(r.GetString(i));
- [SuppressMessage("Security", "CA2100",
- Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
+ [SuppressMessage(
+ "Security",
+ "CA2100",
+ Justification = "Audited: SQL is built from compile-time schema constants at call sites; values use bound parameters.")]
private static object?[] ReadRow(SqliteConnection cn, string sql)
{
using var cmd = cn.CreateCommand();
cmd.CommandText = sql;
using var r = cmd.ExecuteReader();
- if (!r.Read()) throw new InvalidOperationException("No incident in file.");
+ if (!r.Read())
+ {
+ throw new InvalidOperationException("No incident in file.");
+ }
+
var values = new object?[r.FieldCount];
for (var i = 0; i < r.FieldCount; i++)
+ {
values[i] = r.IsDBNull(i) ? null : r.GetValue(i);
+ }
+
return values;
}
- [SuppressMessage("Security", "CA2100",
- Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
+ [SuppressMessage(
+ "Security",
+ "CA2100",
+ Justification = "Audited: SQL is built from compile-time schema constants at call sites; values use bound parameters.")]
private static List ReadAll(SqliteConnection cn, string sql, Func map)
{
using var cmd = cn.CreateCommand();
cmd.CommandText = sql;
using var r = cmd.ExecuteReader();
var list = new List();
- while (r.Read()) list.Add(map(r));
+ while (r.Read())
+ {
+ list.Add(map(r));
+ }
+
return list;
}
- [SuppressMessage("Security", "CA2100",
- Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
+ [SuppressMessage(
+ "Security",
+ "CA2100",
+ Justification = "Audited: SQL is built from compile-time schema constants at call sites; values use bound parameters.")]
private static void Run(SqliteConnection cn, SqliteTransaction tx, string sql, Action> bind)
{
using var cmd = cn.CreateCommand();
@@ -512,8 +710,10 @@ private static void Run(SqliteConnection cn, SqliteTransaction tx, string sql, A
cmd.ExecuteNonQuery();
}
- [SuppressMessage("Security", "CA2100",
- Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
+ [SuppressMessage(
+ "Security",
+ "CA2100",
+ Justification = "Audited: SQL is built from compile-time schema constants at call sites; values use bound parameters.")]
private static void Exec(SqliteConnection cn, SqliteTransaction tx, string sql)
{
using var cmd = cn.CreateCommand();
diff --git a/src/LageBuch.Persistence/MasterData/MasterDataSet.cs b/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
index 275ce76..1b23daf 100644
--- a/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
+++ b/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
@@ -5,6 +5,72 @@
namespace LageBuch.Persistence.MasterData;
+public sealed record MasterDataSet(
+ IReadOnlyList Roles,
+ IReadOnlyList Status,
+ IReadOnlyList Equipment,
+ IReadOnlyList Districts,
+ IReadOnlyList RadioCallSigns,
+ IReadOnlyList Brigades,
+
+ // UnitStatus is the status of a single unit (Alarmiert, Auf Anfahrt, ...) and is deliberately
+ // separate from Status above, which is the incident-level vocabulary (aufgenommen, ...).
+ IReadOnlyList UnitStatus,
+ IReadOnlyList Streets,
+ IReadOnlyList Links,
+ IReadOnlyList ChecklistTemplateAufbau,
+ IReadOnlyList ChecklistTemplateAbbau,
+ IReadOnlyList TruppTypes,
+ IReadOnlyList Personnel,
+
+ // Einsatzart values (ABek Bayern) — the leading token of the complete Einsatznummer.
+ IReadOnlyList Einsatzarten,
+
+ // Vehicles per Wache with their seat count (#76).
+ IReadOnlyList Vehicles,
+
+ // Operational defaults (timers, durations). Unlike the lists, always populated — a store with
+ // no overrides yields IncidentSettings.Defaults, never a zeroed record.
+ IncidentSettings Settings)
+{
+ ///
+ /// Every category empty. Intended for tests and for callers that need a starting point to
+ /// override with a with expression, so that adding a category to this positional record
+ /// does not force an edit in every construction site.
+ ///
+ public static MasterDataSet Empty { get; } = new(
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ IncidentSettings.Defaults);
+
+ ///
+ /// True when no category holds a single entry. A fresh install starts here, and it is the
+ /// condition under which the Stammdaten editor offers Import — a bootstrap, not a merge.
+ /// deliberately does not count: it always carries defaults, and letting it
+ /// mark the set non-empty would suppress the Import bootstrap on an otherwise fresh install.
+ ///
+ public bool IsEmpty =>
+ Roles.Count == 0 && Status.Count == 0 && Equipment.Count == 0 && Districts.Count == 0
+ && RadioCallSigns.Count == 0 && Brigades.Count == 0 && UnitStatus.Count == 0
+ && Streets.Count == 0 && Links.Count == 0 && ChecklistTemplateAufbau.Count == 0 && ChecklistTemplateAbbau.Count == 0
+ && TruppTypes.Count == 0
+ && Personnel.Count == 0 && Einsatzarten.Count == 0
+ && Vehicles.Count == 0;
+}
+
public sealed record Street(string Name, string District);
/// A named link — Stammdaten entry so useful external resources can be opened from an Einsatz.
@@ -24,19 +90,26 @@ public sealed record ChecklistTemplateItem(string Text, bool IsMandatory);
/// usable values rather than zeros.
///
public sealed record IncidentSettings(
+
// "Rückmeldung an ILS" — minutes until the first reminder is due.
int IlsReminderIntervalMinutes,
+
// "Rückmeldung an ILS" — recurring interval after the first reminder. Stored/editable
// here but not yet consumed by the reminder timer (see #70).
int IlsReminderFollowUpIntervalMinutes,
+
// Atemschutz Einsatzzeit for an ordinary AGT-Trupp.
int AgtMaxDurationMinutes,
+
// Atemschutz Einsatzzeit for a CSA-Trupp (chemical suit) — shorter than an AGT.
int CsaMaxDurationMinutes,
+
// Atemschutz Einsatzzeit for an LPA-Trupp (long-duration apparatus) — longer than an AGT.
int LpaMaxDurationMinutes,
+
// Interval between Druckkontrollen (Atemschutzkontrolle).
int PressureControlIntervalMinutes,
+
// Rückzugsdruck: pressure at or below which a Trupp must turn back.
int ReturnPressureBar)
{
@@ -97,6 +170,7 @@ public static class AnonymizedExampleData
public const string OperatorSurname = "Müller";
public const string OperatorSurnameAlt = "Schmidt";
public const string OperatorSurnameThird = "Wagner";
+
// First name for the full-name example (OperatorPromptView's NAME field). Deliberately not
// "Thomas" — that would read as a real contributor's actual name rather than a placeholder.
public const string OperatorFirstName = "Jens";
@@ -129,6 +203,7 @@ public static class AnonymizedExampleData
public const string OperatorNamePlaceholder = "z. B. " + OperatorSurname;
public const string OperatorNamePlaceholderAlt = "z. B. " + OperatorSurnameAlt;
public const string OperatorNamePlaceholderThird = "z. B. " + OperatorSurnameThird;
+
// Full-name form, for the one field that asks for a proper name rather than a short crew/
// assignee entry (OperatorPromptView's NAME field).
public const string OperatorFullNamePlaceholder = "z. B. " + OperatorSurname + ", " + OperatorFirstName;
@@ -173,59 +248,6 @@ public static class AnonymizedExampleData
};
}
-public sealed record MasterDataSet(
- IReadOnlyList Roles,
- IReadOnlyList Status,
- IReadOnlyList Equipment,
- IReadOnlyList Districts,
- IReadOnlyList RadioCallSigns,
- IReadOnlyList Brigades,
- // UnitStatus is the status of a single unit (Alarmiert, Auf Anfahrt, ...) and is deliberately
- // separate from Status above, which is the incident-level vocabulary (aufgenommen, ...).
- IReadOnlyList UnitStatus,
- IReadOnlyList Streets,
- IReadOnlyList Links,
- IReadOnlyList ChecklistTemplateAufbau,
- IReadOnlyList ChecklistTemplateAbbau,
- IReadOnlyList TruppTypes,
- IReadOnlyList Personnel,
- // Einsatzart values (ABek Bayern) — the leading token of the complete Einsatznummer.
- IReadOnlyList Einsatzarten,
- // Vehicles per Wache with their seat count (#76).
- IReadOnlyList Vehicles,
- // Operational defaults (timers, durations). Unlike the lists, always populated — a store with
- // no overrides yields IncidentSettings.Defaults, never a zeroed record.
- IncidentSettings Settings)
-{
- ///
- /// Every category empty. Intended for tests and for callers that need a starting point to
- /// override with a with expression, so that adding a category to this positional record
- /// does not force an edit in every construction site.
- ///
- public static MasterDataSet Empty { get; } = new(
- Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(),
- Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(),
- Array.Empty(),
- Array.Empty(), Array.Empty(),
- Array.Empty(), Array.Empty(), Array.Empty(),
- Array.Empty(),
- IncidentSettings.Defaults);
-
- ///
- /// True when no category holds a single entry. A fresh install starts here, and it is the
- /// condition under which the Stammdaten editor offers Import — a bootstrap, not a merge.
- /// deliberately does not count: it always carries defaults, and letting it
- /// mark the set non-empty would suppress the Import bootstrap on an otherwise fresh install.
- ///
- public bool IsEmpty =>
- Roles.Count == 0 && Status.Count == 0 && Equipment.Count == 0 && Districts.Count == 0
- && RadioCallSigns.Count == 0 && Brigades.Count == 0 && UnitStatus.Count == 0
- && Streets.Count == 0 && Links.Count == 0 && ChecklistTemplateAufbau.Count == 0 && ChecklistTemplateAbbau.Count == 0
- && TruppTypes.Count == 0
- && Personnel.Count == 0 && Einsatzarten.Count == 0
- && Vehicles.Count == 0;
-}
-
///
/// Reads and writes the master-data interchange format — one JSON object whose top-level keys are
/// all optional (a missing key means an empty category). The same shape covers the whole set, so a
@@ -306,11 +328,15 @@ private static (IReadOnlyList Aufbau, IReadOnlyList new ChecklistTemplateItem(x.GetString()!, false)).ToList(),
Array.Empty());
+ }
return (Array.Empty(), Array.Empty());
@@ -333,7 +359,9 @@ private static IncidentSettings ParseSettings(JsonElement root)
{
var d = IncidentSettings.Defaults;
if (!root.TryGetProperty("settings", out var s) || s.ValueKind != JsonValueKind.Object)
+ {
return d;
+ }
static int Int(JsonElement e, string prop, int fallback) =>
e.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : fallback;
@@ -351,7 +379,9 @@ static int Int(JsonElement e, string prop, int fallback) =>
private static IReadOnlyList ParsePersonnel(JsonElement root)
{
if (!root.TryGetProperty("personnel", out var arr) || arr.ValueKind != JsonValueKind.Array)
+ {
return Array.Empty();
+ }
return arr.EnumerateArray()
.Select(p => new Person(
diff --git a/src/LageBuch.Persistence/MasterData/MasterDataStore.cs b/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
index 285512e..684ad7b 100644
--- a/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
+++ b/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
@@ -41,18 +41,46 @@ public static void Save(string path, MasterDataSet set)
Run(cn, tx, "DELETE FROM md_streets;", _ => { });
foreach (var s in set.Streets)
- Run(cn, tx, "INSERT INTO md_streets (name, district) VALUES ($n,$d);",
- p => { p("$n", s.Name); p("$d", s.District); });
+ {
+ Run(
+ cn,
+ tx,
+ "INSERT INTO md_streets (name, district) VALUES ($n,$d);",
+ p =>
+ {
+ p("$n", s.Name);
+ p("$d", s.District);
+ });
+ }
Run(cn, tx, "DELETE FROM md_links;", _ => { });
foreach (var l in set.Links)
- Run(cn, tx, "INSERT INTO md_links (name, url) VALUES ($n,$u);",
- p => { p("$n", l.Name); p("$u", l.Url); });
+ {
+ Run(
+ cn,
+ tx,
+ "INSERT INTO md_links (name, url) VALUES ($n,$u);",
+ p =>
+ {
+ p("$n", l.Name);
+ p("$u", l.Url);
+ });
+ }
Run(cn, tx, "DELETE FROM md_vehicles;", _ => { });
foreach (var v in set.Vehicles)
- Run(cn, tx, "INSERT INTO md_vehicles (wache, call_sign, seats) VALUES ($w,$c,$s);",
- p => { p("$w", v.Wache); p("$c", v.CallSign); p("$s", v.Seats); });
+ {
+ Run(
+ cn,
+ tx,
+ "INSERT INTO md_vehicles (wache, call_sign, seats) VALUES ($w,$c,$s);",
+ p =>
+ {
+ p("$w", v.Wache);
+ p("$c", v.CallSign);
+ p("$s", v.Seats);
+ });
+ }
Run(cn, tx, "DELETE FROM md_checklist_template;", _ => { });
InsertChecklistTemplate(cn, tx, set.ChecklistTemplateAufbau, kind: 0, ordinalOffset: 0);
@@ -60,21 +88,33 @@ public static void Save(string path, MasterDataSet set)
Run(cn, tx, "DELETE FROM md_personnel;", _ => { });
foreach (var person in set.Personnel)
- Run(cn, tx, "INSERT INTO md_personnel (last_name, first_name, role, call_sign, phone) VALUES ($l,$f,$r,$c,$p);",
+ {
+ Run(
+ cn,
+ tx,
+ "INSERT INTO md_personnel (last_name, first_name, role, call_sign, phone) VALUES ($l,$f,$r,$c,$p);",
p =>
{
- p("$l", person.LastName); p("$f", person.FirstName);
+ p("$l", person.LastName);
+ p("$f", person.FirstName);
p("$r", (object?)person.Role ?? DBNull.Value);
p("$c", (object?)person.CallSign ?? DBNull.Value);
p("$p", (object?)person.Phone ?? DBNull.Value);
});
+ }
// Settings are a single row per key; UPSERT rather than delete-and-reinsert so a key the
// store already carries keeps its identity, and so writing a subset never drops the rest.
foreach (var (key, value) in SettingsRows(set.Settings))
- Run(cn, tx,
+ Run(
+ cn,
+ tx,
"INSERT INTO md_settings (key, value) VALUES ($k,$v) ON CONFLICT(key) DO UPDATE SET value=excluded.value;",
- p => { p("$k", key); p("$v", value); });
+ p =>
+ {
+ p("$k", key);
+ p("$v", value);
+ });
tx.Commit();
}
@@ -88,8 +128,17 @@ private static void InsertChecklistTemplate(
{
var item = items[i];
var ordinal = ordinalOffset + i;
- Run(cn, tx, "INSERT INTO md_checklist_template (ordinal, text, is_mandatory, kind) VALUES ($o,$t,$m,$k);",
- p => { p("$o", ordinal); p("$t", item.Text); p("$m", item.IsMandatory ? 1 : 0); p("$k", kind); });
+ Run(
+ cn,
+ tx,
+ "INSERT INTO md_checklist_template (ordinal, text, is_mandatory, kind) VALUES ($o,$t,$m,$k);",
+ p =>
+ {
+ p("$o", ordinal);
+ p("$t", item.Text);
+ p("$m", item.IsMandatory ? 1 : 0);
+ p("$k", kind);
+ });
}
}
@@ -112,7 +161,7 @@ private static void ReplaceList(SqliteConnection cn, SqliteTransaction tx, strin
private static void EnsureSchema(SqliteConnection cn)
{
- Exec(cn, """
+ const string schema = """
CREATE TABLE IF NOT EXISTS md_roles (value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS md_status (value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS md_equipment (value TEXT NOT NULL);
@@ -139,7 +188,8 @@ CREATE TABLE IF NOT EXISTS md_personnel (
phone TEXT
);
CREATE TABLE IF NOT EXISTS md_settings (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
- """);
+ """;
+ Exec(cn, schema);
// Widen a pre-existing md_checklist_template that predates the Aufbau/Abbau split — this
// store has no version marker, so every open re-checks rather than gating on one.
@@ -184,6 +234,7 @@ private static (IReadOnlyList Aufbau, IReadOnlyList values)
{
foreach (var v in values)
+ {
Run(cn, tx, $"INSERT INTO {table} (value) VALUES ($v);", p => p("$v", v));
+ }
}
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
private static List ReadColumn(SqliteConnection cn, string sql)
{
@@ -225,7 +283,11 @@ private static List ReadColumn(SqliteConnection cn, string sql)
cmd.CommandText = sql;
using var r = cmd.ExecuteReader();
var list = new List();
- while (r.Read()) list.Add(r.GetString(0));
+ while (r.Read())
+ {
+ list.Add(r.GetString(0));
+ }
+
return list;
}
@@ -235,7 +297,11 @@ private static List ReadStreets(SqliteConnection cn)
cmd.CommandText = "SELECT name, district FROM md_streets;";
using var r = cmd.ExecuteReader();
var list = new List();
- while (r.Read()) list.Add(new Street(r.GetString(0), r.GetString(1)));
+ while (r.Read())
+ {
+ list.Add(new Street(r.GetString(0), r.GetString(1)));
+ }
+
return list;
}
@@ -245,7 +311,11 @@ private static List ReadLinks(SqliteConnection cn)
cmd.CommandText = "SELECT name, url FROM md_links;";
using var r = cmd.ExecuteReader();
var list = new List();
- while (r.Read()) list.Add(new Link(r.GetString(0), r.GetString(1)));
+ while (r.Read())
+ {
+ list.Add(new Link(r.GetString(0), r.GetString(1)));
+ }
+
return list;
}
@@ -255,7 +325,11 @@ private static List ReadVehicles(SqliteConnection cn)
cmd.CommandText = "SELECT wache, call_sign, seats FROM md_vehicles;";
using var r = cmd.ExecuteReader();
var list = new List();
- while (r.Read()) list.Add(new Vehicle(r.GetString(0), r.GetString(1), r.GetInt32(2)));
+ while (r.Read())
+ {
+ list.Add(new Vehicle(r.GetString(0), r.GetString(1), r.GetInt32(2)));
+ }
+
return list;
}
@@ -266,13 +340,18 @@ private static List ReadPersonnel(SqliteConnection cn)
using var r = cmd.ExecuteReader();
var list = new List();
while (r.Read())
+ {
list.Add(new Person(r.GetString(0), r.GetString(1), Str(r, 2), Str(r, 3), Str(r, 4)));
+ }
+
return list;
static string? Str(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetString(i);
}
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
private static void Run(SqliteConnection cn, SqliteTransaction tx, string sql, Action> bind)
{
@@ -283,7 +362,9 @@ private static void Run(SqliteConnection cn, SqliteTransaction tx, string sql, A
cmd.ExecuteNonQuery();
}
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: SQL built from compile-time schema constants; values use bound parameters.")]
private static void Exec(SqliteConnection cn, string sql)
{
diff --git a/src/LageBuch.Persistence/Sqlite/Migrations.cs b/src/LageBuch.Persistence/Sqlite/Migrations.cs
index 9c8363d..2b58874 100644
--- a/src/LageBuch.Persistence/Sqlite/Migrations.cs
+++ b/src/LageBuch.Persistence/Sqlite/Migrations.cs
@@ -17,6 +17,7 @@ public static int GetVersion(SqliteConnection cn)
create.CommandText = "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);";
create.ExecuteNonQuery();
}
+
using var read = cn.CreateCommand();
read.CommandText = "SELECT version FROM schema_version LIMIT 1;";
var result = read.ExecuteScalar();
@@ -33,84 +34,103 @@ public static void Migrate(SqliteConnection cn)
// so the file silently claims a schema it does not have -- and the next read fails deep in
// a SELECT against a column the newer build had already dropped.
if (version > CurrentVersion)
+ {
throw new UnsupportedSchemaVersionException(version, CurrentVersion);
+ }
using var tx = cn.BeginTransaction();
if (version < 1)
{
ApplyV1(cn, tx);
}
+
if (version < 2)
{
ApplyV2(cn, tx);
}
+
if (version < 3)
{
ApplyV3(cn, tx);
}
+
if (version < 4)
{
ApplyV4(cn, tx);
}
+
if (version < 5)
{
ApplyV5(cn, tx);
}
+
if (version < 6)
{
ApplyV6(cn, tx);
}
+
if (version < 7)
{
ApplyV7(cn, tx);
}
+
if (version < 8)
{
ApplyV8(cn, tx);
}
+
if (version < 9)
{
ApplyV9(cn, tx);
}
+
if (version < 10)
{
ApplyV10(cn, tx);
}
+
if (version < 11)
{
ApplyV11(cn, tx);
}
+
if (version < 12)
{
ApplyV12(cn, tx);
}
+
if (version < 13)
{
ApplyV13(cn, tx);
}
+
if (version < 14)
{
ApplyV14(cn, tx);
}
+
if (version < 15)
{
ApplyV15(cn, tx);
}
+
if (version < 16)
{
ApplyV16(cn, tx);
}
+
if (version < 17)
{
ApplyV17(cn, tx);
}
+
SetVersion(cn, tx, CurrentVersion);
tx.Commit();
}
private static void ApplyV1(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql1 = """
CREATE TABLE incident_meta (
id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
@@ -124,8 +144,9 @@ CREATE TABLE incident_meta (
closed_at TEXT,
closed_by TEXT
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql1);
+ const string sql2 = """
CREATE TABLE checklist_items (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -133,8 +154,9 @@ CREATE TABLE checklist_items (
is_done INTEGER NOT NULL,
note TEXT
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql2);
+ const string sql3 = """
CREATE TABLE etb_entries (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -145,8 +167,9 @@ CREATE TABLE etb_entries (
text TEXT NOT NULL,
entered_by TEXT NOT NULL
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql3);
+ const string sql4 = """
CREATE TABLE role_assignments (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -156,8 +179,9 @@ CREATE TABLE role_assignments (
from_time TEXT,
to_time TEXT
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql4);
+ const string sql5 = """
CREATE TABLE force_units (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -167,20 +191,22 @@ CREATE TABLE force_units (
status TEXT,
notes TEXT
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql5);
+ const string sql6 = """
CREATE TABLE audit_events (
ordinal INTEGER PRIMARY KEY,
at TEXT NOT NULL,
action TEXT NOT NULL,
by_operator TEXT NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql6);
}
private static void ApplyV2(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql7 = """
CREATE TABLE scba_trupps (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -194,8 +220,9 @@ CREATE TABLE scba_trupps (
return_pressure_bar INTEGER NOT NULL,
exit_time TEXT
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql7);
+ const string sql8 = """
CREATE TABLE scba_pressure_readings (
id TEXT PRIMARY KEY,
trupp_id TEXT NOT NULL,
@@ -203,7 +230,8 @@ CREATE TABLE scba_pressure_readings (
reading_time TEXT NOT NULL,
bar INTEGER NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql8);
}
private static void ApplyV3(SqliteConnection cn, SqliteTransaction tx)
@@ -212,7 +240,7 @@ private static void ApplyV3(SqliteConnection cn, SqliteTransaction tx)
// registered_at and a nullable start_time/start_pressure (null while on standby), plus a
// pressure-control interval. Rebuild the table (portable across SQLite versions) and map
// any existing V2 rows — those were already "under air", so start == entry.
- Exec(cn, tx, """
+ const string sql9 = """
CREATE TABLE scba_trupps_v3 (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -228,8 +256,9 @@ CREATE TABLE scba_trupps_v3 (
pressure_control_interval_minutes INTEGER NOT NULL,
exit_time TEXT
);
- """);
- Exec(cn, tx, $"""
+ """;
+ Exec(cn, tx, sql9);
+ var migrateTruppsSql = $"""
INSERT INTO scba_trupps_v3
(id, ordinal, designation, members, call_sign, task, registered_at, start_time,
start_pressure, max_duration_minutes, return_pressure_bar,
@@ -238,7 +267,8 @@ INSERT INTO scba_trupps_v3
entry_pressure, max_duration_minutes, return_pressure_bar,
{AtemschutzTrupp.DefaultPressureControlIntervalMinutes}, exit_time
FROM scba_trupps;
- """);
+ """;
+ Exec(cn, tx, migrateTruppsSql);
Exec(cn, tx, "DROP TABLE scba_trupps;");
Exec(cn, tx, "ALTER TABLE scba_trupps_v3 RENAME TO scba_trupps;");
}
@@ -264,17 +294,20 @@ private static void ApplyV6(SqliteConnection cn, SqliteTransaction tx)
{
// A Trupp's crew stops being one free-text string and becomes addressable rows, mirroring
// how scba_pressure_readings already hangs off a Trupp.
- Exec(cn, tx, """
+ const string sql10 = """
CREATE TABLE IF NOT EXISTS scba_trupp_members (
trupp_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
role INTEGER NOT NULL,
name TEXT NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql10);
if (!SchemaHelpers.TableExists(cn, tx, "scba_trupps") || !SchemaHelpers.ColumnExists(cn, tx, "scba_trupps", "members"))
+ {
return;
+ }
// Split the old "Müller / Schmidt" convention into rows. The separator was only ever a
// watermark hint, so anything that does not split cleanly is kept whole as the Truppführer
@@ -287,7 +320,9 @@ name TEXT NOT NULL
read.CommandText = "SELECT id, members FROM scba_trupps;";
using var r = read.ExecuteReader();
while (r.Read())
+ {
legacy.Add((r.GetString(0), r.IsDBNull(1) ? string.Empty : r.GetString(1)));
+ }
}
foreach (var (id, members) in legacy)
@@ -296,7 +331,9 @@ name TEXT NOT NULL
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
if (names.Count == 0)
+ {
names.Add(string.IsNullOrWhiteSpace(members) ? "Unbekannt" : members.Trim());
+ }
for (var i = 0; i < names.Count; i++)
{
@@ -314,7 +351,7 @@ name TEXT NOT NULL
// Drop the members column by rebuilding, exactly as V3 did: portable across SQLite
// versions, and the explicit DDL keeps the resulting shape visible in the diff.
- Exec(cn, tx, """
+ const string sql11 = """
CREATE TABLE scba_trupps_v6 (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -329,8 +366,9 @@ CREATE TABLE scba_trupps_v6 (
pressure_control_interval_minutes INTEGER NOT NULL,
exit_time TEXT
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql11);
+ const string sql12 = """
INSERT INTO scba_trupps_v6
(id, ordinal, designation, call_sign, task, registered_at, start_time,
start_pressure, max_duration_minutes, return_pressure_bar,
@@ -339,7 +377,8 @@ INSERT INTO scba_trupps_v6
start_pressure, max_duration_minutes, return_pressure_bar,
pressure_control_interval_minutes, exit_time
FROM scba_trupps;
- """);
+ """;
+ Exec(cn, tx, sql12);
Exec(cn, tx, "DROP TABLE scba_trupps;");
Exec(cn, tx, "ALTER TABLE scba_trupps_v6 RENAME TO scba_trupps;");
}
@@ -357,7 +396,7 @@ private static void ApplyV7(SqliteConnection cn, SqliteTransaction tx)
// the anchor + cadence are stored, live values are recomputed from now (like the SCBA countdowns).
private static void ApplyV8(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql13 = """
CREATE TABLE IF NOT EXISTS incident_timers (
key TEXT PRIMARY KEY,
cycle_anchor TEXT NOT NULL,
@@ -365,7 +404,8 @@ CREATE TABLE IF NOT EXISTS incident_timers (
recurring_interval_minutes INTEGER NOT NULL,
is_running INTEGER NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql13);
}
// Checkliste splits into two independent lists (Aufbau/Abbau) with a mandatory flag per item.
@@ -382,7 +422,7 @@ private static void ApplyV9(SqliteConnection cn, SqliteTransaction tx)
// full-rewrite-per-save table set so an unrelated edit never rewrites attachment content.
private static void ApplyV10(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql14 = """
CREATE TABLE IF NOT EXISTS incident_files (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -392,7 +432,8 @@ CREATE TABLE IF NOT EXISTS incident_files (
added_at TEXT NOT NULL,
added_by TEXT NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql14);
}
// A file's display label, editable independently of its original file_name (#62 follow-up).
@@ -405,7 +446,7 @@ private static void ApplyV11(SqliteConnection cn, SqliteTransaction tx) =>
// history lives in this table (#73).
private static void ApplyV12(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql15 = """
CREATE TABLE IF NOT EXISTS etb_entry_edits (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
@@ -414,7 +455,8 @@ CREATE TABLE IF NOT EXISTS etb_entry_edits (
edited_by TEXT NOT NULL,
edited_at TEXT NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql15);
}
// Kräfte gain a Führungskräfte counter and corrigible Stärke (#76). officer_count is NOT NULL
@@ -424,7 +466,7 @@ edited_at TEXT NOT NULL
private static void ApplyV13(SqliteConnection cn, SqliteTransaction tx)
{
SchemaHelpers.AddColumnIfMissing(cn, tx, "force_units", "officer_count", "INTEGER NOT NULL DEFAULT 0");
- Exec(cn, tx, """
+ const string sql16 = """
CREATE TABLE IF NOT EXISTS force_unit_edits (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL,
@@ -435,7 +477,8 @@ CREATE TABLE IF NOT EXISTS force_unit_edits (
edited_by TEXT NOT NULL,
edited_at TEXT NOT NULL
);
- """);
+ """;
+ Exec(cn, tx, sql16);
}
// Aufgabenliste (#88): one row per task, ordered by ordinal. due_at carries the timer target
@@ -443,7 +486,7 @@ edited_at TEXT NOT NULL
// countdown/overdue display is recomputed from now, exactly like the SCBA timers.
private static void ApplyV14(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql17 = """
CREATE TABLE IF NOT EXISTS incident_tasks (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -457,12 +500,13 @@ CREATE TABLE IF NOT EXISTS incident_tasks (
completed_at TEXT,
completed_by TEXT
);
- """);
+ """;
+ Exec(cn, tx, sql17);
}
private static void ApplyV15(SqliteConnection cn, SqliteTransaction tx)
{
- Exec(cn, tx, """
+ const string sql18 = """
CREATE TABLE IF NOT EXISTS co_buildings (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -471,8 +515,9 @@ CREATE TABLE IF NOT EXISTS co_buildings (
floor_descriptions TEXT NOT NULL DEFAULT '{}',
ordinal INTEGER NOT NULL
);
- """);
- Exec(cn, tx, """
+ """;
+ Exec(cn, tx, sql18);
+ const string sql19 = """
CREATE TABLE IF NOT EXISTS co_dwellings (
id TEXT PRIMARY KEY,
building_id TEXT NOT NULL,
@@ -483,7 +528,8 @@ CREATE TABLE IF NOT EXISTS co_dwellings (
key_available INTEGER,
co_value INTEGER
);
- """);
+ """;
+ Exec(cn, tx, sql19);
}
private static void ApplyV16(SqliteConnection cn, SqliteTransaction tx)
@@ -505,9 +551,11 @@ private static void ApplyV17(SqliteConnection cn, SqliteTransaction tx)
// up trying to rebuild from a start_pressure column that is already gone.
if (!SchemaHelpers.TableExists(cn, tx, "scba_trupps") ||
!SchemaHelpers.ColumnExists(cn, tx, "scba_trupps", "start_pressure"))
+ {
return;
+ }
- Exec(cn, tx, """
+ const string sql20 = """
CREATE TABLE scba_trupps_v17 (
id TEXT PRIMARY KEY,
ordinal INTEGER NOT NULL,
@@ -524,12 +572,14 @@ CREATE TABLE scba_trupps_v17 (
pressure_control_interval_minutes INTEGER NOT NULL,
exit_time TEXT
);
- """);
+ """;
+ Exec(cn, tx, sql20);
+
// Backfill trupp_number from the existing registration-order "ordinal" (+1, since ordinal
// is 0-based) so pre-existing rows get stable, unique, sequential numbers instead of all
// colliding on the same value -- a fresh Incident.AddScbaTrupp rejects a duplicate the
// moment one more Trupp is added to an old file.
- Exec(cn, tx, """
+ const string sql21 = """
INSERT INTO scba_trupps_v17
(id, ordinal, trupp_number, designation, call_sign, task, registered_at, start_time,
entry_pressure, withdraw_time, max_duration_minutes, return_pressure_bar,
@@ -538,7 +588,8 @@ INSERT INTO scba_trupps_v17
start_pressure, NULL, max_duration_minutes, return_pressure_bar,
pressure_control_interval_minutes, exit_time
FROM scba_trupps;
- """);
+ """;
+ Exec(cn, tx, sql21);
Exec(cn, tx, "DROP TABLE scba_trupps;");
Exec(cn, tx, "ALTER TABLE scba_trupps_v17 RENAME TO scba_trupps;");
}
@@ -553,7 +604,9 @@ private static void SetVersion(SqliteConnection cn, SqliteTransaction tx, int ve
cmd.ExecuteNonQuery();
}
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: SQL is migration DDL/data built from compile-time constants; values use bound parameters.")]
private static void Exec(SqliteConnection cn, SqliteTransaction tx, string sql)
{
diff --git a/src/LageBuch.Persistence/Sqlite/SchemaHelpers.cs b/src/LageBuch.Persistence/Sqlite/SchemaHelpers.cs
index 427be5f..0051561 100644
--- a/src/LageBuch.Persistence/Sqlite/SchemaHelpers.cs
+++ b/src/LageBuch.Persistence/Sqlite/SchemaHelpers.cs
@@ -12,13 +12,18 @@ namespace LageBuch.Persistence.Sqlite;
///
internal static class SchemaHelpers
{
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: identifiers are compile-time schema constants from call sites; values use bound parameters.")]
public static void AddColumnIfMissing(
SqliteConnection cn, SqliteTransaction? tx, string table, string column, string type)
{
if (!TableExists(cn, tx, table) || ColumnExists(cn, tx, table, column))
+ {
return;
+ }
+
using var cmd = cn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type};";
@@ -34,7 +39,9 @@ public static bool TableExists(SqliteConnection cn, SqliteTransaction? tx, strin
return (long)cmd.ExecuteScalar()! > 0;
}
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: identifiers are compile-time schema constants from call sites; values use bound parameters.")]
public static bool ColumnExists(SqliteConnection cn, SqliteTransaction? tx, string table, string column)
{
diff --git a/src/LageBuch.Persistence/Sqlite/SqliteConnectionFactory.cs b/src/LageBuch.Persistence/Sqlite/SqliteConnectionFactory.cs
index 6b79bd3..b541562 100644
--- a/src/LageBuch.Persistence/Sqlite/SqliteConnectionFactory.cs
+++ b/src/LageBuch.Persistence/Sqlite/SqliteConnectionFactory.cs
@@ -34,20 +34,25 @@ private static SqliteConnection Open(string path, SqliteOpenMode mode, bool jour
{
DataSource = path,
Mode = mode,
+
// Pooling keeps the sqlite handle open after Dispose, which on Windows keeps the file
// locked. For a desktop app whose whole job is opening and closing the user's Einsatz
// files, a handle outliving its connection is a bug, not an optimisation -- and the
// pool saves nothing here, since a file is opened a handful of times per session.
- Pooling = false
+ Pooling = false,
}.ToString());
try
{
cn.Open();
+
// WAL is a write to the database header, so it is meaningless -- and refused -- on a
// read-only connection.
if (journal)
+ {
Execute(cn, "PRAGMA journal_mode=WAL;");
+ }
+
Execute(cn, "PRAGMA foreign_keys=ON;");
return cn;
}
@@ -58,7 +63,9 @@ private static SqliteConnection Open(string path, SqliteOpenMode mode, bool jour
}
}
- [SuppressMessage("Security", "CA2100",
+ [SuppressMessage(
+ "Security",
+ "CA2100",
Justification = "Audited: PRAGMA statements are compile-time constants.")]
private static void Execute(SqliteConnection cn, string sql)
{
diff --git a/src/LageBuch.Persistence/Sqlite/UnsupportedSchemaVersionException.cs b/src/LageBuch.Persistence/Sqlite/UnsupportedSchemaVersionException.cs
index 1907b38..f28a93d 100644
--- a/src/LageBuch.Persistence/Sqlite/UnsupportedSchemaVersionException.cs
+++ b/src/LageBuch.Persistence/Sqlite/UnsupportedSchemaVersionException.cs
@@ -18,11 +18,20 @@ public UnsupportedSchemaVersionException(int fileVersion, int supportedVersion)
SupportedVersion = supportedVersion;
}
- public UnsupportedSchemaVersionException() : this(0, 0) { }
+ public UnsupportedSchemaVersionException()
+ : this(0, 0)
+ {
+ }
- public UnsupportedSchemaVersionException(string message) : base(message) { }
+ public UnsupportedSchemaVersionException(string message)
+ : base(message)
+ {
+ }
- public UnsupportedSchemaVersionException(string message, Exception innerException) : base(message, innerException) { }
+ public UnsupportedSchemaVersionException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
public int FileVersion { get; }
diff --git a/src/LageBuch.Sync.Hosting/IncidentHost.cs b/src/LageBuch.Sync.Hosting/IncidentHost.cs
index 8a63592..78146a4 100644
--- a/src/LageBuch.Sync.Hosting/IncidentHost.cs
+++ b/src/LageBuch.Sync.Hosting/IncidentHost.cs
@@ -43,14 +43,18 @@ public IncidentHost(LocalIncidentSession session, IClock clock, string appVersio
public async Task StartAsync(IPAddress bindAddress, int port = SyncProtocol.Port)
{
if (_app is not null)
+ {
return;
+ }
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls($"http://{bindAddress}:{port}");
+
// Keep the hub's JSON aligned with SyncJson: enums as strings, web (camelCase) naming.
builder.Services.AddSignalR().AddJsonProtocol(o =>
o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+
// Bind the polymorphic SyncCommand body with the same enum-as-string contract as the client.
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
@@ -68,6 +72,7 @@ public async Task StartAsync(IPAddress bindAddress, int port = SyncProtocol.Port
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
+
await next();
});
@@ -93,6 +98,7 @@ private async Task HandleCommand(SyncCommand command)
return await _ui.InvokeAsync(() =>
{
CommandApplier.Apply(command, _session.Incident, _clock, _session.SaveFileBytes);
+
// Persist + raise the session's Changed, which refreshes the host's own UI and, through
// OnSessionChanged, broadcasts the new snapshot to every client — the same path a host
// edit takes (§5), so a client's contribution appears live on the host too.
@@ -118,7 +124,10 @@ private async Task HandleGetFile(Guid id)
{
var bytes = await _session.GetFileBytesAsync(id);
if (bytes is null)
+ {
return Results.NotFound();
+ }
+
var file = _session.Incident.Files.FirstOrDefault(f => f.Id == id);
return Results.Bytes(bytes, file?.ContentType ?? "application/octet-stream", fileDownloadName: file?.FileName);
}
diff --git a/src/LageBuch.Sync.Hosting/LocalNetwork.cs b/src/LageBuch.Sync.Hosting/LocalNetwork.cs
index 0c43f1d..6e5a0b5 100644
--- a/src/LageBuch.Sync.Hosting/LocalNetwork.cs
+++ b/src/LageBuch.Sync.Hosting/LocalNetwork.cs
@@ -21,22 +21,34 @@ public static IPAddress DisplayAddress()
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up)
+ {
continue;
+ }
+
var looksLikeTailscale = nic.Name.StartsWith("tailscale", StringComparison.OrdinalIgnoreCase)
|| nic.Name.StartsWith("ts", StringComparison.OrdinalIgnoreCase);
foreach (var addr in nic.GetIPProperties().UnicastAddresses)
{
var ip = addr.Address;
if (ip.AddressFamily != AddressFamily.InterNetwork)
+ {
continue;
+ }
+
// A tailnet address is the best answer — return the moment we see one.
if (looksLikeTailscale || IsCarrierGradeNat(ip))
+ {
return ip;
+ }
+
// Otherwise remember the first private LAN address as the fallback below Tailscale.
if (privateLan is null && IsPrivateLan(ip))
+ {
privateLan = ip;
+ }
}
}
+
return privateLan ?? IPAddress.Loopback;
}
diff --git a/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs
index 241dd20..fcd4df0 100644
--- a/src/LageBuch.Sync/CommandApplier.cs
+++ b/src/LageBuch.Sync/CommandApplier.cs
@@ -52,23 +52,44 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
incident.EditRolePhone(clock, Operator(c.Operator), c.AssignmentId, c.Phone);
break;
case AddForceUnitCommand c:
- incident.AddForceUnit(clock, Operator(c.Operator), c.Brigade, c.PersonnelCount,
- c.CallSign, c.Status, c.Notes, c.ScbaCount, c.OfficerCount);
+ incident.AddForceUnit(
+ clock,
+ Operator(c.Operator),
+ c.Brigade,
+ c.PersonnelCount,
+ c.CallSign,
+ c.Status,
+ c.Notes,
+ c.ScbaCount,
+ c.OfficerCount);
break;
case UpdateForceUnitCommand c:
incident.UpdateForceUnit(clock, Operator(c.Operator), c.UnitId, c.Status, c.Notes);
break;
case UpdateForceStrengthCommand c:
- incident.UpdateForceStrength(clock, Operator(c.Operator), c.UnitId,
- c.OfficerCount, c.PersonnelCount, c.ScbaCount);
+ incident.UpdateForceStrength(
+ clock,
+ Operator(c.Operator),
+ c.UnitId,
+ c.OfficerCount,
+ c.PersonnelCount,
+ c.ScbaCount);
break;
case RemoveForceUnitCommand c:
incident.RemoveForceUnit(clock, Operator(c.Operator), c.UnitId);
break;
case AddScbaTruppCommand c:
- incident.AddScbaTrupp(clock, c.Designation,
- c.Members.Select(m => new TruppMember(m.Role, m.Name)), c.EntryPressure, c.TruppNumber,
- c.CallSign, c.Task, c.MaxDurationMinutes, c.ReturnPressureBar, c.PressureControlIntervalMinutes);
+ incident.AddScbaTrupp(
+ clock,
+ c.Designation,
+ c.Members.Select(m => new TruppMember(m.Role, m.Name)),
+ c.EntryPressure,
+ c.TruppNumber,
+ c.CallSign,
+ c.Task,
+ c.MaxDurationMinutes,
+ c.ReturnPressureBar,
+ c.PressureControlIntervalMinutes);
break;
case StartScbaTruppCommand c:
incident.StartScbaTrupp(clock, c.TruppId);
@@ -105,8 +126,14 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
incident.RenameFile(c.FileId, c.DisplayName);
break;
case AddTaskCommand c:
- incident.AddTask(clock, Operator(c.Operator), c.Text, c.Assignee,
- c.Importance, c.Urgency, c.TimerMinutes);
+ incident.AddTask(
+ clock,
+ Operator(c.Operator),
+ c.Text,
+ c.Assignee,
+ c.Importance,
+ c.Urgency,
+ c.TimerMinutes);
break;
case SetTaskCompletedCommand c:
incident.SetTaskCompleted(c.TaskId, c.IsDone, clock, Operator(c.Operator));
@@ -136,7 +163,8 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
incident.SetApartmentLabel(c.BuildingId, c.ApartmentNumber, c.Label);
break;
default:
- throw new ArgumentOutOfRangeException(nameof(command),
+ throw new ArgumentOutOfRangeException(
+ nameof(command),
$"Unbekannter Befehl: {command.GetType().Name}");
}
}
diff --git a/src/LageBuch.Sync/IIncidentSession.cs b/src/LageBuch.Sync/IIncidentSession.cs
index 715256b..096235a 100644
--- a/src/LageBuch.Sync/IIncidentSession.cs
+++ b/src/LageBuch.Sync/IIncidentSession.cs
@@ -1,10 +1,10 @@
+using System.Diagnostics.CodeAnalysis;
using LageBuch.Domain;
using LageBuch.Domain.Atemschutz;
using LageBuch.Domain.CoMeasurement;
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
-using System.Diagnostics.CodeAnalysis;
namespace LageBuch.Sync;
@@ -40,18 +40,35 @@ public interface IIncidentSession
event Action? Changed;
void AddJournalEntry(EtbDirection direction, string text, string? from = null, string? to = null);
+
void EditJournalEntry(Guid entryId, string text);
+
void ToggleChecklistItem(Guid itemId);
- void AssignRole(string role, string personName, string? callSign = null,
- DateTimeOffset? from = null, DateTimeOffset? to = null, string? section = null, string? phone = null);
+
+ void AssignRole(
+ string role,
+ string personName,
+ string? callSign = null,
+ DateTimeOffset? from = null,
+ DateTimeOffset? to = null,
+ string? section = null,
+ string? phone = null);
/// Ends a running assignment and starts a new one for the same role/section — a handover.
void TransferRole(Guid assignmentId, string newPersonName, string? newCallSign = null, string? newPhone = null);
/// Corrects a role assignment's phone number. Logs to the ETB only on a real change.
void EditRolePhone(Guid assignmentId, string? phone);
- void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
- string? status = null, string? notes = null, int scbaCount = 0, int officerCount = 0);
+
+ void AddForceUnit(
+ string brigade,
+ int personnelCount,
+ string? callSign = null,
+ string? status = null,
+ string? notes = null,
+ int scbaCount = 0,
+ int officerCount = 0);
+
void UpdateForceUnit(Guid unitId, string? status, string? notes);
/// Corrects a unit's Stärke (GF / Gesamt / davon AGT). Logs to the ETB and retains the
@@ -68,20 +85,32 @@ void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
/// Stamps/clears a task's completion (#88).
void SetTaskCompleted(Guid taskId, bool isDone);
- void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
+
+ void AddScbaTrupp(
+ string designation,
+ IEnumerable members,
+ int entryPressure,
int? truppNumber = null,
string? callSign = null,
string? task = null,
int maxDurationMinutes = AtemschutzTrupp.DefaultMaxDurationMinutes,
int returnPressureBar = AtemschutzTrupp.DefaultReturnPressureBar,
int pressureControlIntervalMinutes = AtemschutzTrupp.DefaultPressureControlIntervalMinutes);
+
void StartScbaTrupp(Guid truppId);
+
void RecordScbaPressure(Guid truppId, int bar);
+
void WithdrawScbaTrupp(Guid truppId);
+
void MarkScbaRemoved(Guid truppId);
+
void SetIncidentNumber(IncidentNumber? number);
+
void SetKeyword(string? keyword);
+
void SetAddress(string? street, string? district);
+
void SetStatus(string? status);
///
@@ -110,11 +139,18 @@ void AddScbaTrupp(string designation, IEnumerable members, int entr
void RenameFile(Guid fileId, string? displayName);
void AddCoBuilding(string name, int floorCount, int apartmentsPerFloor);
+
void UpdateCoBuildingStructure(Guid buildingId, int floorCount, int apartmentsPerFloor);
+
void RemoveCoBuilding(Guid buildingId);
+
void RecordCoValue(Guid buildingId, int floorOrdinal, int apartmentNumber, int? coValue);
+
void SetDwellingStatus(Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status);
+
void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentNumber, string? residentName, bool? keyAvailable);
+
void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description);
+
void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label);
}
diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs
index 3fe2078..b434408 100644
--- a/src/LageBuch.Sync/RemoteIncidentSession.cs
+++ b/src/LageBuch.Sync/RemoteIncidentSession.cs
@@ -1,7 +1,7 @@
+using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
using System.Text.Json.Serialization;
-using System.Diagnostics.CodeAnalysis;
using LageBuch.Domain;
using LageBuch.Domain.Atemschutz;
using LageBuch.Domain.CoMeasurement;
@@ -30,6 +30,7 @@ public sealed class RemoteIncidentSession : IIncidentSession, IAsyncDisposable
private Incident _incident;
public SessionOperator? Operator { get; }
+
public Incident Incident => _incident;
// The client never writes locally, so it is never "read-only" in the editing sense — but a
@@ -93,26 +94,40 @@ private RemoteIncidentSession(HttpClient http, HubConnection hub, IUiDispatcher
///
/// Cancels the connect handshake.
public static async Task ConnectAsync(
- string host, SessionOperator op, string localVersion, IUiDispatcher ui, string? pin = null,
- int port = SyncProtocol.Port, IRetryPolicy? reconnectPolicy = null, string? cacheRoot = null,
+ string host,
+ SessionOperator op,
+ string localVersion,
+ IUiDispatcher ui,
+ string? pin = null,
+ int port = SyncProtocol.Port,
+ IRetryPolicy? reconnectPolicy = null,
+ string? cacheRoot = null,
CancellationToken ct = default)
{
var baseUri = new Uri($"http://{host}:{port}");
var http = new HttpClient { BaseAddress = baseUri };
if (!string.IsNullOrEmpty(pin))
+ {
http.DefaultRequestHeaders.Add(SyncProtocol.PinHeader, pin);
+ }
+
try
{
// The PIN gates every endpoint, so the first request already reflects it: a 401 means the
// PIN is wrong/missing — reported as such before the version compare (auth precedes content).
var versionResponse = await http.GetAsync(new Uri(SyncProtocol.VersionPath, UriKind.RelativeOrAbsolute), ct);
if (versionResponse.StatusCode == HttpStatusCode.Unauthorized)
+ {
throw new PinRejectedException();
+ }
+
versionResponse.EnsureSuccessStatusCode();
var hostVersion = SyncJson.Deserialize(await versionResponse.Content.ReadAsStringAsync(ct)).Version;
if (hostVersion != localVersion)
+ {
throw new VersionMismatchException(localVersion, hostVersion);
+ }
var initial = SnapshotMapper.FromSnapshot(
SyncJson.Deserialize(await http.GetStringAsync(new Uri(SyncProtocol.SnapshotPath, UriKind.RelativeOrAbsolute), ct)));
@@ -121,7 +136,9 @@ public static async Task ConnectAsync(
.WithUrl(new Uri(baseUri, SyncProtocol.HubPath), o =>
{
if (!string.IsNullOrEmpty(pin))
+ {
o.Headers.Add(SyncProtocol.PinHeader, pin);
+ }
})
.WithAutomaticReconnect(reconnectPolicy ?? new ReconnectForAWhile())
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()))
@@ -129,14 +146,27 @@ public static async Task ConnectAsync(
var session = new RemoteIncidentSession(http, hub, ui, op, initial, cacheRoot);
hub.On(SyncProtocol.SnapshotMethod, session.OnSnapshot);
+
// Every SignalR callback below arrives on the hub's receive loop, off the UI thread; each is
// marshalled onto the UI thread because it drives view state (the reconnect banner, the
// return-Home navigation) exactly as OnSnapshot drives the journal.
// Reconnecting = transient drop (keep the workspace open, disable input); Closed = the
// reconnect window ran out or the host went away for good (return to Home).
- hub.Reconnecting += _ => { session._ui.Post(() => session.Disconnected?.Invoke()); return Task.CompletedTask; };
- hub.Reconnected += async _ => { await session.ResyncAsync(); session._ui.Post(() => session.Reconnected?.Invoke()); };
- hub.Closed += _ => { session._ui.Post(() => session.Ended?.Invoke()); return Task.CompletedTask; };
+ hub.Reconnecting += _ =>
+ {
+ session._ui.Post(() => session.Disconnected?.Invoke());
+ return Task.CompletedTask;
+ };
+ hub.Reconnected += async _ =>
+ {
+ await session.ResyncAsync();
+ session._ui.Post(() => session.Reconnected?.Invoke());
+ };
+ hub.Closed += _ =>
+ {
+ session._ui.Post(() => session.Ended?.Invoke());
+ return Task.CompletedTask;
+ };
await hub.StartAsync(ct);
return session;
}
@@ -149,7 +179,6 @@ public static async Task ConnectAsync(
// --- IIncidentSession mutation surface: every call is a fire-and-forget command to the host;
// the resulting state arrives via the broadcast, never from these calls. ---
-
public void AddJournalEntry(EtbDirection direction, string text, string? from = null, string? to = null) =>
Send(new AddJournalEntryCommand(Op(), direction, text, from, to));
@@ -158,8 +187,14 @@ public void EditJournalEntry(Guid entryId, string text) =>
public void ToggleChecklistItem(Guid itemId) => Send(new ToggleChecklistItemCommand(Op(), itemId));
- public void AssignRole(string role, string personName, string? callSign = null,
- DateTimeOffset? from = null, DateTimeOffset? to = null, string? section = null, string? phone = null) =>
+ public void AssignRole(
+ string role,
+ string personName,
+ string? callSign = null,
+ DateTimeOffset? from = null,
+ DateTimeOffset? to = null,
+ string? section = null,
+ string? phone = null) =>
Send(new AssignRoleCommand(Op(), role, personName, callSign, from, to, section, phone));
public void TransferRole(Guid assignmentId, string newPersonName, string? newCallSign = null, string? newPhone = null) =>
@@ -168,8 +203,14 @@ public void TransferRole(Guid assignmentId, string newPersonName, string? newCal
public void EditRolePhone(Guid assignmentId, string? phone) =>
Send(new EditRolePhoneCommand(Op(), assignmentId, phone));
- public void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
- string? status = null, string? notes = null, int scbaCount = 0, int officerCount = 0) =>
+ public void AddForceUnit(
+ string brigade,
+ int personnelCount,
+ string? callSign = null,
+ string? status = null,
+ string? notes = null,
+ int scbaCount = 0,
+ int officerCount = 0) =>
Send(new AddForceUnitCommand(Op(), brigade, personnelCount, callSign, status, notes, scbaCount, officerCount));
public void UpdateForceUnit(Guid unitId, string? status, string? notes) =>
@@ -187,31 +228,49 @@ public void AddTask(string text, string? assignee, TaskImportance importance, Ta
public void SetTaskCompleted(Guid taskId, bool isDone) =>
Send(new SetTaskCompletedCommand(Op(), taskId, isDone));
- public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
+ public void AddScbaTrupp(
+ string designation,
+ IEnumerable members,
+ int entryPressure,
int? truppNumber = null,
string? callSign = null,
string? task = null,
int maxDurationMinutes = AtemschutzTrupp.DefaultMaxDurationMinutes,
int returnPressureBar = AtemschutzTrupp.DefaultReturnPressureBar,
int pressureControlIntervalMinutes = AtemschutzTrupp.DefaultPressureControlIntervalMinutes) =>
- Send(new AddScbaTruppCommand(designation,
+ Send(new AddScbaTruppCommand(
+ designation,
members.Select(m => new TruppMemberDto(m.Role, m.Name)).ToList(),
- callSign, task, maxDurationMinutes, returnPressureBar, pressureControlIntervalMinutes,
- entryPressure, truppNumber));
+ callSign,
+ task,
+ maxDurationMinutes,
+ returnPressureBar,
+ pressureControlIntervalMinutes,
+ entryPressure,
+ truppNumber));
public void StartScbaTrupp(Guid truppId) => Send(new StartScbaTruppCommand(truppId));
+
public void RecordScbaPressure(Guid truppId, int bar) => Send(new RecordScbaPressureCommand(truppId, bar));
+
public void WithdrawScbaTrupp(Guid truppId) => Send(new WithdrawScbaTruppCommand(truppId));
+
public void MarkScbaRemoved(Guid truppId) => Send(new MarkScbaRemovedCommand(truppId));
+
public void SetIncidentNumber(IncidentNumber? number) => Send(new SetIncidentNumberCommand(number?.Value));
+
public void SetKeyword(string? keyword) => Send(new SetKeywordCommand(keyword));
+
public void SetAddress(string? street, string? district) => Send(new SetAddressCommand(street, district));
+
public void SetStatus(string? status) => Send(new SetStatusCommand(status));
// No-op: incident-level timers (the ILS reminder) are host-authoritative and never built on a
// joined client (IncidentWorkspaceViewModel gates the reminder on !IsRemote), so this is unreachable
// here. The host's persisted timer state still rides the broadcast snapshot as read-only display.
- public void UpsertTimer(string key, DateTimeOffset cycleAnchor, int intervalMinutes, int recurringIntervalMinutes, bool isRunning) { }
+ public void UpsertTimer(string key, DateTimeOffset cycleAnchor, int intervalMinutes, int recurringIntervalMinutes, bool isRunning)
+ {
+ }
public void Close() => Send(new CloseIncidentCommand(Op()));
@@ -222,8 +281,11 @@ public async Task AddFileAsync(string fileName, string contentType, byte[] bytes
{
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.LongLength > IncidentFile.MaxSizeBytes)
+ {
throw new ArgumentException(
$"Datei ist größer als das Limit von {IncidentFile.MaxSizeBytes / (1024 * 1024)} MB.", nameof(bytes));
+ }
+
await SendAsync(new AddFileCommand(Op(), fileName, contentType, bytes));
}
@@ -234,11 +296,15 @@ public async Task AddFileAsync(string fileName, string contentType, byte[] bytes
{
var file = _incident.Files.FirstOrDefault(f => f.Id == fileId);
if (file is null)
+ {
return null;
+ }
var cachePath = CachePathFor(fileId, file.FileName);
if (cachePath is not null && File.Exists(cachePath))
+ {
return await File.ReadAllBytesAsync(cachePath);
+ }
HttpResponseMessage response;
try
@@ -249,8 +315,11 @@ public async Task AddFileAsync(string fileName, string contentType, byte[] bytes
{
return null; // host unreachable — degrade quietly, same as a missing local file
}
+
if (!response.IsSuccessStatusCode)
+ {
return null;
+ }
var bytes = await response.Content.ReadAsByteArrayAsync();
if (cachePath is not null)
@@ -258,6 +327,7 @@ public async Task AddFileAsync(string fileName, string contentType, byte[] bytes
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
await File.WriteAllBytesAsync(cachePath, bytes);
}
+
return bytes;
}
diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs
index 5cdbdb9..3987afa 100644
--- a/src/LageBuch.Sync/SnapshotMapper.cs
+++ b/src/LageBuch.Sync/SnapshotMapper.cs
@@ -34,26 +34,58 @@ public static IncidentSnapshot ToSnapshot(Incident incident)
incident.ClosedBy,
incident.ChecklistAufbau.Select(c => new ChecklistItemDto(c.Id, c.Text, c.IsDone, c.Note, c.IsMandatory)).ToList(),
incident.ChecklistAbbau.Select(c => new ChecklistItemDto(c.Id, c.Text, c.IsDone, c.Note, c.IsMandatory)).ToList(),
- incident.Journal.Select(e => new EtbEntryDto(e.Id, e.Timestamp, e.Direction, e.Text, e.EnteredBy, e.From, e.To,
+ incident.Journal.Select(e => new EtbEntryDto(
+ e.Id,
+ e.Timestamp,
+ e.Direction,
+ e.Text,
+ e.EnteredBy,
+ e.From,
+ e.To,
e.Edits.Select(x => new EtbEntryEditDto(x.PreviousText, x.EditedBy, x.EditedAt)).ToList())).ToList(),
incident.Roles.Select(r => new RoleAssignmentDto(r.Id, r.Role, r.PersonName, r.CallSign, r.From, r.To, r.Section, r.Phone)).ToList(),
- incident.Forces.Select(f => new ForceUnitDto(f.Id, f.Brigade, f.CallSign, f.PersonnelCount, f.ScbaCount, f.Status, f.Notes,
+ incident.Forces.Select(f => new ForceUnitDto(
+ f.Id,
+ f.Brigade,
+ f.CallSign,
+ f.PersonnelCount,
+ f.ScbaCount,
+ f.Status,
+ f.Notes,
f.OfficerCount,
f.Edits.Select(x => new ForceUnitStrengthEditDto(x.PreviousOfficerCount, x.PreviousPersonnelCount, x.PreviousScbaCount, x.EditedBy, x.EditedAt)).ToList())).ToList(),
incident.ScbaTrupps.Select(ToDto).ToList(),
incident.Audit.Select(a => new AuditEventDto(a.At, a.Action, a.By)).ToList(),
incident.Timers.Select(t => new TimerDto(t.Key, t.CycleAnchor, t.IntervalMinutes, t.RecurringIntervalMinutes, t.IsRunning)).ToList(),
incident.Files.Select(f => new IncidentFileDto(f.Id, f.FileName, f.DisplayName, f.ContentType, f.SizeBytes, f.AddedAt, f.AddedBy)).ToList(),
- incident.Tasks.Select(t => new TaskDto(t.Id, t.Text, t.Assignee, t.Importance, t.Urgency,
- t.CreatedBy, t.CreatedAt, t.DueAt, t.CompletedAt, t.CompletedBy)).ToList(),
+ incident.Tasks.Select(t => new TaskDto(
+ t.Id,
+ t.Text,
+ t.Assignee,
+ t.Importance,
+ t.Urgency,
+ t.CreatedBy,
+ t.CreatedAt,
+ t.DueAt,
+ t.CompletedAt,
+ t.CompletedBy)).ToList(),
incident.Buildings.Select(b => new BuildingDto(
- b.Id, b.Name, b.FloorCount, b.ApartmentsPerFloor,
+ b.Id,
+ b.Name,
+ b.FloorCount,
+ b.ApartmentsPerFloor,
b.FloorDescriptions.ToDictionary(kv => kv.Key.ToString(CultureInfo.InvariantCulture), kv => kv.Value),
b.Ordinal,
b.ApartmentLabels.ToDictionary(kv => kv.Key.ToString(CultureInfo.InvariantCulture), kv => kv.Value))).ToList(),
incident.Dwellings.Select(d => new DwellingDto(
- d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber,
- d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList());
+ d.Id,
+ d.BuildingId,
+ d.FloorOrdinal,
+ d.ApartmentNumber,
+ d.ResidentName,
+ d.Status,
+ d.KeyAvailable,
+ d.CoValue)).ToList());
}
public static Incident FromSnapshot(IncidentSnapshot snapshot)
@@ -72,30 +104,58 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot)
snapshot.ClosedBy,
snapshot.ChecklistAufbau.Select(c => ChecklistItem.Rehydrate(c.Id, c.Text, c.IsDone, c.Note, c.IsMandatory)),
snapshot.ChecklistAbbau.Select(c => ChecklistItem.Rehydrate(c.Id, c.Text, c.IsDone, c.Note, c.IsMandatory)),
- snapshot.Journal.Select(e => EtbEntry.Rehydrate(e.Id, e.Timestamp, e.Direction, e.Text, e.EnteredBy, e.From, e.To,
+ snapshot.Journal.Select(e => EtbEntry.Rehydrate(
+ e.Id,
+ e.Timestamp,
+ e.Direction,
+ e.Text,
+ e.EnteredBy,
+ e.From,
+ e.To,
e.Edits.Select(x => new EtbEntryEdit(x.PreviousText, x.EditedBy, x.EditedAt)))),
snapshot.Roles.Select(r => new RoleAssignment(r.Id, r.Role, r.PersonName, r.CallSign, r.From, r.To, r.Section, r.Phone)),
- snapshot.Forces.Select(f => ForceUnit.Rehydrate(f.Id, f.Brigade, f.CallSign, f.PersonnelCount, f.ScbaCount, f.Status, f.Notes,
+ snapshot.Forces.Select(f => ForceUnit.Rehydrate(
+ f.Id,
+ f.Brigade,
+ f.CallSign,
+ f.PersonnelCount,
+ f.ScbaCount,
+ f.Status,
+ f.Notes,
f.OfficerCount,
f.Edits.Select(x => new ForceUnitStrengthEdit(x.PreviousOfficerCount, x.PreviousPersonnelCount, x.PreviousScbaCount, x.EditedBy, x.EditedAt)))),
snapshot.ScbaTrupps.Select(FromDto),
snapshot.Audit.Select(a => new AuditEvent(a.At, a.Action, a.By)),
snapshot.Timers.Select(t => new IncidentTimerState(t.Key, t.CycleAnchor, t.IntervalMinutes, t.RecurringIntervalMinutes, t.IsRunning)),
snapshot.Files.Select(f => IncidentFile.Rehydrate(f.Id, f.FileName, f.DisplayName, f.ContentType, f.SizeBytes, f.AddedAt, f.AddedBy)),
- snapshot.Tasks.Select(t => IncidentTask.Rehydrate(t.Id, t.CreatedAt, t.Text, t.Assignee,
- t.Importance, t.Urgency, t.CreatedBy, t.DueAt, t.CompletedAt, t.CompletedBy)),
+ snapshot.Tasks.Select(t => IncidentTask.Rehydrate(
+ t.Id,
+ t.CreatedAt,
+ t.Text,
+ t.Assignee,
+ t.Importance,
+ t.Urgency,
+ t.CreatedBy,
+ t.DueAt,
+ t.CompletedAt,
+ t.CompletedBy)),
snapshot.Buildings.Select(b => Building.Rehydrate(
- b.Id, b.Name, b.FloorCount, b.ApartmentsPerFloor,
- b.FloorDescriptions.ToDictionary(
- kv => int.Parse(kv.Key, CultureInfo.InvariantCulture),
- kv => kv.Value),
+ b.Id,
+ b.Name,
+ b.FloorCount,
+ b.ApartmentsPerFloor,
+ b.FloorDescriptions.ToDictionary(kv => int.Parse(kv.Key, CultureInfo.InvariantCulture), kv => kv.Value),
b.Ordinal,
- b.ApartmentLabels?.ToDictionary(
- kv => int.Parse(kv.Key, CultureInfo.InvariantCulture),
- kv => kv.Value))),
+ b.ApartmentLabels?.ToDictionary(kv => int.Parse(kv.Key, CultureInfo.InvariantCulture), kv => kv.Value))),
snapshot.Dwellings.Select(d => Dwelling.Rehydrate(
- d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber,
- d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)));
+ d.Id,
+ d.BuildingId,
+ d.FloorOrdinal,
+ d.ApartmentNumber,
+ d.ResidentName,
+ d.Status,
+ d.KeyAvailable,
+ d.CoValue)));
}
private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new(
diff --git a/src/LageBuch.Sync/Protocol.cs b/src/LageBuch.Sync/SyncProtocol.cs
similarity index 84%
rename from src/LageBuch.Sync/Protocol.cs
rename to src/LageBuch.Sync/SyncProtocol.cs
index 63a4f6d..d889ddb 100644
--- a/src/LageBuch.Sync/Protocol.cs
+++ b/src/LageBuch.Sync/SyncProtocol.cs
@@ -35,11 +35,20 @@ public sealed record VersionInfo(string Version);
///
public sealed class PinRejectedException : Exception
{
- public PinRejectedException() : this("Falsche PIN.") { }
+ public PinRejectedException()
+ : this("Falsche PIN.")
+ {
+ }
- public PinRejectedException(string message) : base(message) { }
+ public PinRejectedException(string message)
+ : base(message)
+ {
+ }
- public PinRejectedException(string message, Exception innerException) : base(message, innerException) { }
+ public PinRejectedException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
}
///
@@ -55,11 +64,20 @@ public VersionMismatchException(string localVersion, string hostVersion)
HostVersion = hostVersion;
}
- public VersionMismatchException() : this("unbekannt", "unbekannt") { }
+ public VersionMismatchException()
+ : this("unbekannt", "unbekannt")
+ {
+ }
- public VersionMismatchException(string message) : base(message) { }
+ public VersionMismatchException(string message)
+ : base(message)
+ {
+ }
- public VersionMismatchException(string message, Exception innerException) : base(message, innerException) { }
+ public VersionMismatchException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
public string LocalVersion { get; } = string.Empty;
diff --git a/stylecop.json b/stylecop.json
new file mode 100644
index 0000000..04cc7a0
--- /dev/null
+++ b/stylecop.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
+ "settings": {
+ "orderingRules": {
+ "usingDirectivesPlacement": "outsideNamespace"
+ }
+ }
+}
diff --git a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
index 45e78af..1201842 100644
--- a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
@@ -21,7 +21,10 @@ private static void Capture(Window window, string name)
{
var dir = Environment.GetEnvironmentVariable("RENDER_OUT");
if (string.IsNullOrWhiteSpace(dir))
+ {
return;
+ }
+
Directory.CreateDirectory(dir);
using var frame = window.CaptureRenderedFrame()!;
frame.SavePng(Path.Combine(dir, name));
@@ -90,9 +93,16 @@ private static MainWindowViewModel BuildMainWindowViewModel()
{
var dialogs = new FakeDialogs();
var masterData = new StaticMasterData(WorkspaceRenderHelper.MasterData());
- var home = new HomeViewModel(new FakeStore(), masterData,
- new EmptyRecent(), dialogs, new FixedClock(), new NoopTicker(), new NoopAlarmService(),
- new NoopIncidentHostController(), "0.1.0");
+ var home = new HomeViewModel(
+ new FakeStore(),
+ masterData,
+ new EmptyRecent(),
+ dialogs,
+ new FixedClock(),
+ new NoopTicker(),
+ new NoopAlarmService(),
+ new NoopIncidentHostController(),
+ "0.1.0");
var editor = new MasterDataEditorViewModel(masterData, dialogs, new NoFiles());
return new MainWindowViewModel(home, editor, dialogs, "0.1.0");
}
@@ -100,19 +110,29 @@ private static MainWindowViewModel BuildMainWindowViewModel()
private sealed class StaticMasterData(MasterDataSet set) : IMasterDataProvider
{
public MasterDataSet Get() => set;
- public void Save(MasterDataSet s) { }
+
+ public void Save(MasterDataSet s)
+ {
+ }
}
private sealed class NoFiles : IMasterDataFileService
{
public MasterDataSet Read(string path) => MasterDataSet.Empty;
- public void Write(string path, MasterDataSet set) { }
+
+ public void Write(string path, MasterDataSet set)
+ {
+ }
}
private sealed class EmptyRecent : IRecentFilesStore
{
private readonly List _list = new();
+
public IReadOnlyList GetRecent() => _list;
- public void Add(string path) { }
+
+ public void Add(string path)
+ {
+ }
}
}
diff --git a/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs b/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs
index 40850a5..b1f8bbd 100644
--- a/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs
@@ -14,11 +14,21 @@ public class CoMessprotokollRenderTests
{
private static (Window Window, IncidentWorkspaceViewModel Vm, LocalIncidentSession Session) ShowWorkspace()
{
- var session = LocalIncidentSession.StartNew(new FakeStore(), new FixedClock(),
- new SessionOperator("Müller", "FFB 12/1"), "/x.fwincident",
- new[] { ("Blaulicht aus?", false) }, Array.Empty<(string, bool)>());
- var vm = new IncidentWorkspaceViewModel(session, new FixedClock(), new NoopTicker(), WorkspaceRenderHelper.MasterData(),
- new FakeDialogs(), new NoopAlarmService(), new NoopIncidentHostController());
+ var session = LocalIncidentSession.StartNew(
+ new FakeStore(),
+ new FixedClock(),
+ new SessionOperator("Müller", "FFB 12/1"),
+ "/x.fwincident",
+ new[] { ("Blaulicht aus?", false) },
+ Array.Empty<(string, bool)>());
+ var vm = new IncidentWorkspaceViewModel(
+ session,
+ new FixedClock(),
+ new NoopTicker(),
+ WorkspaceRenderHelper.MasterData(),
+ new FakeDialogs(),
+ new NoopAlarmService(),
+ new NoopIncidentHostController());
var window = new Window { Content = new IncidentWorkspaceView { DataContext = vm }, Width = 1920, Height = 1032 };
window.Show();
Dispatcher.UIThread.RunJobs();
@@ -29,7 +39,10 @@ private static void Capture(Window window, string name)
{
var dir = Environment.GetEnvironmentVariable("RENDER_OUT");
if (string.IsNullOrWhiteSpace(dir))
+ {
return;
+ }
+
Directory.CreateDirectory(dir);
using var frame = window.CaptureRenderedFrame()!;
frame.SavePng(Path.Combine(dir, name));
diff --git a/tests/LageBuch.Acceptance.Tests/CommandBarReachabilityTests.cs b/tests/LageBuch.Acceptance.Tests/CommandBarReachabilityTests.cs
index 49deaa5..4f4492d 100644
--- a/tests/LageBuch.Acceptance.Tests/CommandBarReachabilityTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/CommandBarReachabilityTests.cs
@@ -25,7 +25,7 @@ public class CommandBarReachabilityTests
private static Button ButtonNamed(Visual root, string name) =>
root.GetVisualDescendants().OfType