using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; using System.Collections.ObjectModel; using System.Text.Json; using System.Text.Json.Serialization; namespace LehrerApp.Desktop.ViewModels.Settings; // ── Haupt-ViewModel ─────────────────────────────────────────────────────────── public partial class SettingsViewModel : ObservableObject { private readonly ISubjectRepository _subjects; private readonly ICompetencyDomainRepository _domainRepo; private readonly IGradingKeyTemplateRepository _gradingKeyTemplates; private readonly IGradingSchemeRepository _gradingSchemes; private readonly GradingService _grading; private readonly BackupService _backups; private readonly DatabaseEncryptionService _dbEncryption; private readonly AppLockService _appLock; private readonly LiteDbContext _dbContext; private readonly PrivacySettingsService _privacy; private readonly IDocumentationRepository _documentation; private readonly IStudentRepository _students; // ── Fächer ──────────────────────────────────────────────────────────────── [ObservableProperty] private string _newName = ""; [ObservableProperty] private string _newShort = ""; [ObservableProperty] private string _newNameError = ""; public ObservableCollection Subjects { 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; } = []; // ── 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; } // ── Konstruktor ─────────────────────────────────────────────────────────── public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy, IDocumentationRepository documentation, IStudentRepository students) { _subjects = subjects; _domainRepo = domainRepo; _gradingKeyTemplates = gradingKeyTemplates; _gradingSchemes = gradingSchemes; _grading = grading; _backups = backups; _dbEncryption = dbEncryption; _appLock = appLock; _dbContext = dbContext; _privacy = privacy; _documentation = documentation; _students = students; LoadSubjects(); LoadGradingKeyTemplates(); LoadGradingSchemes(); LoadBackups(); IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath); AppLockEnabled = _appLock.IsEnabled; AppLockTimeoutMinutes = _appLock.TimeoutMinutes; RetentionYears = _privacy.RetentionYears; LoadExpiredDocuments(); } // ── 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(); } // ── 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(new DomainEditItem(d, _domainRepo)); } // ── 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(new DomainEditItem(domain, _domainRepo)); NewDomainName = ""; NewDomainCode = ""; CatalogValidation = ""; } [RelayCommand] private void DeleteDomain(DomainEditItem? item) { if (item is null) return; _domainRepo.Delete(item.Id); Domains.Remove(item); } // ── JSON Import / Export ────────────────────────────────────────────────── public void ImportCatalog(string json) { if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; } try { var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var dto = JsonSerializer.Deserialize(json, opts); if (dto?.Domains is null) { CatalogValidation = "Ungültiges JSON-Format."; return; } _domainRepo.DeleteBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel); for (int i = 0; i < dto.Domains.Count; i++) { var d = dto.Domains[i]; var domain = new CompetencyDomain { SubjectId = CatalogSubject.Id, GradeLevel = CatalogGradeLevel, Name = d.Name ?? "", Code = d.Code ?? "", SortOrder = i, Items = (d.Competencies ?? []) .Select((c, j) => new CompetencyItem { Code = c.Code ?? "", Description = c.Description ?? "", SortOrder = j, }).ToList(), }; _domainRepo.Save(domain); } CatalogValidation = ""; LoadCatalog(); } catch { CatalogValidation = "Import fehlgeschlagen – bitte JSON-Format prüfen."; } } public string ExportCatalog() { var dto = new CatalogDto { Subject = CatalogSubject?.Name ?? "", GradeLevel = CatalogGradeLevel, 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 DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo) { _domain = domain; _repo = repo; Id = domain.Id; Name = domain.Name; Code = domain.Code; DisplayName = string.IsNullOrEmpty(domain.Code) ? domain.Name : $"{domain.Name} ({domain.Code})"; foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) Items.Add(new CompetencyItemVm(item, DeleteItem)); } [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(new CompetencyItemVm(item, DeleteItem)); NewItemCode = ""; NewItemDesc = ""; } private void DeleteItem(CompetencyItemVm vm) { _domain.Items.RemoveAll(i => i.Id == vm.ItemId); _repo.Save(_domain); Items.Remove(vm); } } // ── CompetencyItemVm ────────────────────────────────────────────────────────── public class CompetencyItemVm { public Guid ItemId { get; } public string Code { get; } public string Description { get; } public string Display { get; } public IRelayCommand DeleteCommand { get; } public CompetencyItemVm(CompetencyItem item, Action onDelete) { 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)); } } // ── 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 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 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"); } // ── 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; } }