Files
LehrerApp/LehrerApp.Desktop/App.axaml.cs
T
admin 0962845ead
CI / build-and-test (push) Canceled after 0s
fix: startup speed
2026-09-13 22:04:07 +02:00

313 lines
15 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();
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)));
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();
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); }
});
}
/// <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()
{
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.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);
}
}