using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
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,
WebUntis = 13,
Appearance = 14,
Trash = 15,
}
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
public partial class SettingsViewModel : ObservableObject
{
private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _domainRepo;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly IGradingSchemeRepository _gradingSchemes;
private readonly GradingService _grading;
private readonly BackupService _backups;
private readonly DatabaseEncryptionService _dbEncryption;
private readonly AppLockService _appLock;
private readonly LiteDbContext _dbContext;
private readonly PrivacySettingsService _privacy;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes;
private readonly LetterTemplateService _letterTemplates;
[ObservableProperty] private int _activeTabIndex;
// ── Fächer ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _newName = "";
[ObservableProperty] private string _newShort = "";
[ObservableProperty] private string _newNameError = "";
public ObservableCollection Subjects { get; } = [];
// ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ──────────────────────────
[ObservableProperty] private string _newShorthandCode = "";
[ObservableProperty] private string _newShorthandLabel = "";
[ObservableProperty] private string _newShorthandCodeError = "";
public ObservableCollection ShorthandCodes { get; } = [];
// ── Kompetenzkatalog ──────────────────────────────────────────────────────
[ObservableProperty] private SubjectListItem? _catalogSubject;
[ObservableProperty] private int _catalogGradeLevel = 10;
[ObservableProperty] private string _newDomainName = "";
[ObservableProperty] private string _newDomainCode = "";
[ObservableProperty] private string _catalogValidation = "";
public ObservableCollection Domains { get; } = [];
// ── Notenschlüssel-Vorlagen (1.3.2) ──────────────────────────────────────
[ObservableProperty] private string _newTemplateName = "";
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
[ObservableProperty] private string _newTemplateNameError = "";
public List GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
public ObservableCollection GradingKeyTemplateList { get; } = [];
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
[ObservableProperty] private string _letterTemplateStatus = "";
public ObservableCollection LetterTemplateList { get; } = [];
public IReadOnlyList SupportedLetterPlaceholders =>
LetterTemplateService.SupportedPlaceholders;
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
// ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
[ObservableProperty] private string _backupStatus = "";
public ObservableCollection Backups { get; } = [];
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
public Func>? OnConfirmRestore { get; set; }
// ── Sicherheit: Datenbank-Verschlüsselung (13.3.4) ───────────────────────
[ObservableProperty] private bool _isDbEncrypted;
[ObservableProperty] private string _currentDbPassword = "";
[ObservableProperty] private string _newDbPassword = "";
[ObservableProperty] private string _newDbPasswordConfirm = "";
[ObservableProperty] private string _dbPasswordError = "";
public string DbEncryptionStatusLabel => IsDbEncrypted ? "verschlüsselt" : "nicht verschlüsselt";
partial void OnIsDbEncryptedChanged(bool value) => OnPropertyChanged(nameof(DbEncryptionStatusLabel));
// ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ─────────────────────
[ObservableProperty] private bool _appLockEnabled;
[ObservableProperty] private int _appLockTimeoutMinutes = 10;
[ObservableProperty] private string _appLockNewPassword = "";
[ObservableProperty] private string _appLockNewPasswordConfirm = "";
[ObservableProperty] private string _appLockPasswordError = "";
[ObservableProperty] private string _appLockStatus = "";
public bool AppLockHasPassword => _appLock.HasPassword;
/// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort,
/// ohne dass die App neu gestartet werden muss.
public Action? OnAppLockChanged { get; set; }
// ── Datenschutz: Löschfristen (5.4.2) ────────────────────────────────────
[ObservableProperty] private int _retentionYears = 3;
[ObservableProperty] private string _retentionStatus = "";
public ObservableCollection ExpiredDocuments { get; } = [];
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
public Func>? OnConfirmHardDelete { get; set; }
// ── Ferien & Feiertage (4.3.5, aus dem Stundenplan hierher verschoben) ───
[ObservableProperty] private string _selectedStateName = "";
[ObservableProperty] private string _newHolidayName = "";
[ObservableProperty] private string _newHolidayStartText = "";
[ObservableProperty] private string _newHolidayEndText = "";
[ObservableProperty] private string _holidayNameError = "";
[ObservableProperty] private string _holidayDateError = "";
public List StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection SchoolHolidayEntries { get; } = [];
// ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ────────────
[ObservableProperty] private string _periodTimesError = "";
[ObservableProperty] private string _periodTimesStatus = "";
public ObservableCollection PeriodTimes { get; } = [];
// ── Aufsichten: wiederkehrende Pausenaufsicht (4.3 Nachtrag) ─────────────
[ObservableProperty] private string _newDutyWeekdayName = WeekdayDisplay.Options[0];
[ObservableProperty] private int _newDutyAfterPeriod;
[ObservableProperty] private string _newDutyLocation = "";
[ObservableProperty] private string _newDutyError = "";
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
public ObservableCollection SupervisionDuties { get; } = [];
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
[ObservableProperty] private bool _aiEnabled;
[ObservableProperty] private string _aiUsername = "";
[ObservableProperty] private string _aiPassword = "";
[ObservableProperty] private string _aiLoginError = "";
[ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = "";
// ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ──────────────────────────────
[ObservableProperty] private bool _untisEnabled;
[ObservableProperty] private bool _untisIsConfigured;
[ObservableProperty] private string _untisIcalUrlInput = "";
[ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy;
public Func? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
[ObservableProperty] private bool _annualPlanIsConfigured;
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
[ObservableProperty] private string _annualPlanUrlError = "";
[ObservableProperty] private string _annualPlanStatusDisplay = "";
[ObservableProperty] private bool _annualPlanFetchBusy;
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = "";
[ObservableProperty] private string _syncUsername = "";
[ObservableProperty] private string _syncPassword = "";
[ObservableProperty] private string _syncLoginError = "";
[ObservableProperty] private bool _syncIsLoggedIn;
[ObservableProperty] private string _syncConnectionStatus = "";
[ObservableProperty] private string _syncForceResyncStatus = "";
[ObservableProperty] private bool _syncForceResyncBusy;
[ObservableProperty] private bool _syncKeyWasRegenerated;
[ObservableProperty] private string _recoveryCode = "";
[ObservableProperty] private string _recoveryCodeInput = "";
[ObservableProperty] private string _recoveryStatus = "";
[ObservableProperty] private bool _recoveryBusy;
/// Vom Code-Behind gesetzt: lässt den Nutzer die Wiederherstellungsdatei selbst speichern
/// (z.B. USB-Stick, eigene Cloud). Liefert false bei Abbruch.
public Func>? OnSaveRecoveryFile { get; set; }
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
/// auswählen. Liefert null bei Abbruch.
public Func>? OnPickRecoveryFile { get; set; }
/// Vom Code-Behind gesetzt: bestätigt vor dem Überschreiben des aktuellen Sync-Schlüssels
/// dieses Geräts (die App startet danach neu, siehe RedeemRecoveryCode).
public Func>? OnConfirmRecoveryRestore { get; set; }
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; }
/// Vom Code-Behind gesetzt: zeigt einen Fehlerdialog, den der Nutzer aktiv wegklicken muss —
/// notwendig, weil RedeemPairingCode() nach einem Fehlschlag sofort neu startet (_dbContext ist
/// zu dem Zeitpunkt bereits disposed, siehe dort) und PairingStatus dadurch sonst nie sichtbar
/// gerendert würde.
public Func? OnShowPairingError { get; set; }
// ── Darstellung (12.4) ──────────────────────────────────────────────────────
[ObservableProperty] private AppTheme _selectedTheme;
public string[] ThemeOptions { get; } = AppThemeDisplay.Options;
public string SelectedThemeName
{
get => AppThemeDisplay.Label(SelectedTheme);
set => SelectedTheme = AppThemeDisplay.FromLabel(value);
}
/// Vom Code-Behind gesetzt: wendet die Theme-Variante sofort an (Application.Current, siehe
/// App.ApplyTheme) — ViewModels fassen Avalonia-Framework-Typen nicht direkt an, gleiches
/// Muster wie die übrigen Code-Behind-Hooks in dieser Klasse.
public Action? OnThemeChanged { get; set; }
partial void OnSelectedThemeChanged(AppTheme value)
{
_appearance.Save(value);
OnThemeChanged?.Invoke(value);
}
// ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
private readonly SyncEngine? _syncEngine;
private readonly SnapshotService? _snapshotService;
private readonly SyncKeyRecoveryService _syncKeyRecovery;
private readonly AppearanceSettingsService _appearance;
private readonly CompetencyCatalogImportService _catalogImport;
private readonly AppLogger _logger;
public TrashViewModel TrashTab { get; }
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
_appearance = appearance;
_selectedTheme = appearance.Load();
TrashTab = trashTab;
_subjects = subjects;
_domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates;
_gradingSchemes = gradingSchemes;
_grading = grading;
_backups = backups;
_dbEncryption = dbEncryption;
_appLock = appLock;
_dbContext = dbContext;
_privacy = privacy;
_documentation = documentation;
_students = students;
_shorthandCodes = shorthandCodes;
_schoolHolidays = schoolHolidays;
_calendarSettings = calendarSettings;
_periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates;
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
_snapshotService = snapshotService;
_syncEngine = syncEngine;
_catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects();
LoadShorthandCodes();
LoadGradingKeyTemplates();
LoadGradingSchemes();
LoadBackups();
IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath);
AppLockEnabled = _appLock.IsEnabled;
AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
RetentionYears = _privacy.RetentionYears;
LoadExpiredDocuments();
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
LoadSchoolHolidays();
LoadPeriodTimes();
LoadSupervisionDuties();
LoadLetterTemplates();
LoadAiSettings();
LoadUntisSettings();
LoadAnnualPlanSettings();
LoadSyncSettings();
LoadSyncConflicts();
}
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
private void LoadLetterTemplates()
{
LetterTemplateList.Clear();
foreach (var template in _letterTemplates.GetTemplates())
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
}
public void ImportLetterTemplate(string path)
{
LetterTemplateStatus = "";
try
{
var template = _letterTemplates.Import(path);
var validation = _letterTemplates.Validate(template);
LoadLetterTemplates();
LetterTemplateStatus = validation.Issues.Count == 0
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
}
}
[RelayCommand]
private void ValidateLetterTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
var index = LetterTemplateList.IndexOf(item);
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
if (index >= 0) LetterTemplateList[index] = refreshed;
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
? $"„{item.Name}“ ist ohne Auffälligkeiten."
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
}
[RelayCommand]
private void DeleteLetterTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
_letterTemplates.Delete(item.Id);
LetterTemplateList.Remove(item);
LetterTemplateStatus = "Vorlage gelöscht.";
}
// ── Aufsichten: Laden / Hinzufügen / Löschen ─────────────────────────────
private void LoadSupervisionDuties()
{
SupervisionDuties.Clear();
foreach (var d in _supervisionDuties.GetAll())
SupervisionDuties.Add(new SupervisionDutyItem(d));
}
[RelayCommand]
private void AddSupervisionDuty()
{
NewDutyError = "";
if (string.IsNullOrWhiteSpace(NewDutyLocation)) { NewDutyError = "Ort/Bezeichnung erforderlich."; return; }
if (NewDutyAfterPeriod is < 0 or > 10) { NewDutyError = "Muss zwischen 0 und 10 liegen."; return; }
try
{
_supervisionDuties.Save(new SupervisionDuty
{
Weekday = WeekdayDisplay.FromLabel(NewDutyWeekdayName),
AfterPeriod = NewDutyAfterPeriod,
Location = NewDutyLocation.Trim(),
});
}
catch (InvalidOperationException ex) { NewDutyError = ex.Message; return; }
NewDutyLocation = ""; NewDutyAfterPeriod = 0;
LoadSupervisionDuties();
}
[RelayCommand]
private void RemoveSupervisionDuty(SupervisionDutyItem? item)
{
if (item is null) return;
_supervisionDuties.Delete(item.Id);
SupervisionDuties.Remove(item);
}
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
private void LoadAiSettings()
{
AiEnabled = _aiSettings.Enabled;
AiUsername = _aiSettings.Username;
AiIsLoggedIn = _aiSettings.IsLoggedIn;
if (AiIsLoggedIn) _ = RefreshAiBalance();
}
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
private async Task RefreshAiBalance()
{
var token = _aiSettings.GetToken();
if (token is null) return;
try
{
var balance = await _aiPlanning.GetBalanceAsync(token);
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
}
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
}
[RelayCommand]
private async Task AiLogin()
{
AiLoginError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; }
if (!valid) return;
try
{
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
_aiSettings.SetCredentialsAndToken(AiUsername, token);
AiPassword = "";
AiIsLoggedIn = true;
await RefreshAiBalance();
}
catch (AiBackendException ex) { AiLoginError = ex.Message; }
}
[RelayCommand]
private void AiLogout()
{
_aiSettings.Logout();
AiIsLoggedIn = false;
AiBalanceDisplay = "";
}
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
//
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
private void LoadUntisSettings()
{
UntisEnabled = _untisSettings.Enabled;
UntisIsConfigured = _untisSettings.IsConfigured;
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void UntisSaveUrl()
{
UntisUrlError = "";
if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; }
if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; }
_untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim());
_untisSettings.SetEnabled(true);
UntisIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void UntisRemove()
{
_untisSettings.ClearIcalUrl();
LoadUntisSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task UntisFetchNow()
{
if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; }
UntisFetchBusy = true;
try
{
await _untisSync.PollAsync();
LoadUntisSettings();
}
finally { UntisFetchBusy = false; }
}
[RelayCommand]
private async Task UntisReviewMapping()
{
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings()
{
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void AnnualPlanSaveUrl()
{
AnnualPlanUrlError = "";
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
{
AnnualPlanUrlError = "iCal-URL erforderlich.";
return;
}
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
return;
}
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
_annualPlanSettings.SetEnabled(true);
AnnualPlanIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void AnnualPlanRemove()
{
_annualPlanSync?.Clear();
_annualPlanSettings.ClearIcalUrl();
LoadAnnualPlanSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task AnnualPlanFetchNow()
{
if (_annualPlanSync is null)
{
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
return;
}
AnnualPlanFetchBusy = true;
try
{
await _annualPlanSync.PollAsync();
LoadAnnualPlanSettings();
}
finally
{
AnnualPlanFetchBusy = false;
}
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
//
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
// gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig
// beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad.
private void LoadSyncSettings()
{
SyncServerUrl = _syncSettings.ServerUrl;
SyncUsername = _syncSettings.Username;
SyncIsLoggedIn = _syncSettings.IsLoggedIn;
}
[RelayCommand]
private async Task SyncTestConnection()
{
SyncConnectionStatus = "Teste Verbindung…";
if (string.IsNullOrWhiteSpace(SyncServerUrl))
{
SyncConnectionStatus = "Bitte Server-Adresse eingeben.";
return;
}
var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken());
SyncConnectionStatus = result switch
{
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
SyncConnectionTestResult.IncompatibleVersion =>
"Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " +
"Bitte aktualisiere die LehrerApp.",
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
};
}
[RelayCommand]
private async Task SyncLogin()
{
SyncLoginError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; }
if (!valid) return;
try
{
var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
// Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht
// anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen
// Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet
// serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung
// (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto
// und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem
// dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5).
var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId;
_syncSettings.SetServerUrl(SyncServerUrl);
_syncSettings.SetCredentialsAndToken(SyncUsername, token, userId);
if (accountChanged)
{
_eventQueue.SetLastServerSeq(0);
_eventQueue.ResetKnownServerSeqs();
}
SyncPassword = "";
AppBootstrapper.RestartApplication();
}
catch (SyncAuthException ex) { SyncLoginError = ex.Message; }
}
[RelayCommand]
private void SyncLogout()
{
_syncSettings.Logout();
AppBootstrapper.RestartApplication();
}
///
/// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für
/// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server -
/// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts
/// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene
/// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5).
/// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent
/// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt.
///
[RelayCommand]
private async Task SyncForceFullResync()
{
if (_syncEngine is null) { SyncForceResyncStatus = "Sync nicht konfiguriert."; return; }
SyncForceResyncBusy = true;
SyncForceResyncStatus = "Lade alle Ereignisse erneut…";
try
{
_eventQueue.SetLastServerSeq(0);
_eventQueue.ResetKnownServerSeqs();
var result = await _syncEngine.SyncNowAsync();
SyncForceResyncStatus = result.Success
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
: $"Fehlgeschlagen: {result.Reason}";
LoadSyncConflicts();
}
finally { SyncForceResyncBusy = false; }
}
// ── Sync-Schlüssel: selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────
//
// Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe SyncCrypto) - eine echte
// Wiederherstellung nach Verlust des einzigen Geräts mit dem Schlüssel ist deshalb nur
// möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde. "Code erzeugen"
// verschlüsselt den aktuellen Schlüssel mit einem einmalig angezeigten, zufälligen Code
// (SyncCrypto.EncryptKeyWithRecoveryCode) und lässt den Nutzer die verschlüsselte Datei
// selbst sichern (USB-Stick, eigene Cloud o.ä.) - Datei und Code getrennt aufbewahren, erst
// beides zusammen ergibt den Schlüssel. Läuft komplett offline, ohne Server-Beteiligung.
[RelayCommand]
private async Task CreateRecoveryCode()
{
if (OnSaveRecoveryFile is null) return;
RecoveryBusy = true;
RecoveryStatus = "";
RecoveryCode = "";
try
{
var code = SyncCrypto.GenerateRecoveryCode();
var fileContent = _syncKeyRecovery.CreateRecoveryFile(code);
if (!await OnSaveRecoveryFile(fileContent)) { RecoveryStatus = "Abgebrochen."; return; }
RecoveryCode = code;
RecoveryStatus = "Datei gespeichert. Den Code getrennt von der Datei notieren und " +
"sicher aufbewahren - er wird nirgends gespeichert und lässt sich " +
"nicht erneut anzeigen.";
}
finally { RecoveryBusy = false; }
}
[RelayCommand]
private async Task RedeemRecoveryCode()
{
RecoveryStatus = "";
if (string.IsNullOrWhiteSpace(RecoveryCodeInput)) { RecoveryStatus = "Bitte Code eingeben."; return; }
if (OnPickRecoveryFile is null) return;
var fileContent = await OnPickRecoveryFile();
if (fileContent is null) return;
if (OnConfirmRecoveryRestore is not null && !await OnConfirmRecoveryRestore()) return;
try
{
_syncKeyRecovery.RestoreFromFile(fileContent, RecoveryCodeInput.Trim());
}
catch (Exception ex) when (ex is CryptographicException or InvalidOperationException or JsonException)
{
RecoveryStatus = "Fehlgeschlagen: Code und Datei passen nicht zusammen.";
_logger.Error("Sync-Schlüssel-Wiederherstellung fehlgeschlagen", ex);
return;
}
// Der neue Schlüssel liegt jetzt auf der Platte, aber der aktuell im Speicher gehaltene
// (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht werden -
// gleiches Muster wie RedeemPairingCode. Lokale Cursor/Versionsverfolgung zurücksetzen,
// falls in der Zwischenzeit (mit dem gerade ersetzten, falschen Schlüssel) bereits etwas
// synchronisiert wurde - sicher wiederholbar, siehe SyncForceFullResync.
_eventQueue.SetLastServerSeq(0);
_eventQueue.ResetKnownServerSeqs();
AppBootstrapper.RestartApplication();
}
// ── Synchronisation: Konflikte ────────────────────────────────────────────
//
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
// Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur
// rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion.
private void LoadSyncConflicts()
{
SyncConflicts.Clear();
foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt))
SyncConflicts.Add(new SyncConflictListItem(c));
}
[RelayCommand]
private void MarkConflictReviewed(SyncConflictListItem? item)
{
if (item is null) return;
_eventQueue.MarkReviewed(item.Id);
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}";
_logger.Error("Geräte-Pairing (Redeem) fehlgeschlagen", ex);
// Ohne diesen Dialog würde PairingStatus nie gerendert: der direkt folgende Neustart
// (unten, auch im Fehlerfall nötig, siehe Kommentar oben) beendet den Prozess, bevor
// Avalonia den nächsten Frame zeichnen könnte.
if (OnShowPairingError is not null) await OnShowPairingError(ex.Message);
}
AppBootstrapper.RestartApplication();
}
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes()
{
PeriodTimes.Clear();
for (var period = 1; period <= 10; period++)
{
var times = _periodSchedule.GetTimes(period);
PeriodTimes.Add(new PeriodTimeEditItem(period, times?.Start, times?.End));
}
}
[RelayCommand]
private void SavePeriodTimes()
{
PeriodTimesError = ""; PeriodTimesStatus = "";
var entries = new List();
foreach (var item in PeriodTimes)
{
if (string.IsNullOrWhiteSpace(item.StartText) && string.IsNullOrWhiteSpace(item.EndText))
continue; // Stunde bewusst nicht konfiguriert — ok, keine Pflicht für alle 10.
if (!TimeOnly.TryParseExact(item.StartText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
!TimeOnly.TryParseExact(item.EndText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
{ PeriodTimesError = $"{item.PeriodLabel}: Format HH:MM."; return; }
if (end <= start)
{ PeriodTimesError = $"{item.PeriodLabel}: Ende muss nach dem Beginn liegen."; return; }
entries.Add(new PeriodTimeEntry { PeriodNumber = item.PeriodNumber, Start = start, End = end });
}
_periodSchedule.SetPeriods(entries);
PeriodTimesStatus = "Gespeichert.";
}
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
partial void OnSelectedStateNameChanged(string value) =>
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
private void LoadSchoolHolidays()
{
SchoolHolidayEntries.Clear();
foreach (var h in _schoolHolidays.GetAll().OrderBy(h => h.StartDate))
SchoolHolidayEntries.Add(new SchoolHolidayItem(h));
}
[RelayCommand]
private void AddSchoolHoliday()
{
HolidayNameError = ""; HolidayDateError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(NewHolidayName)) { HolidayNameError = "Name erforderlich."; valid = false; }
var hasStart = DateOnly.TryParseExact(NewHolidayStartText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var start);
var hasEnd = DateOnly.TryParseExact(NewHolidayEndText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var end);
if (!hasStart || !hasEnd) { HolidayDateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; valid = false; }
else if (end < start) { HolidayDateError = "Das Ende darf nicht vor dem Beginn liegen."; valid = false; }
if (!valid) return;
_schoolHolidays.Save(new SchoolHoliday { Name = NewHolidayName.Trim(), StartDate = start, EndDate = end });
NewHolidayName = ""; NewHolidayStartText = ""; NewHolidayEndText = "";
LoadSchoolHolidays();
}
[RelayCommand]
private void RemoveSchoolHoliday(SchoolHolidayItem? item)
{
if (item is null) return;
_schoolHolidays.Delete(item.Id);
SchoolHolidayEntries.Remove(item);
}
// ── Datenschutz: Löschfristen ─────────────────────────────────────────────
private void LoadExpiredDocuments()
{
ExpiredDocuments.Clear();
var cutoff = _privacy.RetentionCutoff();
foreach (var d in _documentation.GetAll().Where(d => d.CreatedAt < cutoff))
{
var student = _students.GetById(d.StudentId);
ExpiredDocuments.Add(new ExpiredDocumentItem(d, student?.FullName ?? "?"));
}
}
[RelayCommand]
private void SaveRetentionYears()
{
_privacy.SetRetentionYears(RetentionYears);
LoadExpiredDocuments();
RetentionStatus = "Gespeichert.";
}
[RelayCommand]
private async Task HardDeleteDocument(ExpiredDocumentItem? item)
{
if (item is null) return;
if (OnConfirmHardDelete is not null && !await OnConfirmHardDelete(item)) return;
_documentation.HardDelete(item.Id);
ExpiredDocuments.Remove(item);
}
// ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
private void LoadBackups()
{
Backups.Clear();
foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b));
}
[RelayCommand]
private void CreateBackupNow()
{
_backups.CreateBackup(AppBootstrapper.DbPath);
LoadBackups();
BackupStatus = "Backup erstellt.";
}
[RelayCommand]
private async Task RestoreBackup(BackupListItem? item)
{
if (item is null) return;
if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return;
_dbContext.Dispose();
_backups.RestoreBackup(item.Path, AppBootstrapper.DbPath);
AppBootstrapper.RestartApplication();
}
// ── Datenbank-Verschlüsselung: Setzen / Ändern / Entfernen ───────────────
[RelayCommand]
private void SaveDbPassword()
{
DbPasswordError = "";
var valid = true;
var wantsPassword = !string.IsNullOrWhiteSpace(NewDbPassword) || !string.IsNullOrWhiteSpace(NewDbPasswordConfirm);
if (IsDbEncrypted && !_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
{
DbPasswordError = "Aktuelles Passwort ist falsch.";
valid = false;
}
if (!wantsPassword)
{
DbPasswordError = "Bitte ein neues Passwort eingeben.";
valid = false;
}
else if (NewDbPassword != NewDbPasswordConfirm)
{
DbPasswordError = "Neue Passwörter stimmen nicht überein.";
valid = false;
}
else if (NewDbPassword.Length < 4)
{
DbPasswordError = "Mindestens 4 Zeichen.";
valid = false;
}
if (!valid) return;
_dbContext.Dispose();
_dbEncryption.SetPassword(AppBootstrapper.DbPath, IsDbEncrypted ? CurrentDbPassword : null, NewDbPassword.Trim());
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void RemoveDbPassword()
{
DbPasswordError = "";
if (!_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
{
DbPasswordError = "Aktuelles Passwort ist falsch.";
return;
}
_dbContext.Dispose();
_dbEncryption.SetPassword(AppBootstrapper.DbPath, CurrentDbPassword, null);
AppBootstrapper.RestartApplication();
}
// ── App-Sperre: Speichern ─────────────────────────────────────────────────
[RelayCommand]
private void SaveAppLockSettings()
{
AppLockPasswordError = "";
var valid = true;
var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm);
if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword)
{
AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen.";
valid = false;
}
else if (wantsNewPassword)
{
if (AppLockNewPassword != AppLockNewPasswordConfirm)
{
AppLockPasswordError = "Passwörter stimmen nicht überein.";
valid = false;
}
else if (AppLockNewPassword.Length < 4)
{
AppLockPasswordError = "Mindestens 4 Zeichen.";
valid = false;
}
}
if (!valid) return;
if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim());
_appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes);
AppLockNewPassword = ""; AppLockNewPasswordConfirm = "";
OnPropertyChanged(nameof(AppLockHasPassword));
AppLockStatus = "Gespeichert.";
OnAppLockChanged?.Invoke();
}
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
private void LoadGradingSchemes()
{
ClassScheme = new GradingSchemeEditItem(
_gradingSchemes.GetDefaultForType(GroupType.Class)
?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 },
"Klassen", _gradingSchemes, _grading);
CourseScheme = new GradingSchemeEditItem(
_gradingSchemes.GetDefaultForType(GroupType.Course)
?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 },
"Kurse", _gradingSchemes, _grading);
}
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
private void LoadGradingKeyTemplates()
{
GradingKeyTemplateList.Clear();
foreach (var t in _gradingKeyTemplates.GetAll())
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(t, _gradingKeyTemplates, _grading));
}
[RelayCommand]
private void AddGradingKeyTemplate()
{
if (string.IsNullOrWhiteSpace(NewTemplateName)) { NewTemplateNameError = "Vorlagenname erforderlich."; return; }
var system = NewTemplateGradingSystemName == "Punkte 0–15"
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
var defaults = system == GradingSystem.Grades1To6
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
var template = new GradingKeyTemplate
{
Name = NewTemplateName.Trim(),
GradingSystem = system,
Entries = defaults.Select(e => new GradingKeyEntry { Grade = e.Grade, MinPercent = e.MinPercent }).ToList(),
};
_gradingKeyTemplates.Save(template);
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
NewTemplateName = "";
NewTemplateNameError = "";
}
[RelayCommand]
private void DeleteGradingKeyTemplate(GradingKeyTemplateEditItem? item)
{
if (item is null) return;
_gradingKeyTemplates.Delete(item.Id);
GradingKeyTemplateList.Remove(item);
}
// ── Fächer: Laden / Hinzufügen / Löschen ─────────────────────────────────
public void LoadSubjects()
{
Subjects.Clear();
foreach (var s in _subjects.GetAll())
Subjects.Add(new SubjectListItem(s));
}
[RelayCommand]
private void AddSubject()
{
if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
try
{
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
}
catch (InvalidOperationException ex)
{
NewNameError = ex.Message;
return;
}
NewName = ""; NewShort = ""; NewNameError = "";
LoadSubjects();
}
[RelayCommand]
private void DeleteSubject(SubjectListItem? item)
{
if (item is null) return;
try
{
_subjects.Delete(item.Id);
}
catch (InvalidOperationException ex)
{
NewNameError = ex.Message;
return;
}
NewNameError = "";
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
LoadSubjects();
}
// ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ─────────────────────────
public void LoadShorthandCodes()
{
ShorthandCodes.Clear();
foreach (var c in _shorthandCodes.GetAll())
ShorthandCodes.Add(new ShorthandCodeListItem(c));
}
[RelayCommand]
private void AddShorthandCode()
{
if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; }
try
{
_shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() });
}
catch (InvalidOperationException ex)
{
NewShorthandCodeError = ex.Message;
return;
}
NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = "";
LoadShorthandCodes();
}
[RelayCommand]
private void DeleteShorthandCode(ShorthandCodeListItem? item)
{
if (item is null) return;
_shorthandCodes.Delete(item.Id);
LoadShorthandCodes();
}
// ── Katalog: Laden ────────────────────────────────────────────────────────
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
partial void OnCatalogGradeLevelChanged(int value) => LoadCatalog();
private void LoadCatalog()
{
Domains.Clear();
CatalogValidation = "";
if (CatalogSubject is null) return;
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel))
Domains.Add(CreateDomainEditItem(d));
RefreshDomainMoveState();
}
// ── Katalog: Bereich hinzufügen / löschen ────────────────────────────────
[RelayCommand]
private void AddDomain()
{
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
if (string.IsNullOrWhiteSpace(NewDomainName)) { CatalogValidation = "Bereichsname erforderlich."; return; }
var domain = new CompetencyDomain
{
SubjectId = CatalogSubject.Id,
GradeLevel = CatalogGradeLevel,
Name = NewDomainName.Trim(),
Code = NewDomainCode.Trim(),
SortOrder = Domains.Count,
};
_domainRepo.Save(domain);
Domains.Add(CreateDomainEditItem(domain));
RefreshDomainMoveState();
NewDomainName = ""; NewDomainCode = ""; CatalogValidation = "";
}
[RelayCommand]
private void DeleteDomain(DomainEditItem? item)
{
if (item is null) return;
_domainRepo.Delete(item.Id);
Domains.Remove(item);
PersistDomainOrder();
}
private DomainEditItem CreateDomainEditItem(CompetencyDomain domain) =>
new(domain, _domainRepo, item => MoveDomain(item, -1), item => MoveDomain(item, 1));
private void MoveDomain(DomainEditItem item, int offset)
{
var oldIndex = Domains.IndexOf(item);
var newIndex = oldIndex + offset;
if (oldIndex < 0 || newIndex < 0 || newIndex >= Domains.Count) return;
Domains.Move(oldIndex, newIndex);
PersistDomainOrder();
}
private void PersistDomainOrder()
{
for (var i = 0; i < Domains.Count; i++) Domains[i].SetSortOrder(i);
RefreshDomainMoveState();
}
private void RefreshDomainMoveState()
{
for (var i = 0; i < Domains.Count; i++)
Domains[i].SetMoveState(i > 0, i < Domains.Count - 1);
}
// ── JSON Import / Export ──────────────────────────────────────────────────
public CompetencyCatalogImportPreview? PrepareCatalogImport(string json)
{
if (CatalogSubject is null)
{
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
return null;
}
try
{
CatalogValidation = "";
return _catalogImport.Analyze(
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel);
}
catch (InvalidDataException ex)
{
CatalogValidation = ex.Message;
return null;
}
}
public void ApplyCatalogImport(CompetencyCatalogImportPreview preview,
CompetencyCatalogImportMode mode, IReadOnlySet useImportedConflicts)
{
_catalogImport.Apply(preview, mode, useImportedConflicts);
LoadCatalog();
CatalogValidation = mode == CompetencyCatalogImportMode.Merge
? "Kompetenzkatalog wurde sicher zusammengeführt."
: "Kompetenzkatalog wurde vollständig ersetzt.";
}
public string ExportCatalog()
=> SerializeCatalog(CatalogGradeLevel);
public CompetencyCatalogImportPreview? PrepareCatalogCopy(int targetGradeLevel)
{
if (CatalogSubject is null)
{
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
return null;
}
if (Domains.Count == 0)
{
CatalogValidation = "Der ausgewählte Katalog enthält keine Bereiche.";
return null;
}
if (targetGradeLevel is < 1 or > 13 || targetGradeLevel == CatalogGradeLevel)
{
CatalogValidation = "Bitte eine andere Zielklassenstufe zwischen 1 und 13 auswählen.";
return null;
}
CatalogValidation = "";
return _catalogImport.Analyze(SerializeCatalog(targetGradeLevel), CatalogSubject.Id,
CatalogSubject.Name, targetGradeLevel);
}
public void SetCatalogCopyStatus(int targetGradeLevel) =>
CatalogValidation = $"Kompetenzkatalog wurde in Klassenstufe {targetGradeLevel} kopiert.";
private string SerializeCatalog(int gradeLevel)
{
var dto = new CatalogDto
{
Subject = CatalogSubject?.Name ?? "",
GradeLevel = gradeLevel,
Domains = Domains.Select(d => new DomainDto
{
Name = d.Name,
Code = d.Code,
Competencies = d.Items.Select(i => new CompetencyDto
{
Code = i.Code,
Description = i.Description,
}).ToList(),
}).ToList(),
};
return JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
}
}
// ── DomainEditItem ────────────────────────────────────────────────────────────
public partial class DomainEditItem : ObservableObject
{
private readonly CompetencyDomain _domain;
private readonly ICompetencyDomainRepository _repo;
public Guid Id { get; }
public string Name { get; }
public string Code { get; }
public string DisplayName { get; }
[ObservableProperty] private string _newItemCode = "";
[ObservableProperty] private string _newItemDesc = "";
public ObservableCollection Items { get; } = [];
public IRelayCommand MoveUpCommand { get; }
public IRelayCommand MoveDownCommand { get; }
public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo,
Action? onMoveUp = null, Action? onMoveDown = null)
{
_domain = domain;
_repo = repo;
Id = domain.Id;
Name = domain.Name;
Code = domain.Code;
DisplayName = string.IsNullOrEmpty(domain.Code)
? domain.Name
: $"{domain.Name} ({domain.Code})";
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
_domain.Items = domain.Items.OrderBy(i => i.SortOrder).ToList();
foreach (var item in _domain.Items)
Items.Add(CreateItemViewModel(item));
RefreshItemMoveState();
}
private bool _canMoveUp;
private bool _canMoveDown;
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
{
_canMoveUp = canMoveUp;
_canMoveDown = canMoveDown;
MoveUpCommand.NotifyCanExecuteChanged();
MoveDownCommand.NotifyCanExecuteChanged();
}
internal void SetSortOrder(int sortOrder)
{
if (_domain.SortOrder == sortOrder) return;
_domain.SortOrder = sortOrder;
_repo.Save(_domain);
}
[RelayCommand]
private void AddItem()
{
if (string.IsNullOrWhiteSpace(NewItemDesc)) return;
var item = new CompetencyItem
{
Code = NewItemCode.Trim(),
Description = NewItemDesc.Trim(),
SortOrder = _domain.Items.Count,
};
_domain.Items.Add(item);
_repo.Save(_domain);
Items.Add(CreateItemViewModel(item));
RefreshItemMoveState();
NewItemCode = ""; NewItemDesc = "";
}
private void DeleteItem(CompetencyItemVm vm)
{
_domain.Items.RemoveAll(i => i.Id == vm.ItemId);
Items.Remove(vm);
PersistItemOrder();
}
private CompetencyItemVm CreateItemViewModel(CompetencyItem item) =>
new(item, DeleteItem, vm => MoveItem(vm, -1), vm => MoveItem(vm, 1));
private void MoveItem(CompetencyItemVm item, int offset)
{
var oldIndex = Items.IndexOf(item);
var newIndex = oldIndex + offset;
if (oldIndex < 0 || newIndex < 0 || newIndex >= Items.Count) return;
Items.Move(oldIndex, newIndex);
PersistItemOrder();
}
private void PersistItemOrder()
{
_domain.Items = Items.Select(x => x.Model).ToList();
for (var i = 0; i < _domain.Items.Count; i++) _domain.Items[i].SortOrder = i;
_repo.Save(_domain);
RefreshItemMoveState();
}
private void RefreshItemMoveState()
{
for (var i = 0; i < Items.Count; i++)
Items[i].SetMoveState(i > 0, i < Items.Count - 1);
}
}
// ── CompetencyItemVm ──────────────────────────────────────────────────────────
public class CompetencyItemVm
{
internal CompetencyItem Model { get; }
public Guid ItemId { get; }
public string Code { get; }
public string Description { get; }
public string Display { get; }
public IRelayCommand DeleteCommand { get; }
public IRelayCommand MoveUpCommand { get; }
public IRelayCommand MoveDownCommand { get; }
public CompetencyItemVm(CompetencyItem item, Action onDelete,
Action? onMoveUp = null, Action? onMoveDown = null)
{
Model = item;
ItemId = item.Id;
Code = item.Code;
Description = item.Description;
Display = string.IsNullOrEmpty(item.Code)
? item.Description
: $"[{item.Code}] {item.Description}";
DeleteCommand = new RelayCommand(() => onDelete(this));
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
}
private bool _canMoveUp;
private bool _canMoveDown;
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
{
_canMoveUp = canMoveUp;
_canMoveDown = canMoveDown;
MoveUpCommand.NotifyCanExecuteChanged();
MoveDownCommand.NotifyCanExecuteChanged();
}
}
// ── GradingKeyTemplateEditItem (1.3.2) ─────────────────────────────────────────
public partial class GradingKeyTemplateEditItem : ObservableObject
{
private readonly GradingKeyTemplate _template;
private readonly IGradingKeyTemplateRepository _repo;
private readonly GradingService _grading;
public Guid Id { get; }
public string Name { get; }
public string GradingSystemLabel { get; }
[ObservableProperty] private string _newGrade = "";
[ObservableProperty] private double _newMinPercent;
[ObservableProperty] private string _newGradeError = "";
[ObservableProperty] private string _newMinPercentError = "";
[ObservableProperty] private string _completenessWarning = "";
public ObservableCollection Entries { get; } = [];
public GradingKeyTemplateEditItem(GradingKeyTemplate template, IGradingKeyTemplateRepository repo,
GradingService grading)
{
_template = template; _repo = repo; _grading = grading;
Id = template.Id;
Name = template.Name;
GradingSystemLabel = template.GradingSystem == GradingSystem.Grades1To6
? "Noten 1–6" : "Punkte 0–15";
foreach (var e in template.Entries.OrderByDescending(e => e.MinPercent))
Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
RecomputeCompleteness();
}
[RelayCommand]
private void AddEntry()
{
NewGradeError = ""; NewMinPercentError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(NewGrade)) { NewGradeError = "Bezeichnung erforderlich."; valid = false; }
if (NewMinPercent is < 0 or > 100) { NewMinPercentError = "Muss zwischen 0 und 100 liegen."; valid = false; }
else if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
{ NewMinPercentError = "Diese Prozentgrenze existiert bereits."; valid = false; }
if (!valid) return;
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
_template.Entries.Add(entry);
_template.Entries = _template.Entries.OrderByDescending(e => e.MinPercent).ToList();
_repo.Save(_template);
Entries.Clear();
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
NewGrade = ""; NewMinPercent = 0;
RecomputeCompleteness();
}
private void DeleteEntry(GradingKeyEntryVm vm)
{
_template.Entries.RemoveAll(e => e.Grade == vm.Grade && Math.Abs(e.MinPercent - vm.MinPercent) < 0.0001);
_repo.Save(_template);
Entries.Remove(vm);
RecomputeCompleteness();
}
private void RecomputeCompleteness() =>
CompletenessWarning = _grading.ValidateGradingKey(_template.Entries) ?? "";
}
// ── GradingKeyEntryVm ─────────────────────────────────────────────────────────
public class GradingKeyEntryVm
{
public string Grade { get; }
public double MinPercent { get; }
public string Display { get; }
public IRelayCommand DeleteCommand { get; }
public GradingKeyEntryVm(GradingKeyEntry e, Action onDelete)
{
Grade = e.Grade;
MinPercent = e.MinPercent;
Display = $"{Grade} — ab {MinPercent.ToString("0.##")} %";
DeleteCommand = new RelayCommand(() => onDelete(this));
}
}
// ── GradingSchemeEditItem (2.3) ────────────────────────────────────────────────
public partial class GradingSchemeEditItem : ObservableObject
{
private readonly GradingScheme _scheme;
private readonly IGradingSchemeRepository _repo;
private readonly GradingService _grading;
public string Label { get; }
[ObservableProperty] private double _examsPercent;
[ObservableProperty] private double _participationPercent;
[ObservableProperty] private double _otherPercent;
[ObservableProperty] private string _validationMessage = "";
[ObservableProperty] private string _statusMessage = "";
public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading)
{
_scheme = scheme; _repo = repo; _grading = grading;
Label = label;
_examsPercent = scheme.ExamsPercent;
_participationPercent = scheme.ParticipationPercent;
_otherPercent = scheme.OtherPercent;
}
[RelayCommand]
private void Save()
{
_scheme.ExamsPercent = ExamsPercent;
_scheme.ParticipationPercent = ParticipationPercent;
_scheme.OtherPercent = OtherPercent;
var error = _grading.ValidateGradingScheme(_scheme);
if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; }
ValidationMessage = "";
_repo.Save(_scheme);
StatusMessage = "Gespeichert.";
}
}
// ── Hilfklassen ───────────────────────────────────────────────────────────────
public class SubjectListItem(Subject s)
{
public Guid Id { get; } = s.Id;
public string Name { get; } = s.Name;
public string ShortName { get; } = s.ShortName;
}
public class ShorthandCodeListItem(ShorthandCode c)
{
public Guid Id { get; } = c.Id;
public string Code { get; } = c.Code;
public string Label { get; } = c.Label;
}
public class BackupListItem(BackupInfo info)
{
public string Path { get; } = info.Path;
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
}
public sealed class LetterTemplateListItem
{
public LetterTemplateInfo Model { get; }
public TemplateValidationResult Validation { get; }
public Guid Id => Model.Id;
public string Name => Model.Name;
public string OriginalFileName => Model.OriginalFileName;
public bool HasIssues => Validation.Issues.Count > 0;
public bool HasNoIssues => !HasIssues;
public string ValidationSummary => HasNoIssues
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
public ObservableCollection Issues { get; }
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
{
Model = model;
Validation = validation;
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
}
}
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
{
public string Icon => issue.Severity switch
{
TemplateIssueSeverity.Error => "⛔",
TemplateIssueSeverity.StrongWarning => "⚠",
_ => "ⓘ",
};
public string Message => issue.Message;
public string Color => issue.Severity switch
{
TemplateIssueSeverity.Error => "#DC2626",
TemplateIssueSeverity.StrongWarning => "#D97706",
_ => "#6B7280",
};
}
public class ExpiredDocumentItem(Documentation d, string studentName)
{
public Guid Id { get; } = d.Id;
public string StudentName { get; } = studentName;
public string Title { get; } = d.Title;
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
}
public class SyncConflictListItem(ConflictEntry c)
{
public Guid Id { get; } = c.Id;
public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)";
public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm");
public string ResolutionDisplay { get; } = c.Resolution switch
{
"LocalWon" => "Lokale Änderung übernommen (dieses Gerät)",
"RemoteWon" => "Änderung vom anderen Gerät übernommen",
_ => c.Resolution,
};
}
public class SchoolHolidayItem(SchoolHoliday h)
{
public Guid Id { get; } = h.Id;
public string Name { get; } = h.Name;
public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}";
}
public partial class PeriodTimeEditItem : ObservableObject
{
public int PeriodNumber { get; }
public string PeriodLabel { get; }
[ObservableProperty] private string _startText;
[ObservableProperty] private string _endText;
public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end)
{
PeriodNumber = periodNumber;
PeriodLabel = $"{periodNumber}. Stunde";
_startText = start?.ToString("HH:mm") ?? "";
_endText = end?.ToString("HH:mm") ?? "";
}
}
// ── Wochentag: deutsche Anzeige (Mo–Fr, für Aufsichten) ────────────────────────
public static class WeekdayDisplay
{
private static readonly (DayOfWeek Day, string Name)[] Entries =
[
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
];
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
}
public class SupervisionDutyItem(SupervisionDuty d)
{
public Guid Id { get; } = d.Id;
public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday);
public int AfterPeriod { get; } = d.AfterPeriod;
public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde";
public string Location { get; } = d.Location;
}
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
internal class CatalogDto
{
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; }
[JsonPropertyName("domains")] public List? Domains { get; set; }
}
internal class DomainDto
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("competencies")] public List? Competencies { get; set; }
}
internal class CompetencyDto
{
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}