feat: Geräte-Pairing-UI + Einstellungen-Neustrukturierung
- Sync-Settings-Tab: neuer Bereich "Gerät koppeln" (10.3.1), nutzt den bereits vorhandenen, bisher nirgends verdrahteten SnapshotService. Ein Gerät erzeugt per CreatePairingCode einen verschlüsselten DB-Snapshot + Einmal-Code, das zweite Gerät übernimmt per RedeemPairingCode Datenbank und Sync-Schlüssel. Bestätigungsdialog vor dem Einlösen, da die lokale Datenbank dabei vollständig ersetzt wird (alter Stand wird automatisch als Backup gesichert). - Einstellungen: TabPlacement von Top auf Left umgestellt (vertikale Liste statt langem horizontalem Balken) und die 13 Tabs in drei logische Gruppen sortiert (Fachliches / Zeitplanung / System). - Neuer SettingsTab-Enum ersetzt rohe int-Tab-Indizes bei MainWindowViewModel.NavigateToSettings/ TimetableViewModel.OnNavigateToSettings. Dabei einen bestehenden Bug gefunden und mitbehoben: das Zahnrad im Stundenplan öffnete über den hartcodierten Index 7 tatsächlich "Datenschutz" statt des laut Kommentar/Tooltip beabsichtigten "Ferien & Feiertage". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SettingsViewModel>();
|
||||
vm.ActiveTabIndex = initialTab;
|
||||
vm.ActiveTabIndex = (int)initialTab;
|
||||
CurrentPage = vm;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TimetableCellItem, Task>? OnEditSlot { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
public Func<Task>? OnAddSubstitution { get; set; }
|
||||
public Action? OnNavigateToSettings { get; set; }
|
||||
public Action<SettingsTab>? 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 ──
|
||||
|
||||
|
||||
@@ -14,6 +14,28 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SyncConflictListItem> 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<Task<bool>>? 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()
|
||||
|
||||
Reference in New Issue
Block a user