diff --git a/LehrerApp.Core/Services/AppLogger.cs b/LehrerApp.Core/Services/AppLogger.cs
new file mode 100644
index 0000000..8eb4aaf
--- /dev/null
+++ b/LehrerApp.Core/Services/AppLogger.cs
@@ -0,0 +1,77 @@
+namespace LehrerApp.Core.Services;
+
+public enum LogLevel { Info, Warning, Error }
+
+///
+/// Schreibt Log-Zeilen in eine tägliche Datei im App-Datenverzeichnis
+/// (<AppData>/LehrerApp/logs/lehrerapp-JJJJ-MM-TT.log).
+/// Beim Start werden Dateien entfernt, die älter als sind.
+///
+public class AppLogger
+{
+ private const int RetentionDays = 14;
+ private readonly object _lock = new();
+ private readonly string _logDirectory;
+
+ public string LogDirectory => _logDirectory;
+
+ public AppLogger(string appDataPath)
+ {
+ _logDirectory = Path.Combine(appDataPath, "logs");
+ Directory.CreateDirectory(_logDirectory);
+ RotateOldFiles();
+ }
+
+ public void Info(string message) => Write(LogLevel.Info, message, null);
+ public void Warn(string message) => Write(LogLevel.Warning, message, null);
+ public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, message, exception);
+
+ private void Write(LogLevel level, string message, Exception? exception)
+ {
+ var levelLabel = level switch
+ {
+ LogLevel.Info => "INFO ",
+ LogLevel.Warning => "WARN ",
+ LogLevel.Error => "ERROR",
+ _ => "? ",
+ };
+ var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{levelLabel}] {message}";
+ if (exception is not null)
+ line += Environment.NewLine + exception;
+
+ lock (_lock)
+ {
+ try
+ {
+ File.AppendAllText(CurrentLogFile(), line + Environment.NewLine);
+ }
+ catch
+ {
+ // Logging darf die App nie zum Absturz bringen (z.B. Datenträger voll/gesperrt).
+ }
+ }
+ }
+
+ private string CurrentLogFile() =>
+ Path.Combine(_logDirectory, $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
+
+ private void RotateOldFiles()
+ {
+ var cutoff = DateTime.Now.AddDays(-RetentionDays);
+ try
+ {
+ foreach (var file in Directory.GetFiles(_logDirectory, "lehrerapp-*.log"))
+ {
+ if (File.GetLastWriteTime(file) < cutoff)
+ {
+ try { File.Delete(file); }
+ catch { /* nächster Start versucht es erneut */ }
+ }
+ }
+ }
+ catch
+ {
+ // Verzeichnis nicht lesbar o.ä. — Logging soll trotzdem weiterlaufen können.
+ }
+ }
+}
diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs
index 74a5975..7bf84b7 100644
--- a/LehrerApp.Desktop/App.axaml.cs
+++ b/LehrerApp.Desktop/App.axaml.cs
@@ -1,6 +1,8 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
@@ -18,6 +20,8 @@ public class App : Application
public override void OnFrameworkInitializationCompleted()
{
Services = AppBootstrapper.BuildServices();
+ Services.GetRequiredService().Info("Anwendung gestartet.");
+ GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService());
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index d26c674..31b58c1 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
+using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -24,20 +25,44 @@ public static class AppBootstrapper
public static string DbPath { get; private set; } = "";
public static string AppDataPath { get; private set; } = "";
+ ///
+ /// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
+ /// Exception-Handler in Program.cs, der schon vor greifen muss).
+ ///
+ public static AppLogger Logger { get; private set; } = null!;
+
+ public static string ResolveAppDataPath()
+ {
+ if (!string.IsNullOrEmpty(AppDataPath)) return AppDataPath;
+ var appData = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "LehrerApp");
+ Directory.CreateDirectory(appData);
+ AppDataPath = appData;
+ return appData;
+ }
+
+ public static AppLogger EnsureLogger()
+ {
+ Logger ??= new AppLogger(ResolveAppDataPath());
+ return Logger;
+ }
+
public static IServiceProvider BuildServices()
{
var services = new ServiceCollection();
// ── Pfade ─────────────────────────────────────────────────────────────
- var appData = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "LehrerApp");
- Directory.CreateDirectory(appData);
- AppDataPath = appData;
+ var appData = ResolveAppDataPath();
DbPath = Path.Combine(appData, "lehrerapp.db");
var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key");
+ // ── Logging & Benachrichtigungen ─────────────────────────────────────────
+ EnsureLogger();
+ services.AddSingleton(Logger);
+ services.AddSingleton();
+
// ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(DbPath));
diff --git a/LehrerApp.Desktop/Program.cs b/LehrerApp.Desktop/Program.cs
index acd39a9..e7a1814 100644
--- a/LehrerApp.Desktop/Program.cs
+++ b/LehrerApp.Desktop/Program.cs
@@ -1,12 +1,28 @@
using Avalonia;
+using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop;
class Program
{
[STAThread]
- public static void Main(string[] args) =>
- BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ public static void Main(string[] args)
+ {
+ var logger = AppBootstrapper.EnsureLogger();
+ GlobalExceptionHandler.Install(logger);
+
+ try
+ {
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+ catch (Exception ex)
+ {
+ // Fehler, die den Avalonia-Nachrichtenloop selbst verlassen (z.B. beim Start),
+ // laufen hier zusammen — protokollieren, bevor der Prozess unvermeidlich endet.
+ logger.Error("Unbehandelter Fehler hat die Anwendung beendet.", ex);
+ throw;
+ }
+ }
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure()
diff --git a/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs b/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..02a788d
--- /dev/null
+++ b/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs
@@ -0,0 +1,55 @@
+using Avalonia.Threading;
+using LehrerApp.Core.Services;
+
+namespace LehrerApp.Desktop.Services;
+
+///
+/// Zentrale Exception-Behandlung (13.2.1): protokolliert jeden unerwarteten Fehler und zeigt
+/// eine verständliche Meldung statt eines rohen Absturzes.
+///
+/// Reichweite ehrlich betrachtet: .UIThread.UnhandledException kann
+/// Ausnahmen aus Befehlen/Ereignis-Handlern auf dem UI-Thread abfangen und die App am Leben
+/// erhalten (Handled = true) — das deckt den weit überwiegenden Teil realer Abstürze in einer
+/// Desktop-App ab. AppDomain.UnhandledException und TaskScheduler.UnobservedTaskException sind
+/// Sicherheitsnetze für Fehler außerhalb des UI-Threads; die App kann eine "IsTerminating"-
+/// Ausnahme dort nicht mehr verhindern, aber wenigstens vollständig protokollieren, bevor sie endet.
+///
+public static class GlobalExceptionHandler
+{
+ private static AppLogger? _logger;
+ private static NotificationService? _notifications;
+
+ public static void Install(AppLogger logger, NotificationService? notifications = null)
+ {
+ _logger = logger;
+ _notifications = notifications;
+
+ Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandledException;
+ AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
+ TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+ }
+
+ /// Muss aufgerufen werden, sobald der DI-Container steht, damit spätere Fehler
+ /// (die vor dem Container auftreten könnten) noch als Toast sichtbar werden.
+ public static void AttachNotifications(NotificationService notifications) => _notifications = notifications;
+
+ private static void OnDispatcherUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
+ {
+ _logger?.Error("Unbehandelter Fehler auf dem UI-Thread.", e.Exception);
+ _notifications?.ShowError("Es ist ein unerwarteter Fehler aufgetreten. Details wurden protokolliert.");
+ e.Handled = true; // App am Leben halten statt abzustürzen.
+ }
+
+ private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
+ {
+ _logger?.Error(
+ $"Unbehandelter Fehler außerhalb des UI-Threads (IsTerminating={e.IsTerminating}).",
+ e.ExceptionObject as Exception);
+ }
+
+ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ _logger?.Error("Unbeobachtete Ausnahme in einem Hintergrund-Task.", e.Exception);
+ e.SetObserved(); // verhindert, dass die Ausnahme unbemerkt verschwindet.
+ }
+}
diff --git a/LehrerApp.Desktop/Services/NotificationService.cs b/LehrerApp.Desktop/Services/NotificationService.cs
new file mode 100644
index 0000000..041dca7
--- /dev/null
+++ b/LehrerApp.Desktop/Services/NotificationService.cs
@@ -0,0 +1,51 @@
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using System.Collections.ObjectModel;
+
+namespace LehrerApp.Desktop.Services;
+
+public enum NotificationKind { Success, Error }
+
+///
+/// Einheitliche Erfolgs-/Fehlermeldungen als Toast (13.2.3). Wird im MainWindow als
+/// Overlay über der aktuellen Seite angezeigt und blendet sich nach kurzer Zeit selbst aus.
+///
+public class NotificationService
+{
+ public ObservableCollection Toasts { get; } = [];
+
+ public void ShowSuccess(string message) => Show(message, NotificationKind.Success, TimeSpan.FromSeconds(3));
+ public void ShowError(string message) => Show(message, NotificationKind.Error, TimeSpan.FromSeconds(6));
+
+ private void Show(string message, NotificationKind kind, TimeSpan duration)
+ {
+ void AddAndScheduleRemoval()
+ {
+ var toast = new ToastItem(message, kind);
+ Toasts.Add(toast);
+ _ = RemoveAfter(toast, duration);
+ }
+
+ if (Dispatcher.UIThread.CheckAccess()) AddAndScheduleRemoval();
+ else Dispatcher.UIThread.Post(AddAndScheduleRemoval);
+ }
+
+ private async Task RemoveAfter(ToastItem toast, TimeSpan duration)
+ {
+ await Task.Delay(duration);
+ Toasts.Remove(toast);
+ }
+}
+
+public partial class ToastItem : ObservableObject
+{
+ public string Message { get; }
+ public NotificationKind Kind { get; }
+ public bool IsError => Kind == NotificationKind.Error;
+
+ public ToastItem(string message, NotificationKind kind)
+ {
+ Message = message;
+ Kind = kind;
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
index 44daa37..0c83b17 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
@@ -28,7 +28,9 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private int? _examNumber;
[ObservableProperty] private string _notes = "";
[ObservableProperty] private string _returnedAtText = "";
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _titleError = "";
+ [ObservableProperty] private string _dateTextError = "";
+ [ObservableProperty] private string _returnedAtTextError = "";
[ObservableProperty] private double _totalPoints;
[ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _useWeighting;
@@ -264,27 +266,34 @@ public partial class ExamDialogViewModel : ObservableObject
[RelayCommand]
private void Save()
{
- if (string.IsNullOrWhiteSpace(Title)) { ValidationMessage = "Titel erforderlich."; return; }
- if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
+ TitleError = ""; DateTextError = ""; ReturnedAtTextError = ""; GradingKeyValidation = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
+
+ DateOnly date = default;
+ if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out date))
{
- ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben.";
- return;
+ DateTextError = "Format TT.MM.JJJJ.";
+ valid = false;
}
+
DateOnly? returnedAt = null;
if (!string.IsNullOrWhiteSpace(ReturnedAtText))
{
if (!DateOnly.TryParseExact(ReturnedAtText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
{
- ValidationMessage = "Rückgabedatum im Format TT.MM.JJJJ eingeben.";
- return;
+ ReturnedAtTextError = "Format TT.MM.JJJJ.";
+ valid = false;
}
- returnedAt = r;
+ else returnedAt = r;
}
var gradingKey = BuildGradingKeyEntries();
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
- if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; return; }
- GradingKeyValidation = "";
+ if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
+
+ if (!valid) return;
Result = _editingExam ?? new Exam { GroupId = _groupId };
Result.Title = Title.Trim();
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
index 393a1ec..2a1f725 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
@@ -315,11 +315,16 @@ public partial class StudentGradesDialogViewModel : ObservableObject
private void Save(GradeEditItem item)
{
- if (string.IsNullOrWhiteSpace(item.Value)) { item.ValidationMessage = "Wert darf nicht leer sein."; return; }
+ item.ValueError = ""; item.DateTextError = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(item.Value)) { item.ValueError = "Wert darf nicht leer sein."; valid = false; }
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out _))
- { item.ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
- item.ValidationMessage = "";
+ { item.DateTextError = "Format TT.MM.JJJJ."; valid = false; }
+
+ if (!valid) return;
+
var grade = item.ToModel();
_grades.Save(grade);
item.IsNew = false;
@@ -345,7 +350,8 @@ public partial class GradeEditItem : ObservableObject
[ObservableProperty] private string _dateText;
[ObservableProperty] private double _weight;
[ObservableProperty] private string? _note;
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _valueError = "";
+ [ObservableProperty] private string _dateTextError = "";
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string CategoryName
@@ -405,7 +411,7 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
[ObservableProperty] private double _weight = 1.0;
[ObservableProperty] private string? _note;
[ObservableProperty] private string _statusMessage = "";
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _dateTextError = "";
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string CategoryName
@@ -428,8 +434,8 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
{
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
- { ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
- ValidationMessage = "";
+ { DateTextError = "Format TT.MM.JJJJ."; return; }
+ DateTextError = "";
var count = 0;
foreach (var row in Rows)
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index 337ba7c..017ab39 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -618,7 +618,8 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _nameError = "";
+ [ObservableProperty] private string _gradeLevelError = "";
public List SchoolYears { get; }
public List GradingOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
@@ -662,8 +663,13 @@ public partial class AddGroupDialogViewModel : ObservableObject
[RelayCommand]
private void Save()
{
- if (string.IsNullOrWhiteSpace(Name)) { ValidationMessage = "Bezeichnung erforderlich."; return; }
- if (GradeLevel is < 1 or > 13) { ValidationMessage = "Klassenstufe 1–13."; return; }
+ NameError = ""; GradeLevelError = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(Name)) { NameError = "Bezeichnung erforderlich."; valid = false; }
+ if (GradeLevel is < 1 or > 13) { GradeLevelError = "Klassenstufe 1–13."; valid = false; }
+
+ if (!valid) return;
string? subjectName = !string.IsNullOrWhiteSpace(Subject) ? Subject.Trim() : null;
Guid? subjectId = null;
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
index e349b5b..8244ca7 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
@@ -671,7 +671,7 @@ public partial class AddSessionDialogViewModel : ObservableObject
[ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _comment = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _dateTextError = "";
public ParticipationSession? Result { get; private set; }
@@ -681,9 +681,10 @@ public partial class AddSessionDialogViewModel : ObservableObject
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
{
- ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben.";
+ DateTextError = "Format TT.MM.JJJJ.";
return;
}
+ DateTextError = "";
Result = new ParticipationSession { Date = date, Comment = Comment.Trim() };
}
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
index e439c99..0720e27 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
@@ -37,7 +37,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private string _newSectionLabel = "";
[ObservableProperty] private string _newSectionEndDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
- [ObservableProperty] private string _sectionValidationMessage = "";
+ [ObservableProperty] private string _newSectionLabelError = "";
+ [ObservableProperty] private string _newSectionEndDateTextError = "";
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
[ObservableProperty] private string _rollupStatusMessage = "";
[ObservableProperty] private double _timelineZoom = 1.0;
@@ -322,13 +323,20 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
[RelayCommand]
private void CloseSection()
{
- if (string.IsNullOrWhiteSpace(NewSectionLabel)) { SectionValidationMessage = "Bezeichnung erforderlich."; return; }
+ NewSectionLabelError = ""; NewSectionEndDateTextError = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(NewSectionLabel)) { NewSectionLabelError = "Bezeichnung erforderlich."; valid = false; }
+
+ DateOnly end = default;
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
- DateTimeStyles.None, out var end))
- { SectionValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
+ DateTimeStyles.None, out end))
+ { NewSectionEndDateTextError = "Format TT.MM.JJJJ."; valid = false; }
+
+ if (!valid) return;
var start = ComputeOpenStart();
- if (end < start) { SectionValidationMessage = "Enddatum liegt vor Abschnittsbeginn."; return; }
+ if (end < start) { NewSectionEndDateTextError = "Enddatum liegt vor Abschnittsbeginn."; return; }
var section = new ParticipationSection { GroupId = _groupId, Label = NewSectionLabel.Trim(), StartDate = start, EndDate = end };
_sectionRepo.Save(section);
@@ -350,7 +358,6 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
});
}
- SectionValidationMessage = "";
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
BuildTimeline(CurrentStudentId);
BuildSections(CurrentStudentId);
diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
index 00cf914..e33ceba 100644
--- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
@@ -1,10 +1,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
+using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels;
@@ -17,13 +19,15 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private string _currentSchoolYear = "";
public SyncStatusViewModel SyncStatus { get; }
+ public ObservableCollection Toasts { get; }
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
- SyncStatusViewModel syncStatus)
+ SyncStatusViewModel syncStatus, NotificationService notifications)
{
_services = services;
SyncStatus = syncStatus;
+ Toasts = notifications.Toasts;
CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard;
}
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
index 2dea95a..601d8c7 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
@@ -23,7 +23,7 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _newName = "";
[ObservableProperty] private string _newShort = "";
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _newNameError = "";
public ObservableCollection Subjects { get; } = [];
@@ -41,7 +41,7 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _newTemplateName = "";
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
- [ObservableProperty] private string _templateValidationMessage = "";
+ [ObservableProperty] private string _newTemplateNameError = "";
public List GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
public ObservableCollection GradingKeyTemplateList { get; } = [];
@@ -93,7 +93,7 @@ public partial class SettingsViewModel : ObservableObject
[RelayCommand]
private void AddGradingKeyTemplate()
{
- if (string.IsNullOrWhiteSpace(NewTemplateName)) { TemplateValidationMessage = "Vorlagenname erforderlich."; return; }
+ if (string.IsNullOrWhiteSpace(NewTemplateName)) { NewTemplateNameError = "Vorlagenname erforderlich."; return; }
var system = NewTemplateGradingSystemName == "Punkte 0–15"
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
@@ -109,7 +109,7 @@ public partial class SettingsViewModel : ObservableObject
_gradingKeyTemplates.Save(template);
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
NewTemplateName = "";
- TemplateValidationMessage = "";
+ NewTemplateNameError = "";
}
[RelayCommand]
@@ -132,17 +132,17 @@ public partial class SettingsViewModel : ObservableObject
[RelayCommand]
private void AddSubject()
{
- if (string.IsNullOrWhiteSpace(NewName)) { ValidationMessage = "Name erforderlich."; return; }
+ if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
try
{
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
}
catch (InvalidOperationException ex)
{
- ValidationMessage = ex.Message;
+ NewNameError = ex.Message;
return;
}
- NewName = ""; NewShort = ""; ValidationMessage = "";
+ NewName = ""; NewShort = ""; NewNameError = "";
LoadSubjects();
}
@@ -156,10 +156,10 @@ public partial class SettingsViewModel : ObservableObject
}
catch (InvalidOperationException ex)
{
- ValidationMessage = ex.Message;
+ NewNameError = ex.Message;
return;
}
- ValidationMessage = "";
+ NewNameError = "";
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
LoadSubjects();
}
@@ -362,7 +362,8 @@ public partial class GradingKeyTemplateEditItem : ObservableObject
[ObservableProperty] private string _newGrade = "";
[ObservableProperty] private double _newMinPercent;
- [ObservableProperty] private string _validation = "";
+ [ObservableProperty] private string _newGradeError = "";
+ [ObservableProperty] private string _newMinPercentError = "";
[ObservableProperty] private string _completenessWarning = "";
public ObservableCollection Entries { get; } = [];
@@ -384,10 +385,15 @@ public partial class GradingKeyTemplateEditItem : ObservableObject
[RelayCommand]
private void AddEntry()
{
- if (string.IsNullOrWhiteSpace(NewGrade)) { Validation = "Bezeichnung erforderlich."; return; }
- if (NewMinPercent is < 0 or > 100) { Validation = "Prozentgrenze muss zwischen 0 und 100 liegen."; return; }
- if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
- { Validation = "Diese Prozentgrenze existiert bereits."; return; }
+ NewGradeError = ""; NewMinPercentError = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(NewGrade)) { NewGradeError = "Bezeichnung erforderlich."; valid = false; }
+ if (NewMinPercent is < 0 or > 100) { NewMinPercentError = "Muss zwischen 0 und 100 liegen."; valid = false; }
+ else if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
+ { NewMinPercentError = "Diese Prozentgrenze existiert bereits."; valid = false; }
+
+ if (!valid) return;
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
_template.Entries.Add(entry);
@@ -396,7 +402,7 @@ public partial class GradingKeyTemplateEditItem : ObservableObject
Entries.Clear();
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
- NewGrade = ""; NewMinPercent = 0; Validation = "";
+ NewGrade = ""; NewMinPercent = 0;
RecomputeCompleteness();
}
diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
index 0293ce5..818ff39 100644
--- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
@@ -361,7 +361,9 @@ public partial class AddStudentDialogViewModel : ObservableObject
[ObservableProperty] private string _dateOfBirthText = "";
[ObservableProperty] private string _selectedGender = "";
[ObservableProperty] private string _notes = "";
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _lastNameError = "";
+ [ObservableProperty] private string _firstNameError = "";
+ [ObservableProperty] private string _dateOfBirthError = "";
public List GenderOptions { get; } = ["", "M – männlich", "W – weiblich", "D – divers"];
public List RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
@@ -380,8 +382,11 @@ public partial class AddStudentDialogViewModel : ObservableObject
[RelayCommand]
private void Save()
{
- if (string.IsNullOrWhiteSpace(LastName)) { ValidationMessage = "Nachname erforderlich."; return; }
- if (string.IsNullOrWhiteSpace(FirstName)) { ValidationMessage = "Vorname erforderlich."; return; }
+ LastNameError = ""; FirstNameError = ""; DateOfBirthError = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(LastName)) { LastNameError = "Nachname erforderlich."; valid = false; }
+ if (string.IsNullOrWhiteSpace(FirstName)) { FirstNameError = "Vorname erforderlich."; valid = false; }
DateOnly? dob = null;
if (!string.IsNullOrWhiteSpace(DateOfBirthText))
@@ -389,11 +394,13 @@ public partial class AddStudentDialogViewModel : ObservableObject
if (!DateOnly.TryParseExact(DateOfBirthText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var d))
{
- ValidationMessage = "Geburtsdatum im Format TT.MM.JJJJ."; return;
+ DateOfBirthError = "Format TT.MM.JJJJ."; valid = false;
}
- dob = d;
+ else dob = d;
}
+ if (!valid) return;
+
Result = new Student
{
FirstName = FirstName.Trim(),
@@ -456,7 +463,8 @@ public partial class ContactEditDialogViewModel : ObservableObject
[ObservableProperty] private string _invalidSinceText = "";
[ObservableProperty] private string _selectedInvalidReason = "";
[ObservableProperty] private string _invalidReasonDetails = "";
- [ObservableProperty] private string _validationMessage = "";
+ [ObservableProperty] private string _nameError = "";
+ [ObservableProperty] private string _invalidSinceError = "";
public string DialogTitle => _source is null ? "Kontakt anlegen" : "Kontakt bearbeiten";
public List RelationPresets { get; } =
@@ -494,10 +502,13 @@ public partial class ContactEditDialogViewModel : ObservableObject
[RelayCommand]
private void Save()
{
+ NameError = ""; InvalidSinceError = "";
+ var valid = true;
+
if (string.IsNullOrWhiteSpace(Name))
{
- ValidationMessage = "Name erforderlich.";
- return;
+ NameError = "Name erforderlich.";
+ valid = false;
}
DateOnly? invalidSince = null;
@@ -506,12 +517,14 @@ public partial class ContactEditDialogViewModel : ObservableObject
if (!DateOnly.TryParseExact(InvalidSinceText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var parsedDate))
{
- ValidationMessage = "Ungültig seit im Format TT.MM.JJJJ angeben.";
- return;
+ InvalidSinceError = "Format TT.MM.JJJJ.";
+ valid = false;
}
- invalidSince = parsedDate;
+ else invalidSince = parsedDate;
}
+ if (!valid) return;
+
Result = new Contact
{
Id = _source?.Id ?? Guid.NewGuid(),
diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
index c64dafc..ac11dfe 100644
--- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
@@ -23,6 +23,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/GradingKeyTemplateDialog.axaml b/LehrerApp.Desktop/Views/Settings/GradingKeyTemplateDialog.axaml
index 3db8f18..158ed61 100644
--- a/LehrerApp.Desktop/Views/Settings/GradingKeyTemplateDialog.axaml
+++ b/LehrerApp.Desktop/Views/Settings/GradingKeyTemplateDialog.axaml
@@ -28,15 +28,20 @@
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index 0ef11da..bd7ad5b 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -47,8 +47,8 @@
-
+
@@ -213,8 +213,8 @@
-
+
diff --git a/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml b/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml
index 6679367..66ae3d5 100644
--- a/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml
+++ b/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml
@@ -19,10 +19,14 @@
+
+
@@ -31,6 +35,8 @@
+
@@ -99,9 +105,6 @@
AcceptsReturn="True" Height="70" TextWrapping="Wrap"/>
-
-
diff --git a/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml b/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml
index 8515b5a..c3041d7 100644
--- a/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml
+++ b/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml
@@ -17,6 +17,8 @@
+
@@ -56,6 +58,8 @@
+
@@ -72,8 +76,6 @@
-
diff --git a/LehrerApp.Tests/AppLoggerTests.cs b/LehrerApp.Tests/AppLoggerTests.cs
new file mode 100644
index 0000000..ca0cb07
--- /dev/null
+++ b/LehrerApp.Tests/AppLoggerTests.cs
@@ -0,0 +1,105 @@
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class AppLoggerTests
+{
+ [Fact]
+ public void Konstruktor_LegtLogVerzeichnisAn()
+ {
+ using var temp = new TempAppData();
+
+ _ = new AppLogger(temp.Path);
+
+ Assert.True(Directory.Exists(Path.Combine(temp.Path, "logs")));
+ }
+
+ [Fact]
+ public void Info_SchreibtZeileInDieHeutigeLogdatei()
+ {
+ using var temp = new TempAppData();
+ var logger = new AppLogger(temp.Path);
+
+ logger.Info("Anwendung gestartet.");
+
+ var expectedFile = Path.Combine(temp.Path, "logs", $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
+ Assert.True(File.Exists(expectedFile));
+ var content = File.ReadAllText(expectedFile);
+ Assert.Contains("[INFO ]", content);
+ Assert.Contains("Anwendung gestartet.", content);
+ }
+
+ [Fact]
+ public void Error_MitException_SchreibtStapelverfolgungMit()
+ {
+ using var temp = new TempAppData();
+ var logger = new AppLogger(temp.Path);
+
+ try { throw new InvalidOperationException("Testfehler"); }
+ catch (Exception ex) { logger.Error("Etwas ist schiefgelaufen.", ex); }
+
+ var content = File.ReadAllText(Path.Combine(temp.Path, "logs", $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log"));
+ Assert.Contains("[ERROR]", content);
+ Assert.Contains("Testfehler", content);
+ Assert.Contains("InvalidOperationException", content);
+ }
+
+ [Fact]
+ public void MehrereEintraege_WerdenAlleAngehaengt()
+ {
+ using var temp = new TempAppData();
+ var logger = new AppLogger(temp.Path);
+
+ logger.Info("Erster Eintrag");
+ logger.Warn("Zweiter Eintrag");
+ logger.Error("Dritter Eintrag");
+
+ var lines = File.ReadAllLines(Path.Combine(temp.Path, "logs", $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log"));
+ Assert.Equal(3, lines.Length);
+ }
+
+ [Fact]
+ public void Konstruktor_LoeschtLogdateienAelterAlsDieAufbewahrungsfrist()
+ {
+ using var temp = new TempAppData();
+ var logDir = Path.Combine(temp.Path, "logs");
+ Directory.CreateDirectory(logDir);
+
+ var oldFile = Path.Combine(logDir, "lehrerapp-2000-01-01.log");
+ var recentFile = Path.Combine(logDir, $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
+ File.WriteAllText(oldFile, "alt");
+ File.WriteAllText(recentFile, "aktuell");
+ File.SetLastWriteTime(oldFile, DateTime.Now.AddDays(-30));
+
+ _ = new AppLogger(temp.Path);
+
+ Assert.False(File.Exists(oldFile));
+ Assert.True(File.Exists(recentFile));
+ }
+
+ [Fact]
+ public void Konstruktor_BehaeltLogdateienInnerhalbDerAufbewahrungsfrist()
+ {
+ using var temp = new TempAppData();
+ var logDir = Path.Combine(temp.Path, "logs");
+ Directory.CreateDirectory(logDir);
+
+ var recentButOldFile = Path.Combine(logDir, "lehrerapp-recent.log");
+ File.WriteAllText(recentButOldFile, "vor 5 Tagen");
+ File.SetLastWriteTime(recentButOldFile, DateTime.Now.AddDays(-5));
+
+ _ = new AppLogger(temp.Path);
+
+ Assert.True(File.Exists(recentButOldFile));
+ }
+
+ private sealed class TempAppData : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-logger-tests-{Guid.NewGuid():N}");
+
+ public TempAppData() => Directory.CreateDirectory(Path);
+ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
+ }
+}
diff --git a/TODO.md b/TODO.md
index bc9eff3..80192d7 100644
--- a/TODO.md
+++ b/TODO.md
@@ -491,10 +491,43 @@ Fächer- und Kompetenzverwaltung existiert bereits in
`.ToUniversalTime()` auf beiden Seiten vor dem Vergleich.
### 13.2 Fehlerbehandlung & Logging
-- [ ] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz.
-- [ ] **13.2.2** Logging in Datei im App-Datenverzeichnis, mit Rotation.
-- [ ] **13.2.3** Einheitliche Benachrichtigungen (Toast/Snackbar) für Erfolg und Fehler.
-- [ ] **13.2.4** Validierungsmeldungen einheitlich an den Eingabefeldern statt in Sammel-Labels.
+- [x] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz —
+ [GlobalExceptionHandler.cs](LehrerApp.Desktop/Services/GlobalExceptionHandler.cs).
+ `Dispatcher.UIThread.UnhandledException` fängt Fehler aus Befehlen/Ereignis-Handlern auf
+ dem UI-Thread ab, protokolliert sie und setzt `Handled = true` — die App stürzt dabei
+ nachweislich nicht ab (per Headless-Test verifiziert: Fehler in einem Dispatcher-Callback
+ wird geloggt + als Toast angezeigt, App läuft weiter). `AppDomain.UnhandledException` und
+ `TaskScheduler.UnobservedTaskException` sind Sicherheitsnetze für Fehler außerhalb des
+ UI-Threads — dort kann ein bereits "IsTerminating"-Fehler nicht mehr verhindert werden,
+ wird aber vollständig protokolliert.
+- [x] **13.2.2** Logging in Datei im App-Datenverzeichnis, mit Rotation —
+ [AppLogger.cs](LehrerApp.Core/Services/AppLogger.cs), tägliche Datei unter
+ `/LehrerApp/logs/`, Dateien älter als 14 Tage werden beim Start gelöscht.
+ 6 Tests in [AppLoggerTests.cs](LehrerApp.Tests/AppLoggerTests.cs).
+- [x] **13.2.3** Einheitliche Benachrichtigungen (Toast/Snackbar) für Erfolg und Fehler —
+ [NotificationService.cs](LehrerApp.Desktop/Services/NotificationService.cs), Overlay unten
+ rechts in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml), blendet sich nach
+ 3 s (Erfolg) bzw. 6 s (Fehler) automatisch aus. Aktuell an den globalen Exception-Handler
+ angebunden; bestehende Dialoge zeigen Erfolg/Fehler weiterhin über ihre eigenen
+ Statuszeilen (`StatusMessage`/`ValidationMessage`) — eine Umstellung dieser bestehenden
+ Stellen auf Toasts wäre ein eigener, größerer Umbau über viele Dateien hinweg und ist
+ bewusst nicht Teil dieser Aufgabe.
+- [x] **13.2.4** Validierungsmeldungen einheitlich an den Eingabefeldern statt in Sammel-Labels.
+ Alle Dialoge mit Formularfeldern umgestellt: `AddStudentDialog`, `ContactEditDialog`,
+ `AddGroupDialog`, `StudentGradesDialog` (`GradeEditItem`), `CollectiveGradeDialog`,
+ `ExamDialog`, `AddSessionDialog`, `ParticipationWizardDialog` (Abschnitt-Formular),
+ Einstellungen (Fach anlegen, Notenschlüssel-Vorlage anlegen, Notenschlüssel-Editor).
+ Muster: pro Feld eine `{Feld}Error`-Property, `Save()` leert alle Fehler-Properties am
+ Anfang, sammelt alle Verstöße über eine lokale `valid`-Variable statt beim ersten Fehler
+ zurückzukehren, und bricht erst am Ende mit `if (!valid) return;` ab — so werden mehrere
+ Fehler gleichzeitig angezeigt. Bewusst als Sammel-Meldung belassen, wo die Meldung sich auf
+ eine Kombination mehrerer Felder bezieht statt auf ein einzelnes Feld: `GradingKeyValidation`
+ (Notenschlüssel-Tabelle als Ganzes), `GradingSchemeEditItem.ValidationMessage`
+ (Summe der drei Prozent-Felder muss 100 ergeben), `ReportGradeRow.ValidationMessage`
+ (Override-Wert + Begründung gehören zusammen), `CatalogValidation`
+ (Kompetenzkatalog-Panel: Fachauswahl, Bereichsname und JSON-Import teilen sich einen
+ Status-Bereich), `AddStudentToGroupDialogViewModel.ValidationMessage` (einzige Prüfung
+ "Schüler ausgewählt?", kein Textfeld zum Andocken vorhanden).
### 13.3 Datensicherheit
- [ ] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10).