Dashboard-Performance, CI-Workflow, Settings-Aufteilung, Backup-Härtung

- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab
  statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden);
  neues IParticipationSessionRepository.GetAll() dafür.
- CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR.
  Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der
  .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der
  Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors
  jetzt aktiv.
- SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen-
  dateien aufgeteilt, Verhalten unverändert.
- Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk,
  best-effort) und Integritätsprüfung nach jedem Backup
  (DatabaseEncryptionService.CanOpenAndRead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:52:44 +02:00
co-authored by Claude Sonnet 5
parent dbd8777e64
commit 2b299fc940
37 changed files with 2547 additions and 1823 deletions
+8 -2
View File
@@ -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<DatabaseEncryptionService>();
services.AddSingleton(_ => new PrivacySettingsService(appData));
@@ -0,0 +1,36 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal sealed class BackupSettingsConfig
{
public string? SecondaryDirectory { get; set; }
}
/// <summary>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 <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
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<BackupSettingsConfig>(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 }));
}
@@ -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<AttendanceWarningItem>();
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));
@@ -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 = "";
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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<AppTheme>? OnThemeChanged { get; set; }
partial void OnSelectedThemeChanged(AppTheme value)
{
_appearance.Save(value);
OnThemeChanged?.Invoke(value);
}
}
@@ -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<BackupListItem> Backups { get; } = [];
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
public Func<BackupListItem, Task<bool>>? OnConfirmRestore { get; set; }
/// Vom Code-Behind gesetzt: öffnet einen Ordnerauswahl-Dialog für das zweite Sicherungsziel.
public Func<Task<string?>>? 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";
}
@@ -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<DomainEditItem> 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<CompetencyItemVm> Items { get; } = [];
public IRelayCommand MoveUpCommand { get; }
public IRelayCommand MoveDownCommand { get; }
public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo,
Action<DomainEditItem>? onMoveUp = null, Action<DomainEditItem>? 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<CompetencyItemVm> onDelete,
Action<CompetencyItemVm>? onMoveUp = null, Action<CompetencyItemVm>? 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) ─────────────────────────────────────────
@@ -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<Task<bool>>? 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<string, Task>? 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();
}
}
@@ -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();
}
}
@@ -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 16";
[ObservableProperty] private string _newTemplateNameError = "";
public List<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"];
public ObservableCollection<GradingKeyTemplateEditItem> 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 015"
? 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<GradingKeyEntryVm> 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 16" : "Punkte 015";
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<GradingKeyEntryVm> onDelete)
{
Grade = e.Grade;
MinPercent = e.MinPercent;
Display = $"{Grade} — ab {MinPercent.ToString("0.##")} %";
DeleteCommand = new RelayCommand(() => onDelete(this));
}
}
@@ -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<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection<SchoolHolidayItem> 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}";
}
@@ -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<string> 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<DomainDto>? Domains { get; set; }
}
internal class DomainDto
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("competencies")] public List<CompetencyDto>? Competencies { get; set; }
}
internal class CompetencyDto
{
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}
@@ -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<LetterTemplateListItem> LetterTemplateList { get; } = [];
public IReadOnlyList<LetterPlaceholder> 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<LetterTemplateIssueItem> 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",
};
}
@@ -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<PeriodTimeEditItem> 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<PeriodTimeEntry>();
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") ?? "";
}
}
@@ -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<ExpiredDocumentItem> ExpiredDocuments { get; } = [];
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
public Func<ExpiredDocumentItem, Task<bool>>? 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");
}
@@ -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<ShorthandCodeListItem> 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;
}
@@ -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<SubjectListItem> 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;
}
@@ -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<SupervisionDutyItem> 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 (MoFr, 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;
}
@@ -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<string, Task<bool>>? OnSaveRecoveryFile { get; set; }
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
/// auswählen. Liefert null bei Abbruch.
public Func<Task<string?>>? 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<Task<bool>>? OnConfirmRecoveryRestore { get; set; }
public ObservableCollection<SyncConflictListItem> 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();
}
/// <summary>
/// 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.
/// </summary>
[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,
};
}
@@ -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<Task>? OnReviewUntisMapping { get; set; }
/// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch.
public Func<Task<(int UntisId, string Name)?>>? 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();
}
}
@@ -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.";
}
}
File diff suppressed because it is too large Load Diff
@@ -619,6 +619,19 @@
<TextBlock Text="{Binding BackupStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding BackupStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="6" Margin="0,6,0,0">
<TextBlock Text="Zweiter Sicherungsordner (optional)" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="Zusätzliches Ziel für jedes Backup, z. B. ein USB-Stick oder Netzlaufwerk — ist es beim Sichern nicht erreichbar, bleibt das Hauptbackup davon unberührt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SecondaryBackupDirectory}" FontSize="12"
IsVisible="{Binding SecondaryBackupDirectory, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Ordner wählen…" Command="{Binding PickBackupDirectoryCommand}"/>
<Button Content="Entfernen" Command="{Binding ClearBackupDirectoryCommand}"
IsVisible="{Binding SecondaryBackupDirectory, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
<ItemsControl ItemsSource="{Binding Backups}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:BackupListItem">
@@ -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<string?> 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<string?> PickRecoveryFile()
{
var topLevel = TopLevel.GetTopLevel(this);