Files
LehrerApp/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
T
adminandClaude Sonnet 5 3270b48850
CI / build-and-test (push) Canceled after 0s
Add student-level Vorgänge tab and homework/lateness dashboard warnings
Dashboard warnings (attendance, and now homework backlog and lateness) apply
across all classes, but the existing Vorgänge case-file tab only works for
the teacher's own class since it resolves the WebUntis classbook roster.
Instead of loosening that coupling, students get their own narrower
Vorgänge tab, and all three cross-class warnings now jump straight there,
offering to open a matching case or create a new one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 23:55:54 +02:00

275 lines
12 KiB
C#

using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LehrerApp.Desktop.ViewModels;
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableObject? _currentPage;
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
[ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private bool _isCommandPaletteOpen;
public SyncStatusViewModel SyncStatus { get; }
public ObservableCollection<ToastItem> Toasts { get; }
public AppLockViewModel AppLock { get; }
public GlobalSearchViewModel CommandPalette { get; }
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
public bool IsStudentsActive => ActiveNavItem == NavItem.Students;
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
GlobalSearchViewModel commandPalette)
{
_services = services;
SyncStatus = syncStatus;
Toasts = notifications.Toasts;
AppLock = appLock;
CommandPalette = commandPalette;
CommandPalette.OnClose = CloseCommandPalette;
CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard;
AppLock.ApplyConfig();
SyncStatus.DataChanged += OnSyncDataChanged;
}
[RelayCommand]
private void OpenCommandPalette()
{
if (AppLock.IsLocked) return;
CommandPalette.Reset();
IsCommandPaletteOpen = true;
}
[RelayCommand]
private void CloseCommandPalette() => IsCommandPaletteOpen = false;
// EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe
// LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier-
// Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten
// manuellen Neuladen (z.B. Tab-Wechsel) auf dem alten Stand (TODO 10.1.11). Lädt bewusst nur
// die aktuell sichtbare Seite über ihren eigenen, längst vorhandenen Lade-Einstieg neu - keine
// neue Lade-Logik, keine Navigation weg von einer offenen Detailansicht. SyncEngine.DataChanged
// feuert aus dem Sync-Timer-Hintergrundthread - daher der Dispatcher-Sprung zurück auf den
// UI-Thread (gleiches Muster wie NotificationService.Show).
private void OnSyncDataChanged()
{
if (Dispatcher.UIThread.CheckAccess()) RefreshCurrentPage();
else Dispatcher.UIThread.Post(RefreshCurrentPage);
}
private void RefreshCurrentPage()
{
switch (CurrentPage)
{
case DashboardViewModel vm: vm.RefreshCommand.Execute(null); break;
case GroupListViewModel vm: vm.RefreshCommand.Execute(null); break;
case StudentListViewModel vm: vm.LoadStudents(); break;
case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
// RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt
// geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen
// WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button.
case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break;
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
case SettingsViewModel vm when vm.ActiveTabIndex == (int)SettingsTab.Competencies:
vm.RefreshCompetencyCatalogCommand.Execute(null); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
case StudentDetailViewModel { Student: { } student, IsEditing: false } vm:
vm.LoadStudent(student.Id); break;
}
}
// GroupDetailViewModel.Group wird erst nach dem Wechsel von CurrentPage per LoadGroup gesetzt
// (siehe Kommentar in NavigateToGroupDetail) - ohne dieses Abonnement bliebe
// CanPersonalizeWorksheet bis zum nächsten Seitenwechsel auf dem alten Stand.
private GroupDetailViewModel? _observedGroupDetail;
partial void OnCurrentPageChanged(ObservableObject? value)
{
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged -= OnGroupDetailPropertyChanged;
_observedGroupDetail = value as GroupDetailViewModel;
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged += OnGroupDetailPropertyChanged;
OnPropertyChanged(nameof(CurrentGroupDetail));
OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
private void OnGroupDetailPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(GroupDetailViewModel.Group)) OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
partial void OnActiveNavItemChanged(NavItem value)
{
OnPropertyChanged(nameof(IsDashboardActive));
OnPropertyChanged(nameof(IsGroupsActive));
OnPropertyChanged(nameof(IsStudentsActive));
OnPropertyChanged(nameof(IsExamsActive));
OnPropertyChanged(nameof(IsPlannerActive));
OnPropertyChanged(nameof(IsWorkloadActive));
OnPropertyChanged(nameof(IsClassTeacherActive));
OnPropertyChanged(nameof(IsSettingsActive));
}
[RelayCommand]
private void NavigateTo(NavItem item)
{
ActiveNavItem = item;
CurrentPage = item switch
{
NavItem.Dashboard => GetDashboard(),
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => GetStudents(),
NavItem.Exams => GetExams(),
NavItem.Planner => GetTimetable(),
NavItem.Workload => GetWorkload(),
NavItem.ClassTeacher => GetClassTeacherOverview(),
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
_ => CurrentPage,
};
}
private DashboardViewModel GetDashboard()
{
var dashboard = _services.GetRequiredService<DashboardViewModel>();
dashboard.RefreshCommand.Execute(null);
return dashboard;
}
private StudentListViewModel GetStudents()
{
var students = _services.GetRequiredService<StudentListViewModel>();
students.SelectedStudent = null;
students.LoadStudents();
return students;
}
private TimetableViewModel GetTimetable()
{
// WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die
// zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten
// (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher
// in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen).
var timetable = _services.GetRequiredService<TimetableViewModel>();
timetable.Load();
timetable.ActiveTabIndex = 0;
return timetable;
}
private WorkloadViewModel GetWorkload()
{
var workload = _services.GetRequiredService<WorkloadViewModel>();
workload.Tasks.Load();
workload.TimeTracking.Load();
workload.Evaluation.Load();
workload.ActiveTabIndex = 0;
return workload;
}
private ExamsOverviewViewModel GetExams()
{
var exams = _services.GetRequiredService<ExamsOverviewViewModel>();
exams.LoadCommand.Execute(null);
return exams;
}
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
{
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
// Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den
// lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über
// den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim
// bloßen Öffnen der Seite.
vm.PrepareForDisplay();
return vm;
}
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
{
ActiveNavItem = NavItem.Groups;
var vm = _services.GetRequiredService<GroupDetailViewModel>();
vm.ActiveTabIndex = initialTab;
CurrentPage = vm; // View bindet zuerst → Callbacks werden registriert
vm.LoadGroup(groupId); // dann Daten laden
}
public void NavigateToSettings(SettingsTab initialTab = SettingsTab.Subjects)
{
ActiveNavItem = NavItem.Settings;
var vm = _services.GetRequiredService<SettingsViewModel>();
vm.ActiveTabIndex = (int)initialTab;
CurrentPage = vm;
}
public void NavigateToStudent(Guid studentId)
{
ActiveNavItem = NavItem.Students;
var vm = _services.GetRequiredService<StudentDetailViewModel>();
vm.OnReturnToStudentList = NavigateToStudents;
vm.LoadStudent(studentId);
CurrentPage = vm;
}
/// Sprungziel aus einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten, Hausaufgaben-
/// Rückstand, Verspätungen) direkt auf den "Vorgänge"-Tab des Schülerdetails. Anders als
/// NavigateToStudent bewusst CurrentPage VOR LoadStudent (wie NavigateToGroupDetail) — die View
/// muss ihre Dialog-Callbacks registriert haben, bevor HandleWarningNavigation ggf. sofort eine
/// Rückfrage/einen Anlege-Dialog auslöst.
public void NavigateToStudentCase(Guid studentId, string suggestedTag, string suggestedTitle)
{
ActiveNavItem = NavItem.Students;
var vm = _services.GetRequiredService<StudentDetailViewModel>();
vm.OnReturnToStudentList = NavigateToStudents;
vm.ActiveTabIndex = StudentDetailViewModel.VorgaengeTabIndex;
CurrentPage = vm; // View bindet zuerst → Callbacks werden registriert
vm.LoadStudent(studentId);
_ = vm.VorgaengeTab.HandleWarningNavigation(suggestedTag, suggestedTitle);
}
public void NavigateToStudents() => NavigateTo(NavItem.Students);
/// Sprungziel aus dem Kurs-Dashboard ("Anstehende Aufgaben für diese Klasse") in den
/// Aufgaben-Bereich — anders als NavigateToGroupDetail/NavigateToSettings kein Tab innerhalb
/// einer Detailansicht, sondern ein eigener Top-Level-Bereich (NavItem.Workload).
public void NavigateToWorkload() => NavigateTo(NavItem.Workload);
}
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, ClassTeacher, Settings }
public partial class PlaceholderViewModel : ObservableObject
{
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _icon = "";
}