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:
2026-08-18 01:09:25 +02:00
co-authored by Claude Sonnet 5
parent 65c2211dab
commit f8058b537b
8 changed files with 411 additions and 234 deletions
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -151,12 +152,12 @@ public sealed class TimetableViewModelTests
public void OpenSettings_RuftOnNavigateToSettingsAuf() public void OpenSettings_RuftOnNavigateToSettingsAuf()
{ {
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var called = false; SettingsTab? navigatedTab = null;
vm.OnNavigateToSettings = () => called = true; vm.OnNavigateToSettings = tab => navigatedTab = tab;
vm.OpenSettingsCommand.Execute(null); vm.OpenSettingsCommand.Execute(null);
Assert.True(called); Assert.Equal(SettingsTab.Holidays, navigatedTab);
} }
// ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ──────────────────── // ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ────────────────────
+1 -1
View File
@@ -109,7 +109,7 @@ public class App : Application
// Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage") // Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage")
var timetable = Services.GetRequiredService<TimetableViewModel>(); var timetable = Services.GetRequiredService<TimetableViewModel>();
timetable.OnNavigateToSettings = () => main.NavigateToSettings(7); timetable.OnNavigateToSettings = tab => main.NavigateToSettings(tab);
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6); timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6);
} }
@@ -116,11 +116,11 @@ public partial class MainWindowViewModel : ObservableObject
vm.LoadGroup(groupId); // dann Daten laden vm.LoadGroup(groupId); // dann Daten laden
} }
public void NavigateToSettings(int initialTab = 0) public void NavigateToSettings(SettingsTab initialTab = SettingsTab.Subjects)
{ {
ActiveNavItem = NavItem.Settings; ActiveNavItem = NavItem.Settings;
var vm = _services.GetRequiredService<SettingsViewModel>(); var vm = _services.GetRequiredService<SettingsViewModel>();
vm.ActiveTabIndex = initialTab; vm.ActiveTabIndex = (int)initialTab;
CurrentPage = vm; CurrentPage = vm;
} }
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning; namespace LehrerApp.Desktop.ViewModels.Planning;
@@ -78,7 +79,7 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; } public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { 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, public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
@@ -234,7 +235,7 @@ public partial class TimetableViewModel : ObservableObject
} }
[RelayCommand] [RelayCommand]
private void OpenSettings() => OnNavigateToSettings?.Invoke(); private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
// ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ── // ── "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; 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 ─────────────────────────────────────────────────────────── // ── Haupt-ViewModel ───────────────────────────────────────────────────────────
public partial class SettingsViewModel : ObservableObject public partial class SettingsViewModel : ObservableObject
@@ -176,6 +198,20 @@ public partial class SettingsViewModel : ObservableObject
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = []; 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 ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -187,6 +223,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly SyncSettingsService _syncSettings; private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth; private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue; private readonly EventQueue _eventQueue;
private readonly SnapshotService? _snapshotService;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -198,7 +235,8 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue) SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
SnapshotService? snapshotService = null)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -223,6 +261,7 @@ public partial class SettingsViewModel : ObservableObject
_syncSettings = syncSettings; _syncSettings = syncSettings;
_syncAuth = syncAuth; _syncAuth = syncAuth;
_eventQueue = eventQueue; _eventQueue = eventQueue;
_snapshotService = snapshotService;
_catalogImport = new CompetencyCatalogImportService(domainRepo); _catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects(); LoadSubjects();
LoadShorthandCodes(); LoadShorthandCodes();
@@ -463,6 +502,72 @@ public partial class SettingsViewModel : ObservableObject
SyncConflicts.Remove(item); 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 ───────────────────────────────────── // ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes() private void LoadPeriodTimes()
@@ -10,7 +10,9 @@
<shared:PageHeader Grid.Row="0" Title="Einstellungen" Margin="32,28,32,0"/> <shared:PageHeader Grid.Row="0" Title="Einstellungen" Margin="32,28,32,0"/>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}"> <TabbedPage Grid.Row="1" TabPlacement="Left" SelectedIndex="{Binding ActiveTabIndex}">
<!-- ═══ Fachliches ═══ -->
<!-- Tab: Fächer --> <!-- Tab: Fächer -->
<ContentPage Header="Fächer"> <ContentPage Header="Fächer">
@@ -282,6 +284,50 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) --> <!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
<ContentPage Header="Briefvorlagen"> <ContentPage Header="Briefvorlagen">
<ScrollViewer> <ScrollViewer>
@@ -362,50 +408,162 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Notenschema --> <!-- ═══ Zeitplanung ═══ -->
<ContentPage Header="Notenschema">
<ContentPage.Resources> <!-- Tab: Ferien & Feiertage (4.3.5) -->
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem"> <ContentPage Header="Ferien &amp; Feiertage">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden." <StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/> FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/> <ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- ═══ System ═══ -->
<!-- Tab: Sicherheit (13.3) --> <!-- Tab: Sicherheit (13.3) -->
<ContentPage Header="Sicherheit"> <ContentPage Header="Sicherheit">
<ScrollViewer> <ScrollViewer>
@@ -570,195 +728,6 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Ferien & Feiertage (4.3.5) -->
<ContentPage Header="Ferien &amp; Feiertage">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Synchronisation (Kapitel 10) --> <!-- Tab: Synchronisation (Kapitel 10) -->
<ContentPage Header="Synchronisation"> <ContentPage Header="Synchronisation">
<ScrollViewer> <ScrollViewer>
@@ -806,6 +775,35 @@
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap" <TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/> Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/>
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Login allein reicht nicht: Verschlüsselt wird lokal mit einem eigenen Schlüssel je Gerät, der nie über den Server läuft. Für ein zweites Gerät hier einen Code erzeugen — auf dem anderen Gerät eingeben, um Datenbank und Schlüssel zu übernehmen."/>
<StackPanel Spacing="6">
<TextBlock Text="Dieses Gerät als Quelle" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<Button Content="Code erzeugen" Command="{Binding CreatePairingCodeCommand}"
IsEnabled="{Binding !PairingBusy}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PairingCode}" FontSize="20" FontWeight="Bold"
FontFamily="Monospace" IsVisible="{Binding PairingCode, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Code von einem anderen Gerät einlösen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Ersetzt die Datenbank auf DIESEM Gerät vollständig — nur auf einem Gerät ohne eigene Daten verwenden."/>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding PairingCodeInput}" PlaceholderText="TIGER-42-BLAU"/>
<Button Grid.Column="2" Content="Einlösen" Command="{Binding RedeemPairingCodeCommand}"
IsEnabled="{Binding !PairingBusy}"/>
</Grid>
</StackPanel>
<TextBlock Text="{Binding PairingStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0" <TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
IsVisible="{Binding SyncConflicts.Count}"/> IsVisible="{Binding SyncConflicts.Count}"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
@@ -836,6 +834,42 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -20,6 +20,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRestore = ShowRestoreConfirmDialog; vm.OnConfirmRestore = ShowRestoreConfirmDialog;
vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig(); vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig();
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog; vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog;
} }
} }
@@ -50,6 +51,21 @@ public partial class SettingsView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner); return owner is not null && await dialog.ShowDialog<bool>(owner);
} }
private async Task<bool> ShowPairingRestoreConfirmDialog()
{
var info = new ConfirmDialogInfo
{
Title = "Gerät koppeln?",
Message = "Die lokale Datenbank auf diesem Gerät wird vollständig durch den Stand des anderen " +
"Geräts ersetzt. Der bisherige Stand wird automatisch als Backup gesichert. " +
"Die App wird danach automatisch neu gestartet.",
ConfirmText = "Ersetzen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async void OnImportClick(object? sender, RoutedEventArgs e) private async void OnImportClick(object? sender, RoutedEventArgs e)
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
+21 -1
View File
@@ -1317,6 +1317,17 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/ RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
`SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen `SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
Live-Re-Registrierungspfad. Live-Re-Registrierungspfad.
**Nachtrag (Einstellungen-Reorg):** Der Tab-Balken war mit 13 Tabs oben (`TabPlacement="Top"`)
unübersichtlich lang geworden und wird mit weiteren Sync-Features noch länger. Umgestellt auf
`TabPlacement="Left"` (vertikale Liste, skaliert besser) und die Tabs in drei logische Gruppen
sortiert (Fachliches / Zeitplanung / System). Neuer Enum `SettingsTab` in
`SettingsViewModel.cs` ersetzt die bisherigen rohen `int`-Tab-Indizes bei
`MainWindowViewModel.NavigateToSettings`/`TimetableViewModel.OnNavigateToSettings` — beim
Umsortieren dabei ein bereits vorhandener Bug aufgefallen und mitbehoben: das Zahnrad-Symbol
im Stundenplan öffnete über den hartcodierten Index `7` tatsächlich den Tab "Datenschutz"
statt des in Kommentar/Tooltip beabsichtigten "Ferien & Feiertage" — mit benanntem Enum kann
diese Klasse von Fehler nicht mehr auftreten.
- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig). - [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar **Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
@@ -1431,7 +1442,16 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher. Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher.
### 10.3 Verschlüsselung ### 10.3 Verschlüsselung
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase). - [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
**Umsetzung:** Der Backend-Mechanismus (`SnapshotService`/`SyncCrypto`, Einmal-Code-Pairing)
existierte bereits, war aber an keiner Stelle im Client verdrahtet. Neuer Bereich "Gerät
koppeln" im Sync-Settings-Tab: ein Gerät erzeugt per `CreatePairingCode` einen verschlüsselten
DB-Snapshot + Code (Format `WORT-ZZ-WORT`, 24h gültig), das zweite Gerät gibt den Code über
`RedeemPairingCode` ein und übernimmt Datenbank + Sync-Schlüssel. Bestätigungsdialog vor dem
Einlösen (überschreibt die lokale Datenbank vollständig, alter Stand wird automatisch als
Backup gesichert). Kein QR-Code (nur Passphrase-Code) — für zwei eigene Geräte per Hand
abtippen ausreichend, QR-Code wäre erst für eine Companion-App relevant.
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel. - [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen — - [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus