diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs index 8b637bb..696853f 100644 --- a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -1,6 +1,7 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Planning; +using LehrerApp.Desktop.ViewModels.Settings; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -151,12 +152,12 @@ public sealed class TimetableViewModelTests public void OpenSettings_RuftOnNavigateToSettingsAuf() { var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); - var called = false; - vm.OnNavigateToSettings = () => called = true; + SettingsTab? navigatedTab = null; + vm.OnNavigateToSettings = tab => navigatedTab = tab; vm.OpenSettingsCommand.Execute(null); - Assert.True(called); + Assert.Equal(SettingsTab.Holidays, navigatedTab); } // ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ──────────────────── diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 5c25180..cbe7916 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -109,7 +109,7 @@ public class App : Application // Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage") var timetable = Services.GetRequiredService(); - timetable.OnNavigateToSettings = () => main.NavigateToSettings(7); + timetable.OnNavigateToSettings = tab => main.NavigateToSettings(tab); timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6); } diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index c7cfb12..b816c0a 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -116,11 +116,11 @@ public partial class MainWindowViewModel : ObservableObject vm.LoadGroup(groupId); // dann Daten laden } - public void NavigateToSettings(int initialTab = 0) + public void NavigateToSettings(SettingsTab initialTab = SettingsTab.Subjects) { ActiveNavItem = NavItem.Settings; var vm = _services.GetRequiredService(); - vm.ActiveTabIndex = initialTab; + vm.ActiveTabIndex = (int)initialTab; CurrentPage = vm; } diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index 2641360..ef642ec 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Settings; using System.Collections.ObjectModel; namespace LehrerApp.Desktop.ViewModels.Planning; @@ -78,7 +79,7 @@ public partial class TimetableViewModel : ObservableObject public Func? OnEditSlot { get; set; } public Action? OnNavigateToGroup { get; set; } public Func? OnAddSubstitution { get; set; } - public Action? OnNavigateToSettings { get; set; } + public Action? OnNavigateToSettings { get; set; } public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, @@ -234,7 +235,7 @@ public partial class TimetableViewModel : ObservableObject } [RelayCommand] - private void OpenSettings() => OnNavigateToSettings?.Invoke(); + private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays); // ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ── diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 0b32a44..92a6b39 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -14,6 +14,28 @@ using System.Text.Json.Serialization; namespace LehrerApp.Desktop.ViewModels.Settings; +/// +/// Reihenfolge MUSS exakt der Reihenfolge der ContentPage-Elemente in SettingsView.axaml +/// entsprechen (ActiveTabIndex bindet per Index, nicht per Name) — beim Umsortieren eines +/// Tabs in der XAML immer auch hier den Enum-Wert verschieben. +/// +public enum SettingsTab +{ + Subjects = 0, + ShorthandCodes = 1, + Competencies = 2, + GradingKeyTemplates = 3, + GradingScheme = 4, + LetterTemplates = 5, + Holidays = 6, + PeriodSchedule = 7, + SupervisionDuties = 8, + Security = 9, + Privacy = 10, + Sync = 11, + Ai = 12, +} + // ── Haupt-ViewModel ─────────────────────────────────────────────────────────── public partial class SettingsViewModel : ObservableObject @@ -176,6 +198,20 @@ public partial class SettingsViewModel : ObservableObject public ObservableCollection SyncConflicts { get; } = []; + // ── Geräte-Pairing (10.3.1: Schlüsselübertragung auf ein zweites Gerät) ─── + // + // SnapshotService ist nur registriert, wenn bereits eine Server-URL konfiguriert ist (siehe + // AppBootstrapper) — daher optional/nullable statt eines Pflicht-Konstruktorparameters. + + [ObservableProperty] private string _pairingCode = ""; + [ObservableProperty] private string _pairingCodeInput = ""; + [ObservableProperty] private string _pairingStatus = ""; + [ObservableProperty] private bool _pairingBusy; + + /// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog, bevor die lokale Datenbank durch + /// den Stand des anderen Geräts ersetzt wird. + public Func>? OnConfirmPairingRestore { get; set; } + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; @@ -187,6 +223,7 @@ public partial class SettingsViewModel : ObservableObject private readonly SyncSettingsService _syncSettings; private readonly SyncAuthService _syncAuth; private readonly EventQueue _eventQueue; + private readonly SnapshotService? _snapshotService; private readonly CompetencyCatalogImportService _catalogImport; public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, @@ -198,7 +235,8 @@ public partial class SettingsViewModel : ObservableObject SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, - SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue) + SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, + SnapshotService? snapshotService = null) { _subjects = subjects; _domainRepo = domainRepo; @@ -223,6 +261,7 @@ public partial class SettingsViewModel : ObservableObject _syncSettings = syncSettings; _syncAuth = syncAuth; _eventQueue = eventQueue; + _snapshotService = snapshotService; _catalogImport = new CompetencyCatalogImportService(domainRepo); LoadSubjects(); LoadShorthandCodes(); @@ -463,6 +502,72 @@ public partial class SettingsViewModel : ObservableObject SyncConflicts.Remove(item); } + // ── Geräte-Pairing: Code erzeugen / einlösen ───────────────────────────── + // + // CreateAndUploadAsync lädt einen verschlüsselten Snapshot der lokalen Datenbank samt + // Sync-Schlüssel hoch, RestoreFromCodeAsync ersetzt auf dem ZWEITEN Gerät die dortige + // Datenbank vollständig durch diesen Snapshot (vorheriger Stand wird automatisch als + // .backup-Datei gesichert, siehe SnapshotService) — deshalb vor dem Einlösen ein + // Bestätigungsdialog wie bei RestoreBackup. + + [RelayCommand] + private async Task CreatePairingCode() + { + if (_snapshotService is null) return; + PairingBusy = true; + PairingCode = ""; + PairingStatus = ""; + void OnProgress(SnapshotProgress p) => PairingStatus = p.Message; + _snapshotService.ProgressChanged += OnProgress; + try + { + var result = await _snapshotService.CreateAndUploadAsync(); + PairingCode = result.Code; + PairingStatus = $"Gültig bis {result.ExpiresAt:dd.MM.yyyy HH:mm} — auf dem anderen Gerät eingeben."; + } + catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException) + { + PairingStatus = $"Fehlgeschlagen: {ex.Message}"; + } + finally + { + _snapshotService.ProgressChanged -= OnProgress; + PairingBusy = false; + } + } + + [RelayCommand] + private async Task RedeemPairingCode() + { + if (_snapshotService is null) return; + if (string.IsNullOrWhiteSpace(PairingCodeInput)) { PairingStatus = "Bitte Code eingeben."; return; } + if (OnConfirmPairingRestore is not null && !await OnConfirmPairingRestore()) return; + + PairingBusy = true; + PairingStatus = ""; + void OnProgress(SnapshotProgress p) => PairingStatus = p.Message; + _snapshotService.ProgressChanged += OnProgress; + + // LiteDB hält beim Öffnen einen exklusiven Dateilock — muss vor dem Überschreiben + // geschlossen sein. Läuft danach ein Fehler (falscher Code, Server nicht erreichbar), ist + // _dbContext bereits disposed und die App nicht mehr sicher weiter benutzbar, ohne dass + // die Datenbankdatei selbst angefasst wurde (RestoreFromCodeAsync schreibt sie erst ganz + // am Ende) — deshalb in JEDEM Fall (Erfolg wie Fehlschlag) neu starten, nicht nur bei + // Erfolg. Ein Neustart öffnet dann wieder dieselbe, unveränderte Datenbank. + _dbContext.Dispose(); + try + { + await _snapshotService.RestoreFromCodeAsync(PairingCodeInput.Trim(), AppBootstrapper.DbPath); + } + catch (Exception ex) when (ex is SnapshotNotFoundException or InvalidOperationException or HttpRequestException) + { + // _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld + // ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher. + PairingStatus = $"Fehlgeschlagen: {ex.Message}"; + } + AppBootstrapper.RestartApplication(); + } + // ── Stundenraster: Laden / Speichern ───────────────────────────────────── private void LoadPeriodTimes() diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index a26def9..b20d081 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -10,7 +10,9 @@ - + + + @@ -282,6 +284,50 @@ + + + + + + + + + + + + + + + + + + +