diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9d0d522 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/dotnet/sdk:10.0 + steps: + - uses: actions/checkout@v4 + + - name: Restore + run: dotnet restore LehrerApp.sln + + - name: Build + run: dotnet build LehrerApp.sln --no-restore --configuration Release + + - name: Test + run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal" diff --git a/Directory.Build.props b/Directory.Build.props index 0618bd7..2c92591 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable latest - false + true CS8618 diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index a8909d1..151132d 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -225,6 +225,9 @@ public interface IParticipationSessionRepository { List GetByGroup(Guid groupId); ParticipationSession? GetById(Guid id); + // Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro + // Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster. + List GetAll(); void Save(ParticipationSession session); void Delete(Guid id); } diff --git a/LehrerApp.Core/Services/BackupService.cs b/LehrerApp.Core/Services/BackupService.cs index 8cd9e79..02557fa 100644 --- a/LehrerApp.Core/Services/BackupService.cs +++ b/LehrerApp.Core/Services/BackupService.cs @@ -14,6 +14,13 @@ public class BackupService public string BackupDirectory => _backupDirectory; + /// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst + /// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem + /// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings, + /// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht + /// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen. + public string? SecondaryBackupDirectory { get; set; } + public BackupService(string appDataPath) { _backupDirectory = Path.Combine(appDataPath, "backups"); @@ -34,9 +41,36 @@ public class BackupService File.Copy(databasePath, target, overwrite: true); PruneOldBackups(keepCount); + + if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory)) + TryMirrorToSecondary(target, fileName, keepCount); + return target; } + private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount) + { + try + { + Directory.CreateDirectory(SecondaryBackupDirectory!); + File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true); + + foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db") + .Select(p => new FileInfo(p)) + .OrderByDescending(f => f.LastWriteTime) + .Skip(keepCount)) + { + try { stale.Delete(); } + catch { /* nächster Lauf versucht es erneut */ } + } + } + catch + { + // Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon + // unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern. + } + } + public List ListBackups() => Directory.GetFiles(_backupDirectory, "lehrerapp-*.db") .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length)) diff --git a/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs index 124cb3c..a3239a1 100644 --- a/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs +++ b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs @@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests Assert.Single(reopened.GetCollection("t").FindAll()); } + [Fact] + public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck() + { + using var temp = new TempDatabase(); + var service = new DatabaseEncryptionService(); + + Assert.False(service.CanOpenAndRead(temp.Path, null)); + } + + [Fact] + public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(temp.Path)) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + + var service = new DatabaseEncryptionService(); + + Assert.True(service.CanOpenAndRead(temp.Path, null)); + } + + [Fact] + public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" })) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + + var service = new DatabaseEncryptionService(); + + Assert.True(service.CanOpenAndRead(temp.Path, "geheim123")); + } + + [Fact] + public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" })) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + + var service = new DatabaseEncryptionService(); + + Assert.False(service.CanOpenAndRead(temp.Path, "falsch")); + } + + [Fact] + public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck() + { + using var temp = new TempDatabase(); + // Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank + // (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das + // Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung + // (z.B. durch einen vollen Datenträger während der Backup-Kopie). + File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray()); + var service = new DatabaseEncryptionService(); + + Assert.False(service.CanOpenAndRead(temp.Path, null)); + } + private sealed class TempDatabase : IDisposable { private readonly string _directory = System.IO.Path.Combine( diff --git a/LehrerApp.Data/DatabaseEncryptionService.cs b/LehrerApp.Data/DatabaseEncryptionService.cs index 90329eb..b09bd31 100644 --- a/LehrerApp.Data/DatabaseEncryptionService.cs +++ b/LehrerApp.Data/DatabaseEncryptionService.cs @@ -37,6 +37,29 @@ public class DatabaseEncryptionService } } + /// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige + /// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines + /// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick + /// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen + /// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort. + public bool CanOpenAndRead(string dbPath, string? password) + { + if (!File.Exists(dbPath)) return false; + try + { + var connection = password is null + ? new ConnectionString(dbPath) + : new ConnectionString(dbPath) { Password = password }; + using var db = new LiteDatabase(connection); + _ = db.GetCollectionNames().ToList(); + return true; + } + catch + { + return false; + } + } + /// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das /// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere /// Verbindung (z.B. der laufende ) die Datei offen hält. diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index b94ca06..c3ece27 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -492,6 +492,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe public List GetByGroup(Guid groupId) => db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList(); public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id); + public List GetAll() => db.ParticipationSessions.FindAll().ToList(); public void Save(ParticipationSession s) { ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index ed3f77e..3cc31fc 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -170,6 +170,7 @@ public class FakeSessions(List all) : IParticipationSessio { public List GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList(); public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); + public List GetAll() => all.ToList(); public void Save(ParticipationSession session) { all.RemoveAll(s => s.Id == session.Id); diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 9d99a8c..32d48d6 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -28,6 +28,7 @@ public sealed class SettingsViewModelTests return new SettingsViewModel( subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), + new BackupSettingsService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), @@ -314,6 +315,7 @@ public sealed class SettingsViewModelTests var vm = new SettingsViewModel( new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), + new BackupSettingsService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), @@ -341,6 +343,7 @@ public sealed class SettingsViewModelTests var vm = new SettingsViewModel( new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), + new BackupSettingsService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), @@ -372,6 +375,7 @@ public sealed class SettingsViewModelTests var vm = new SettingsViewModel( new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), + new BackupSettingsService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index dd7c620..ed874da 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -121,9 +121,15 @@ public static class AppBootstrapper // ── Datensicherheit (13.3) ─────────────────────────────────────────── // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. - var backup = new BackupService(appData); - backup.CreateBackup(DbPath); + var backupSettings = new BackupSettingsService(appData); + var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() }; + var backupPath = backup.CreateBackup(DbPath); + // Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den + // Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten. + if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword)) + Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt."); services.AddSingleton(backup); + services.AddSingleton(backupSettings); services.AddSingleton(_ => new AppLockService(appData)); services.AddSingleton(); services.AddSingleton(_ => new PrivacySettingsService(appData)); diff --git a/LehrerApp.Desktop/Services/BackupSettingsService.cs b/LehrerApp.Desktop/Services/BackupSettingsService.cs new file mode 100644 index 0000000..bc501f9 --- /dev/null +++ b/LehrerApp.Desktop/Services/BackupSettingsService.cs @@ -0,0 +1,36 @@ +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +internal sealed class BackupSettingsConfig +{ + public string? SecondaryDirectory { get; set; } +} + +/// Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein +/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank +/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches +/// Muster wie . Das eigentliche Kopieren übernimmt +/// bewusst best-effort. +public sealed class BackupSettingsService +{ + private readonly string _configPath; + + public BackupSettingsService(string appDataPath) => + _configPath = Path.Combine(appDataPath, "backupsettings.json"); + + public string? LoadSecondaryDirectory() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?.SecondaryDirectory; + } + catch { /* beschädigte Konfiguration -> kein zweites Ziel */ } + return null; + } + + public void SaveSecondaryDirectory(string? path) => + File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path })); +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 7812450..b6be559 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -301,12 +301,16 @@ public partial class DashboardViewModel : ObservableObject var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); + // Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers + // einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen). + var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date); + var items = new List(); foreach (var student in _students.GetAll()) { var entries = _participationEntries.GetByStudent(student.Id) - .Select(e => _participationSessions.GetById(e.SessionId) is { } session - ? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance)) + .Select(e => sessionDates.TryGetValue(e.SessionId, out var date) + ? ((DateOnly?)date, e.Attendance) : (null, e.Attendance)) .Where(t => t.Item1.HasValue) .Select(t => (t.Item1!.Value, t.Attendance)); diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs new file mode 100644 index 0000000..8a6850f --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs @@ -0,0 +1,81 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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 = ""; + + // ── 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 = ""; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AnnualPlan.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AnnualPlan.cs new file mode 100644 index 0000000..f883b34 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AnnualPlan.cs @@ -0,0 +1,90 @@ +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; + +public partial class SettingsViewModel +{ + // ── Schulweiter Jahresplan (ClassyPlan-iCal) ───────────────────────────── + + [ObservableProperty] private bool _annualPlanIsConfigured; + [ObservableProperty] private string _annualPlanIcalUrlInput = ""; + [ObservableProperty] private string _annualPlanUrlError = ""; + [ObservableProperty] private string _annualPlanStatusDisplay = ""; + [ObservableProperty] private bool _annualPlanFetchBusy; + + // ── 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; + } + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AppLock.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AppLock.cs new file mode 100644 index 0000000..b7c2c81 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AppLock.cs @@ -0,0 +1,74 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } + + // ── 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(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Appearance.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Appearance.cs new file mode 100644 index 0000000..b252d81 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Appearance.cs @@ -0,0 +1,41 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Backup.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Backup.cs new file mode 100644 index 0000000..809f4c8 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Backup.cs @@ -0,0 +1,92 @@ +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; + +public partial class SettingsViewModel +{ + // ── Sicherheit: Datensicherung (13.3.1/13.3.2) ─────────────────────────── + + [ObservableProperty] private string _backupStatus = ""; + [ObservableProperty] private string _secondaryBackupDirectory = ""; + public ObservableCollection Backups { get; } = []; + + /// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen. + public Func>? OnConfirmRestore { get; set; } + + /// Vom Code-Behind gesetzt: öffnet einen Ordnerauswahl-Dialog für das zweite Sicherungsziel. + public Func>? OnPickBackupDirectory { get; set; } + + // ── Datensicherung: Laden / Erstellen / Wiederherstellen ───────────────── + + private void LoadBackups() + { + Backups.Clear(); + foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b)); + SecondaryBackupDirectory = _backupSettings.LoadSecondaryDirectory() ?? ""; + } + + [RelayCommand] + private void CreateBackupNow() + { + var path = _backups.CreateBackup(AppBootstrapper.DbPath); + LoadBackups(); + if (path is null) { BackupStatus = "Kein Backup erstellt (Datenbankdatei fehlt)."; return; } + + BackupStatus = _dbEncryption.CanOpenAndRead(path, AppBootstrapper.DbPassword) + ? "Backup erstellt und geprüft." + : "Backup erstellt — Prüfung fehlgeschlagen, Datei könnte beschädigt sein!"; + } + + [RelayCommand] + private async Task PickBackupDirectory() + { + if (OnPickBackupDirectory is null) return; + var path = await OnPickBackupDirectory(); + if (path is null) return; + + _backupSettings.SaveSecondaryDirectory(path); + _backups.SecondaryBackupDirectory = path; + SecondaryBackupDirectory = path; + BackupStatus = "Zweiter Sicherungsordner gespeichert."; + } + + [RelayCommand] + private void ClearBackupDirectory() + { + _backupSettings.SaveSecondaryDirectory(null); + _backups.SecondaryBackupDirectory = null; + SecondaryBackupDirectory = ""; + BackupStatus = "Zweiter Sicherungsordner entfernt."; + } + + [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(); + } +} + +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"; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Competencies.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Competencies.cs new file mode 100644 index 0000000..a32853c --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Competencies.cs @@ -0,0 +1,251 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } = []; + + // ── 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); + } +} + +// ── 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) ───────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.DevicePairing.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.DevicePairing.cs new file mode 100644 index 0000000..26786e5 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.DevicePairing.cs @@ -0,0 +1,111 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } + + // ── 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(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Encryption.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Encryption.cs new file mode 100644 index 0000000..c99c4a5 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Encryption.cs @@ -0,0 +1,85 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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)); + + // ── 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(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.GradingKeyTemplates.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.GradingKeyTemplates.cs new file mode 100644 index 0000000..650107f --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.GradingKeyTemplates.cs @@ -0,0 +1,154 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } = []; + + // ── 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); + } +} + +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)); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Holidays.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Holidays.cs new file mode 100644 index 0000000..9b66d5c --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Holidays.cs @@ -0,0 +1,146 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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 = ""; + [ObservableProperty] private string _schoolName = ""; + [ObservableProperty] private string _schoolStreet = ""; + [ObservableProperty] private string _schoolPostalCode = ""; + [ObservableProperty] private string _schoolCity = ""; + [ObservableProperty] private string _schoolNameError = ""; + [ObservableProperty] private string _schoolStreetError = ""; + [ObservableProperty] private string _schoolPostalCodeError = ""; + [ObservableProperty] private string _schoolCityError = ""; + [ObservableProperty] private string _schoolLocationStatus = ""; + [ObservableProperty] private string _resolvedSchoolAddress = ""; + + public List StateOptions { get; } = GermanStateDisplay.Options.ToList(); + public ObservableCollection SchoolHolidayEntries { get; } = []; + + // ── Ferien & Feiertage: Bundesland / Schulferien pflegen ───────────────── + + partial void OnSelectedStateNameChanged(string value) => + _calendarSettings.SetState(GermanStateDisplay.FromLabel(value)); + + [RelayCommand] + private async Task LoadSchoolLocation() + { + if (_schoolWeather is null) return; + try + { + var location = await _schoolWeather.GetLocationAsync(); + if (location is null) return; + SchoolName = location.SchoolName; + SchoolStreet = location.Street; + SchoolPostalCode = location.PostalCode; + SchoolCity = location.City; + SelectedStateName = GermanStateDisplay.Label(location.State); + ResolvedSchoolAddress = location.ResolvedAddress; + } + catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; } + } + + [RelayCommand] + private async Task SaveSchoolLocation() + { + SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = ""; + SchoolCityError = ""; SchoolLocationStatus = ""; + var valid = true; + if (string.IsNullOrWhiteSpace(SchoolName)) + { SchoolNameError = "Name der Schule erforderlich."; valid = false; } + if (string.IsNullOrWhiteSpace(SchoolStreet)) + { SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; } + if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$")) + { SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; } + if (string.IsNullOrWhiteSpace(SchoolCity)) + { SchoolCityError = "Ort erforderlich."; valid = false; } + if (!valid) return; + if (_schoolWeather is null) + { + SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar."; + return; + } + + SchoolLocationStatus = "Adresse wird geprüft …"; + try + { + var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest + { + SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(), + PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(), + State = GermanStateDisplay.FromLabel(SelectedStateName), + }); + ResolvedSchoolAddress = profile.ResolvedAddress; + SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen."; + } + catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; } + } + + 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); + } +} + +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}"; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.JsonImportExport.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.JsonImportExport.cs new file mode 100644 index 0000000..0f2b36f --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.JsonImportExport.cs @@ -0,0 +1,123 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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 }); + } +} + +// ── 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; } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs new file mode 100644 index 0000000..fa085d8 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs @@ -0,0 +1,114 @@ +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; + +public partial class SettingsViewModel +{ + // ── Word-Briefvorlagen (7.1.4 / 11.5) ─────────────────────────────────── + + [ObservableProperty] private string _letterTemplateStatus = ""; + public ObservableCollection LetterTemplateList { get; } = []; + public IReadOnlyList SupportedLetterPlaceholders => + LetterTemplateService.SupportedPlaceholders; + + // ── 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."; + } +} + +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", + }; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.PeriodSchedule.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.PeriodSchedule.cs new file mode 100644 index 0000000..44e802e --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.PeriodSchedule.cs @@ -0,0 +1,81 @@ +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; + +public partial class SettingsViewModel +{ + // ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ──────────── + + [ObservableProperty] private string _periodTimesError = ""; + [ObservableProperty] private string _periodTimesStatus = ""; + + public ObservableCollection PeriodTimes { get; } = []; + + // ── 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."; + } +} + +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") ?? ""; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs new file mode 100644 index 0000000..682de6a --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs @@ -0,0 +1,68 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } + + // ── 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); + } +} + +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"); +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.ShorthandCodes.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.ShorthandCodes.cs new file mode 100644 index 0000000..a08dc1f --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.ShorthandCodes.cs @@ -0,0 +1,69 @@ +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; + +public partial class SettingsViewModel +{ + // ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ────────────────────────── + + [ObservableProperty] private string _newShorthandCode = ""; + [ObservableProperty] private string _newShorthandLabel = ""; + [ObservableProperty] private string _newShorthandCodeError = ""; + + public ObservableCollection ShorthandCodes { get; } = []; + + // ── 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(); + } +} + +public class ShorthandCodeListItem(ShorthandCode c) +{ + public Guid Id { get; } = c.Id; + public string Code { get; } = c.Code; + public string Label { get; } = c.Label; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Subjects.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Subjects.cs new file mode 100644 index 0000000..8b04696 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Subjects.cs @@ -0,0 +1,79 @@ +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; + +public partial class SettingsViewModel +{ + // ── Fächer ──────────────────────────────────────────────────────────────── + + [ObservableProperty] private string _newName = ""; + [ObservableProperty] private string _newShort = ""; + [ObservableProperty] private string _newNameError = ""; + + public ObservableCollection Subjects { get; } = []; + + // ── 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(); + } +} + +public class SubjectListItem(Subject s) +{ + public Guid Id { get; } = s.Id; + public string Name { get; } = s.Name; + public string ShortName { get; } = s.ShortName; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.SupervisionDuties.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.SupervisionDuties.cs new file mode 100644 index 0000000..c1e4270 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.SupervisionDuties.cs @@ -0,0 +1,94 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } = []; + + // ── 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); + } +} + +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; +} + +// ── 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; +} + diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs new file mode 100644 index 0000000..a234b6d --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs @@ -0,0 +1,245 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; } = []; + + // ── 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); + } +} + +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, + }; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs new file mode 100644 index 0000000..9b97daf --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WebUntis.cs @@ -0,0 +1,170 @@ +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; + +public partial class SettingsViewModel +{ + // ── 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; + [ObservableProperty] private bool _untisApiIsConfigured; + [ObservableProperty] private string _untisSchool = ""; + [ObservableProperty] private string _untisHost = ""; + [ObservableProperty] private string _untisUsername = ""; + [ObservableProperty] private string _untisPassword = ""; + [ObservableProperty] private string _untisApiStatus = ""; + [ObservableProperty] private bool _untisApiBusy; + [ObservableProperty] private string? _untisHomeroomClassName; + public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name + ? $"Ausgewählt: {name}" + : "Keine Klasse ausgewählt."; + partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay)); + public Func? OnReviewUntisMapping { get; set; } + /// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch. + public Func>? OnPickHomeroomClass { get; set; } + + // ── 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."; + UntisApiIsConfigured = _untisSettings.ApiIsConfigured; + if (_untisSettings.GetApiCredentials() is { } credentials) + { + UntisSchool = credentials.School; + UntisHost = credentials.Host; + UntisUsername = credentials.Username; + UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert."; + } + UntisHomeroomClassName = _untisSettings.HomeroomClassName; + } + + [RelayCommand] + private async Task UntisSaveApi() + { + UntisApiStatus = ""; + if (_untisIntegration is null) + { + UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar."; + return; + } + if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) || + string.IsNullOrWhiteSpace(UntisPassword)) + { + UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich."; + return; + } + UntisApiBusy = true; + try + { + var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(), + UntisUsername.Trim(), UntisPassword); + await _untisIntegration.ConnectAsync(credentials); + _untisSettings.SetApiCredentials(credentials); + UntisPassword = ""; + UntisApiIsConfigured = true; + UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen."; + } + catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; } + finally { UntisApiBusy = false; } + } + + [RelayCommand] + private async Task UntisRemoveApi() + { + UntisApiBusy = true; + try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); } + catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ } + finally + { + _untisSettings.ClearApiCredentials(); + UntisPassword = ""; + UntisApiIsConfigured = false; + UntisApiStatus = "WebUntis-API-Zugang entfernt."; + UntisApiBusy = false; + } + } + + [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(); + } + + [RelayCommand] + private async Task UntisPickHomeroomClass() + { + if (OnPickHomeroomClass is null) return; + var result = await OnPickHomeroomClass(); + if (result is null) return; + _untisSettings.SetHomeroomClass(result.Value.UntisId, result.Value.Name); + LoadUntisSettings(); + } + + [RelayCommand] + private void UntisClearHomeroomClass() + { + _untisSettings.SetHomeroomClass(null, null); + LoadUntisSettings(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Weighting.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Weighting.cs new file mode 100644 index 0000000..6f709f8 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Weighting.cs @@ -0,0 +1,80 @@ +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; + +public partial class SettingsViewModel +{ + // ── Gewichtungsschema-Voreinstellungen (2.3.3) ─────────────────────────── + + [ObservableProperty] private GradingSchemeEditItem _classScheme = null!; + [ObservableProperty] private GradingSchemeEditItem _courseScheme = null!; + + // ── 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); + } +} + +// ── 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."; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 8d77b88..b6fd28e 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -51,6 +51,7 @@ public partial class SettingsViewModel : ObservableObject private readonly IGradingSchemeRepository _gradingSchemes; private readonly GradingService _grading; private readonly BackupService _backups; + private readonly BackupSettingsService _backupSettings; private readonly DatabaseEncryptionService _dbEncryption; private readonly AppLockService _appLock; private readonly LiteDbContext _dbContext; @@ -62,247 +63,6 @@ public partial class SettingsViewModel : ObservableObject [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 = ""; - [ObservableProperty] private string _schoolName = ""; - [ObservableProperty] private string _schoolStreet = ""; - [ObservableProperty] private string _schoolPostalCode = ""; - [ObservableProperty] private string _schoolCity = ""; - [ObservableProperty] private string _schoolNameError = ""; - [ObservableProperty] private string _schoolStreetError = ""; - [ObservableProperty] private string _schoolPostalCodeError = ""; - [ObservableProperty] private string _schoolCityError = ""; - [ObservableProperty] private string _schoolLocationStatus = ""; - [ObservableProperty] private string _resolvedSchoolAddress = ""; - - 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; - [ObservableProperty] private bool _untisApiIsConfigured; - [ObservableProperty] private string _untisSchool = ""; - [ObservableProperty] private string _untisHost = ""; - [ObservableProperty] private string _untisUsername = ""; - [ObservableProperty] private string _untisPassword = ""; - [ObservableProperty] private string _untisApiStatus = ""; - [ObservableProperty] private bool _untisApiBusy; - [ObservableProperty] private string? _untisHomeroomClassName; - public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name - ? $"Ausgewählt: {name}" - : "Keine Klasse ausgewählt."; - partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay)); - public Func? OnReviewUntisMapping { get; set; } - /// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch. - public Func>? OnPickHomeroomClass { 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; @@ -331,7 +91,8 @@ public partial class SettingsViewModel : ObservableObject public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, - GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, + GradingService grading, BackupService backups, BackupSettingsService backupSettings, + DatabaseEncryptionService dbEncryption, AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy, IDocumentationRepository documentation, IStudentRepository students, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, @@ -359,6 +120,7 @@ public partial class SettingsViewModel : ObservableObject _gradingSchemes = gradingSchemes; _grading = grading; _backups = backups; + _backupSettings = backupSettings; _dbEncryption = dbEncryption; _appLock = appLock; _dbContext = dbContext; @@ -407,1580 +169,4 @@ public partial class SettingsViewModel : ObservableObject 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."; - UntisApiIsConfigured = _untisSettings.ApiIsConfigured; - if (_untisSettings.GetApiCredentials() is { } credentials) - { - UntisSchool = credentials.School; - UntisHost = credentials.Host; - UntisUsername = credentials.Username; - UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert."; - } - UntisHomeroomClassName = _untisSettings.HomeroomClassName; - } - - [RelayCommand] - private async Task UntisSaveApi() - { - UntisApiStatus = ""; - if (_untisIntegration is null) - { - UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar."; - return; - } - if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) || - string.IsNullOrWhiteSpace(UntisPassword)) - { - UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich."; - return; - } - UntisApiBusy = true; - try - { - var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(), - UntisUsername.Trim(), UntisPassword); - await _untisIntegration.ConnectAsync(credentials); - _untisSettings.SetApiCredentials(credentials); - UntisPassword = ""; - UntisApiIsConfigured = true; - UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen."; - } - catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; } - finally { UntisApiBusy = false; } - } - - [RelayCommand] - private async Task UntisRemoveApi() - { - UntisApiBusy = true; - try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); } - catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ } - finally - { - _untisSettings.ClearApiCredentials(); - UntisPassword = ""; - UntisApiIsConfigured = false; - UntisApiStatus = "WebUntis-API-Zugang entfernt."; - UntisApiBusy = false; - } - } - - [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(); - } - - [RelayCommand] - private async Task UntisPickHomeroomClass() - { - if (OnPickHomeroomClass is null) return; - var result = await OnPickHomeroomClass(); - if (result is null) return; - _untisSettings.SetHomeroomClass(result.Value.UntisId, result.Value.Name); - LoadUntisSettings(); - } - - [RelayCommand] - private void UntisClearHomeroomClass() - { - _untisSettings.SetHomeroomClass(null, null); - LoadUntisSettings(); - } - - // ── 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)); - - [RelayCommand] - private async Task LoadSchoolLocation() - { - if (_schoolWeather is null) return; - try - { - var location = await _schoolWeather.GetLocationAsync(); - if (location is null) return; - SchoolName = location.SchoolName; - SchoolStreet = location.Street; - SchoolPostalCode = location.PostalCode; - SchoolCity = location.City; - SelectedStateName = GermanStateDisplay.Label(location.State); - ResolvedSchoolAddress = location.ResolvedAddress; - } - catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; } - } - - [RelayCommand] - private async Task SaveSchoolLocation() - { - SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = ""; - SchoolCityError = ""; SchoolLocationStatus = ""; - var valid = true; - if (string.IsNullOrWhiteSpace(SchoolName)) - { SchoolNameError = "Name der Schule erforderlich."; valid = false; } - if (string.IsNullOrWhiteSpace(SchoolStreet)) - { SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; } - if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$")) - { SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; } - if (string.IsNullOrWhiteSpace(SchoolCity)) - { SchoolCityError = "Ort erforderlich."; valid = false; } - if (!valid) return; - if (_schoolWeather is null) - { - SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar."; - return; - } - - SchoolLocationStatus = "Adresse wird geprüft …"; - try - { - var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest - { - SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(), - PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(), - State = GermanStateDisplay.FromLabel(SelectedStateName), - }); - ResolvedSchoolAddress = profile.ResolvedAddress; - SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen."; - } - catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; } - } - - 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; } } diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index ec7095a..88ae2e3 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -619,6 +619,19 @@ + + + + + +