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 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
index 3ef74aa..52ecb9d 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
@@ -34,6 +34,7 @@ public partial class SettingsView : UserControl
vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
+ vm.OnPickBackupDirectory = PickBackupDirectoryAsync;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
}
}
@@ -84,6 +85,19 @@ public partial class SettingsView : UserControl
return true;
}
+ private async Task PickBackupDirectoryAsync()
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null) return null;
+
+ var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Zweiten Sicherungsordner wählen",
+ AllowMultiple = false,
+ });
+ return folders.Count == 0 ? null : folders[0].Path.LocalPath;
+ }
+
private async Task PickRecoveryFile()
{
var topLevel = TopLevel.GetTopLevel(this);
diff --git a/LehrerApp.Tests/BackupServiceTests.cs b/LehrerApp.Tests/BackupServiceTests.cs
index 491c4f1..e29bc3d 100644
--- a/LehrerApp.Tests/BackupServiceTests.cs
+++ b/LehrerApp.Tests/BackupServiceTests.cs
@@ -92,6 +92,62 @@ public sealed class BackupServiceTests
service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
}
+ [Fact]
+ public void CreateBackup_MitSecondaryDirectory_SpiegeltDasBackupDorthin()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ File.WriteAllText(dbPath, "Testinhalt");
+ var secondaryDir = Path.Combine(temp.Path, "secondary");
+ var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
+
+ var backupPath = service.CreateBackup(dbPath);
+
+ Assert.NotNull(backupPath);
+ var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backupPath!));
+ Assert.True(File.Exists(mirrored));
+ Assert.Equal("Testinhalt", File.ReadAllText(mirrored));
+ }
+
+ [Fact]
+ public void CreateBackup_SecondaryDirectoryNichtErreichbar_HauptbackupBleibtErhalten()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ File.WriteAllText(dbPath, "Testinhalt");
+ // Eine gewöhnliche Datei blockiert den Pfad, sodass darunter kein Verzeichnis angelegt
+ // werden kann — simuliert plattformunabhängig ein nicht eingestecktes USB-Ziel bzw. eine
+ // nicht erreichbare Netzwerkfreigabe.
+ var blockingFile = Path.Combine(temp.Path, "blockiert");
+ File.WriteAllText(blockingFile, "x");
+ var unreachable = Path.Combine(blockingFile, "secondary");
+ var service = new BackupService(temp.Path) { SecondaryBackupDirectory = unreachable };
+
+ var backupPath = service.CreateBackup(dbPath);
+
+ Assert.NotNull(backupPath);
+ Assert.True(File.Exists(backupPath));
+ }
+
+ [Fact]
+ public void CreateBackup_MitSecondaryDirectory_BehaeltNurDieLetztenNAuchDort()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ var secondaryDir = Path.Combine(temp.Path, "secondary");
+ var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
+
+ for (var i = 0; i < 5; i++)
+ {
+ File.WriteAllText(dbPath, $"v{i}");
+ var backup = service.CreateBackup(dbPath, keepCount: 3)!;
+ var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backup));
+ File.SetLastWriteTime(mirrored, DateTime.Now.AddMinutes(-5 + i));
+ }
+
+ Assert.Equal(3, Directory.GetFiles(secondaryDir, "lehrerapp-*.db").Length);
+ }
+
private sealed class TempAppData : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
diff --git a/LehrerApp.sln b/LehrerApp.sln
index 9b71d4f..e6a0bb1 100644
--- a/LehrerApp.sln
+++ b/LehrerApp.sln
@@ -40,6 +40,8 @@ Global
{A1000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU
@@ -50,6 +52,8 @@ Global
{A1000002-0000-0000-0000-000000000002}.Debug|x64.Build.0 = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x64.ActiveCfg = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x64.Build.0 = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x86.ActiveCfg = Release|Any CPU
@@ -60,6 +64,8 @@ Global
{A1000003-0000-0000-0000-000000000003}.Debug|x64.Build.0 = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x64.ActiveCfg = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU
@@ -70,6 +76,8 @@ Global
{A1000004-0000-0000-0000-000000000004}.Debug|x64.Build.0 = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x64.ActiveCfg = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x64.Build.0 = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x86.ActiveCfg = Release|Any CPU
@@ -80,6 +88,8 @@ Global
{A1000005-0000-0000-0000-000000000005}.Debug|x64.Build.0 = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1000005-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000005-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x64.ActiveCfg = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x64.Build.0 = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x86.ActiveCfg = Release|Any CPU
@@ -90,6 +100,8 @@ Global
{A1000006-0000-0000-0000-000000000006}.Debug|x64.Build.0 = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1000006-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000006-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x64.ActiveCfg = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x64.Build.0 = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x86.ActiveCfg = Release|Any CPU