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:
@@ -0,0 +1,24 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore LehrerApp.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build LehrerApp.sln --no-restore --configuration Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||
<NoWarn>CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -225,6 +225,9 @@ public interface IParticipationSessionRepository
|
||||
{
|
||||
List<ParticipationSession> GetByGroup(Guid groupId);
|
||||
ParticipationSession? GetById(Guid id);
|
||||
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
|
||||
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
|
||||
List<ParticipationSession> GetAll();
|
||||
void Save(ParticipationSession session);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ public class BackupService
|
||||
|
||||
public string BackupDirectory => _backupDirectory;
|
||||
|
||||
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
|
||||
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
|
||||
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
|
||||
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
|
||||
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
|
||||
public string? SecondaryBackupDirectory { get; set; }
|
||||
|
||||
public BackupService(string appDataPath)
|
||||
{
|
||||
_backupDirectory = Path.Combine(appDataPath, "backups");
|
||||
@@ -34,9 +41,36 @@ public class BackupService
|
||||
File.Copy(databasePath, target, overwrite: true);
|
||||
|
||||
PruneOldBackups(keepCount);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
|
||||
TryMirrorToSecondary(target, fileName, keepCount);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SecondaryBackupDirectory!);
|
||||
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
|
||||
|
||||
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
|
||||
.Select(p => new FileInfo(p))
|
||||
.OrderByDescending(f => f.LastWriteTime)
|
||||
.Skip(keepCount))
|
||||
{
|
||||
try { stale.Delete(); }
|
||||
catch { /* nächster Lauf versucht es erneut */ }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
|
||||
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
|
||||
}
|
||||
}
|
||||
|
||||
public List<BackupInfo> ListBackups() =>
|
||||
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
||||
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
||||
|
||||
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
|
||||
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(temp.Path))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
|
||||
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
|
||||
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
|
||||
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
|
||||
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
private sealed class TempDatabase : IDisposable
|
||||
{
|
||||
private readonly string _directory = System.IO.Path.Combine(
|
||||
|
||||
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
|
||||
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
|
||||
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
|
||||
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
|
||||
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
|
||||
public bool CanOpenAndRead(string dbPath, string? password)
|
||||
{
|
||||
if (!File.Exists(dbPath)) return false;
|
||||
try
|
||||
{
|
||||
var connection = password is null
|
||||
? new ConnectionString(dbPath)
|
||||
: new ConnectionString(dbPath) { Password = password };
|
||||
using var db = new LiteDatabase(connection);
|
||||
_ = db.GetCollectionNames().ToList();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
||||
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
||||
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
||||
|
||||
@@ -492,6 +492,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
||||
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
|
||||
public void Save(ParticipationSession s)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||
|
||||
@@ -170,6 +170,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
|
||||
{
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||
public List<ParticipationSession> GetAll() => all.ToList();
|
||||
public void Save(ParticipationSession session)
|
||||
{
|
||||
all.RemoveAll(s => s.Id == session.Id);
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class SettingsViewModelTests
|
||||
return new SettingsViewModel(
|
||||
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
@@ -314,6 +315,7 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
@@ -341,6 +343,7 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
@@ -372,6 +375,7 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
|
||||
@@ -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 1–6";
|
||||
[ObservableProperty] private string _newTemplateNameError = "";
|
||||
|
||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||
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 0–15"
|
||||
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
||||
var defaults = system == GradingSystem.Grades1To6
|
||||
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
|
||||
|
||||
var template = new GradingKeyTemplate
|
||||
{
|
||||
Name = NewTemplateName.Trim(),
|
||||
GradingSystem = system,
|
||||
Entries = defaults.Select(e => new GradingKeyEntry { Grade = e.Grade, MinPercent = e.MinPercent }).ToList(),
|
||||
};
|
||||
_gradingKeyTemplates.Save(template);
|
||||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
|
||||
NewTemplateName = "";
|
||||
NewTemplateNameError = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteGradingKeyTemplate(GradingKeyTemplateEditItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_gradingKeyTemplates.Delete(item.Id);
|
||||
GradingKeyTemplateList.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class GradingKeyTemplateEditItem : ObservableObject
|
||||
{
|
||||
private readonly GradingKeyTemplate _template;
|
||||
private readonly IGradingKeyTemplateRepository _repo;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string GradingSystemLabel { get; }
|
||||
|
||||
[ObservableProperty] private string _newGrade = "";
|
||||
[ObservableProperty] private double _newMinPercent;
|
||||
[ObservableProperty] private string _newGradeError = "";
|
||||
[ObservableProperty] private string _newMinPercentError = "";
|
||||
[ObservableProperty] private string _completenessWarning = "";
|
||||
|
||||
public ObservableCollection<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 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<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 (Mo–Fr, für Aufsichten) ────────────────────────
|
||||
|
||||
public static class WeekdayDisplay
|
||||
{
|
||||
private static readonly (DayOfWeek Day, string Name)[] Entries =
|
||||
[
|
||||
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
|
||||
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
|
||||
];
|
||||
|
||||
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
|
||||
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
|
||||
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -92,6 +92,62 @@ public sealed class BackupServiceTests
|
||||
service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBackup_MitSecondaryDirectory_SpiegeltDasBackupDorthin()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||
File.WriteAllText(dbPath, "Testinhalt");
|
||||
var secondaryDir = Path.Combine(temp.Path, "secondary");
|
||||
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
|
||||
|
||||
var backupPath = service.CreateBackup(dbPath);
|
||||
|
||||
Assert.NotNull(backupPath);
|
||||
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backupPath!));
|
||||
Assert.True(File.Exists(mirrored));
|
||||
Assert.Equal("Testinhalt", File.ReadAllText(mirrored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBackup_SecondaryDirectoryNichtErreichbar_HauptbackupBleibtErhalten()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||
File.WriteAllText(dbPath, "Testinhalt");
|
||||
// Eine gewöhnliche Datei blockiert den Pfad, sodass darunter kein Verzeichnis angelegt
|
||||
// werden kann — simuliert plattformunabhängig ein nicht eingestecktes USB-Ziel bzw. eine
|
||||
// nicht erreichbare Netzwerkfreigabe.
|
||||
var blockingFile = Path.Combine(temp.Path, "blockiert");
|
||||
File.WriteAllText(blockingFile, "x");
|
||||
var unreachable = Path.Combine(blockingFile, "secondary");
|
||||
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = unreachable };
|
||||
|
||||
var backupPath = service.CreateBackup(dbPath);
|
||||
|
||||
Assert.NotNull(backupPath);
|
||||
Assert.True(File.Exists(backupPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBackup_MitSecondaryDirectory_BehaeltNurDieLetztenNAuchDort()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||
var secondaryDir = Path.Combine(temp.Path, "secondary");
|
||||
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
File.WriteAllText(dbPath, $"v{i}");
|
||||
var backup = service.CreateBackup(dbPath, keepCount: 3)!;
|
||||
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backup));
|
||||
File.SetLastWriteTime(mirrored, DateTime.Now.AddMinutes(-5 + i));
|
||||
}
|
||||
|
||||
Assert.Equal(3, Directory.GetFiles(secondaryDir, "lehrerapp-*.db").Length);
|
||||
}
|
||||
|
||||
private sealed class TempAppData : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
|
||||
@@ -40,6 +40,8 @@ Global
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -50,6 +52,8 @@ Global
|
||||
{A1000002-0000-0000-0000-000000000002}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -60,6 +64,8 @@ Global
|
||||
{A1000003-0000-0000-0000-000000000003}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -70,6 +76,8 @@ Global
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -80,6 +88,8 @@ Global
|
||||
{A1000005-0000-0000-0000-000000000005}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -90,6 +100,8 @@ Global
|
||||
{A1000006-0000-0000-0000-000000000006}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
|
||||
Reference in New Issue
Block a user