From ab96d15df129ef047786e85b15f9d5089d955349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:33:49 +0200 Subject: [PATCH 1/2] chore(stylecop): add StyleCop.Analyzers with curated ruleset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StyleCop.Analyzers 1.2.0-beta.556 as a solution-wide analyzer via Directory.Build.props, with a stylecop.json and .editorconfig severity overrides curated to fit this codebase: - SA1101/SA1309: disabled — the codebase already uses _camelCase private fields without a `this.` prefix. - SA1600-SA1648 (documentation-required family): disabled — XML docs are optional here (CS1591 is already suppressed). - SA1201/SA1202/SA1204 (member ordering by kind/access/static): disabled — no code-fix support exists, and the codebase organizes members by domain/lifecycle grouping rather than kind or access level. - SA1402 (one type per file): disabled — several files deliberately group a closed set of small related record types (SyncCommand.cs, IncidentSnapshot.cs, MasterDataSet.cs, ...). - SA1312: disabled — its walker doesn't recognize `using var _ = ...` as the discard idiom it is; the test suite uses this pattern throughout. orderingRules.usingDirectivesPlacement is set to outsideNamespace to match the file-scoped namespaces already enforced in this repo. Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .editorconfig | 81 +++++++++++++++++++ Directory.Build.props | 5 ++ Directory.Packages.props | 1 + ...dentFileStore.cs => IIncidentFileStore.cs} | 0 .../{Protocol.cs => SyncProtocol.cs} | 0 stylecop.json | 8 ++ 6 files changed, 95 insertions(+) rename src/LageBuch.Persistence/{IncidentFileStore.cs => IIncidentFileStore.cs} (100%) rename src/LageBuch.Sync/{Protocol.cs => SyncProtocol.cs} (100%) create mode 100644 stylecop.json diff --git a/.editorconfig b/.editorconfig index 697191c..fb07cc4 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 @@ -38,3 +39,83 @@ dotnet_diagnostic.CA2213.severity = none dotnet_diagnostic.CA1308.severity = none dotnet_diagnostic.CA2000.severity = none dotnet_diagnostic.CA2213.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 + 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.Persistence/IncidentFileStore.cs b/src/LageBuch.Persistence/IIncidentFileStore.cs similarity index 100% rename from src/LageBuch.Persistence/IncidentFileStore.cs rename to src/LageBuch.Persistence/IIncidentFileStore.cs diff --git a/src/LageBuch.Sync/Protocol.cs b/src/LageBuch.Sync/SyncProtocol.cs similarity index 100% rename from src/LageBuch.Sync/Protocol.cs rename to src/LageBuch.Sync/SyncProtocol.cs 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" + } + } +} From dedb3c502dcb1623e08522f3d924c255de533389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:34:17 +0200 Subject: [PATCH 2/2] style: bring src and tests into StyleCop.Analyzers compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical fixes across the whole solution to satisfy the newly-enabled curated ruleset: - Parameter/argument lists reformatted to either one line or one parameter per line (SA1117), and multi-line non-lambda arguments extracted to local variables (SA1118). - Files with a mismatched first type reordered so the type matching the filename comes first (SA1649); a few genuine grab-bags of small, unrelated test doubles instead get a scoped `#pragma warning disable SA1649` with a comment, matching the SA1402 rationale. - Incident.cs's AuditEvent record split into its own file. - A tuple-element casing fix (SA1316) and a couple of raw-SQL literals extracted to local consts to satisfy SA1118. No behavior changes — the full test suite (802 tests) still passes. Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .editorconfig | 47 +- src/LageBuch.App.Android/MainActivity.cs | 4 + src/LageBuch.App.Android/MainApplication.cs | 3 +- .../Services/AndroidAlarmService.cs | 4 +- .../Services/AndroidFileDialogService.cs | 22 +- src/LageBuch.App.Shared/App.axaml.cs | 3 +- .../Behaviors/EnterSubmit.cs | 5 + .../Behaviors/IntegerOnly.cs | 4 + .../Services/AvaloniaUiDispatcher.cs | 4 + .../Services/DispatcherTimerTicker.cs | 21 +- src/LageBuch.App.Shared/ViewLocator.cs | 2 + .../Views/AboutView.axaml.cs | 1 + .../Views/ConfirmDialogView.axaml.cs | 4 + .../Views/EtbView.axaml.cs | 1 + .../Views/HomeView.axaml.cs | 2 + .../Views/IncidentWorkspaceView.axaml.cs | 7 + .../Views/MainView.axaml.cs | 2 + .../Views/MainWindow.axaml.cs | 3 +- .../Views/OperatorPromptView.axaml.cs | 1 + .../Services/IncidentHostController.cs | 10 + src/LageBuch.App/Services/SerialAudioQueue.cs | 8 +- .../StorageProviderFileDialogService.cs | 64 ++- .../Services/SystemAlarmService.cs | 29 +- src/LageBuch.AppLogic/LocalIncidentSession.cs | 65 ++- .../Services/IFileDialogService.cs | 4 + .../Services/IIncidentHostController.cs | 5 + .../Services/IIncidentStore.cs | 1 + .../Services/ILastSaveFolderStore.cs | 1 + .../Services/IMasterDataFileService.cs | 1 + .../Services/IRecentFilesStore.cs | 1 + .../Services/IncidentStore.cs | 2 +- .../Services/JsonLastSaveFolderStore.cs | 2 + .../Services/JsonRecentFilesStore.cs | 4 + .../Services/MasterDataProvider.cs | 1 + .../ViewModels/AboutViewModel.cs | 10 +- .../ViewModels/ChecklistTemplateRow.cs | 7 +- .../ViewModels/ChecklistTemplateSection.cs | 26 +- .../ViewModels/ChecklistViewModel.cs | 19 +- .../ViewModels/CoMessprotokollViewModel.cs | 62 ++- .../ViewModels/ConfirmDialogViewModel.cs | 2 + .../ViewModels/EditableListSection.cs | 26 +- .../ViewModels/EtbViewModel.cs | 139 +++--- .../ViewModels/FilesViewModel.cs | 42 +- .../ViewModels/ForcesViewModel.cs | 42 +- .../ViewModels/HomeViewModel.cs | 32 +- .../ViewModels/IncidentWorkspaceViewModel.cs | 48 +- src/LageBuch.AppLogic/ViewModels/LinkRow.cs | 9 +- .../ViewModels/LinksSection.cs | 26 +- .../ViewModels/LinksViewModel.cs | 4 +- .../ViewModels/MainWindowViewModel.cs | 21 +- .../ViewModels/MasterDataEditorViewModel.cs | 56 ++- src/LageBuch.AppLogic/ViewModels/PersonRow.cs | 19 +- .../ViewModels/PersonnelSection.cs | 15 +- .../ViewModels/ReminderViewModel.cs | 22 +- .../ViewModels/RolesViewModel.cs | 53 ++- .../ViewModels/ScbaViewModel.cs | 128 +++++- .../ViewModels/SettingsSection.cs | 9 +- .../ViewModels/TaskDialogViewModel.cs | 1 + .../ViewModels/TasksViewModel.cs | 101 +++- .../ViewModels/VehicleRow.cs | 19 +- .../ViewModels/VehiclesSection.cs | 20 +- src/LageBuch.Documents/Formatting.cs | 4 +- src/LageBuch.Documents/PdfAttachmentMerger.cs | 2 + src/LageBuch.Documents/PdfLicense.cs | 2 + .../Sections/AtemschutzSection.cs | 2 + .../Sections/ChecklistSection.cs | 7 + .../Sections/CoMessprotokollSection.cs | 14 +- src/LageBuch.Documents/Sections/EtbSection.cs | 6 + .../Sections/FilesSection.cs | 6 + .../Sections/ForcesSection.cs | 4 + .../Sections/RolesSection.cs | 3 + .../Sections/TasksSection.cs | 7 +- .../Atemschutz/AtemschutzTrupp.cs | 45 +- src/LageBuch.Domain/Atemschutz/TruppMember.cs | 10 +- src/LageBuch.Domain/AuditEvent.cs | 3 + src/LageBuch.Domain/ChecklistItem.cs | 7 + src/LageBuch.Domain/ChecklistKind.cs | 2 +- src/LageBuch.Domain/CoMeasurement/Building.cs | 46 +- .../CoMeasurement/CoMeasurementLabels.cs | 6 +- src/LageBuch.Domain/CoMeasurement/Dwelling.cs | 23 +- .../CoMeasurement/DwellingStatus.cs | 2 +- src/LageBuch.Domain/Etb/EtbDirection.cs | 3 +- src/LageBuch.Domain/Etb/EtbEntry.cs | 29 +- src/LageBuch.Domain/Files/IncidentFile.cs | 30 +- src/LageBuch.Domain/ForceUnit.cs | 41 +- src/LageBuch.Domain/Incident.cs | 163 ++++++- .../IncidentClosedException.cs | 14 +- src/LageBuch.Domain/IncidentState.cs | 2 +- src/LageBuch.Domain/RoleAssignment.cs | 11 + src/LageBuch.Domain/SessionOperator.cs | 13 +- src/LageBuch.Domain/Tasks/IncidentTask.cs | 22 +- src/LageBuch.Domain/Time/ReminderTimer.cs | 15 + .../ValueObjects/IncidentNumber.cs | 3 + .../IIncidentFileStore.cs | 4 +- .../IncidentRepository.cs | 434 +++++++++++++----- .../MasterData/MasterDataSet.cs | 136 +++--- .../MasterData/MasterDataStore.cs | 125 ++++- src/LageBuch.Persistence/Sqlite/Migrations.cs | 143 ++++-- .../Sqlite/SchemaHelpers.cs | 11 +- .../Sqlite/SqliteConnectionFactory.cs | 11 +- .../UnsupportedSchemaVersionException.cs | 15 +- src/LageBuch.Sync.Hosting/IncidentHost.cs | 9 + src/LageBuch.Sync.Hosting/LocalNetwork.cs | 12 + src/LageBuch.Sync/CommandApplier.cs | 48 +- src/LageBuch.Sync/IIncidentSession.cs | 48 +- src/LageBuch.Sync/RemoteIncidentSession.cs | 102 +++- src/LageBuch.Sync/SnapshotMapper.cs | 100 +++- src/LageBuch.Sync/SyncProtocol.cs | 30 +- .../AboutRenderTests.cs | 32 +- .../CoMessprotokollRenderTests.cs | 23 +- .../CommandBarReachabilityTests.cs | 8 +- .../EnterOnDropdownTests.cs | 11 +- .../EtbTabRenderTests.cs | 20 +- .../FilesDisplayNameFocusTests.cs | 13 +- .../FilesTabRenderTests.cs | 24 +- .../ForcesNotesFocusTests.cs | 14 +- .../ForcesTabRenderTests.cs | 40 +- .../HeaderHeroTests.cs | 23 +- .../HomeOpenErrorTests.cs | 46 +- .../JoinPromptRenderTests.cs | 3 + .../LayoutAlignmentTests.cs | 15 +- .../LinksTabRenderTests.cs | 23 +- .../MasterDataEditorRenderTests.cs | 18 +- .../ModuleTabsScrollingTests.cs | 10 +- .../NumericInputTests.cs | 28 +- .../OperatorPromptFocusTests.cs | 15 +- .../RolesPhoneFocusTests.cs | 14 +- .../RolesTabRenderTests.cs | 20 +- .../SharePanelRenderTests.cs | 26 +- .../TaskDialogRenderTests.cs | 10 +- .../TasksTabRenderTests.cs | 23 +- .../WorkspaceAcceptanceTests.cs | 142 +++++- .../WorkspaceRenderHelper.cs | 59 ++- tests/LageBuch.App.Tests/AppPathsTests.cs | 5 +- .../SerialAudioQueueTests.cs | 18 +- .../AboutViewModelTests.cs | 8 + .../ChecklistViewModelTests.cs | 21 +- .../CoMessprotokollViewModelTests.cs | 31 +- .../EtbViewModelTests.cs | 97 +++- .../FilesViewModelTests.cs | 85 +++- .../ForcesViewModelTests.cs | 165 +++++-- .../HomeViewModelTests.cs | 192 ++++++-- .../IncidentSessionTests.cs | 194 ++++++-- .../IncidentWorkspaceViewModelTests.cs | 345 ++++++++++---- .../JsonLastSaveFolderStoreTests.cs | 5 +- .../JsonRecentFilesStoreTests.cs | 7 +- .../LinksViewModelTests.cs | 10 +- .../LocalSessionTaskTests.cs | 9 +- .../MainWindowViewModelTests.cs | 64 ++- .../MasterDataEditorViewModelTests.cs | 47 +- .../MasterDataProviderTests.cs | 10 +- .../MasterDataSectionTests.cs | 8 +- .../OperatorPromptViewModelTests.cs | 8 +- .../ReminderViewModelTests.cs | 119 +++-- .../RolesViewModelTests.cs | 78 +++- .../ScbaViewModelTests.cs | 52 ++- .../TaskDialogViewModelTests.cs | 24 +- .../TasksViewModelTests.cs | 50 +- .../IncidentPdfTests.cs | 34 +- tests/LageBuch.Documents.Tests/PdfAssert.cs | 2 + .../LageBuch.Domain.Tests/AtemschutzTests.cs | 220 ++++++--- .../CoMeasurementTests.cs | 7 +- .../ForceRemovalTests.cs | 1 + .../ForceStrengthEditTests.cs | 2 + .../IncidentCreationTests.cs | 18 +- .../IncidentOperationsTests.cs | 11 +- .../IncidentTaskAggregateTests.cs | 28 +- .../IncidentTaskTests.cs | 23 +- .../IncidentTimerTests.cs | 21 +- .../LageBuch.Domain.Tests/RehydrationTests.cs | 52 ++- .../SupportingEntitiesTests.cs | 2 +- .../ClosedIncidentReadOnlyTests.cs | 11 +- .../CoMeasurementPersistenceTests.cs | 5 +- .../IncidentFileStoreTests.cs | 5 +- .../IncidentRepositoryLoadTests.cs | 5 +- .../IncidentRepositorySaveTests.cs | 13 +- .../IncidentRoundTripTests.cs | 86 +++- .../IncidentStateProbeTests.cs | 17 +- .../MasterDataJsonTests.cs | 2 + .../MasterDataStoreTests.cs | 21 +- .../MigrationForwardCompatTests.cs | 36 +- .../MigrationsTests.cs | 9 +- .../HomeViewModelJoinTests.cs | 21 +- .../IncidentHostTests.cs | 120 +++-- .../LocalNetworkTests.cs | 1 + .../RemoteClientTests.cs | 43 +- .../TestDoubles.cs | 74 ++- .../WorkspaceCollaborationTests.cs | 43 +- .../CommandApplierTests.cs | 96 +++- .../CommandSerializationTests.cs | 25 +- .../SnapshotRoundTripTests.cs | 48 +- 191 files changed, 5050 insertions(+), 1435 deletions(-) create mode 100644 src/LageBuch.Domain/AuditEvent.cs diff --git a/.editorconfig b/.editorconfig index fb07cc4..d1a0e92 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,30 +16,6 @@ dotnet_diagnostic.CA2007.severity = none # LageBuch.App.Shared namespace root cannot be sanely renamed. Suppress app-wide. dotnet_diagnostic.CA1716.severity = none -[tests/**/*.cs] -# xUnit method names with underscores are idiomatic. -dotnet_diagnostic.CA1707.severity = none - -# Perf/impl-noise rules — they add churn to test code without catching real bugs. -dotnet_diagnostic.CA1861.severity = none -dotnet_diagnostic.CA2000.severity = none -dotnet_diagnostic.CA1063.severity = none -dotnet_diagnostic.CA1816.severity = none -dotnet_diagnostic.CA1826.severity = none -dotnet_diagnostic.CA2008.severity = none -dotnet_diagnostic.CA1829.severity = none -dotnet_diagnostic.CA2213.severity = none - -[{src/LageBuch.App.Android/*.cs,src/LageBuch.App.Android/**/*.cs}] -# Java-managed objects (Intent, Java.IO.File, ActivityResultLauncher, ActivityResultContracts.*) -# have their lifetime owned by the Android runtime: they must survive past the C# call that hands -# them to the platform (StartActivity / RegisterForActivityResult), so "dispose" here would break -# the OS callbacks. MimeTypeOf likewise deliberately produces lowercase MIME values, which -# Intent.SetDataAndType expects — ToUpperInvariant (CA1308) would violate that contract. -dotnet_diagnostic.CA1308.severity = none -dotnet_diagnostic.CA2000.severity = none -dotnet_diagnostic.CA2213.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 @@ -119,3 +95,26 @@ 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 + +# Perf/impl-noise rules — they add churn to test code without catching real bugs. +dotnet_diagnostic.CA1861.severity = none +dotnet_diagnostic.CA2000.severity = none +dotnet_diagnostic.CA1063.severity = none +dotnet_diagnostic.CA1816.severity = none +dotnet_diagnostic.CA1826.severity = none +dotnet_diagnostic.CA2008.severity = none +dotnet_diagnostic.CA1829.severity = none +dotnet_diagnostic.CA2213.severity = none + +[{src/LageBuch.App.Android/*.cs,src/LageBuch.App.Android/**/*.cs}] +# Java-managed objects (Intent, Java.IO.File, ActivityResultLauncher, ActivityResultContracts.*) +# have their lifetime owned by the Android runtime: they must survive past the C# call that hands +# them to the platform (StartActivity / RegisterForActivityResult), so "dispose" here would break +# the OS callbacks. MimeTypeOf likewise deliberately produces lowercase MIME values, which +# Intent.SetDataAndType expects — ToUpperInvariant (CA1308) would violate that contract. +dotnet_diagnostic.CA1308.severity = none +dotnet_diagnostic.CA2000.severity = none +dotnet_diagnostic.CA2213.severity = none 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/IIncidentFileStore.cs b/src/LageBuch.Persistence/IIncidentFileStore.cs index c36bb2a..be82589 100644 --- a/src/LageBuch.Persistence/IIncidentFileStore.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/SyncProtocol.cs b/src/LageBuch.Sync/SyncProtocol.cs index 63a4f6d..d889ddb 100644 --- a/src/LageBuch.Sync/SyncProtocol.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/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