From db1afff7e48436509e0c167e895bbb33f0991480 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 13 Aug 2026 12:38:30 +0200 Subject: [PATCH] Fehlerbehandlung, Logging und feldbezogene Validierung (Kapitel 13.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zentrale Exception-Behandlung (Dispatcher.UIThread.UnhandledException, AppDomain, TaskScheduler) verhindert Abstürze und protokolliert Fehler über AppLogger in eine rotierende Log-Datei im App-Datenverzeichnis. Toast-Benachrichtigungen zeigen Erfolg/Fehler global an. Alle Dialoge mit Formularfeldern zeigen Validierungsmeldungen jetzt direkt am betroffenen Feld statt in einem Sammel-Label. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Services/AppLogger.cs | 77 +++++++++++++ LehrerApp.Desktop/App.axaml.cs | 4 + LehrerApp.Desktop/AppBootstrapper.cs | 35 +++++- LehrerApp.Desktop/Program.cs | 20 +++- .../Services/GlobalExceptionHandler.cs | 55 +++++++++ .../Services/NotificationService.cs | 51 +++++++++ .../ViewModels/Groups/ExamViewModels.cs | 29 +++-- .../Groups/GradeOverviewViewModels.cs | 20 ++-- .../ViewModels/Groups/GroupViewModels.cs | 12 +- .../Groups/ParticipationViewModels.cs | 5 +- .../Groups/ParticipationWizardViewModels.cs | 19 +++- .../ViewModels/MainWindowViewModel.cs | 6 +- .../ViewModels/Settings/SettingsViewModel.cs | 36 +++--- .../ViewModels/Students/StudentViewModels.cs | 35 ++++-- .../Views/Groups/AddGroupDialog.axaml | 6 +- .../Views/Groups/AddSessionDialog.axaml | 5 +- .../Views/Groups/CollectiveGradeDialog.axaml | 8 +- .../Views/Groups/ExamDialog.axaml | 9 +- .../Groups/ParticipationWizardDialog.axaml | 14 ++- .../Views/Groups/StudentGradesDialog.axaml | 14 ++- LehrerApp.Desktop/Views/MainWindow.axaml | 30 +++++ .../Settings/GradingKeyTemplateDialog.axaml | 21 ++-- .../Views/Settings/SettingsView.axaml | 8 +- .../Views/Students/AddStudentDialog.axaml | 9 +- .../Views/Students/ContactEditDialog.axaml | 6 +- LehrerApp.Tests/AppLoggerTests.cs | 105 ++++++++++++++++++ TODO.md | 41 ++++++- 27 files changed, 578 insertions(+), 102 deletions(-) create mode 100644 LehrerApp.Core/Services/AppLogger.cs create mode 100644 LehrerApp.Desktop/Services/GlobalExceptionHandler.cs create mode 100644 LehrerApp.Desktop/Services/NotificationService.cs create mode 100644 LehrerApp.Tests/AppLoggerTests.cs 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 @@ - - - - + + + + + + + +