feat: Dunkelmodus, Fenstergröße merken, Papierkorb für Löschvorgänge (12.4, 14.3, 14.6)

Dunkelmodus über neuen Einstellungen-Tab "Darstellung" (Systemvorgabe/Hell/Dunkel),
Fenstergröße/Maximiert-Status wird über Sitzungen hinweg gemerkt (bewusst ohne
Fensterposition), und ein generischer Snapshot-basierter Papierkorb (30 Tage) für
Sitzpläne, Noten, Notenschlüssel-Vorlagen, Aufgaben und Zeiteinträge. Details und
bewusste Scope-Entscheidungen (Spaltenbreiten zurückgestellt, Farb-Audit für
Dunkelmodus offen, welche Entitäten der Papierkorb abdeckt) in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:26:10 +02:00
co-authored by Claude Sonnet 5
parent d9d007cd4b
commit 331033db5c
20 changed files with 1023 additions and 12 deletions
@@ -25,6 +25,9 @@ public interface ISeatingPlanRepository
List<SeatingPlan> GetByGroup(Guid groupId); List<SeatingPlan> GetByGroup(Guid groupId);
void Save(SeatingPlan plan); void Save(SeatingPlan plan);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Stellt einen über <see cref="Delete"/> in den Papierkorb verschobenen Sitzplan
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
void Restore(Guid trashId);
} }
public interface IGroupMembershipRepository public interface IGroupMembershipRepository
{ {
@@ -57,6 +60,8 @@ public interface IGradingKeyTemplateRepository
GradingKeyTemplate? GetById(Guid id); GradingKeyTemplate? GetById(Guid id);
void Save(GradingKeyTemplate template); void Save(GradingKeyTemplate template);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface IGradeRepository public interface IGradeRepository
{ {
@@ -64,6 +69,8 @@ public interface IGradeRepository
List<Grade> GetByGroup(Guid groupId); List<Grade> GetByGroup(Guid groupId);
void Save(Grade grade); void Save(Grade grade);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface IGradingSchemeRepository public interface IGradingSchemeRepository
{ {
@@ -138,6 +145,8 @@ public interface IWorkTaskRepository
List<WorkTask> GetAll(); List<WorkTask> GetAll();
void Save(WorkTask task); void Save(WorkTask task);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface ITimeEntryRepository public interface ITimeEntryRepository
{ {
@@ -146,6 +155,8 @@ public interface ITimeEntryRepository
List<TimeEntry> GetByTask(Guid taskId); List<TimeEntry> GetByTask(Guid taskId);
void Save(TimeEntry entry); void Save(TimeEntry entry);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface IParticipationSessionRepository public interface IParticipationSessionRepository
{ {
@@ -209,3 +220,11 @@ public interface IAlternativeLessonPathRepository
void Save(AlternativeLessonPath path); void Save(AlternativeLessonPath path);
void Delete(Guid id); void Delete(Guid id);
} }
/// <summary>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).</summary>
public interface ITrashRepository
{
List<TrashedItem> GetAll();
void PurgeOlderThan(DateTime cutoffUtc);
}
+21
View File
@@ -0,0 +1,21 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// 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
/// <c>LiteDbContext.MoveToTrash</c>/<c>RestoreFromTrash</c> und TODO.md 14.3.
/// </summary>
public class TrashedItem
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Modellname (<c>nameof(Grade)</c> etc.) — bestimmt, wie der Schnappschuss beim
/// Wiederherstellen deserialisiert und welche Repository-Methode aufgerufen wird.</summary>
public string EntityType { get; set; } = "";
public Guid EntityId { get; set; }
/// <summary>Vollständiger JSON-Schnappschuss der gelöschten Entität.</summary>
public string Snapshot { get; set; } = "";
/// <summary>Kurze, für die Papierkorb-Liste lesbare Beschreibung (z.B. "Note 2 (12.09.2026)").</summary>
public string Summary { get; set; } = "";
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;
}
+181
View File
@@ -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);
}
}
+33
View File
@@ -66,6 +66,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays"); public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties"); public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries"); public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
public void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
@@ -182,6 +183,38 @@ public class LiteDbContext : IDisposable
Documentation.Delete(id); 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.
/// <summary>Legt einen Papierkorb-Eintrag an, BEVOR die aufrufende Repository die Entität aus
/// ihrer eigentlichen Collection löscht.</summary>
internal void MoveToTrash<T>(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,
});
/// <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).</summary>
internal T? RestoreFromTrash<T>(Guid trashId)
{
var item = TrashedItems.FindById(trashId);
if (item is null) return default;
TrashedItems.Delete(trashId);
return System.Text.Json.JsonSerializer.Deserialize<T>(item.Snapshot);
}
internal void ExecuteInTransaction(Action action) internal void ExecuteInTransaction(Action action)
{ {
_db.BeginTrans(); _db.BeginTrans();
+53 -2
View File
@@ -161,11 +161,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.SeatingPlans.FindById(id) is { } plan) if (db.SeatingPlans.FindById(id) is not { } plan) return;
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); 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.SeatingPlans.Delete(id);
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null); db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
} }
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<SeatingPlan>(trashId) is { } plan) Save(plan);
}
} }
public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository
@@ -260,9 +267,16 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
} }
public void Delete(Guid id) 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.GradingKeyTemplates.Delete(id);
db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null); db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null);
} }
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<GradingKeyTemplate>(trashId) is { } template) Save(template);
}
} }
public class GradeRepository(LiteDbContext db) : IGradeRepository public class GradeRepository(LiteDbContext db) : IGradeRepository
@@ -279,11 +293,18 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Grades.FindById(id) is { } grade) if (db.Grades.FindById(id) is not { } grade) return;
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.MoveToTrash(nameof(Grade), id, grade,
$"Note {grade.Value} ({grade.Date:dd.MM.yyyy})");
db.Grades.Delete(id); db.Grades.Delete(id);
db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null); db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null);
} }
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<Grade>(trashId) is { } grade) Save(grade);
}
} }
public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository
@@ -421,9 +442,16 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
} }
public void Delete(Guid id) 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.Tasks.Delete(id);
db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null); db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null);
} }
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<WorkTask>(trashId) is { } task) Save(task);
}
} }
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
@@ -441,9 +469,17 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
} }
public void Delete(Guid id) 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.TimeEntries.Delete(id);
db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null); db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null);
} }
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<TimeEntry>(trashId) is { } entry) Save(entry);
}
} }
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
@@ -764,3 +800,18 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
}); });
} }
} }
/// <summary>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.</summary>
public class TrashRepository(LiteDbContext db) : ITrashRepository
{
public List<TrashedItem> 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);
}
}
@@ -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<AppTheme>())
Assert.Equal(theme, AppThemeDisplay.FromLabel(AppThemeDisplay.Label(theme)));
}
[Fact]
public void FromLabel_UnbekanntesLabel_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, AppThemeDisplay.FromLabel("Nonsens"));
}
+29
View File
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Sync.Crypto; 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. /// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen.
public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated); 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 <see cref="BuildAiSettingsService"/>, 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 /// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe
/// sync.key stören. /// sync.key stören.
public static SyncKeyRecoveryService BuildSyncKeyRecoveryService() public static SyncKeyRecoveryService BuildSyncKeyRecoveryService()
@@ -123,6 +137,17 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
_all.Add(plan); _all.Add(plan);
} }
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id); 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<TrashedItem>? initial = null) : ITrashRepository
{
private readonly List<TrashedItem> _all = initial ?? [];
public void Add(TrashedItem item) => _all.Add(item);
public List<TrashedItem> GetAll() => _all.OrderByDescending(t => t.DeletedAt).ToList();
public void PurgeOlderThan(DateTime cutoffUtc) => _all.RemoveAll(t => t.DeletedAt < cutoffUtc);
} }
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
@@ -190,6 +215,7 @@ public class FakeGrades : IGradeRepository
_all.Add(grade); _all.Add(grade);
} }
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id); public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeExams(List<Exam> all) : IExamRepository public class FakeExams(List<Exam> all) : IExamRepository
@@ -227,6 +253,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id); 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 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 Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeSchemes : IGradingSchemeRepository public class FakeSchemes : IGradingSchemeRepository
@@ -407,6 +434,7 @@ public class FakeWorkTasks : IWorkTaskRepository
public List<WorkTask> GetAll() => _all.ToList(); public List<WorkTask> GetAll() => _all.ToList();
public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); } 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 Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeTimeEntries : ITimeEntryRepository public class FakeTimeEntries : ITimeEntryRepository
@@ -419,6 +447,7 @@ public class FakeTimeEntries : ITimeEntryRepository
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList(); public List<TimeEntry> 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 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 Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeReportGrades : IReportGradeRepository public class FakeReportGrades : IReportGradeRepository
@@ -16,7 +16,8 @@ public sealed class SettingsViewModelTests
FakeSupervisionDuties? supervisionDuties = null, FakeSupervisionDuties? supervisionDuties = null,
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null, FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = 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, // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -36,7 +37,37 @@ public sealed class SettingsViewModelTests
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), 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) ──────────────────────────── // ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ────────────────────────────
@@ -259,7 +290,8 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), 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"; vm.SelectedStateName = "Bayern";
@@ -283,7 +315,8 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), 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].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45"; vm.PeriodTimes[0].EndText = "08:45";
@@ -311,7 +344,8 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), 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].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00"; vm.PeriodTimes[0].EndText = "08:00";
@@ -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);
}
}
@@ -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);
}
}
+25
View File
@@ -1,6 +1,8 @@
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
@@ -24,6 +26,13 @@ public class App : Application
public override void OnFrameworkInitializationCompleted() 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) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank // Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
@@ -57,6 +66,10 @@ public class App : Application
Services = _serviceProvider; Services = _serviceProvider;
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet."); Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>()); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
// 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<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
if (!_exitHandlerAttached) if (!_exitHandlerAttached)
{ {
@@ -67,12 +80,24 @@ public class App : Application
var mainVm = Services.GetRequiredService<MainWindowViewModel>(); var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm); WireCallbacks(mainVm);
var main = new MainWindow { DataContext = mainVm }; var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
if (Services.GetService<SyncEngine>() is { } syncEngine) if (Services.GetService<SyncEngine>() is { } syncEngine)
main.EnableFinalSync(syncEngine); main.EnableFinalSync(syncEngine);
desktop.MainWindow = main; desktop.MainWindow = main;
if (showImmediately) main.Show(); if (showImmediately) main.Show();
} }
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
/// gesamten sichtbaren Baum automatisch neu, sobald sich <see cref="ThemeVariant"/>
/// ändert.</summary>
public static void ApplyTheme(AppTheme theme) =>
Current!.RequestedThemeVariant = theme switch
{
AppTheme.Light => ThemeVariant.Light,
AppTheme.Dark => ThemeVariant.Dark,
_ => ThemeVariant.Default,
};
private static void DisposeServices() private static void DisposeServices()
{ {
if (_serviceProvider is null) return; if (_serviceProvider is null) return;
+4
View File
@@ -139,6 +139,7 @@ public static class AppBootstrapper
services.AddSingleton<IStudentRepository, StudentRepository>(); services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>(); services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>(); services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
services.AddSingleton<ITrashRepository, TrashRepository>();
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>(); services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
services.AddSingleton<IExamRepository, ExamRepository>(); services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>(); services.AddSingleton<IExamResultRepository, ExamResultRepository>();
@@ -175,6 +176,8 @@ public static class AppBootstrapper
services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData)); services.AddSingleton(_ => new LetterTemplateService(appData));
services.AddSingleton<PlanningExchangeService>(); services.AddSingleton<PlanningExchangeService>();
@@ -270,6 +273,7 @@ public static class AppBootstrapper
services.AddTransient<SeatingPlanTabViewModel>(); services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>(); services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
@@ -0,0 +1,54 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
/// <summary>Systemvorgabe folgt <see cref="Avalonia.Styling.ThemeVariant.Default"/> (bisheriges,
/// unverändertes Verhalten); Hell/Dunkel erzwingen die jeweilige Variante unabhängig vom
/// Betriebssystem.</summary>
public enum AppTheme { System, Light, Dark }
internal sealed class AppearanceSettingsConfig
{
public AppTheme Theme { get; set; } = AppTheme.System;
}
/// <summary>Merkt sich die gewählte Darstellung (12.4) über Sitzungen hinweg.</summary>
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<AppearanceSettingsConfig>(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<AppTheme>().Select(Label).ToArray();
public static AppTheme FromLabel(string? label) =>
Enum.GetValues<AppTheme>().FirstOrDefault(t => Label(t) == label, AppTheme.System);
}
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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<WindowSettings>(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));
}
@@ -36,6 +36,8 @@ public enum SettingsTab
Privacy = 10, Privacy = 10,
Sync = 11, Sync = 11,
Ai = 12, Ai = 12,
Appearance = 13,
Trash = 14,
} }
// ── Haupt-ViewModel ─────────────────────────────────────────────────────────── // ── Haupt-ViewModel ───────────────────────────────────────────────────────────
@@ -237,6 +239,27 @@ public partial class SettingsViewModel : ObservableObject
/// gerendert würde. /// gerendert würde.
public Func<string, Task>? OnShowPairingError { get; set; } public Func<string, Task>? 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<AppTheme>? OnThemeChanged { get; set; }
partial void OnSelectedThemeChanged(AppTheme value)
{
_appearance.Save(value);
OnThemeChanged?.Invoke(value);
}
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -251,9 +274,12 @@ public partial class SettingsViewModel : ObservableObject
private readonly SyncEngine? _syncEngine; private readonly SyncEngine? _syncEngine;
private readonly SnapshotService? _snapshotService; private readonly SnapshotService? _snapshotService;
private readonly SyncKeyRecoveryService _syncKeyRecovery; private readonly SyncKeyRecoveryService _syncKeyRecovery;
private readonly AppearanceSettingsService _appearance;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
private readonly AppLogger _logger; private readonly AppLogger _logger;
public TrashViewModel TrashTab { get; }
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
@@ -265,11 +291,15 @@ public partial class SettingsViewModel : ObservableObject
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null) SnapshotService? snapshotService = null, SyncEngine? syncEngine = null)
{ {
_logger = logger; _logger = logger;
_syncKeyRecovery = syncKeyRecovery; _syncKeyRecovery = syncKeyRecovery;
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated; SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
_appearance = appearance;
_selectedTheme = appearance.Load();
TrashTab = trashTab;
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates; _gradingKeyTemplates = gradingKeyTemplates;
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<TrashItemViewModel> 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)";
}
}
@@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Sync; using LehrerApp.Sync;
@@ -26,6 +27,32 @@ public partial class MainWindow : Window
Closing += OnClosing; Closing += OnClosing;
} }
/// <summary>Wendet die zuletzt gespeicherte Fenstergröße an und merkt sich die aktuelle beim
/// Schließen (14.6). Eigenständig von <see cref="EnableFinalSync"/> verdrahtet (mehrere
/// Closing-Handler sind unproblematisch) und immer aktiv, auch ohne konfigurierten
/// Sync-Server.</summary>
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) private async void OnClosing(object? sender, WindowClosingEventArgs e)
{ {
if (_closeAfterFinalSync || _finalSyncStarted || _syncEngine is null) if (_closeAfterFinalSync || _finalSyncStarted || _syncEngine is null)
@@ -920,6 +920,68 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Darstellung (12.4) -->
<ContentPage Header="Darstellung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Darstellung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wechselt sofort, ohne Neustart."/>
<StackPanel Spacing="4">
<TextBlock Text="Design" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding ThemeOptions}" SelectedItem="{Binding SelectedThemeName}"
HorizontalAlignment="Left" MinWidth="180"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Papierkorb (14.3) -->
<ContentPage Header="Papierkorb">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="560" DataContext="{Binding TrashTab}"
x:DataType="vm:TrashViewModel">
<TextBlock Text="Papierkorb" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Gelöschte Noten, Aufgaben, Zeiteinträge, Sitzpläne und Notenschlüssel-Vorlagen bleiben 30 Tage wiederherstellbar, danach werden sie endgültig entfernt. Andere Löschvorgänge (z. B. eine ganze Lerngruppe) sind davon nicht erfasst."/>
<TextBlock Text="{Binding Status}" Foreground="Green" FontSize="12"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="Der Papierkorb ist leer." Classes="emptyhint"
IsVisible="{Binding !HasItems}"/>
<ItemsControl ItemsSource="{Binding Items}" IsVisible="{Binding HasItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TrashItemViewModel">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock FontSize="13">
<Run Text="{Binding TypeLabel}" FontWeight="SemiBold"/><Run Text=": "/><Run Text="{Binding Summary}"/>
</TextBlock>
<TextBlock FontSize="11" Opacity="0.6">
<Run Text="Gelöscht am "/><Run Text="{Binding DeletedAtDisplay}"/><Run Text=" · "/><Run Text="{Binding ExpiresInDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Wiederherstellen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:TrashViewModel)DataContext).RestoreCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -25,6 +25,7 @@ public partial class SettingsView : UserControl
vm.OnSaveRecoveryFile = SaveRecoveryFile; vm.OnSaveRecoveryFile = SaveRecoveryFile;
vm.OnPickRecoveryFile = PickRecoveryFile; vm.OnPickRecoveryFile = PickRecoveryFile;
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog; vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme;
} }
} }
+76 -3
View File
@@ -2217,7 +2217,20 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
- [ ] **12.2** Schuljahr-Einstellungen: Beginn/Ende, Halbjahresgrenze, Ferien - [ ] **12.2** Schuljahr-Einstellungen: Beginn/Ende, Halbjahresgrenze, Ferien
(`SchoolYearService` erweitern). (`SchoolYearService` erweitern).
- [ ] **12.3** Standard-Notenschlüssel und Standard-Gewichtungsschema hinterlegen (2.3.3, 1.3.2). - [ ] **12.3** Standard-Notenschlüssel und Standard-Gewichtungsschema hinterlegen (2.3.3, 1.3.2).
- [ ] **12.4** Darstellung: Hell/Dunkel-Modus, Schriftgröße. - [~] **12.4** Darstellung: Hell/Dunkel-Modus, Schriftgröße.
**Umsetzung (Hell/Dunkel):** neuer Tab "Darstellung" in
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml) mit Auswahl
Systemvorgabe/Hell/Dunkel, gespeichert über
[AppearanceSettingsService.cs](LehrerApp.Desktop/Services/AppearanceSettingsService.cs)
(gleiches Muster wie `DashboardSettingsService`). `App.axaml` folgte über
`RequestedThemeVariant="Default"` ohnehin schon der Systemvorgabe; neu ist die manuelle
Umschaltung zur Laufzeit über `App.ApplyTheme(...)` (setzt `Application.Current!.
RequestedThemeVariant`), ohne Neustart. Der gespeicherte Stand wird beim App-Start noch
vor dem ersten Fenster angewendet. **Bewusst nicht Teil dieser Umsetzung:** ein Audit der
ca. 23 XAML-Dateien mit fest codierten Hex-Farben statt Theme-Ressourcen — die
Umschaltung selbst funktioniert, aber einzelne Stellen dürften im Dunkelmodus optisch
nicht passen. Eigene, größere Aufgabe. Schriftgröße nicht umgesetzt.
- [ ] **12.5** Speicherort der Datenbank anzeigen und ändern. - [ ] **12.5** Speicherort der Datenbank anzeigen und ändern.
- [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen. - [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen.
@@ -2397,10 +2410,70 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
- [ ] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar - [ ] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar
(Vorbild: Mitarbeit-Schnelleingabe). (Vorbild: Mitarbeit-Schnelleingabe).
- [ ] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel. - [ ] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel.
- [ ] **14.3** Rückgängig-Funktion für Löschvorgänge (mindestens Bestätigungsdialog überall). - [~] **14.3** Rückgängig-Funktion für Löschvorgänge (mindestens Bestätigungsdialog überall).
**Umsetzung:** generischer, nicht-invasiver "Papierkorb light" statt einer
`IsDeleted`-Markierung pro Modell (Letzteres hätte jede Lese-Abfrage der betroffenen
Collections ändern müssen — bereits bei `Documentation.IsDeleted` sichtbar, das genau
dieses Muster nutzt und **keine** Wiederherstellungs-Oberfläche hat). Stattdessen:
[Trash.cs](LehrerApp.Core/Models/Trash.cs) definiert `TrashedItem` (Id, EntityType,
EntityId, JSON-Snapshot, Anzeigetext, Löschzeitpunkt) als eigene LiteDB-Collection.
`LiteDbContext.MoveToTrash<T>()`/`RestoreFromTrash<T>()` sind neue interne Hilfsmethoden,
die von den bestehenden `Delete(id)`-Methoden einzelner Repositories vor dem eigentlichen
Löschen aufgerufen werden — **keine neue Konstruktor-Abhängigkeit** für diese
Repositories. `Restore(Guid trashId)` (neu auf den betroffenen Repository-Interfaces)
dedserialisiert den Snapshot und ruft die **eigene** `Save()`-Methode auf, damit
Validierung und `OnChange`-Sync-Ereignis wie bei jedem normalen Speichern greifen. Neuer
Tab "Papierkorb" in
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)
([TrashViewModel.cs](LehrerApp.Desktop/ViewModels/Settings/TrashViewModel.cs)) listet alle
Einträge mit Typ, Löschzeitpunkt und Restlaufzeit; "Wiederherstellen" dispatcht anhand des
`EntityType` an das passende Repository. Aufbewahrung 30 Tage, Bereinigung
(`ITrashRepository.PurgeOlderThan`) läuft beim App-Start.
**Bewusst nur für 5 Entitäten ohne Kaskaden umgesetzt:** `SeatingPlan`, `Grade`,
`GradingKeyTemplate`, `WorkTask`, `TimeEntry` — jeweils hoher Alltagswert (Fehlklick beim
Löschen eines Sitzplans/einer Note ist besonders ärgerlich) bei überschaubarem Risiko
(keine abhängigen Collections). **Bewusst ausgeschlossen:** `LearningGroup` (14
kaskadierende Collections beim Löschen — deutlich riskanter, eigener Umbau nötig),
`Exam` (kaskadiert `ExamResult`), `ParticipationSession` (kaskadiert
`ParticipationEntry`), sowie alle übrigen, einfacheren Entitäten (u. a. `ShorthandCode`,
`SubstitutionEntry`, `SupervisionDuty`, `TimetableSlot`, `SchoolHoliday`,
`CompetencyDomain`, `Subject`, `Student`, `Unit`, `GroupMembership`,
`ParticipationAspect`, `ParticipationSection`, `AlternativeLessonPath`, `GradingScheme`,
`ReportGrade`) — der Mechanismus ist bewusst so gebaut, dass sich weitere Entitäten später
mit demselben Muster (drei Zeilen in `Delete()`, eine `Restore()`-Methode) ergänzen lassen.
Der Papierkorb ist **rein lokal, nicht Teil des Geräte-Sync** — `TrashedItems` löst nie
`db.OnChange` aus und landet damit nie im Sync-Ereignisstrom; nur das eigentliche
Save-/Delete-Ereignis der betroffenen Entität synchronisiert wie bisher. Bewusste
Vereinfachung, um keine neue Sync-Infrastruktur (Ereignistyp, Konfliktbehandlung für
Papierkorb-Einträge) einzuführen. Tests: 10 in
[TrashTests.cs](LehrerApp.Data.Tests/TrashTests.cs) (Löschen/Wiederherstellen je
Repository, Sortierung, Bereinigung), 6 in
[TrashViewModelTests.cs](LehrerApp.Desktop.Tests/TrashViewModelTests.cs).
- [ ] **14.4** Ladeanzeigen bei längeren Operationen (Import, Sync, Export). - [ ] **14.4** Ladeanzeigen bei längeren Operationen (Import, Sync, Export).
- [ ] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen. - [ ] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen.
- [ ] **14.6** Fenstergröße und Spaltenbreiten über Sitzungen hinweg merken. - [~] **14.6** Fenstergröße und Spaltenbreiten über Sitzungen hinweg merken.
**Umsetzung (Fenstergröße):**
[WindowSettingsService.cs](LehrerApp.Desktop/Services/WindowSettingsService.cs)
(gleiches Muster wie `DashboardSettingsService`) speichert Breite/Höhe/Maximiert-Status
beim Schließen und stellt sie beim Start wieder her
(`MainWindow.EnableWindowSizePersistence`). **Bewusst nicht gespeichert: die
Fensterposition** — bei wechselnder Monitor-Konfiguration (Laptop im Unterricht, externer
Monitor zuhause) könnte das Fenster sonst außerhalb des sichtbaren Bereichs landen. War die
App beim Schließen maximiert, bleibt die zuletzt bekannte "normale" Größe erhalten statt
der (dann bedeutungslosen) Bildschirmgröße. Tests in
[WindowSettingsServiceTests.cs](LehrerApp.Desktop.Tests/WindowSettingsServiceTests.cs).
**Spaltenbreiten: zurückgestellt.** Bestandsaufnahme aller 9 `DataGrid`s ergab: 5 haben
eine feste, in XAML deklarierte Spaltenliste (dort wäre Persistierung tatsächlich billig),
aber 4 bauen ihre Spalten im Code-behind dynamisch aus den Daten neu auf — je eine Spalte
pro Klausur bzw. Mitarbeits-Aspekt (`GradeOverviewTabView`, `ParticipationTabView`,
`ExamGradingDialog`, `AttendanceHomeworkQuickInputDialog`). Für diese vier gibt es keine
stabile Spalten-Identität, an der eine gespeicherte Breite verlässlich hängen könnte —
eine generische Lösung ist damit kein "billiger" Zusatz, sondern ein eigener, größerer
Umbau. Nicht in diesem Durchgang umgesetzt.
- [ ] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht). - [ ] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht).
- [ ] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie - [ ] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie
125/150/200 % Skalierung; starre Master-Detail-Spalten bei Bedarf stapeln). Der kompakte 125/150/200 % Skalierung; starre Master-Detail-Spalten bei Bedarf stapeln). Der kompakte