CI / build-and-test (push) Canceled after 0s
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>
342 lines
16 KiB
C#
342 lines
16 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Markup.Xaml;
|
|
using Avalonia.Styling;
|
|
using Avalonia.Threading;
|
|
using LehrerApp.Core.Interfaces;
|
|
using LehrerApp.Core.Models;
|
|
using LehrerApp.Core.Services;
|
|
using LehrerApp.Data;
|
|
using LehrerApp.Desktop.Services;
|
|
using LehrerApp.Desktop.ViewModels;
|
|
using LehrerApp.Desktop.ViewModels.Groups;
|
|
using LehrerApp.Desktop.ViewModels.Planning;
|
|
using LehrerApp.Desktop.ViewModels.Students;
|
|
using LehrerApp.Desktop.ViewModels.Workload;
|
|
using LehrerApp.Desktop.Views;
|
|
using LehrerApp.Desktop.Views.Workload;
|
|
using LehrerApp.Sync;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace LehrerApp.Desktop;
|
|
|
|
public class App : Application
|
|
{
|
|
public static IServiceProvider Services { get; private set; } = null!;
|
|
private static ServiceProvider? _serviceProvider;
|
|
private static bool _exitHandlerAttached;
|
|
private static Task _initialPolls = Task.CompletedTask;
|
|
private static readonly CancellationTokenSource StartupCancellation = new();
|
|
private static DispatcherTimer? _homeworkOverdueTimer;
|
|
|
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
|
|
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)
|
|
{
|
|
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
|
|
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
|
|
var splash = new SplashWindow();
|
|
desktop.MainWindow = splash;
|
|
Dispatcher.UIThread.Post(
|
|
() => ContinueStartup(desktop, splash),
|
|
DispatcherPriority.Background);
|
|
}
|
|
|
|
base.OnFrameworkInitializationCompleted();
|
|
}
|
|
|
|
private static async void ContinueStartup(
|
|
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
|
|
{
|
|
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
|
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
|
if (AppBootstrapper.IsDbEncrypted())
|
|
{
|
|
var promptVm = new DbPasswordPromptViewModel(
|
|
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
|
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
|
promptVm.OnUnlocked = async password =>
|
|
{
|
|
AppBootstrapper.DbPassword = password;
|
|
var unlockedSplash = new SplashWindow();
|
|
desktop.MainWindow = unlockedSplash;
|
|
unlockedSplash.Show();
|
|
promptWindow.Close();
|
|
await StartMainAppAsync(desktop, unlockedSplash);
|
|
};
|
|
desktop.MainWindow = promptWindow;
|
|
promptWindow.Show();
|
|
splash.Close();
|
|
return;
|
|
}
|
|
|
|
await StartMainAppAsync(desktop, splash);
|
|
}
|
|
|
|
private static async Task StartMainAppAsync(
|
|
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
|
{
|
|
var splash = windowToClose as SplashWindow;
|
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
|
var progress = new Progress<(int Value, string Text)>(step =>
|
|
splash?.SetProgress(step.Value, step.Text));
|
|
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
|
|
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
|
|
Services = _serviceProvider;
|
|
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
|
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.
|
|
splash?.SetProgress(60, "Papierkorb aufräumen …");
|
|
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
|
|
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
|
|
await AdvanceHomeworkAsync();
|
|
|
|
if (!_exitHandlerAttached)
|
|
{
|
|
desktop.Exit += (_, _) => DisposeServices();
|
|
_exitHandlerAttached = true;
|
|
}
|
|
|
|
// MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht
|
|
// aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des
|
|
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
|
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
|
|
|
splash?.SetProgress(75, "Übersicht vorbereiten …");
|
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
|
WireCallbacks(mainVm);
|
|
|
|
splash?.SetProgress(90, "Hauptfenster öffnen …");
|
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
|
|
|
var main = new MainWindow { DataContext = mainVm };
|
|
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
|
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
|
main.EnableFinalSync(syncEngine);
|
|
desktop.MainWindow = main;
|
|
main.Show();
|
|
StartHomeworkOverdueTimer();
|
|
splash?.SetProgress(100, "Bereit");
|
|
windowToClose?.Close();
|
|
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
|
|
|
|
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
|
|
var untis = Services.GetService<UntisSyncService>();
|
|
var annualPlan = Services.GetService<AnnualPlanSyncService>();
|
|
if (untis is not null)
|
|
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
|
|
{
|
|
Services.GetRequiredService<TimetableViewModel>().Load();
|
|
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
|
|
});
|
|
_initialPolls = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Task.WhenAll(
|
|
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
|
|
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
|
|
}
|
|
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
|
|
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
|
|
});
|
|
}
|
|
|
|
private static async Task AdvanceHomeworkAsync()
|
|
{
|
|
try
|
|
{
|
|
var changed = await Task.Run(() => Services.GetRequiredService<HomeworkOverdueService>()
|
|
.Advance(DateOnly.FromDateTime(DateTime.Today)));
|
|
if (changed > 0) AppBootstrapper.Logger.Info($"{changed} offene Hausaufgaben als nicht nachgereicht markiert.");
|
|
}
|
|
catch (Exception ex) { AppBootstrapper.Logger.Error("Hausaufgaben-Fristprüfung fehlgeschlagen.", ex); }
|
|
}
|
|
|
|
private static void StartHomeworkOverdueTimer()
|
|
{
|
|
var timer = new DispatcherTimer { Interval = TimeSpan.FromHours(1) };
|
|
timer.Tick += async (_, _) =>
|
|
{
|
|
timer.Stop();
|
|
await AdvanceHomeworkAsync();
|
|
if (_serviceProvider is not null) timer.Start();
|
|
};
|
|
_homeworkOverdueTimer = timer;
|
|
timer.Start();
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
_homeworkOverdueTimer?.Stop();
|
|
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
|
|
if (serviceProvider is null) return;
|
|
|
|
try
|
|
{
|
|
StartupCancellation.Cancel();
|
|
_initialPolls.GetAwaiter().GetResult();
|
|
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppBootstrapper.Logger.Error("Datenbank-Checkpoint beim Beenden fehlgeschlagen.", ex);
|
|
}
|
|
finally
|
|
{
|
|
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
|
|
// Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
|
|
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
|
|
try
|
|
{
|
|
Task.Run(async () =>
|
|
await serviceProvider.DisposeAsync().ConfigureAwait(false))
|
|
.GetAwaiter().GetResult();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void WireCallbacks(MainWindowViewModel main)
|
|
{
|
|
// GroupList → GroupDetail (OnAddGroup wird in GroupListView.axaml.cs verdrahtet)
|
|
var gl = Services.GetRequiredService<GroupListViewModel>();
|
|
gl.OnNavigateToDetail = (id, tab) => main.NavigateToGroupDetail(id, tab);
|
|
|
|
// Dashboard → GroupDetail (Chips) / StudentDetail (Fehlzeiten-/Förderplan-Hinweise)
|
|
var dash = Services.GetRequiredService<DashboardViewModel>();
|
|
dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
|
|
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
|
|
dash.OnNavigateToStudentCase = (id, tag, title) => main.NavigateToStudentCase(id, tag, title);
|
|
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
|
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
|
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
|
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
|
|
|
|
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
|
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
|
|
|
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
|
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
|
var search = Services.GetRequiredService<GlobalSearchViewModel>();
|
|
search.OnNavigate = result =>
|
|
{
|
|
if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId)
|
|
main.NavigateToStudent(studentId);
|
|
else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId)
|
|
main.NavigateToGroupDetail(groupId);
|
|
else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId)
|
|
main.NavigateToGroupDetail(examGroupId, 4);
|
|
else if (result.Kind == GlobalSearchResultKind.Task)
|
|
main.NavigateToWorkload();
|
|
};
|
|
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
|
search.OnQuickAddStudent = ShowAddStudentDialog;
|
|
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
|
|
|
|
// StudentList → StudentDetail + Anlegen
|
|
var sl = Services.GetRequiredService<StudentListViewModel>();
|
|
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
|
sl.OnAddStudent = () => ShowAddStudentDialog();
|
|
|
|
// Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage")
|
|
var timetable = Services.GetRequiredService<TimetableViewModel>();
|
|
timetable.OnNavigateToSettings = tab => main.NavigateToSettings(tab);
|
|
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6);
|
|
}
|
|
|
|
private static async Task ShowAddStudentDialog()
|
|
{
|
|
var vm = new ViewModels.Students.AddStudentDialogViewModel(
|
|
Services.GetRequiredService<Core.Interfaces.IStudentRepository>());
|
|
var dialog = new Views.Students.AddStudentDialog { DataContext = vm };
|
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
|
await dialog.ShowDialog<bool>(owner);
|
|
}
|
|
|
|
private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard)
|
|
{
|
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
|
{ MainWindow: { } owner }) return;
|
|
|
|
var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
|
if (result is null) return;
|
|
|
|
Services.GetRequiredService<IWorkTaskRepository>().Save(result);
|
|
dashboard.RefreshCommand.Execute(null);
|
|
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
|
}
|
|
|
|
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
|
|
{
|
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
|
{ MainWindow: { } owner }) return;
|
|
|
|
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
|
|
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
|
var vm = new AddTimeEntryDialogViewModel(tasks)
|
|
{
|
|
DateText = item.Date.ToString("dd.MM.yyyy"),
|
|
SelectedCategory = "Unterricht",
|
|
StartTimeText = item.WindowStart.ToString("HH:mm"),
|
|
EndTimeText = item.WindowEnd.ToString("HH:mm"),
|
|
};
|
|
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
|
await dialog.ShowDialog<bool>(owner);
|
|
if (vm.Result is null) return;
|
|
|
|
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
|
|
dashboard.RefreshCommand.Execute(null);
|
|
Services.GetRequiredService<TimeTrackingViewModel>().Load();
|
|
}
|
|
|
|
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
|
{
|
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
|
{ MainWindow: { } owner }) return;
|
|
|
|
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
|
|
.ToDictionary(s => s.Id);
|
|
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
|
|
.Where(g => g.IsActive)
|
|
.OrderBy(g => g.Name)
|
|
.Select(g => new GroupDocumentationOption(g.Id,
|
|
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
|
|
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
|
|
: g.Name));
|
|
var vm = new GroupDocumentationQuickViewModel(options);
|
|
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
|
|
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
|
|
|
|
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
|
|
dashboard.RefreshCommand.Execute(null);
|
|
}
|
|
}
|