Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-14 00:54:43 +02:00
39 changed files with 1157 additions and 105 deletions
+45 -13
View File
@@ -26,6 +26,8 @@ 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);
@@ -65,7 +67,11 @@ public class App : Application
promptVm.OnUnlocked = async password =>
{
AppBootstrapper.DbPassword = password;
await StartMainAppAsync(desktop, promptWindow);
var unlockedSplash = new SplashWindow();
desktop.MainWindow = unlockedSplash;
unlockedSplash.Show();
promptWindow.Close();
await StartMainAppAsync(desktop, unlockedSplash);
};
desktop.MainWindow = promptWindow;
promptWindow.Show();
@@ -79,14 +85,21 @@ public class App : Application
private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{
_serviceProvider = AppBootstrapper.BuildServices();
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.
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
splash?.SetProgress(60, "Papierkorb aufräumen …");
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
if (!_exitHandlerAttached)
{
@@ -99,19 +112,13 @@ public class App : Application
// 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);
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
// Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
// aktiviert zugleich die periodischen Timer.
var initialPolls = new List<Task>();
if (Services.GetService<UntisSyncService>() is { } untisSync)
initialPolls.Add(untisSync.PollAsync());
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
initialPolls.Add(annualPlanSync.PollAsync());
await Task.WhenAll(initialPolls);
splash?.SetProgress(90, "Hauptfenster öffnen …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
@@ -119,7 +126,30 @@ public class App : Application
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
@@ -140,6 +170,8 @@ public class App : Application
try
{
StartupCancellation.Cancel();
_initialPolls.GetAwaiter().GetResult();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
+5 -1
View File
@@ -101,7 +101,7 @@ public static class AppBootstrapper
Environment.Exit(0);
}
public static ServiceProvider BuildServices()
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
{
var services = new ServiceCollection();
@@ -130,7 +130,9 @@ public static class AppBootstrapper
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backupSettings = new BackupSettingsService(appData);
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
reportProgress?.Invoke(10, "Sicherung erstellen …");
var backupPath = backup.CreateBackup(DbPath);
reportProgress?.Invoke(25, "Sicherung prüfen …");
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
@@ -363,7 +365,9 @@ public static class AppBootstrapper
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
var provider = services.BuildServiceProvider();
provider.GetRequiredService<LiteDbContext>();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
@@ -188,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = l.StartTime,
Homework = l.Homework,
Reflection = l.Reflection,
PlanningIdeas = l.PlanningIdeas,
Phases = ToAiPhases(l.Phases, pathNames),
})
.ToList();
@@ -270,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
diffs.Add("Reflexion geändert");
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
diffs.Add("Planungsideen geändert");
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
var existingPhases = ToAiPhases(existing.Phases, pathNames);
if (!PhasesEqual(existingPhases, proposed.Phases))
@@ -586,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = ai.StartTime,
Homework = ai.Homework,
Reflection = ai.Reflection,
PlanningIdeas = ai.PlanningIdeas,
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
@@ -597,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
Activity = p.Activity,
Material = p.Material,
Shorthand = p.Shorthand,
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
? null : BuildMaterialPrompt(unit, ai, p),
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
? pathId : null,
}).ToList(),
@@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
public async Task PollAsync(CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
string icsText;
try
{
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
+9 -3
View File
@@ -24,14 +24,20 @@ public record TimeEntryDto(
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
public record LessonPhaseDto(Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand);
/// <summary><see cref="MaterialPrompt"/> ist der beim letzten KI-"Übernehmen" gespeicherte
/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen
/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne
/// erst den Desktop-Dialog öffnen zu müssen.</summary>
public record LessonPhaseDto(
Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand,
string? MaterialPrompt);
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, LessonStatus Status, List<string> Competencies, List<LessonPhaseDto> Phases,
List<LessonAttachmentDto> Attachments);
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
@@ -188,11 +188,12 @@ public class LessonPlanTools(
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Planungsideen, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLesson(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
[Description("Neue Planungsideen (grober Entwurf vor der Feinplanung). Unverändert lassen: weglassen.")] string? planningIdeas = null,
[Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
@@ -204,6 +205,7 @@ public class LessonPlanTools(
var changes = new StringBuilder();
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; }
if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; }
if (planningIdeas is not null && planningIdeas != lesson.PlanningIdeas) { changes.AppendLine($"Planungsideen: „{lesson.PlanningIdeas}“ → „{planningIdeas}“"); lesson.PlanningIdeas = planningIdeas; }
if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; }
if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; }
if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; }
@@ -228,6 +230,7 @@ public class LessonPlanTools(
[Description("Tätigkeit/Sozialform.")] string activity = "",
[Description("Material.")] string material = "",
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "",
[Description("Optionaler, vollständiger Prompt zur Materialerstellung für diese Phase (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt) - z.B. wenn eine externe KI-Sitzung ihn selbst formuliert hat und er zur Wiederverwendung gespeichert werden soll.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
@@ -240,7 +243,7 @@ public class LessonPlanTools(
var phase = new LessonPhaseStep
{
Name = name, DurationMinutes = durationMinutes, Activity = activity,
Material = material, Shorthand = shorthand,
Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt,
};
lesson.Phases.Add(phase);
lessons.Save(lesson);
@@ -256,6 +259,7 @@ public class LessonPlanTools(
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null,
[Description("Neuer Prompt zur Materialerstellung (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt). Unverändert lassen: weglassen.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
@@ -269,6 +273,7 @@ public class LessonPlanTools(
if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; }
if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; }
if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; }
if (materialPrompt is not null && materialPrompt != phase.MaterialPrompt) { changes.AppendLine("Materialerstellungs-Prompt geändert."); phase.MaterialPrompt = materialPrompt; }
if (changes.Length == 0)
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
@@ -409,7 +414,7 @@ public class LessonPlanTools(
}
private static LessonDto ToDto(Lesson l) => new(
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, l.Competencies,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(),
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(),
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
}
@@ -40,7 +40,7 @@ public sealed class UntisHubService(
public List<UntisHubJobRow> GetRows()
{
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null)
.Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub)
.OrderBy(g => g.Name)
.ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
@@ -72,8 +72,14 @@ public sealed class UntisHubService(
public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{
// Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die
// denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen,
// bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe
// (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen.
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
states.Where(s => s.Kind == kind && s.GroupId == groupId)
.OrderByDescending(s => s.LastRunAt)
.FirstOrDefault();
var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups)
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
}
public async Task PollAsync()
public async Task PollAsync(CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
string icsText;
try
{
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear;
public ObservableCollection<Lesson> TodayLessons { get; } = [];
[ObservableProperty] private Lesson? _selectedTeachingLesson;
public bool HasTodayLessons => TodayLessons.Count > 0;
public Action<Lesson>? OnOpenTeachingMode { get; set; }
[RelayCommand] private void StartTeachingMode()
{
if (SelectedTeachingLesson is { } lesson) OnOpenTeachingMode?.Invoke(lesson);
}
private Guid _groupId;
private string _groupName = "";
@@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject
public void Refresh()
{
var today = DateOnly.FromDateTime(DateTime.Today);
TodayLessons.Clear();
if (_groups.GetById(_groupId)?.IsActive == true)
foreach (var lesson in _lessons.GetByGroupAndRange(_groupId, today, today)
.Where(l => l.Status != LessonStatus.Cancelled)
.OrderBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
TodayLessons.Add(lesson);
var now = TimeOnly.FromDateTime(DateTime.Now);
SelectedTeachingLesson = TodayLessons.LastOrDefault(l => l.StartTime <= now)
?? TodayLessons.FirstOrDefault();
OnPropertyChanged(nameof(HasTodayLessons));
LoadNextLesson(today);
LoadNextExam(today);
LoadYearComparison();
@@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private bool _excludedFromUntisHub;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId;
ExcludedFromUntisHub = group.ExcludedFromUntisHub;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId;
Result.ExcludedFromUntisHub = ExcludedFromUntisHub;
_groups.Save(Result);
}
}
@@ -737,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject
[ObservableProperty] private int? _lessonNumber;
[ObservableProperty] private string _topic = "";
[ObservableProperty] private string _startTimeText = "";
[ObservableProperty] private string _planningIdeas = "";
[ObservableProperty] private string _homework = "";
[ObservableProperty] private bool _homeworkChecked;
[ObservableProperty] private bool _homeworkCheckDismissed;
@@ -823,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject
LessonNumber = editingLesson.LessonNumber;
Topic = editingLesson.Topic;
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
PlanningIdeas = editingLesson.PlanningIdeas ?? "";
Homework = editingLesson.Homework ?? "";
HomeworkChecked = editingLesson.HomeworkChecked;
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
@@ -888,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject
Activity = source?.Activity ?? "",
Material = source?.Material ?? "",
Shorthand = source?.Shorthand ?? "",
MaterialPrompt = source?.MaterialPrompt,
};
item.OnChanged = RecomputeTimes;
item.OnRemove = RemovePhase;
@@ -1075,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject
Result.LessonNumber = LessonNumber;
Result.Topic = Topic.Trim();
Result.StartTime = startTime;
Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim();
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
Result.HomeworkChecked = HomeworkChecked;
@@ -1103,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject
[ObservableProperty] private string _material = "";
[ObservableProperty] private string _shorthand = "";
[ObservableProperty] private string _computedTimeDisplay = "";
/// Gespeicherter Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI beim
/// letzten "Übernehmen" einen Medienvorschlag für diese Phase gemacht hatte. Steuert die
/// Sichtbarkeit des Kopieren-Buttons im Verlaufsplan-Editor.
[ObservableProperty] private string? _materialPrompt;
public bool HasMaterialPrompt => !string.IsNullOrWhiteSpace(MaterialPrompt);
partial void OnMaterialPromptChanged(string? value) => OnPropertyChanged(nameof(HasMaterialPrompt));
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
@@ -1162,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject
Activity = Activity.Trim(),
Material = Material.Trim(),
Shorthand = Shorthand.Trim(),
MaterialPrompt = MaterialPrompt,
AlternativePathId = AlternativePathId,
};
}
@@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private readonly IParticipationRepository _participation;
private readonly IParticipationAspectRepository _aspects;
private readonly IDocumentationRepository? _documentation;
[ObservableProperty] private bool _isTeachingMode;
[ObservableProperty] private string _quickMode = "";
public bool IsQuickMode => QuickMode.Length > 0;
public string QuickModeDisplay => QuickMode switch
{
"Attendance" => "Anwesenheit kontrollieren · Fehlend = Entschuldigung offen",
"Homework" => "Hausaufgaben kontrollieren",
_ => "Klicken: bewerten"
};
[RelayCommand] private void CheckAttendance() => QuickMode = "Attendance";
[RelayCommand] private void CheckHomework() => QuickMode = "Homework";
[RelayCommand] private void EndQuickCheck() => QuickMode = "";
partial void OnQuickModeChanged(string value)
{
if (value.Length > 0) IsEditMode = false;
foreach (var seat in Seats) seat.QuickMode = value;
OnPropertyChanged(nameof(IsQuickMode));
OnPropertyChanged(nameof(QuickModeDisplay));
}
private void SaveQuickStatus(SeatCellViewModel seat, bool positive)
{
if (!IsEditable || !IsQuickMode || seat.SelectedOption.StudentId is not Guid studentId) return;
var session = EnsureTodaySession();
if (session is null) return;
// Always merge with the latest entry, so quick checks preserve ratings and counters.
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
if (QuickMode == "Attendance") entry.Attendance = positive ? AttendanceStatus.Present : AttendanceStatus.ExcusePending;
else
{
entry.Homework = positive ? HomeworkStatus.Completed : HomeworkStatus.MissingOpen;
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
}
_participation.Save(entry);
RefreshSeatLessonData();
OnAssessmentChanged?.Invoke();
}
private Guid _groupId;
private SeatingPlan? _currentPlan;
private bool _isReadOnly;
@@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
?? StudentSeatOption.Empty;
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)
{
QuickMode = QuickMode,
OnQuickStatus = SaveQuickStatus,
OnQuickSpecial = AssessStudent
});
}
UpdateAssignmentSummary();
RefreshSeatLessonData();
@@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
partial void OnIsEditModeChanged(bool value)
{
if (value) QuickMode = "";
OnPropertyChanged(nameof(CanEditLayout));
foreach (var seat in Seats)
{
@@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session)
public partial class SeatCellViewModel : ObservableObject
{
[ObservableProperty] private string _quickMode = "";
public bool ShowQuickCheck => QuickMode.Length > 0 && IsOccupied && CanRecordLesson;
public bool ShowNormalActions => ShowLessonOverview && QuickMode.Length == 0;
public bool ShowSituationActions => ShowNormalActions && CanRecordLesson;
public string QuickPositiveLabel => QuickMode == "Attendance" ? "Anwesend" : "Gemacht";
public string QuickNegativeLabel => QuickMode == "Attendance" ? "Fehlend" : "Fehlt";
public Action<SeatCellViewModel, bool>? OnQuickStatus { get; init; }
public Func<SeatCellViewModel, Task>? OnQuickSpecial { get; init; }
[RelayCommand] private void QuickPositive() => OnQuickStatus?.Invoke(this, true);
[RelayCommand] private void QuickNegative() => OnQuickStatus?.Invoke(this, false);
[RelayCommand] private Task QuickSpecial() => OnQuickSpecial?.Invoke(this) ?? Task.CompletedTask;
partial void OnQuickModeChanged(string value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(QuickPositiveLabel));
OnPropertyChanged(nameof(QuickNegativeLabel));
OnPropertyChanged(nameof(DisplayOpacity));
}
partial void OnCanRecordLessonChanged(bool value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowSituationActions));
}
private readonly Action<SeatCellViewModel> _onChanged;
private bool _suppressChange;
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
@@ -581,7 +652,7 @@ public partial class SeatCellViewModel : ObservableObject
/// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal
/// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
public double DisplayOpacity => IsHidden ? 0.4 : ShowQuickCheck ? 1 : LessonOpacity;
private readonly Action<SeatCellViewModel, bool> _tally;
@@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnSelectedOptionChanged(StudentSeatOption value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(IsOccupied));
OnPropertyChanged(nameof(StudentName));
OnPropertyChanged(nameof(ShowLessonOverview));
@@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnCanEditChanged(bool value)
{
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(ShowLessonOverview));
OnPropertyChanged(nameof(ShowSeat));
OnPropertyChanged(nameof(CanToggleHidden));
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
/// </summary>
public class TeachingModeViewModel
{
public TeachingTimelineViewModel Timeline { get; }
public string GroupName { get; }
public LessonViewerViewModel LessonInfo { get; }
public SeatingPlanTabViewModel SeatingPlan { get; }
@@ -38,9 +39,11 @@ public class TeachingModeViewModel
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
{
GroupName = group.Name;
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
SeatingPlan = seatingPlan;
SeatingPlan.IsTeachingMode = true;
SeatingPlan.Initialize(group.Id, !group.IsActive);
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
@@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject
if (_previousLesson is null) return;
_previousLesson.HomeworkChecked = value;
if (value) _previousLesson.HomeworkCheckDismissed = false;
_lessons.Save(_previousLesson);
var latest = _lessons.GetById(_previousLesson.Id) ?? _previousLesson;
latest.HomeworkChecked = value;
if (value) latest.HomeworkCheckDismissed = false;
_lessons.Save(latest);
}
[RelayCommand]
private void SaveCurrentHomework()
{
_lesson.Homework = CurrentHomework;
_lessons.Save(_lesson);
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.Homework = CurrentHomework;
_lessons.Save(latest);
SaveStatus = "Gespeichert.";
}
}
@@ -0,0 +1,224 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class TeachingTimelineViewModel : ObservableObject
{
private readonly Lesson _lesson;
private readonly ILessonRepository _lessons;
private readonly Func<DateTime> _utcNow;
private TeachingTimelineState? _state;
public bool IsEditable { get; }
public ObservableCollection<TeachingPhaseViewModel> Phases { get; } = [];
public ObservableCollection<Lesson> TransferTargets { get; } = [];
[ObservableProperty] private Lesson? _transferTarget;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _needsStart;
public bool HasPhases => Phases.Count > 0;
public bool HasTransferTargets => TransferTargets.Count > 0;
public bool HasRemainder => Phases.Any(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred);
public TeachingTimelineViewModel(Lesson lesson, ILessonRepository lessons, bool readOnly = false,
Func<DateTime>? utcNow = null)
{
_lesson = lesson;
_lessons = lessons;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
IsEditable = !readOnly;
_state = lesson.TeachingTimeline;
if (_state is not null)
{
// LiteDB returns local DateTimes by default; arithmetic below uses UTC.
_state.StartUtc = _state.StartUtc.ToUniversalTime();
_state.EndUtc = _state.EndUtc.ToUniversalTime();
_state.HeldSinceUtc = _state.HeldSinceUtc?.ToUniversalTime();
}
foreach (var phase in lesson.Phases.Where(p => p.AlternativePathId is null))
Phases.Add(new TeachingPhaseViewModel(phase, this));
if (_state is null && lesson.StartTime is { } start)
CreateState(lesson.Date.ToDateTime(start).ToUniversalTime());
foreach (var target in lessons.GetByGroupAndRange(lesson.GroupId, lesson.Date, lesson.Date.AddDays(120))
.Where(l => l.Id != lesson.Id && l.Status is not (LessonStatus.Cancelled or LessonStatus.Conducted)
&& (l.Date > lesson.Date || l.StartTime > lesson.StartTime || l.LessonNumber > lesson.LessonNumber))
.OrderBy(l => l.Date).ThenBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
TransferTargets.Add(target);
TransferTarget = TransferTargets.FirstOrDefault();
Refresh();
}
private void CreateState(DateTime start)
{
_state = new TeachingTimelineState
{
StartUtc = start,
EndUtc = start.AddMinutes(Phases.Sum(p => Math.Max(0, p.Source.DurationMinutes))),
Phases = Phases.Select(p => new TeachingPhaseTiming
{ PhaseId = p.Source.Id, Minutes = Math.Max(0, p.Source.DurationMinutes) }).ToList()
};
}
[RelayCommand] private void StartNow()
{
if (!IsEditable || _state is not null) return;
CreateState(_utcNow());
Save();
Refresh();
}
public void Refresh()
{
NeedsStart = _state is null && HasPhases;
if (_state is null) return;
var now = _utcNow();
var cursor = _state.StartUtc;
_state.Phases.RemoveAll(t => !Phases.Any(p => p.Source.Id == t.PhaseId));
var ordered = _state.Phases.Select(t => Phases.FirstOrDefault(p => p.Source.Id == t.PhaseId))
.OfType<TeachingPhaseViewModel>().ToList();
// Keep surviving IDs in their live order when a plan was edited between openings.
foreach (var phase in Phases.Where(p => !ordered.Contains(p)).ToList())
{
_state.Phases.Add(new TeachingPhaseTiming { PhaseId = phase.Source.Id, Minutes = Math.Max(0, phase.Source.DurationMinutes) });
ordered.Add(phase);
}
for (var i = 0; i < ordered.Count; i++)
if (Phases.IndexOf(ordered[i]) != i) Phases.Move(Phases.IndexOf(ordered[i]), i);
foreach (var phase in Phases)
{
var timing = _state.Phases.First(t => t.PhaseId == phase.Source.Id);
var held = _state.HeldPhaseId == phase.Source.Id && _state.HeldSinceUtc.HasValue;
var extension = held ? Math.Max(0, (now - _state.HeldSinceUtc!.Value).TotalMinutes) : 0;
var end = cursor.AddMinutes(timing.Minutes + extension);
// Phases pushed entirely out of the lesson remain pending, even after closing
// the window overnight. They run only if explicitly brought forward.
var canRun = cursor < _state.EndUtc || timing.ExplicitlyStarted || held;
phase.StartUtc = cursor;
phase.EndUtc = end;
phase.IsActive = canRun && cursor <= now && (now < end || held)
&& (now < _state.EndUtc || timing.ExplicitlyStarted || held);
phase.IsCompleted = canRun && !held && end <= now
&& (end <= _state.EndUtc || timing.ExplicitlyStarted);
phase.IsOverflow = end > _state.EndUtc && !phase.IsCompleted;
phase.IsTransferred = _state.TransferredPhaseIds.Contains(phase.Source.Id);
phase.IsHeld = held;
var effectiveNow = timing.ExplicitlyStarted || held || now < _state.EndUtc ? now : _state.EndUtc;
var elapsed = Math.Clamp((effectiveNow - cursor).TotalMinutes, 0, timing.Minutes + extension);
phase.RemainingMinutes = phase.IsCompleted ? 0 : timing.Minutes + extension - elapsed;
phase.Progress = !canRun ? 0 : phase.IsCompleted ? 100 : elapsed / Math.Max(0.01, timing.Minutes + extension) * 100;
phase.TimeDisplay = $"{cursor.ToLocalTime():HH:mm}{end.ToLocalTime():HH:mm}";
phase.CanStartNow = IsEditable && !phase.IsCompleted && !phase.IsActive && !phase.IsTransferred;
cursor = end;
}
OnPropertyChanged(nameof(HasRemainder));
}
public void Extend(TeachingPhaseViewModel phase, int minutes)
{
Refresh();
if (!IsEditable || !phase.IsActive || _state is null) return;
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
timing.Minutes += minutes;
timing.ExplicitlyStarted = true;
Save(); Refresh();
}
public void Hold(TeachingPhaseViewModel phase)
{
Refresh();
if (!IsEditable || !phase.IsActive || phase.IsHeld || _state is null) return;
_state.HeldPhaseId = phase.Source.Id;
_state.HeldSinceUtc = _utcNow();
_state.Phases.First(p => p.PhaseId == phase.Source.Id).ExplicitlyStarted = true;
Save(); Refresh();
}
public void Finish(TeachingPhaseViewModel phase, bool advanceNext = true)
{
Refresh();
if (!IsEditable || !phase.IsActive || _state is null) return;
_state.Phases.First(p => p.PhaseId == phase.Source.Id).Minutes = Math.Max(0, (_utcNow() - phase.StartUtc).TotalMinutes);
_state.HeldPhaseId = null;
_state.HeldSinceUtc = null;
if (advanceNext)
{
var nextIndex = _state.Phases.FindIndex(p => p.PhaseId == phase.Source.Id) + 1;
if (nextIndex < _state.Phases.Count) _state.Phases[nextIndex].ExplicitlyStarted = true;
}
Save(); Refresh();
}
public void BringForward(TeachingPhaseViewModel phase)
{
Refresh();
if (!phase.CanStartNow || _state is null) return;
var active = Phases.FirstOrDefault(p => p.IsActive);
if (active is not null) Finish(active, advanceNext: false);
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
timing.Minutes = phase.RemainingMinutes;
_state.Phases.Remove(timing);
var completed = Phases.TakeWhile(p => p.IsCompleted).Count();
_state.Phases.Insert(Math.Min(completed, _state.Phases.Count), timing);
timing.ExplicitlyStarted = true;
var now = _utcNow();
// A pending phase may be selected long after the scheduled end. Anchor it to
// now instead of letting yesterday's timestamps immediately complete it.
var prefix = _state.Phases.Take(completed).Sum(p => p.Minutes);
var delay = (now - _state.StartUtc.AddMinutes(prefix)).TotalMinutes;
if (completed == 0) _state.StartUtc = now;
else if (delay > 0) _state.Phases[completed - 1].Minutes += delay;
Save(); Refresh();
}
[RelayCommand] private void TransferRemainder()
{
Refresh();
if (!IsEditable || _state is null || TransferTarget is null) return;
var target = _lessons.GetById(TransferTarget.Id);
if (target is null || target.Status is LessonStatus.Cancelled or LessonStatus.Conducted) return;
var remainder = Phases.Where(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred).ToList();
if (remainder.Count == 0) return;
foreach (var phase in remainder)
{
var source = phase.Source;
target.Phases.Add(new LessonPhaseStep { Name = source.Name, DurationMinutes = (int)Math.Ceiling(phase.RemainingMinutes),
Activity = source.Activity, Material = source.Material, Shorthand = source.Shorthand });
}
_lessons.Save(target);
_state.TransferredPhaseIds.AddRange(remainder.Select(p => p.Source.Id));
Save(); Refresh();
Status = $"{remainder.Count} Phase(n) nach {target.Date:dd.MM.yyyy} · {target.Topic} kopiert.";
}
private void Save()
{
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.TeachingTimeline = _state;
_lesson.TeachingTimeline = _state;
_lessons.Save(latest);
}
}
public partial class TeachingPhaseViewModel(LessonPhaseStep source, TeachingTimelineViewModel owner) : ObservableObject
{
public LessonPhaseStep Source { get; } = source;
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public double RemainingMinutes { get; set; }
[ObservableProperty] private bool _isActive;
[ObservableProperty] private bool _isCompleted;
[ObservableProperty] private bool _isOverflow;
[ObservableProperty] private bool _isHeld;
[ObservableProperty] private bool _isTransferred;
[ObservableProperty] private bool _canStartNow;
[ObservableProperty] private double _progress;
[ObservableProperty] private string _timeDisplay = "";
public bool IsEditable => owner.IsEditable;
[RelayCommand] private void ExtendFive() => owner.Extend(this, 5);
[RelayCommand] private void ExtendTen() => owner.Extend(this, 10);
[RelayCommand] private void Hold() => owner.Hold(this);
[RelayCommand] private void Finish() => owner.Finish(this);
[RelayCommand] private void BringForward() => owner.BringForward(this);
}
@@ -84,6 +84,9 @@
ToolTip.Tip="lsid aus WebUntis (Unterricht -&gt; Mein Unterricht -&gt; Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/>
</StackPanel>
<CheckBox Content="Nicht im Untis-Hub verfolgen" IsChecked="{Binding ExcludedFromUntisHub}"
ToolTip.Tip="Blendet diese Gruppe im Untis-Hub aus (keine Fälligkeits-Erinnerungen für Fehlzeiten/Abgleiche), auch wenn eine WebUntis-Unterrichtsnummer hinterlegt ist - z.B. für Klassenrat oder AGs."/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -1,5 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
@@ -42,6 +43,19 @@
<StackPanel Spacing="0">
<WrapPanel>
<Border Classes="card" IsVisible="{Binding HasTodayLessons}">
<StackPanel Spacing="8">
<TextBlock Text="UNTERRICHT HEUTE" Classes="cardTitle"/>
<ComboBox ItemsSource="{Binding TodayLessons}" SelectedItem="{Binding SelectedTeachingLesson}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Lesson">
<TextBlock><Run Text="{Binding StartTime}"/><Run Text=" · "/><Run Text="{Binding Topic}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Unterrichtsansicht öffnen" Command="{Binding StartTeachingModeCommand}"/>
</StackPanel>
</Border>
<!-- Nächste Stunde -->
<Border Classes="card">
<StackPanel>
@@ -1,8 +1,14 @@
using Avalonia.Controls;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupOverviewTabView : UserControl
{
public GroupOverviewTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is GroupOverviewViewModel vm) vm.OnOpenTeachingMode = TeachingModeWindow.Open;
}
}
@@ -49,6 +49,13 @@
IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Planungsideen (Rohentwurf)" FontSize="12" Opacity="0.7"
ToolTip.Tip="Erste, noch grobe Ideen — lange bevor der Verlaufsplan unten feingeplant wird. Fließt auch als Kontext in die KI-Unterstützung (Backend und MCP) ein."/>
<TextBox Text="{Binding PlanningIdeas}" AcceptsReturn="True" Height="64" TextWrapping="Wrap"
PlaceholderText="Grobe Ideen, mögliche Aufhänger, erste Materialgedanken ..."/>
</StackPanel>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto">
@@ -66,7 +73,7 @@
</Grid>
<!-- Tabellenkopf -->
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26" Margin="4,0,0,0">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26,26" Margin="4,0,0,0">
<TextBlock Grid.Column="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Pfad" FontSize="11" FontWeight="SemiBold" Opacity="0.6"
ToolTip.Tip="Ankreuzen, wenn diese Phase zu einem alternativen Ablauf gehört (z.B. Kurzversion bei Zeitnot) — öffnet die Zuweisung. Phasen mit demselben Ablauf werden im Verlaufsplan-Viewer gruppiert."/>
@@ -83,7 +90,7 @@
<DataTemplate x:DataType="vm:PhaseStepEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26,26">
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
VerticalAlignment="Top" Margin="0,0,6,0"/>
<CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
@@ -110,11 +117,14 @@
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE"
ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001-&gt;S), mal nur eine Sozialform (Plenum, LDE)."/>
<Button Grid.Column="6" Content="" Command="{Binding MoveUpCommand}" Padding="4,2"
<Button Grid.Column="6" Content="📋" Padding="4,2" VerticalAlignment="Top" Margin="0,0,2,0"
IsVisible="{Binding HasMaterialPrompt}" Click="OnCopyMaterialPrompt"
ToolTip.Tip="Gespeicherten Prompt zur Materialerstellung erneut in die Zwischenablage kopieren."/>
<Button Grid.Column="7" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
VerticalAlignment="Top" ToolTip.Tip="Nach oben"/>
<Button Grid.Column="7" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
<Button Grid.Column="8" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="8" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
<Button Grid.Column="9" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
</Grid>
</Border>
@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
@@ -48,6 +49,20 @@ public partial class LessonDialog : Window
Close(false);
}
/// Kopiert den beim letzten KI-"Übernehmen" gespeicherten Materialerstellungs-Prompt (4.5.36,
/// Nachtrag zu 4.5.20) erneut in die Zwischenablage — derselbe Mechanismus wie im AiAssistDialog,
/// hier aber für einen bereits gespeicherten, nicht mehr nur transienten Vorschlag.
private async void OnCopyMaterialPrompt(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PhaseStepEditItem item } || string.IsNullOrWhiteSpace(item.MaterialPrompt))
return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(item.MaterialPrompt);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Prompt in die Zwischenablage kopiert.");
}
private async void OnAddAttachment(object? sender, RoutedEventArgs e)
{
if (DataContext is not LessonDialogViewModel vm) return;
@@ -24,8 +24,8 @@
<Setter Property="Background" Value="Transparent"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="260,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="260" IsVisible="{Binding !IsTeachingMode}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16">
<Grid RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12">
@@ -72,26 +72,35 @@
<Grid RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24">
<Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3">
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Plans}" SelectedItem="{Binding SelectedPlan}" DisplayMemberBinding="{Binding Name}"
IsVisible="{Binding IsTeachingMode}" MinWidth="180"/>
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold" IsVisible="{Binding !IsTeachingMode}"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
<TextBlock Text="{Binding QuickModeDisplay}" FontSize="12" TextWrapping="Wrap" IsVisible="{Binding IsTeachingMode}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210"/>
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210" IsEnabled="{Binding !IsTeachingMode}"/>
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}"/>
</Border>
</StackPanel>
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
<TextBlock Text="Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<TextBlock Text="{Binding QuickModeDisplay}" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
</Border>
</StackPanel>
</Grid>
@@ -158,10 +167,20 @@
IsVisible="{Binding HasDayHighlightBadge}"
ToolTip.Tip="Tagesflagge"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding ShowQuickCheck}">
<Grid ColumnDefinitions="*,*">
<Button Content="{Binding QuickPositiveLabel}" Command="{Binding QuickPositiveCommand}"
FontSize="11" Padding="5,5" HorizontalAlignment="Stretch" Margin="0,0,3,0"/>
<Button Grid.Column="1" Content="{Binding QuickNegativeLabel}" Command="{Binding QuickNegativeCommand}"
FontSize="11" Padding="5,5" HorizontalAlignment="Stretch"/>
</Grid>
<Button Content="Sonderfälle …" Command="{Binding QuickSpecialCommand}"
FontSize="10" Padding="5,3" HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
vollen Bewertungsdialog zu öffnen -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
IsVisible="{Binding ShowLessonOverview}">
IsVisible="{Binding ShowNormalActions}">
<Button Padding="6,2" FontSize="10"
Command="{Binding TallyRaisedHandCommand}"
ToolTip.Tip="Meldung zählen">
@@ -174,7 +193,7 @@
</Button>
</StackPanel>
<Expander Header=" Situation" FontSize="10"
IsVisible="{Binding CanRecordLesson}">
IsVisible="{Binding ShowSituationActions}">
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
@@ -1,5 +1,6 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow"
@@ -8,8 +9,23 @@
Width="1400" Height="850" MinWidth="1000" MinHeight="600"
WindowState="Maximized" CanResize="True" WindowStartupLocation="CenterScreen">
<Window.Styles>
<Style Selector="Border.phase">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="Border.phase.overflow">
<Setter Property="Background" Value="#22E57373"/>
<Setter Property="BorderBrush" Value="#99E57373"/>
</Style>
<Style Selector="Border.phase.active">
<Setter Property="Background" Value="#223BA6C8"/>
<Setter Property="BorderBrush" Value="#3BA6C8"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*" Margin="20">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
<Grid Grid.Row="0" RowDefinitions="Auto,Auto" Margin="0,0,0,14">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock FontSize="20" FontWeight="SemiBold">
<Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/>
@@ -26,19 +42,22 @@
</TextBlock>
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<WrapPanel Grid.Row="1" ItemSpacing="8" LineSpacing="6" Margin="0,10,0,0">
<!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
<Button Content="Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}"
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/>
<Button Content="Anwesenheit kontrollieren" Command="{Binding SeatingPlan.CheckAttendanceCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
<Button Content="Hausaufgaben kontrollieren" Command="{Binding SeatingPlan.CheckHomeworkCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
<Button Content="Kontrolle beenden" Command="{Binding SeatingPlan.EndQuickCheckCommand}" IsVisible="{Binding SeatingPlan.IsQuickMode}"/>
<Button Content="Listenansicht …" Command="{Binding Participation.StatusQuickInputCommand}" ToolTip.Tip="Auch Schüler ohne Sitzplatz erfassen."/>
<Button Content="Vollbild ↔" Click="OnFullScreen" ToolTip.Tip="Vollbild umschalten (F11); mit Escape verlassen."/>
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
<Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/>
</StackPanel>
</WrapPanel>
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="360,16,*">
@@ -48,44 +67,78 @@
<StackPanel Spacing="14">
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding LessonInfo.PhaseGroups}">
<TextBlock Text="Hauptweg · Live-Verlauf" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Noch keine Phasen geplant." IsVisible="{Binding !Timeline.HasPhases}"/>
<Button Content="Zeitmessung jetzt starten" Command="{Binding Timeline.StartNowCommand}" IsVisible="{Binding Timeline.NeedsStart}" IsEnabled="{Binding Timeline.IsEditable}"/>
<ItemsControl ItemsSource="{Binding Timeline.Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
<StackPanel Margin="0,0,0,10">
<StackPanel IsVisible="{Binding $parent[ItemsControl].((vm:LessonViewerViewModel)DataContext).HasAlternatives}">
<TextBlock Text="{Binding Label}" FontSize="12" FontWeight="SemiBold" Opacity="0.75" Margin="0,6,0,2"/>
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.55" TextWrapping="Wrap" Margin="0,0,0,6"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<DataTemplate x:DataType="vm:TeachingPhaseViewModel">
<Border Classes="phase" Classes.active="{Binding IsActive}" Classes.overflow="{Binding IsOverflow}" CornerRadius="6" Padding="10,8" Margin="0,0,0,8">
<StackPanel Spacing="5">
<TextBlock Text="{Binding Source.Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding TimeDisplay}" FontSize="12"/>
<TextBlock Text="{Binding Source.DurationMinutes, StringFormat='Geplant: {0} Min.'}" FontSize="11" Opacity="0.7"/>
<TextBlock Text="{Binding Source.Activity}" TextWrapping="Wrap" FontSize="12"
IsVisible="{Binding Source.Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Source.Material}" TextWrapping="Wrap" FontSize="11" Opacity="0.7"
IsVisible="{Binding Source.Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Source.Shorthand}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Source.Shorthand, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="3" IsVisible="{Binding IsActive}"/>
<TextBlock Text="Über dem geplanten Stundenende" FontSize="11" IsVisible="{Binding IsOverflow}"/>
<TextBlock Text="Erledigt" FontSize="11" Opacity="0.6" IsVisible="{Binding IsCompleted}"/>
<TextBlock Text="In Folgestunde kopiert" FontSize="11" IsVisible="{Binding IsTransferred}"/>
<StackPanel IsVisible="{Binding IsActive}" IsEnabled="{Binding IsEditable}" Spacing="4">
<WrapPanel ItemSpacing="4" LineSpacing="4">
<Button Content="Weiter" Command="{Binding FinishCommand}" FontSize="11" Padding="6,4"/>
<Button Content="+5 Min." Command="{Binding ExtendFiveCommand}" FontSize="11" Padding="6,4"/>
<Button Content="+10 Min." Command="{Binding ExtendTenCommand}" FontSize="11" Padding="6,4"/>
<Button Content="Halten bis Weiter" Command="{Binding HoldCommand}" FontSize="11" Padding="6,4" IsEnabled="{Binding !IsHeld}"/>
</WrapPanel>
<TextBlock Text="Gehalten mit Weiter nächste Phase starten" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding IsHeld}"/>
</StackPanel>
<Button Content="Jetzt vorziehen" Command="{Binding BringForwardCommand}" IsVisible="{Binding CanStartNow}" FontSize="11"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="10,8" Margin="0,0,0,6">
<StackPanel Spacing="2">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Grid.Column="1" FontSize="11" Opacity="0.6">
<Run Text="{Binding TimeDisplay}"/><Run Text=" · "/>
<Run Text="{Binding DurationMinutes, StringFormat='{}{0} Min.'}"/>
</TextBlock>
</Grid>
<TextBlock Text="{Binding Activity}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock FontSize="11" Opacity="0.55" TextWrapping="Wrap"
IsVisible="{Binding Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Run Text="Material: "/><Run Text="{Binding Material}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Spacing="6" IsVisible="{Binding Timeline.HasRemainder}" IsEnabled="{Binding Timeline.IsEditable}">
<TextBlock Text="Rest für eine Folgestunde" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Timeline.TransferTargets}" SelectedItem="{Binding Timeline.TransferTarget}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Lesson">
<TextBlock><Run Text="{Binding Date, StringFormat='{}{0:dd.MM.yyyy}'}"/><Run Text=" · "/><Run Text="{Binding Topic}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Rötlich markierte Phasen kopieren" Command="{Binding Timeline.TransferRemainderCommand}" IsEnabled="{Binding Timeline.HasTransferTargets}"/>
<TextBlock Text="Zuerst eine Folgestunde in der Planung anlegen." IsVisible="{Binding !Timeline.HasTransferTargets}" TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Text="{Binding Timeline.Status}" TextWrapping="Wrap" FontSize="11"/>
<Expander Header="Alternative Abläufe" IsVisible="{Binding LessonInfo.HasAlternatives}">
<ItemsControl ItemsSource="{Binding LessonInfo.PhaseGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
<StackPanel IsVisible="{Binding !IsMainPath}" Spacing="4" Margin="0,4">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Description}" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<StackPanel Margin="0,4">
<TextBlock><Run Text="{Binding Name}"/><Run Text="{Binding DurationMinutes, StringFormat=' · {0} Min.'}"/></TextBlock>
<TextBlock Text="{Binding Activity}" TextWrapping="Wrap" FontSize="12"/>
<TextBlock Text="{Binding Material}" TextWrapping="Wrap" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
@@ -1,5 +1,9 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Core.Models;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -8,7 +12,53 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class TeachingModeWindow : Window
{
public TeachingModeWindow() => InitializeComponent();
private static readonly Dictionary<Guid, TeachingModeWindow> OpenWindows = [];
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(1) };
private WindowState _previousState = WindowState.Maximized;
public static void Open(Lesson lesson)
{
if (OpenWindows.TryGetValue(lesson.Id, out var existing))
{
if (existing.WindowState == WindowState.Minimized) existing.WindowState = WindowState.Normal;
existing.Activate();
return;
}
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
if (group is null) return;
var window = new TeachingModeWindow
{
DataContext = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>())
};
OpenWindows.Add(lesson.Id, window);
window.Closed += (_, _) => OpenWindows.Remove(lesson.Id);
window.Show();
}
public TeachingModeWindow()
{
InitializeComponent();
_clock.Tick += (_, _) => (DataContext as TeachingModeViewModel)?.Timeline.Refresh();
Opened += (_, _) => _clock.Start();
Closed += (_, _) => _clock.Stop();
KeyDown += (_, e) =>
{
if (e.Key == Key.F11) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.Escape && WindowState == WindowState.FullScreen)
{ WindowState = _previousState; e.Handled = true; }
};
}
private void ToggleFullScreen()
{
if (WindowState == WindowState.FullScreen) WindowState = _previousState;
else { _previousState = WindowState; WindowState = WindowState.FullScreen; }
}
private void OnFullScreen(object? sender, RoutedEventArgs e) => ToggleFullScreen();
protected override void OnDataContextChanged(EventArgs e)
{
@@ -35,6 +85,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
{
if (tabVm.StudentRows.Count == 0) return;
tabVm.RefreshCurrentGrid();
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
@@ -44,6 +95,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
{
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
tabVm.RefreshCurrentGrid();
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
@@ -185,19 +185,10 @@ public partial class TimetableView : UserControl
await dialog.ShowDialog<bool>(owner);
}
private async Task ShowTeachingMode(Lesson lesson)
private Task ShowTeachingMode(Lesson lesson)
{
var owner = TopLevel.GetTopLevel(this) as Window;
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
if (owner is null || group is null) return;
var teachingModeVm = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>());
var window = new TeachingModeWindow { DataContext = teachingModeVm };
await window.ShowDialog(owner);
TeachingModeWindow.Open(lesson);
return Task.CompletedTask;
}
private async Task ShowLessonViewerDialog(Lesson lesson)
+17 -4
View File
@@ -7,8 +7,21 @@
ShowInTaskbar="False"
WindowDecorations="None"
WindowStartupLocation="CenterScreen">
<Image Source="/Assets/SplashScreen.png"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
<Viewbox Stretch="UniformToFill">
<Canvas Width="1086" Height="1448">
<Image Source="/Assets/SplashScreen.png" Width="1086" Height="1448"/>
<Border Canvas.Left="326" Canvas.Top="1004" Width="480" Height="31"
Background="#303234" CornerRadius="16" ClipToBounds="True">
<ProgressBar x:Name="StartupProgress" Minimum="0" Maximum="100" Value="0"
Height="31" Background="#303234" Foreground="#229DDD"
ShowProgressText="False"/>
</Border>
<Border Canvas.Left="295" Canvas.Top="1042" Width="520" Height="58"
Background="#202326" CornerRadius="12">
<TextBlock x:Name="StartupStatus" Text="Start wird vorbereitet …"
Foreground="White" FontSize="24" TextAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Canvas>
</Viewbox>
</Window>
@@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views;
public partial class SplashWindow : Window
{
public SplashWindow() => InitializeComponent();
public void SetProgress(int value, string status)
{
StartupProgress.Value = value;
StartupStatus.Text = status;
}
}