diff --git a/src/LageBuch.App.Android/MainActivity.cs b/src/LageBuch.App.Android/MainActivity.cs
index 21d06b6..0aee49d 100644
--- a/src/LageBuch.App.Android/MainActivity.cs
+++ b/src/LageBuch.App.Android/MainActivity.cs
@@ -65,6 +65,7 @@ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
new NoopIncidentHostController(),
new LageBuch.App.Shared.Services.AvaloniaUiDispatcher(),
typeof(MainActivity).Assembly.GetName().Version?.ToString() ?? "0.0.0",
+ AndroidAppPaths.RegionsDir(this),
lastSaveFolder: null,
attachmentCacheRoot: AndroidAppPaths.AttachmentCacheDir(this));
return base.CustomizeAppBuilder(builder).WithInterFont();
diff --git a/src/LageBuch.App.Android/Services/AndroidAppPaths.cs b/src/LageBuch.App.Android/Services/AndroidAppPaths.cs
index 62deec0..d053fb9 100644
--- a/src/LageBuch.App.Android/Services/AndroidAppPaths.cs
+++ b/src/LageBuch.App.Android/Services/AndroidAppPaths.cs
@@ -27,4 +27,7 @@ public static string RecentFilesJsonPath(Context context) =>
public static string AttachmentCacheDir(Context context) =>
System.IO.Path.Combine(CacheDir(context), "attachment-cache");
+
+ public static string RegionsDir(Context context) =>
+ System.IO.Path.Combine(context.FilesDir!.AbsolutePath, "regions");
}
diff --git a/src/LageBuch.App.Shared/CompositionRoot.cs b/src/LageBuch.App.Shared/CompositionRoot.cs
index 9d7bd52..d8a09e9 100644
--- a/src/LageBuch.App.Shared/CompositionRoot.cs
+++ b/src/LageBuch.App.Shared/CompositionRoot.cs
@@ -14,6 +14,13 @@ namespace LageBuch.App.Shared;
///
public static class CompositionRoot
{
+ ///
+ /// Raw-served manifest of published Wasserförderung region packs (#150 follow-up) — see
+ /// tools/build-region-pack/README.md for how a pack is built and published here.
+ ///
+ public const string RegionPackManifestUrl =
+ "https://raw.githubusercontent.com/CodeForFire/lagebuch-regions/main/regions.json";
+
public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentStore store,
IMasterDataProvider masterData,
@@ -26,11 +33,15 @@ public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentHostController hostController,
IUiDispatcher uiDispatcher,
string appVersion,
+ string regionsDir,
ILastSaveFolderStore? lastSaveFolder = null,
string? attachmentCacheRoot = null)
{
var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot, new RouteOverviewRenderer());
- var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService);
+ var httpClient = new HttpClient();
+ var regionCatalog = new RegionPackCatalogService(httpClient, RegionPackManifestUrl);
+ var regionInstaller = new RegionPackInstaller(httpClient, regionsDir);
+ var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService, regionCatalog, regionInstaller);
return new MainWindowViewModel(home, editor, dialogs, appVersion);
}
}
diff --git a/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
index 2561551..8bdea8f 100644
--- a/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
+++ b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LageBuch.AppLogic.ViewModels;assembly=LageBuch.AppLogic"
xmlns:md="clr-namespace:LageBuch.Persistence.MasterData;assembly=LageBuch.Persistence"
+ xmlns:services="clr-namespace:LageBuch.AppLogic.Services;assembly=LageBuch.AppLogic"
x:Class="LageBuch.App.Shared.Views.MasterDataEditorView"
x:DataType="vm:MasterDataEditorViewModel">
@@ -108,18 +109,63 @@
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/LageBuch.App/AppPaths.cs b/src/LageBuch.App/AppPaths.cs
index 48937db..4d44bd7 100644
--- a/src/LageBuch.App/AppPaths.cs
+++ b/src/LageBuch.App/AppPaths.cs
@@ -13,6 +13,9 @@ public static class AppPaths
public static string AttachmentCacheDir => Path.Combine(AppDataDir, "attachment-cache");
+ /// Where downloaded Wasserförderung region packs (#150 follow-up) get extracted, one subfolder per slug.
+ public static string RegionsDir => Path.Combine(AppDataDir, "regions");
+
public static string GetAppDataDir(string baseDir)
{
var dir = Path.Combine(baseDir, "Lagebuch");
diff --git a/src/LageBuch.App/Program.cs b/src/LageBuch.App/Program.cs
index f36d325..2fa7e10 100644
--- a/src/LageBuch.App/Program.cs
+++ b/src/LageBuch.App/Program.cs
@@ -37,6 +37,7 @@ private static MainWindowViewModel CreateMainViewModel()
new IncidentHostController(clock, version, uiDispatcher),
uiDispatcher,
version,
+ AppPaths.RegionsDir,
new JsonLastSaveFolderStore(AppPaths.LastSaveFolderJsonPath),
AppPaths.AttachmentCacheDir);
}
diff --git a/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs b/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
new file mode 100644
index 0000000..58286bc
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
@@ -0,0 +1,28 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Lists the region packs (map tiles + elevation) publicly available for download (#150 follow-up)
+/// — the map-side counterpart to Stammdaten's Einsatzgebiet, which used to require hand-preparing
+/// region.mbtiles/region.dem with no guidance at all.
+///
+public interface IRegionPackCatalogService
+{
+ ///
+ /// Never throws — a Stammdaten editor must stay usable offline, so a fetch/parse failure
+ /// yields an empty list rather than propagating an exception.
+ ///
+ Task> GetAvailableRegionsAsync(CancellationToken ct = default);
+}
+
+/// One published, downloadable region pack.
+public sealed record RegionPackInfo(
+ string Name,
+ string Slug,
+ string DownloadUrl,
+ long SizeBytes,
+ double MinLat,
+ double MinLon,
+ double MaxLat,
+ double MaxLon,
+ string BuiltAt,
+ string Attribution);
diff --git a/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs b/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
new file mode 100644
index 0000000..1b3e45a
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
@@ -0,0 +1,8 @@
+namespace LageBuch.AppLogic.Services;
+
+/// Downloads and unpacks a region pack (#150 follow-up) into a local folder.
+public interface IRegionPackInstaller
+{
+ /// Returns the folder the pack was extracted into (ready to use as Einsatzgebiet.FolderPath).
+ Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default);
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs b/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
new file mode 100644
index 0000000..08df716
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
@@ -0,0 +1,85 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Reads the region-pack manifest format (#150 follow-up) — a flat JSON array, each entry
+/// describing one downloadable pack. Defensive like MasterDataJson: malformed input, or an
+/// entry missing a required field, is skipped rather than thrown — the manifest is fetched from a
+/// third-party-controlled URL, so a partially bad response must degrade gracefully, not crash
+/// Stammdaten.
+///
+public static class RegionPackCatalogJson
+{
+ // RegionPackInstaller joins this straight onto a base directory (/) —
+ // reject anything that could escape that directory (path separators, "..", empty) here rather
+ // than trusting the installer alone to catch it.
+ private static readonly Regex SafeSlug = new("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.Compiled);
+
+ public static IReadOnlyList Parse(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.ValueKind != JsonValueKind.Array)
+ return Array.Empty();
+
+ var result = new List();
+ foreach (var entry in doc.RootElement.EnumerateArray())
+ {
+ if (TryParseEntry(entry, out var region))
+ result.Add(region);
+ }
+ return result;
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static bool TryParseEntry(JsonElement entry, out RegionPackInfo region)
+ {
+ region = null!;
+ if (entry.ValueKind != JsonValueKind.Object)
+ return false;
+
+ if (!TryGetString(entry, "name", out var name) ||
+ !TryGetString(entry, "slug", out var slug) || !SafeSlug.IsMatch(slug) ||
+ !TryGetString(entry, "downloadUrl", out var downloadUrl) ||
+ !TryGetString(entry, "builtAt", out var builtAt) ||
+ !TryGetString(entry, "attribution", out var attribution) ||
+ !entry.TryGetProperty("sizeBytes", out var sizeBytesEl) || sizeBytesEl.ValueKind != JsonValueKind.Number ||
+ !entry.TryGetProperty("boundingBox", out var bbox) || bbox.ValueKind != JsonValueKind.Object ||
+ !TryGetNumber(bbox, "minLat", out var minLat) ||
+ !TryGetNumber(bbox, "minLon", out var minLon) ||
+ !TryGetNumber(bbox, "maxLat", out var maxLat) ||
+ !TryGetNumber(bbox, "maxLon", out var maxLon))
+ {
+ return false;
+ }
+
+ region = new RegionPackInfo(name, slug, downloadUrl, sizeBytesEl.GetInt64(),
+ minLat, minLon, maxLat, maxLon, builtAt, attribution);
+ return true;
+ }
+
+ private static bool TryGetString(JsonElement e, string prop, out string value)
+ {
+ value = string.Empty;
+ if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.String)
+ return false;
+ value = v.GetString()!;
+ return true;
+ }
+
+ private static bool TryGetNumber(JsonElement e, string prop, out double value)
+ {
+ value = 0;
+ if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.Number)
+ return false;
+ value = v.GetDouble();
+ return true;
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs b/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
new file mode 100644
index 0000000..dd6e0bb
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
@@ -0,0 +1,23 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Fetches the region-pack manifest over HTTP (#150 follow-up). The one place this offline-first
+/// app makes an unprompted network call — but only when the operator opens the Einsatzgebiet
+/// section, and it degrades to an empty list rather than surfacing any error, so Stammdaten stays
+/// fully usable without a connection.
+///
+public sealed class RegionPackCatalogService(HttpClient httpClient, string manifestUrl) : IRegionPackCatalogService
+{
+ public async Task> GetAvailableRegionsAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var json = await httpClient.GetStringAsync(manifestUrl, ct);
+ return RegionPackCatalogJson.Parse(json);
+ }
+ catch (HttpRequestException)
+ {
+ return Array.Empty();
+ }
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs b/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
new file mode 100644
index 0000000..d8bd319
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
@@ -0,0 +1,63 @@
+using System.IO.Compression;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Downloads a region pack's zip (region.mbtiles + region.dem) and extracts it into
+/// <regionsBaseDir>/<slug> (#150 follow-up). A re-install of the same slug replaces the
+/// folder outright, so a pack update never leaves stale files from a previous version behind.
+///
+public sealed class RegionPackInstaller(HttpClient httpClient, string regionsBaseDir) : IRegionPackInstaller
+{
+ // Downloading is the slow part; reserve a small tail of the progress range for extraction so
+ // the caller sees forward motion continue past "download done" instead of jumping straight to 1.0.
+ private const double DownloadProgressShare = 0.9;
+
+ public async Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default)
+ {
+ progress?.Report(0.0);
+
+ var zipBytes = await DownloadAsync(pack.DownloadUrl, progress, ct);
+
+ // Defense in depth: RegionPackCatalogJson already rejects unsafe slugs when parsing the
+ // manifest, but a slug ending up here from anywhere else must not be able to escape
+ // regionsBaseDir either.
+ var baseFull = Path.GetFullPath(regionsBaseDir) + Path.DirectorySeparatorChar;
+ var folder = Path.GetFullPath(Path.Combine(regionsBaseDir, pack.Slug));
+ if (!folder.StartsWith(baseFull, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Region slug '{pack.Slug}' escapes the regions directory.");
+
+ if (Directory.Exists(folder))
+ Directory.Delete(folder, recursive: true);
+ Directory.CreateDirectory(folder);
+
+ using (var zip = new ZipArchive(new MemoryStream(zipBytes), ZipArchiveMode.Read))
+ zip.ExtractToDirectory(folder);
+
+ progress?.Report(1.0);
+ return folder;
+ }
+
+ private async Task DownloadAsync(string url, IProgress? progress, CancellationToken ct)
+ {
+ using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ var totalBytes = response.Content.Headers.ContentLength;
+ await using var source = await response.Content.ReadAsStreamAsync(ct);
+ using var buffer = new MemoryStream();
+
+ var chunk = new byte[81920];
+ long readSoFar = 0;
+ int read;
+ while ((read = await source.ReadAsync(chunk, ct)) > 0)
+ {
+ await buffer.WriteAsync(chunk.AsMemory(0, read), ct);
+ readSoFar += read;
+ if (totalBytes is > 0)
+ progress?.Report(Math.Min(DownloadProgressShare, (double)readSoFar / totalBytes.Value * DownloadProgressShare));
+ }
+
+ return buffer.ToArray();
+ }
+}
diff --git a/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
index 72b7e12..ff4f68b 100644
--- a/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
@@ -1,21 +1,31 @@
+using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LageBuch.AppLogic.Services;
using LageBuch.Persistence.MasterData;
namespace LageBuch.AppLogic.ViewModels;
///
-/// Editor for the Wasserförderung region of operation (#150 phase 2): a name and a folder path
-/// expected to hold region.mbtiles and region.dem. Two scalar strings, so this
-/// mirrors 's pattern rather than the list sections' — one bindable
-/// property each, reporting through the shared dirty callback.
+/// Editor for the Wasserförderung region of operation (#150 phase 2, region-pack follow-up):
+/// a name and a folder path expected to hold region.mbtiles and region.dem.
+/// Primarily populated by downloading a published pack from
+/// via — manual / entry
+/// stays available as a fallback for a self-built or hand-placed pack.
///
public sealed partial class EinsatzgebietSection : EditorSection
{
private readonly Action _onChanged;
+ private readonly IRegionPackCatalogService _catalog;
+ private readonly IRegionPackInstaller _installer;
- public EinsatzgebietSection(string title, Einsatzgebiet einsatzgebiet, Action onChanged) : base(title)
+ public EinsatzgebietSection(
+ string title, Einsatzgebiet einsatzgebiet, Action onChanged,
+ IRegionPackCatalogService catalog, IRegionPackInstaller installer) : base(title)
{
_onChanged = onChanged;
+ _catalog = catalog;
+ _installer = installer;
_name = einsatzgebiet.Name;
_folderPath = einsatzgebiet.FolderPath;
}
@@ -24,10 +34,81 @@ public EinsatzgebietSection(string title, Einsatzgebiet einsatzgebiet, Action on
private string _name = string.Empty;
[ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(KartendatenGefunden))]
+ [NotifyPropertyChangedFor(nameof(KartendatenStatus))]
private string _folderPath = string.Empty;
partial void OnNameChanged(string value) => _onChanged();
partial void OnFolderPathChanged(string value) => _onChanged();
public Einsatzgebiet ToEinsatzgebiet() => new(Name, FolderPath);
+
+ // --- Region-pack catalog / download ---
+
+ public ObservableCollection AvailableRegions { get; } = new();
+
+ [ObservableProperty]
+ private RegionPackInfo? _selectedRegion;
+
+ [ObservableProperty]
+ private string? _catalogStatus;
+
+ [ObservableProperty]
+ private double _downloadProgress;
+
+ [RelayCommand]
+ private async Task LoadCatalog()
+ {
+ var regions = await _catalog.GetAvailableRegionsAsync();
+ AvailableRegions.Clear();
+ foreach (var region in regions)
+ AvailableRegions.Add(region);
+
+ CatalogStatus = AvailableRegions.Count == 0
+ ? "Keine Regionen verfügbar — bitte Internetverbindung prüfen, oder Ordner manuell angeben."
+ : null;
+ }
+
+ private bool CanDownloadSelectedRegion => SelectedRegion is not null;
+
+ [RelayCommand(CanExecute = nameof(CanDownloadSelectedRegion))]
+ private async Task DownloadSelectedRegion()
+ {
+ var region = SelectedRegion!;
+ DownloadProgress = 0;
+ var progress = new Progress(p => DownloadProgress = p);
+ var folder = await _installer.DownloadAndInstallAsync(region, progress);
+
+ Name = region.Name;
+ FolderPath = folder;
+ _onChanged();
+ }
+
+ partial void OnSelectedRegionChanged(RegionPackInfo? value) => DownloadSelectedRegionCommand.NotifyCanExecuteChanged();
+
+ // --- File-presence validation: is region.mbtiles/region.dem actually at FolderPath? ---
+
+ public bool KartendatenGefunden =>
+ !string.IsNullOrWhiteSpace(FolderPath)
+ && File.Exists(Path.Combine(FolderPath, "region.mbtiles"))
+ && File.Exists(Path.Combine(FolderPath, "region.dem"));
+
+ public string? KartendatenStatus
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(FolderPath))
+ return null;
+
+ var mbtilesFound = File.Exists(Path.Combine(FolderPath, "region.mbtiles"));
+ var demFound = File.Exists(Path.Combine(FolderPath, "region.dem"));
+ if (mbtilesFound && demFound)
+ return "✓ Kartendaten gefunden.";
+
+ var missing = new List();
+ if (!mbtilesFound) missing.Add("region.mbtiles");
+ if (!demFound) missing.Add("region.dem");
+ return $"✗ Fehlt: {string.Join(", ", missing)}.";
+ }
+ }
}
diff --git a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
index a8d7cb4..a210e7d 100644
--- a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
@@ -17,6 +17,8 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private readonly IMasterDataProvider _provider;
private readonly IFileDialogService _dialogs;
private readonly IMasterDataFileService _files;
+ private readonly IRegionPackCatalogService _regionCatalog;
+ private readonly IRegionPackInstaller _regionInstaller;
private MasterDataSet _original = MasterDataSet.Empty;
private bool _originalIsEmpty = true;
@@ -30,11 +32,15 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private SettingsSection _settings = null!;
private EinsatzgebietSection _einsatzgebiet = null!;
- public MasterDataEditorViewModel(IMasterDataProvider provider, IFileDialogService dialogs, IMasterDataFileService files)
+ public MasterDataEditorViewModel(
+ IMasterDataProvider provider, IFileDialogService dialogs, IMasterDataFileService files,
+ IRegionPackCatalogService regionCatalog, IRegionPackInstaller regionInstaller)
{
_provider = provider;
_dialogs = dialogs;
_files = files;
+ _regionCatalog = regionCatalog;
+ _regionInstaller = regionInstaller;
Load();
}
@@ -100,7 +106,9 @@ private void PopulateSections(MasterDataSet set)
Sections.Add(_checklistAbbau = new ChecklistTemplateSection("Checkliste Abbau", set.ChecklistTemplateAbbau, MarkDirty));
Sections.Add(_personnel = new PersonnelSection("Personal", set.Personnel, MarkDirty));
Sections.Add(_vehicles = new VehiclesSection("Fahrzeuge", set.Vehicles, set.Brigades, set.RadioCallSigns, OnVehiclesChanged));
- Sections.Add(_einsatzgebiet = new EinsatzgebietSection("Einsatzgebiet", set.Einsatzgebiet, MarkDirty));
+ Sections.Add(_einsatzgebiet = new EinsatzgebietSection(
+ "Einsatzgebiet", set.Einsatzgebiet, MarkDirty, _regionCatalog, _regionInstaller));
+ _einsatzgebiet.LoadCatalogCommand.Execute(null);
SelectedSection = Sections[Math.Clamp(previousIndex < 0 ? 0 : previousIndex, 0, Sections.Count - 1)];
}
diff --git a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
index dfd56d1..7a9f813 100644
--- a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
@@ -93,7 +93,7 @@ private static MainWindowViewModel BuildMainWindowViewModel()
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());
+ var editor = new MasterDataEditorViewModel(masterData, dialogs, new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
return new MainWindowViewModel(home, editor, dialogs, "0.1.0");
}
@@ -109,6 +109,18 @@ private sealed class NoFiles : IMasterDataFileService
public void Write(string path, MasterDataSet set) { }
}
+ private sealed class NoRegionCatalog : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
+ }
+
+ private sealed class NoRegionInstaller : IRegionPackInstaller
+ {
+ public Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default) =>
+ Task.FromResult(string.Empty);
+ }
+
private sealed class EmptyRecent : IRecentFilesStore
{
private readonly List _list = new();
diff --git a/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
index d294d42..de9f816 100644
--- a/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
@@ -51,10 +51,22 @@ private sealed class NoFiles : IMasterDataFileService
public void Write(string path, MasterDataSet set) { }
}
+ private sealed class NoRegionCatalog : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
+ }
+
+ private sealed class NoRegionInstaller : IRegionPackInstaller
+ {
+ public Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default) =>
+ Task.FromResult(string.Empty);
+ }
+
[AvaloniaFact]
public void The_editor_renders_with_every_category()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
window.Show();
@@ -80,7 +92,7 @@ public void The_editor_renders_with_every_category()
[AvaloniaFact]
public void Checkliste_aufbau_section_renders_text_and_mandatory_checkbox_per_row()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
vm.SelectedSection = vm.Sections.Single(s => s.Title == "Checkliste Aufbau");
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
@@ -104,7 +116,7 @@ public void Checkliste_aufbau_section_renders_text_and_mandatory_checkbox_per_ro
[AvaloniaFact]
public void Links_section_renders_name_and_url_per_row()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
vm.SelectedSection = vm.Sections.Single(s => s.Title == "Links");
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
@@ -127,7 +139,7 @@ public void Links_section_renders_name_and_url_per_row()
[AvaloniaFact]
public void Fahrzeuge_section_renders_wache_callsign_and_seats_per_row()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
var section = (VehiclesSection)vm.Sections.Single(s => s.Title == "Fahrzeuge");
section.AddCommand.Execute(null);
section.Rows[0].Wache = "FFB Wache 1";
@@ -158,4 +170,51 @@ public void Fahrzeuge_section_renders_wache_callsign_and_seats_per_row()
using var frame = window.CaptureRenderedFrame()!;
frame.SavePng(Path.Combine(dir, "master-data-editor-fahrzeuge-after.png"));
}
+
+ private sealed class FakeRegionCatalog(IReadOnlyList regions) : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult(regions);
+ }
+
+ // #150 follow-up (downloadable region packs): a dropdown lists the published catalog, and
+ // the old manual-folder fields collapse behind "Erweitert" instead of being the primary path.
+ [AvaloniaFact]
+ public void Einsatzgebiet_section_renders_region_dropdown_with_advanced_fields_collapsed()
+ {
+ var pack = new RegionPackInfo(
+ "Landkreis Fürstenfeldbruck", "ffb", "https://example.org/ffb.zip", 12_345_678,
+ 48.0877067, 10.9930275, 48.2967233, 11.4128816, "2026-09-01",
+ "© OpenStreetMap contributors (ODbL). Höhendaten: SRTM (NASA/USGS, gemeinfrei).");
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(),
+ new FakeRegionCatalog(new[] { pack }), new NoRegionInstaller());
+ vm.SelectedSection = vm.Sections.Single(s => s.Title == "Einsatzgebiet");
+ var view = new MasterDataEditorView { DataContext = vm };
+ var window = new Window { Content = view, Width = 1080, Height = 680 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ var comboBox = view.GetVisualDescendants().OfType().Single(c => c.Name == "RegionComboBox");
+ Assert.Single(comboBox.ItemsSource!.Cast