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:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user