diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 50cce1d..435303e 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -25,6 +25,9 @@ public interface ISeatingPlanRepository List GetByGroup(Guid groupId); void Save(SeatingPlan plan); void Delete(Guid id); + /// Stellt einen über in den Papierkorb verschobenen Sitzplan + /// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert. + void Restore(Guid trashId); } public interface IGroupMembershipRepository { @@ -57,6 +60,8 @@ public interface IGradingKeyTemplateRepository GradingKeyTemplate? GetById(Guid id); void Save(GradingKeyTemplate template); void Delete(Guid id); + /// Siehe ISeatingPlanRepository.Restore (14.3). + void Restore(Guid trashId); } public interface IGradeRepository { @@ -64,6 +69,8 @@ public interface IGradeRepository List GetByGroup(Guid groupId); void Save(Grade grade); void Delete(Guid id); + /// Siehe ISeatingPlanRepository.Restore (14.3). + void Restore(Guid trashId); } public interface IGradingSchemeRepository { @@ -138,6 +145,8 @@ public interface IWorkTaskRepository List GetAll(); void Save(WorkTask task); void Delete(Guid id); + /// Siehe ISeatingPlanRepository.Restore (14.3). + void Restore(Guid trashId); } public interface ITimeEntryRepository { @@ -146,6 +155,8 @@ public interface ITimeEntryRepository List GetByTask(Guid taskId); void Save(TimeEntry entry); void Delete(Guid id); + /// Siehe ISeatingPlanRepository.Restore (14.3). + void Restore(Guid trashId); } public interface IParticipationSessionRepository { @@ -209,3 +220,11 @@ public interface IAlternativeLessonPathRepository void Save(AlternativeLessonPath path); void Delete(Guid id); } +/// Papierkorb (14.3) — reine Übersicht/Aufräumen; das eigentliche Wiederherstellen läuft +/// über die jeweilige Fach-Repository (z.B. IGradeRepository.Restore), da nur die kennt, wie ein +/// Schnappschuss ihres Modells korrekt gespeichert wird (Validierung, OnChange-Ereignis). +public interface ITrashRepository +{ + List GetAll(); + void PurgeOlderThan(DateTime cutoffUtc); +} diff --git a/LehrerApp.Core/Models/Trash.cs b/LehrerApp.Core/Models/Trash.cs new file mode 100644 index 0000000..507a805 --- /dev/null +++ b/LehrerApp.Core/Models/Trash.cs @@ -0,0 +1,21 @@ +namespace LehrerApp.Core.Models; + +/// +/// Papierkorb-Eintrag (14.3): ein gelöschter Datensatz, als vollständiger JSON-Schnappschuss +/// aufbewahrt, damit er wiederhergestellt werden kann. Deckt bewusst nur Löschvorgänge OHNE +/// Kaskade ab (z.B. eine einzelne Note, keine ganze Lerngruppe) — Details siehe +/// LiteDbContext.MoveToTrash/RestoreFromTrash und TODO.md 14.3. +/// +public class TrashedItem +{ + public Guid Id { get; set; } = Guid.NewGuid(); + /// Modellname (nameof(Grade) etc.) — bestimmt, wie der Schnappschuss beim + /// Wiederherstellen deserialisiert und welche Repository-Methode aufgerufen wird. + public string EntityType { get; set; } = ""; + public Guid EntityId { get; set; } + /// Vollständiger JSON-Schnappschuss der gelöschten Entität. + public string Snapshot { get; set; } = ""; + /// Kurze, für die Papierkorb-Liste lesbare Beschreibung (z.B. "Note 2 (12.09.2026)"). + public string Summary { get; set; } = ""; + public DateTime DeletedAt { get; set; } = DateTime.UtcNow; +} diff --git a/LehrerApp.Data.Tests/TrashTests.cs b/LehrerApp.Data.Tests/TrashTests.cs new file mode 100644 index 0000000..6dd6a5e --- /dev/null +++ b/LehrerApp.Data.Tests/TrashTests.cs @@ -0,0 +1,181 @@ +using LehrerApp.Core.Models; +using LehrerApp.Data.Repositories; +using Xunit; + +namespace LehrerApp.Data.Tests; + +public sealed class TrashTests +{ + private static LiteDbContext NewInMemoryContext() => new(new MemoryStream()); + + [Fact] + public void GradeRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var student = new Student { FirstName = "Anna", LastName = "A" }; + db.Students.Insert(student); + var repo = new GradeRepository(db); + var grade = new Grade + { + StudentId = student.Id, GroupId = group.Id, Category = GradeCategory.Oral, + Value = "2", Date = new DateOnly(2026, 3, 4), + }; + repo.Save(grade); + + repo.Delete(grade.Id); + + Assert.Null(db.Grades.FindById(grade.Id)); + var trashed = db.TrashedItems.FindAll().Single(); + Assert.Equal(nameof(Grade), trashed.EntityType); + Assert.Equal("Note 2 (04.03.2026)", trashed.Summary); + + repo.Restore(trashed.Id); + + var restored = db.Grades.FindById(grade.Id); + Assert.NotNull(restored); + Assert.Equal("2", restored!.Value); + Assert.Empty(db.TrashedItems.FindAll()); + } + + [Fact] + public void GradeRepository_RestoreMitUnbekannterTrashIdTutNichts() + { + using var db = NewInMemoryContext(); + var repo = new GradeRepository(db); + + repo.Restore(Guid.NewGuid()); + + Assert.Empty(db.Grades.FindAll()); + } + + [Fact] + public void WorkTaskRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer() + { + using var db = NewInMemoryContext(); + var repo = new WorkTaskRepository(db); + var task = new WorkTask { Title = "Elternbrief schreiben", Category = TaskCategory.Admin }; + repo.Save(task); + + repo.Delete(task.Id); + + Assert.Null(db.Tasks.FindById(task.Id)); + Assert.Equal("Elternbrief schreiben", db.TrashedItems.FindAll().Single().Summary); + + repo.Restore(db.TrashedItems.FindAll().Single().Id); + + Assert.NotNull(db.Tasks.FindById(task.Id)); + } + + [Fact] + public void TimeEntryRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer() + { + using var db = NewInMemoryContext(); + var repo = new TimeEntryRepository(db); + var entry = new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 3, 4), DurationMinutes = 45 }; + repo.Save(entry); + + repo.Delete(entry.Id); + + Assert.Null(db.TimeEntries.FindById(entry.Id)); + Assert.Equal("45 Min. Korrektur (04.03.2026)", db.TrashedItems.FindAll().Single().Summary); + + repo.Restore(db.TrashedItems.FindAll().Single().Id); + + Assert.NotNull(db.TimeEntries.FindById(entry.Id)); + } + + [Fact] + public void SeatingPlanRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var repo = new SeatingPlanRepository(db); + var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Room = "B204", Rows = 2, Columns = 2 }; + repo.Save(plan); + + repo.Delete(plan.Id); + + Assert.Null(db.SeatingPlans.FindById(plan.Id)); + Assert.Equal("Standard (Raum B204)", db.TrashedItems.FindAll().Single().Summary); + + repo.Restore(db.TrashedItems.FindAll().Single().Id); + + Assert.NotNull(db.SeatingPlans.FindById(plan.Id)); + } + + [Fact] + public void SeatingPlanRepository_DeleteOhneRaumNutztNurDenNamenAlsSummary() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var repo = new SeatingPlanRepository(db); + var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 }; + repo.Save(plan); + + repo.Delete(plan.Id); + + Assert.Equal("Standard", db.TrashedItems.FindAll().Single().Summary); + } + + [Fact] + public void GradingKeyTemplateRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer() + { + using var db = NewInMemoryContext(); + var repo = new GradingKeyTemplateRepository(db); + var template = new GradingKeyTemplate { Name = "Standard 1-6", GradingSystem = GradingSystem.Grades1To6 }; + repo.Save(template); + + repo.Delete(template.Id); + + Assert.Null(db.GradingKeyTemplates.FindById(template.Id)); + Assert.Equal("Standard 1-6", db.TrashedItems.FindAll().Single().Summary); + + repo.Restore(db.TrashedItems.FindAll().Single().Id); + + Assert.NotNull(db.GradingKeyTemplates.FindById(template.Id)); + } + + [Fact] + public void TrashRepository_GetAllSortiertNeuesteZuerst() + { + using var db = NewInMemoryContext(); + db.TrashedItems.Insert(new TrashedItem + { + EntityType = nameof(WorkTask), Summary = "Älter", DeletedAt = DateTime.UtcNow.AddDays(-2), + }); + db.TrashedItems.Insert(new TrashedItem + { + EntityType = nameof(WorkTask), Summary = "Neuer", DeletedAt = DateTime.UtcNow, + }); + + var all = new TrashRepository(db).GetAll(); + + Assert.Equal(2, all.Count); + Assert.Equal("Neuer", all[0].Summary); + Assert.Equal("Älter", all[1].Summary); + } + + [Fact] + public void TrashRepository_PurgeOlderThanEntferntNurAeltereEintraege() + { + using var db = NewInMemoryContext(); + db.TrashedItems.Insert(new TrashedItem + { + EntityType = nameof(WorkTask), Summary = "Alt", DeletedAt = DateTime.UtcNow.AddDays(-40), + }); + db.TrashedItems.Insert(new TrashedItem + { + EntityType = nameof(WorkTask), Summary = "Frisch", DeletedAt = DateTime.UtcNow, + }); + + new TrashRepository(db).PurgeOlderThan(DateTime.UtcNow.AddDays(-30)); + + var remaining = db.TrashedItems.FindAll().ToList(); + Assert.Single(remaining); + Assert.Equal("Frisch", remaining[0].Summary); + } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 164c2bd..e32f74d 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -66,6 +66,7 @@ public class LiteDbContext : IDisposable public ILiteCollection SchoolHolidays => _db.GetCollection("school_holidays"); public ILiteCollection SupervisionDuties => _db.GetCollection("supervision_duties"); public ILiteCollection SubstitutionEntries => _db.GetCollection("substitution_entries"); + public ILiteCollection TrashedItems => _db.GetCollection("trash"); public void Checkpoint() => _db.Checkpoint(); @@ -182,6 +183,38 @@ public class LiteDbContext : IDisposable Documentation.Delete(id); } + // ── Papierkorb (14.3) ──────────────────────────────────────────────────── + // + // Absichtlich hier statt in einer eigenen TrashRepository-Konstruktorabhängigkeit: jede + // Fach-Repository hat db (LiteDbContext) bereits im Konstruktor, ein zusätzlicher Papierkorb- + // Parameter wäre nur Boilerplate. Snapshot/Restore sind generisch über den Modelltyp, die + // eigentliche Wiederherstellung ruft aber bewusst die jeweilige Repository.Save-Methode auf + // (nicht direkt die LiteDB-Collection) — nur die kennt Validierung und OnChange-Ereignis für + // ihr Modell. + + /// Legt einen Papierkorb-Eintrag an, BEVOR die aufrufende Repository die Entität aus + /// ihrer eigentlichen Collection löscht. + internal void MoveToTrash(string entityType, Guid entityId, T entity, string summary) => + TrashedItems.Insert(new TrashedItem + { + EntityType = entityType, + EntityId = entityId, + Snapshot = System.Text.Json.JsonSerializer.Serialize(entity), + Summary = summary, + }); + + /// Liefert die gelöschte Entität aus dem Papierkorb-Eintrag zurück und entfernt den + /// Eintrag - unabhängig davon, ob die aufrufende Repository den Schnappschuss danach + /// erfolgreich speichert (ein fehlgeschlagenes Restore soll nicht endlos wiederholbar sein, + /// derselbe Datensatz landet notfalls einfach nicht wieder im Papierkorb). + internal T? RestoreFromTrash(Guid trashId) + { + var item = TrashedItems.FindById(trashId); + if (item is null) return default; + TrashedItems.Delete(trashId); + return System.Text.Json.JsonSerializer.Deserialize(item.Snapshot); + } + internal void ExecuteInTransaction(Action action) { _db.BeginTrans(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 17a7def..4223bae 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -161,11 +161,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository public void Delete(Guid id) { - if (db.SeatingPlans.FindById(id) is { } plan) - ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); + if (db.SeatingPlans.FindById(id) is not { } plan) return; + ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); + db.MoveToTrash(nameof(SeatingPlan), id, plan, + string.IsNullOrWhiteSpace(plan.Room) ? plan.Name : $"{plan.Name} (Raum {plan.Room})"); db.SeatingPlans.Delete(id); db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null); } + + public void Restore(Guid trashId) + { + if (db.RestoreFromTrash(trashId) is { } plan) Save(plan); + } } public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository @@ -260,9 +267,16 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat } public void Delete(Guid id) { + if (db.GradingKeyTemplates.FindById(id) is { } template) + db.MoveToTrash(nameof(GradingKeyTemplate), id, template, template.Name); db.GradingKeyTemplates.Delete(id); db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null); } + + public void Restore(Guid trashId) + { + if (db.RestoreFromTrash(trashId) is { } template) Save(template); + } } public class GradeRepository(LiteDbContext db) : IGradeRepository @@ -279,11 +293,18 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository } public void Delete(Guid id) { - if (db.Grades.FindById(id) is { } grade) - ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); + if (db.Grades.FindById(id) is not { } grade) return; + ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); + db.MoveToTrash(nameof(Grade), id, grade, + $"Note {grade.Value} ({grade.Date:dd.MM.yyyy})"); db.Grades.Delete(id); db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null); } + + public void Restore(Guid trashId) + { + if (db.RestoreFromTrash(trashId) is { } grade) Save(grade); + } } public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository @@ -421,9 +442,16 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository } public void Delete(Guid id) { + if (db.Tasks.FindById(id) is { } task) + db.MoveToTrash(nameof(WorkTask), id, task, task.Title); db.Tasks.Delete(id); db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null); } + + public void Restore(Guid trashId) + { + if (db.RestoreFromTrash(trashId) is { } task) Save(task); + } } public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository @@ -441,9 +469,17 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository } public void Delete(Guid id) { + if (db.TimeEntries.FindById(id) is { } entry) + db.MoveToTrash(nameof(TimeEntry), id, entry, + $"{entry.DurationMinutes} Min. {entry.Category} ({entry.Date:dd.MM.yyyy})"); db.TimeEntries.Delete(id); db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null); } + + public void Restore(Guid trashId) + { + if (db.RestoreFromTrash(trashId) is { } entry) Save(entry); + } } public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository @@ -764,3 +800,18 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep }); } } + +/// Papierkorb (14.3) — bewusst nicht sync-fähig: TrashedItem wird nie über +/// db.OnChange gemeldet, bleibt also rein lokal auf dem Gerät, auf dem gelöscht wurde. Ein +/// "Fehlklick sofort rückgängig machen"-Werkzeug, kein geräteübergreifendes Archiv. +public class TrashRepository(LiteDbContext db) : ITrashRepository +{ + public List GetAll() => + db.TrashedItems.FindAll().OrderByDescending(t => t.DeletedAt).ToList(); + + public void PurgeOlderThan(DateTime cutoffUtc) + { + foreach (var item in db.TrashedItems.Find(t => t.DeletedAt < cutoffUtc).ToList()) + db.TrashedItems.Delete(item.Id); + } +} diff --git a/LehrerApp.Desktop.Tests/AppearanceSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/AppearanceSettingsServiceTests.cs new file mode 100644 index 0000000..40283e6 --- /dev/null +++ b/LehrerApp.Desktop.Tests/AppearanceSettingsServiceTests.cs @@ -0,0 +1,62 @@ +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class AppearanceSettingsServiceTests +{ + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void Load_OhneVorhandeneDatei_LiefertSystemvorgabe() => + Assert.Equal(AppTheme.System, new AppearanceSettingsService(BuildTempPath()).Load()); + + [Theory] + [InlineData(AppTheme.Light)] + [InlineData(AppTheme.Dark)] + [InlineData(AppTheme.System)] + public void SaveUndLoad_PersistiertUeberNeueInstanz(AppTheme theme) + { + var path = BuildTempPath(); + new AppearanceSettingsService(path).Save(theme); + + var reloaded = new AppearanceSettingsService(path).Load(); + + Assert.Equal(theme, reloaded); + } + + [Fact] + public void Load_BeschaedigteDatei_FaelltAufSystemvorgabeZurueck() + { + var path = BuildTempPath(); + File.WriteAllText(Path.Combine(path, "appearancesettings.json"), "{ kein json"); + + Assert.Equal(AppTheme.System, new AppearanceSettingsService(path).Load()); + } +} + +public sealed class AppThemeDisplayTests +{ + [Theory] + [InlineData(AppTheme.System, "Systemvorgabe")] + [InlineData(AppTheme.Light, "Hell")] + [InlineData(AppTheme.Dark, "Dunkel")] + public void Label_LiefertDeutscheBeschriftung(AppTheme theme, string expected) => + Assert.Equal(expected, AppThemeDisplay.Label(theme)); + + [Fact] + public void FromLabel_UndLabel_SindZueinanderInvers() + { + foreach (var theme in Enum.GetValues()) + Assert.Equal(theme, AppThemeDisplay.FromLabel(AppThemeDisplay.Label(theme))); + } + + [Fact] + public void FromLabel_UnbekanntesLabel_LiefertSystemvorgabe() => + Assert.Equal(AppTheme.System, AppThemeDisplay.FromLabel("Nonsens")); +} diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 84b3479..3bcde73 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Sync; using LehrerApp.Sync.Crypto; @@ -69,6 +70,19 @@ public static class TestSupport /// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen. public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated); + /// Neue, leere Fakes je Aufruf - für Tests, die nicht speziell TrashViewModel-Verhalten prüfen. + public static TrashViewModel BuildTrashViewModel() => new( + new FakeTrash(), new FakeGrades(), new FakeWorkTasks(), new FakeTimeEntries(), + new FakeSeatingPlans(), new FakeGradingKeyTemplates()); + + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. + public static AppearanceSettingsService BuildAppearanceSettingsService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new AppearanceSettingsService(tempPath); + } + /// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe /// sync.key stören. public static SyncKeyRecoveryService BuildSyncKeyRecoveryService() @@ -123,6 +137,17 @@ public class FakeSeatingPlans(List? initial = null) : ISeatingPlanR _all.Add(plan); } public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id); + // Trash-Mechanismus (14.3) lebt real in LiteDbContext.MoveToTrash/RestoreFromTrash - Fakes + // bilden ihn bewusst nicht nach, ViewModel-Tests brauchen das nicht. + public void Restore(Guid trashId) { } +} + +public class FakeTrash(List? initial = null) : ITrashRepository +{ + private readonly List _all = initial ?? []; + public void Add(TrashedItem item) => _all.Add(item); + public List GetAll() => _all.OrderByDescending(t => t.DeletedAt).ToList(); + public void PurgeOlderThan(DateTime cutoffUtc) => _all.RemoveAll(t => t.DeletedAt < cutoffUtc); } public class FakeSessions(List all) : IParticipationSessionRepository @@ -190,6 +215,7 @@ public class FakeGrades : IGradeRepository _all.Add(grade); } public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id); + public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore } public class FakeExams(List all) : IExamRepository @@ -227,6 +253,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id); public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); } public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); + public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore } public class FakeSchemes : IGradingSchemeRepository @@ -407,6 +434,7 @@ public class FakeWorkTasks : IWorkTaskRepository public List GetAll() => _all.ToList(); public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); } public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); + public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore } public class FakeTimeEntries : ITimeEntryRepository @@ -419,6 +447,7 @@ public class FakeTimeEntries : ITimeEntryRepository public List GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList(); public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); + public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore } public class FakeReportGrades : IReportGradeRepository diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 7119e7b..94d820a 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -16,7 +16,8 @@ public sealed class SettingsViewModelTests FakeSupervisionDuties? supervisionDuties = null, FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null, EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null, - SyncKeyRecoveryService? syncKeyRecovery = null) + SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null, + TrashViewModel? trashTab = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -36,7 +37,37 @@ public sealed class SettingsViewModelTests TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), - syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService()); + syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService(), + appearance ?? TestSupport.BuildAppearanceSettingsService(), + trashTab ?? TestSupport.BuildTrashViewModel()); + } + + // ── Darstellung (12.4) ──────────────────────────────────────────────────────────────────── + + [Fact] + public void SelectedTheme_SpiegeltDenGespeichertenStandBeimStartWider() + { + var appearance = TestSupport.BuildAppearanceSettingsService(); + appearance.Save(AppTheme.Dark); + + var vm = BuildViewModel(appearance: appearance); + + Assert.Equal(AppTheme.Dark, vm.SelectedTheme); + Assert.Equal("Dunkel", vm.SelectedThemeName); + } + + [Fact] + public void SelectedThemeName_Aendern_PersistiertUndBenachrichtigtDenCodeBehind() + { + var appearance = TestSupport.BuildAppearanceSettingsService(); + var vm = BuildViewModel(appearance: appearance); + AppTheme? notified = null; + vm.OnThemeChanged = t => notified = t; + + vm.SelectedThemeName = "Dunkel"; + + Assert.Equal(AppTheme.Dark, notified); + Assert.Equal(AppTheme.Dark, appearance.Load()); } // ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ──────────────────────────── @@ -259,7 +290,8 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), - TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService()); + TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), + TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); vm.SelectedStateName = "Bayern"; @@ -283,7 +315,8 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), - TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService()); + TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), + TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -311,7 +344,8 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), - TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService()); + TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), + TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop.Tests/TrashViewModelTests.cs b/LehrerApp.Desktop.Tests/TrashViewModelTests.cs new file mode 100644 index 0000000..4543372 --- /dev/null +++ b/LehrerApp.Desktop.Tests/TrashViewModelTests.cs @@ -0,0 +1,99 @@ +using LehrerApp.Core.Models; +using LehrerApp.Data; +using LehrerApp.Data.Repositories; +using LehrerApp.Desktop.ViewModels.Settings; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class TrashViewModelTests +{ + private static TrashViewModel BuildViewModel(LiteDbContext db) => new( + new TrashRepository(db), new GradeRepository(db), new WorkTaskRepository(db), + new TimeEntryRepository(db), new SeatingPlanRepository(db), new GradingKeyTemplateRepository(db)); + + [Fact] + public void Konstruktor_LaedtVorhandenePapierkorbEintraege() + { + using var db = new LiteDbContext(new MemoryStream()); + var taskRepo = new WorkTaskRepository(db); + var task = new WorkTask { Title = "Elternbrief schreiben" }; + taskRepo.Save(task); + taskRepo.Delete(task.Id); + + var vm = BuildViewModel(db); + + Assert.True(vm.HasItems); + var item = Assert.Single(vm.Items); + Assert.Equal("Aufgabe", item.TypeLabel); + Assert.Equal("Elternbrief schreiben", item.Summary); + } + + [Fact] + public void LeererPapierkorb_HasItemsIstFalsch() + { + using var db = new LiteDbContext(new MemoryStream()); + + var vm = BuildViewModel(db); + + Assert.False(vm.HasItems); + Assert.Empty(vm.Items); + } + + [Fact] + public void RestoreCommand_StelltAufgabeWiederHerUndAktualisiertDieListe() + { + using var db = new LiteDbContext(new MemoryStream()); + var taskRepo = new WorkTaskRepository(db); + var task = new WorkTask { Title = "Klausuren korrigieren" }; + taskRepo.Save(task); + taskRepo.Delete(task.Id); + var vm = BuildViewModel(db); + var item = Assert.Single(vm.Items); + + vm.RestoreCommand.Execute(item); + + Assert.NotNull(db.Tasks.FindById(task.Id)); + Assert.False(vm.HasItems); + Assert.Contains("Klausuren korrigieren", vm.Status); + } + + [Fact] + public void RestoreCommand_MitNullTutNichts() + { + using var db = new LiteDbContext(new MemoryStream()); + var vm = BuildViewModel(db); + + vm.RestoreCommand.Execute(null); + + Assert.Equal("", vm.Status); + } + + [Theory] + [InlineData(nameof(Grade), "Note")] + [InlineData(nameof(WorkTask), "Aufgabe")] + [InlineData(nameof(TimeEntry), "Zeiteintrag")] + [InlineData(nameof(SeatingPlan), "Sitzplan")] + [InlineData(nameof(GradingKeyTemplate), "Notenschlüssel-Vorlage")] + public void TypeLabel_UebersetztDenEntityTypeInsDeutsche(string entityType, string expectedLabel) + { + using var db = new LiteDbContext(new MemoryStream()); + db.TrashedItems.Insert(new TrashedItem { EntityType = entityType, Summary = "Testeintrag" }); + + var vm = BuildViewModel(db); + + var item = Assert.Single(vm.Items); + Assert.Equal(expectedLabel, item.TypeLabel); + } + + [Fact] + public void TypeLabel_UnbekannterEntityTypeFaelltAufDenRohenNamenZurueck() + { + using var db = new LiteDbContext(new MemoryStream()); + db.TrashedItems.Insert(new TrashedItem { EntityType = "Sonstiges", Summary = "Testeintrag" }); + + var vm = BuildViewModel(db); + + Assert.Equal("Sonstiges", Assert.Single(vm.Items).TypeLabel); + } +} diff --git a/LehrerApp.Desktop.Tests/WindowSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/WindowSettingsServiceTests.cs new file mode 100644 index 0000000..a3c1051 --- /dev/null +++ b/LehrerApp.Desktop.Tests/WindowSettingsServiceTests.cs @@ -0,0 +1,65 @@ +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class WindowSettingsServiceTests +{ + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-windowsettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void Load_OhneVorhandeneDatei_LiefertStandardgroesse() + { + var service = new WindowSettingsService(BuildTempPath()); + + var settings = service.Load(); + + Assert.Equal(1280, settings.Width); + Assert.Equal(800, settings.Height); + Assert.False(settings.IsMaximized); + } + + [Fact] + public void SaveUndLoad_PersistiertUeberNeueInstanz() + { + var path = BuildTempPath(); + new WindowSettingsService(path).Save(new WindowSettings { Width = 1600, Height = 950, IsMaximized = true }); + + var reloaded = new WindowSettingsService(path).Load(); + + Assert.Equal(1600, reloaded.Width); + Assert.Equal(950, reloaded.Height); + Assert.True(reloaded.IsMaximized); + } + + /// Eine winzige oder negative Größe (z.B. durch eine beschädigte/manuell editierte Datei) + /// darf das Fenster beim nächsten Start nicht unbenutzbar klein machen. + [Fact] + public void Load_UnplausibelKleineGroesse_FaelltAufStandardgroesseZurueck() + { + var path = BuildTempPath(); + new WindowSettingsService(path).Save(new WindowSettings { Width = 5, Height = 5 }); + + var reloaded = new WindowSettingsService(path).Load(); + + Assert.Equal(1280, reloaded.Width); + Assert.Equal(800, reloaded.Height); + } + + [Fact] + public void Load_BeschaedigteDatei_FaelltAufStandardgroesseZurueck() + { + var path = BuildTempPath(); + File.WriteAllText(Path.Combine(path, "windowsettings.json"), "{ kein json"); + + var settings = new WindowSettingsService(path).Load(); + + Assert.Equal(1280, settings.Width); + Assert.Equal(800, settings.Height); + } +} diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 9ba92b6..9e73574 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -1,6 +1,8 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using LehrerApp.Core.Interfaces; using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Desktop.Services; @@ -24,6 +26,13 @@ public class App : Application public override void OnFrameworkInitializationCompleted() { + // Vor jedem Fenster (auch dem DB-Passwort-Prompt) anwenden, damit kein kurzes Aufblitzen + // der Systemvorgabe zu sehen ist, falls Hell/Dunkel manuell erzwungen wurde. Eigenständige + // Instanz statt über den DI-Container (der erst in BuildServices() entsteht, die den + // Passwort-Prompt bereits verzögert) - dieselbe appData-Datei wie später der + // DI-Singleton, rein lesend also unproblematisch doppelt konstruiert. + ApplyTheme(new AppearanceSettingsService(AppBootstrapper.ResolveAppDataPath()).Load()); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank @@ -57,6 +66,10 @@ public class App : Application Services = _serviceProvider; Services.GetRequiredService().Info("Anwendung gestartet."); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService()); + // Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per + // Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein + // soll, kein dauerhaftes Archiv. + Services.GetRequiredService().PurgeOlderThan(DateTime.UtcNow.AddDays(-30)); if (!_exitHandlerAttached) { @@ -67,12 +80,24 @@ public class App : Application var mainVm = Services.GetRequiredService(); WireCallbacks(mainVm); var main = new MainWindow { DataContext = mainVm }; + main.EnableWindowSizePersistence(Services.GetRequiredService()); if (Services.GetService() is { } syncEngine) main.EnableFinalSync(syncEngine); desktop.MainWindow = main; if (showImmediately) main.Show(); } + /// Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den + /// gesamten sichtbaren Baum automatisch neu, sobald sich + /// ändert. + public static void ApplyTheme(AppTheme theme) => + Current!.RequestedThemeVariant = theme switch + { + AppTheme.Light => ThemeVariant.Light, + AppTheme.Dark => ThemeVariant.Dark, + _ => ThemeVariant.Default, + }; + private static void DisposeServices() { if (_serviceProvider is null) return; diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 1b812b8..a6c9d89 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -139,6 +139,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -175,6 +176,8 @@ public static class AppBootstrapper services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData)); + services.AddSingleton(_ => new WindowSettingsService(appData)); + services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(_ => new LetterTemplateService(appData)); services.AddSingleton(); @@ -270,6 +273,7 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); var provider = services.BuildServiceProvider(); diff --git a/LehrerApp.Desktop/Services/AppearanceSettingsService.cs b/LehrerApp.Desktop/Services/AppearanceSettingsService.cs new file mode 100644 index 0000000..bc6f7b1 --- /dev/null +++ b/LehrerApp.Desktop/Services/AppearanceSettingsService.cs @@ -0,0 +1,54 @@ +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +/// Systemvorgabe folgt (bisheriges, +/// unverändertes Verhalten); Hell/Dunkel erzwingen die jeweilige Variante unabhängig vom +/// Betriebssystem. +public enum AppTheme { System, Light, Dark } + +internal sealed class AppearanceSettingsConfig +{ + public AppTheme Theme { get; set; } = AppTheme.System; +} + +/// Merkt sich die gewählte Darstellung (12.4) über Sitzungen hinweg. +public sealed class AppearanceSettingsService +{ + private readonly string _configPath; + + public AppearanceSettingsService(string appDataPath) => + _configPath = Path.Combine(appDataPath, "appearancesettings.json"); + + public AppTheme Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?.Theme ?? AppTheme.System; + } + catch { /* beschädigte Konfiguration -> Systemvorgabe */ } + return AppTheme.System; + } + + public void Save(AppTheme theme) => + File.WriteAllText(_configPath, JsonSerializer.Serialize(new AppearanceSettingsConfig { Theme = theme })); +} + +// ComboBox-Anzeige: deutsche Beschriftung statt des rohen Enum-Namens (etabliertes Muster, siehe +// z.B. NiveauDisplay/GradeCategoryDisplay). +public static class AppThemeDisplay +{ + public static string Label(AppTheme theme) => theme switch + { + AppTheme.Light => "Hell", + AppTheme.Dark => "Dunkel", + _ => "Systemvorgabe", + }; + + public static string[] Options { get; } = Enum.GetValues().Select(Label).ToArray(); + + public static AppTheme FromLabel(string? label) => + Enum.GetValues().FirstOrDefault(t => Label(t) == label, AppTheme.System); +} diff --git a/LehrerApp.Desktop/Services/WindowSettingsService.cs b/LehrerApp.Desktop/Services/WindowSettingsService.cs new file mode 100644 index 0000000..a6474fa --- /dev/null +++ b/LehrerApp.Desktop/Services/WindowSettingsService.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +public sealed class WindowSettings +{ + public double Width { get; set; } = 1280; + public double Height { get; set; } = 800; + public bool IsMaximized { get; set; } +} + +/// +/// Merkt sich Fenstergröße und Maximiert-Status über Sitzungen hinweg (14.6). Bewusst OHNE +/// Fensterposition: ein Laptop, der mal mit, mal ohne externen Monitor läuft, könnte sonst dazu +/// führen, dass das Fenster bei der nächsten Sitzung außerhalb jedes sichtbaren Bildschirms +/// landet — die Größe allein ist der Teil, der täglich beim Korrigieren am Abend stört. +/// +public sealed class WindowSettingsService +{ + private readonly string _configPath; + + public WindowSettingsService(string appDataPath) => + _configPath = Path.Combine(appDataPath, "windowsettings.json"); + + public WindowSettings Load() + { + try + { + if (File.Exists(_configPath)) + { + var loaded = JsonSerializer.Deserialize(File.ReadAllText(_configPath)); + if (loaded is { Width: >= 200, Height: >= 150 }) return loaded; + } + } + catch { /* beschädigte Konfiguration -> Standardgröße */ } + return new WindowSettings(); + } + + public void Save(WindowSettings settings) => + File.WriteAllText(_configPath, JsonSerializer.Serialize(settings)); +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 1583ca4..04c3e98 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -36,6 +36,8 @@ public enum SettingsTab Privacy = 10, Sync = 11, Ai = 12, + Appearance = 13, + Trash = 14, } // ── Haupt-ViewModel ─────────────────────────────────────────────────────────── @@ -237,6 +239,27 @@ public partial class SettingsViewModel : ObservableObject /// gerendert würde. public Func? OnShowPairingError { get; set; } + // ── Darstellung (12.4) ────────────────────────────────────────────────────── + + [ObservableProperty] private AppTheme _selectedTheme; + public string[] ThemeOptions { get; } = AppThemeDisplay.Options; + public string SelectedThemeName + { + get => AppThemeDisplay.Label(SelectedTheme); + set => SelectedTheme = AppThemeDisplay.FromLabel(value); + } + + /// Vom Code-Behind gesetzt: wendet die Theme-Variante sofort an (Application.Current, siehe + /// App.ApplyTheme) — ViewModels fassen Avalonia-Framework-Typen nicht direkt an, gleiches + /// Muster wie die übrigen Code-Behind-Hooks in dieser Klasse. + public Action? OnThemeChanged { get; set; } + + partial void OnSelectedThemeChanged(AppTheme value) + { + _appearance.Save(value); + OnThemeChanged?.Invoke(value); + } + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; @@ -251,9 +274,12 @@ public partial class SettingsViewModel : ObservableObject private readonly SyncEngine? _syncEngine; private readonly SnapshotService? _snapshotService; private readonly SyncKeyRecoveryService _syncKeyRecovery; + private readonly AppearanceSettingsService _appearance; private readonly CompetencyCatalogImportService _catalogImport; private readonly AppLogger _logger; + public TrashViewModel TrashTab { get; } + public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, @@ -265,11 +291,15 @@ public partial class SettingsViewModel : ObservableObject AiSettingsService aiSettings, AiPlanningService aiPlanning, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, + AppearanceSettingsService appearance, TrashViewModel trashTab, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null) { _logger = logger; _syncKeyRecovery = syncKeyRecovery; SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated; + _appearance = appearance; + _selectedTheme = appearance.Load(); + TrashTab = trashTab; _subjects = subjects; _domainRepo = domainRepo; _gradingKeyTemplates = gradingKeyTemplates; diff --git a/LehrerApp.Desktop/ViewModels/Settings/TrashViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/TrashViewModel.cs new file mode 100644 index 0000000..f024031 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/TrashViewModel.cs @@ -0,0 +1,100 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Settings; + +/// +/// Papierkorb (14.3): zeigt gelöschte Datensätze der abgedeckten Modelle (siehe +/// TODO.md 14.3 für die vollständige Liste und die bewusst ausgeschlossenen, kaskadierenden +/// Löschvorgänge wie eine ganze Lerngruppe) und erlaubt das Wiederherstellen. Rein lokal — der +/// Papierkorb selbst wird nicht synchronisiert (siehe TrashRepository-Klassenkommentar); +/// wiederhergestellte Einträge synchronisieren sich danach ganz normal wie jede andere Änderung. +/// +public partial class TrashViewModel : ObservableObject +{ + private readonly ITrashRepository _trash; + private readonly IGradeRepository _grades; + private readonly IWorkTaskRepository _tasks; + private readonly ITimeEntryRepository _timeEntries; + private readonly ISeatingPlanRepository _seatingPlans; + private readonly IGradingKeyTemplateRepository _gradingKeyTemplates; + + [ObservableProperty] private string _status = ""; + + public ObservableCollection Items { get; } = []; + public bool HasItems => Items.Count > 0; + + public TrashViewModel(ITrashRepository trash, IGradeRepository grades, IWorkTaskRepository tasks, + ITimeEntryRepository timeEntries, ISeatingPlanRepository seatingPlans, + IGradingKeyTemplateRepository gradingKeyTemplates) + { + _trash = trash; + _grades = grades; + _tasks = tasks; + _timeEntries = timeEntries; + _seatingPlans = seatingPlans; + _gradingKeyTemplates = gradingKeyTemplates; + Load(); + } + + public void Load() + { + Items.Clear(); + foreach (var item in _trash.GetAll()) + Items.Add(new TrashItemViewModel(item, TypeLabel(item.EntityType))); + OnPropertyChanged(nameof(HasItems)); + } + + [RelayCommand] + private void Restore(TrashItemViewModel? item) + { + if (item is null) return; + switch (item.EntityType) + { + case nameof(Grade): _grades.Restore(item.Id); break; + case nameof(WorkTask): _tasks.Restore(item.Id); break; + case nameof(TimeEntry): _timeEntries.Restore(item.Id); break; + case nameof(SeatingPlan): _seatingPlans.Restore(item.Id); break; + case nameof(GradingKeyTemplate): _gradingKeyTemplates.Restore(item.Id); break; + default: return; // Unbekannter Typ (sollte nicht vorkommen) - Eintrag unverändert lassen. + } + Status = $"„{item.Summary}“ wiederhergestellt."; + Load(); + } + + private static string TypeLabel(string entityType) => entityType switch + { + nameof(Grade) => "Note", + nameof(WorkTask) => "Aufgabe", + nameof(TimeEntry) => "Zeiteintrag", + nameof(SeatingPlan) => "Sitzplan", + nameof(GradingKeyTemplate) => "Notenschlüssel-Vorlage", + _ => entityType, + }; +} + +public class TrashItemViewModel +{ + private const int RetentionDays = 30; + + public Guid Id { get; } + public string EntityType { get; } + public string TypeLabel { get; } + public string Summary { get; } + public string DeletedAtDisplay { get; } + public string ExpiresInDisplay { get; } + + public TrashItemViewModel(TrashedItem item, string typeLabel) + { + Id = item.Id; + EntityType = item.EntityType; + TypeLabel = typeLabel; + Summary = item.Summary; + DeletedAtDisplay = item.DeletedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"); + var daysLeft = RetentionDays - (DateTime.UtcNow - item.DeletedAt).Days; + ExpiresInDisplay = daysLeft <= 0 ? "läuft heute ab" : $"noch {daysLeft} Tag(e)"; + } +} diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml.cs b/LehrerApp.Desktop/Views/MainWindow.axaml.cs index 33ebaa8..ebc28f5 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml.cs +++ b/LehrerApp.Desktop/Views/MainWindow.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Input; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Sync; @@ -26,6 +27,32 @@ public partial class MainWindow : Window Closing += OnClosing; } + /// Wendet die zuletzt gespeicherte Fenstergröße an und merkt sich die aktuelle beim + /// Schließen (14.6). Eigenständig von verdrahtet (mehrere + /// Closing-Handler sind unproblematisch) und immer aktiv, auch ohne konfigurierten + /// Sync-Server. + public void EnableWindowSizePersistence(WindowSettingsService settings) + { + var saved = settings.Load(); + Width = saved.Width; + Height = saved.Height; + if (saved.IsMaximized) WindowState = WindowState.Maximized; + + Closing += (_, _) => + { + var current = settings.Load(); + settings.Save(new WindowSettings + { + IsMaximized = WindowState == WindowState.Maximized, + // Im maximierten Zustand spiegeln Width/Height die Bildschirmgröße wider, nicht + // die zuletzt genutzte Normalgröße - dann den vorherigen Wert beibehalten statt + // ihn zu überschreiben. + Width = WindowState == WindowState.Normal ? Width : current.Width, + Height = WindowState == WindowState.Normal ? Height : current.Height, + }); + }; + } + private async void OnClosing(object? sender, WindowClosingEventArgs e) { if (_closeAfterFinalSync || _finalSyncStarted || _syncEngine is null) diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index b096634..f8b0426 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -920,6 +920,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +