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
@@ -76,6 +76,9 @@ public class AiLesson
public List<AiPhaseStep> Phases { get; set; } = []; public List<AiPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; } public string? Homework { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
public string? PlanningIdeas { get; set; }
} }
public class AiPhaseStep public class AiPhaseStep
+8
View File
@@ -27,6 +27,14 @@ public class LearningGroup
/// </summary> /// </summary>
public int? WebUntisLessonId { get; set; } public int? WebUntisLessonId { get; set; }
/// <summary> /// <summary>
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
/// verhalten sich unverändert wie bisher).
/// </summary>
public bool ExcludedFromUntisHub { get; set; }
/// <summary>
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) — /// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg. /// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds /// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
+34
View File
@@ -54,7 +54,15 @@ public class Lesson : IHasAttachments
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell /// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>. /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
public TimeOnly? StartTime { get; set; } public TimeOnly? StartTime { get; set; }
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
public string? PlanningIdeas { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = []; public List<LessonPhaseStep> Phases { get; set; } = [];
public TeachingTimelineState? TeachingTimeline { get; set; }
public string? Homework { get; set; } public string? Homework { get; set; }
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert /// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4). /// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
@@ -94,6 +102,14 @@ public class LessonPhaseStep
public string Activity { get; set; } = ""; public string Activity { get; set; } = "";
public string Material { get; set; } = ""; public string Material { get; set; } = "";
public string Shorthand { get; set; } = ""; public string Shorthand { get; set; } = "";
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
public string? MaterialPrompt { get; set; }
/// <summary> /// <summary>
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus /// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören — /// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
@@ -244,3 +260,21 @@ public class ReportGrade
public bool IsLocked { get; set; } public bool IsLocked { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>Live timing is separate from the original lesson plan.</summary>
public class TeachingTimelineState
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public DateTime? HeldSinceUtc { get; set; }
public Guid? HeldPhaseId { get; set; }
public List<TeachingPhaseTiming> Phases { get; set; } = [];
public List<Guid> TransferredPhaseIds { get; set; } = [];
}
public class TeachingPhaseTiming
{
public Guid PhaseId { get; set; }
public double Minutes { get; set; }
public bool ExplicitlyStarted { get; set; }
}
+20
View File
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
Assert.Null(exception); Assert.Null(exception);
} }
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
[Fact]
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
{
using var db = NewInMemoryContext();
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
var repo = new UntisHubJobStateRepository(db);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
repo.Save(state);
var call = Assert.Single(calls);
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
Assert.Equal(state.Id.ToString(), call.EntityId);
Assert.Equal("Save", call.Operation);
Assert.Same(state, call.Payload);
}
} }
@@ -907,7 +907,11 @@ public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRep
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList(); public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state); public void Save(UntisHubJobState state)
{
db.UntisHubJobStates.Upsert(state);
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
}
} }
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
@@ -10,6 +10,33 @@ namespace LehrerApp.Desktop.Tests;
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten. /// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
public sealed class GroupOverviewViewModelTests public sealed class GroupOverviewViewModelTests
{ {
[Fact]
public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl()
{
var group = new LearningGroup { IsActive = true };
var today = DateOnly.FromDateTime(DateTime.Today);
var lessons = new FakeLessons();
var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" };
var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" };
lessons.Add(first);
lessons.Add(second);
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled });
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) });
lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today });
var vm = NewVm(lessons: lessons, groups: new FakeGroups([group]));
vm.Initialize(group.Id, group.Name);
Assert.True(vm.HasTodayLessons);
Assert.Equal(2, vm.TodayLessons.Count);
Lesson? opened = null;
vm.OnOpenTeachingMode = lesson => opened = lesson;
vm.SelectedTeachingLesson = second;
vm.StartTeachingModeCommand.Execute(null);
Assert.Same(second, opened);
group.IsActive = false;
vm.Refresh();
Assert.False(vm.HasTodayLessons);
}
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
@@ -0,0 +1,81 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SeatingQuickCheckTests
{
private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false)
{
var group = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) };
var entries = new FakeEntries();
var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] };
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]),
new FakeSessions([session]), entries, new FakeAspects());
vm.Initialize(group, readOnly);
return (vm, entries, session, student);
}
[Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 });
vm.CheckAttendanceCommand.Execute(null);
var seat = vm.Seats[0];
Assert.True(seat.ShowQuickCheck);
Assert.False(vm.Seats[1].ShowQuickCheck);
seat.QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.Equal(3, entry.RaisedHandCount);
Assert.Equal(2, entry.CalledOnCount);
Assert.Equal(1, seat.DisplayOpacity);
seat.QuickPositiveCommand.Execute(null);
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
}
[Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing);
Assert.Equal(AttendanceStatus.Late, entry.Attendance);
vm.Seats[0].QuickPositiveCommand.Execute(null);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.False(entry.HomeworkMissing);
vm.EndQuickCheckCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.True(vm.Seats[0].ShowNormalActions);
}
[Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession()
{
var (vm, _, _, _) = Build();
var calls = 0;
vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; };
vm.CheckAttendanceCommand.Execute(null);
await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null);
Assert.Equal(1, calls);
}
[Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks()
{
var (vm, entries, session, _) = Build(readOnly: true);
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
vm.Seats[1].QuickPositiveCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.Empty(entries.GetBySession(session.Id));
}
}
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingTimelineViewModelTests
{
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
private DateTime _now;
private readonly FakeLessons _lessons = new();
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
{
_now = _start.AddMinutes(5);
var local = _start.ToLocalTime();
var lesson = new Lesson
{
Date = DateOnly.FromDateTime(local),
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
new() { Name = "Arbeit", DurationMinutes = 10 },
new() { Name = "Sicherung", DurationMinutes = 10 }]
};
_lessons.Add(lesson);
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
}
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
{
var (_, vm) = Build();
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(50, vm.Phases[0].Progress);
_now = _start.AddMinutes(10);
vm.Refresh();
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Single(vm.Phases, p => p.IsActive);
}
[Fact] public void NoStartTime_RequiresExplicitStart()
{
var (_, vm) = Build(scheduled: false);
Assert.True(vm.NeedsStart);
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
vm.StartNowCommand.Execute(null);
Assert.False(vm.NeedsStart);
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(_now, vm.Phases[0].StartUtc);
}
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
Assert.True(vm.Phases[2].IsOverflow);
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
}
[Fact] public void FinishEarly_StartsNextPhaseNow()
{
var (_, vm) = Build();
vm.Phases[0].FinishCommand.Execute(null);
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Equal(_now, vm.Phases[1].StartUtc);
}
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
{
var (lesson, vm) = Build();
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(65);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsHeld);
Assert.True(reopened.Phases[0].IsActive);
Assert.Single(reopened.Phases, p => p.IsActive);
reopened.Phases[0].FinishCommand.Execute(null);
Assert.True(reopened.Phases[1].IsActive);
Assert.False(reopened.Phases[0].IsHeld);
Assert.Equal(_now, reopened.Phases[1].StartUtc);
}
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
{
var (_, vm) = Build();
var chosen = vm.Phases[2];
chosen.BringForwardCommand.Execute(null);
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
Assert.True(chosen.IsActive);
Assert.Equal(_now, chosen.StartUtc);
Assert.False(vm.Phases[2].IsCompleted);
}
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.True(remainder.IsOverflow);
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now, remainder.StartUtc);
}
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
{
var (lesson, _) = Build();
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
_lessons.Add(target);
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
vm.Phases[0].ExtendTenCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
var copy = Assert.Single(target.Phases);
Assert.Equal("Sicherung", copy.Name);
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
Assert.Equal(3, lesson.Phases.Count);
Assert.True(vm.Phases[2].IsTransferred);
}
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendFiveCommand.Execute(null);
_now = _start.AddMinutes(28);
vm.Refresh();
Assert.True(vm.Phases[2].IsActive);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
Assert.Equal(5, remainder.RemainingMinutes);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
}
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
{
var (lesson, _) = Build(scheduled: false);
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
vm.StartNowCommand.Execute(null);
Assert.Null(lesson.TeachingTimeline);
Assert.True(vm.NeedsStart);
}
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
{
var (lesson, _) = Build();
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.Equal(3, vm.Phases.Count);
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
}
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
{
using var stream = new MemoryStream();
using var db = new LehrerApp.Data.LiteDbContext(stream);
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
var (lesson, _) = Build();
repository.Save(lesson);
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(40);
var loaded = repository.GetById(lesson.Id)!;
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsActive);
Assert.True(reopened.Phases[0].IsHeld);
Assert.Equal(_start, reopened.Phases[0].StartUtc);
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
}
}
+45 -13
View File
@@ -26,6 +26,8 @@ public class App : Application
public static IServiceProvider Services { get; private set; } = null!; public static IServiceProvider Services { get; private set; } = null!;
private static ServiceProvider? _serviceProvider; private static ServiceProvider? _serviceProvider;
private static bool _exitHandlerAttached; 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 Initialize() => AvaloniaXamlLoader.Load(this);
@@ -65,7 +67,11 @@ public class App : Application
promptVm.OnUnlocked = async password => promptVm.OnUnlocked = async password =>
{ {
AppBootstrapper.DbPassword = 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; desktop.MainWindow = promptWindow;
promptWindow.Show(); promptWindow.Show();
@@ -79,14 +85,21 @@ public class App : Application
private static async Task StartMainAppAsync( private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null) 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 = _serviceProvider;
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet."); Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>()); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per // 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 // Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
// soll, kein dauerhaftes Archiv. // 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) if (!_exitHandlerAttached)
{ {
@@ -99,19 +112,13 @@ public class App : Application
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein. // Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start(); Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
splash?.SetProgress(75, "Übersicht vorbereiten …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var mainVm = Services.GetRequiredService<MainWindowViewModel>(); var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm); WireCallbacks(mainVm);
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre splash?.SetProgress(90, "Hauptfenster öffnen …");
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
// 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);
var main = new MainWindow { DataContext = mainVm }; var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>()); main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
@@ -119,7 +126,30 @@ public class App : Application
main.EnableFinalSync(syncEngine); main.EnableFinalSync(syncEngine);
desktop.MainWindow = main; desktop.MainWindow = main;
main.Show(); main.Show();
splash?.SetProgress(100, "Bereit");
windowToClose?.Close(); 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 /// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
@@ -140,6 +170,8 @@ public class App : Application
try try
{ {
StartupCancellation.Cancel();
_initialPolls.GetAwaiter().GetResult();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint(); serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
} }
catch (Exception ex) catch (Exception ex)
+5 -1
View File
@@ -101,7 +101,7 @@ public static class AppBootstrapper
Environment.Exit(0); Environment.Exit(0);
} }
public static ServiceProvider BuildServices() public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -130,7 +130,9 @@ public static class AppBootstrapper
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backupSettings = new BackupSettingsService(appData); var backupSettings = new BackupSettingsService(appData);
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() }; var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
reportProgress?.Invoke(10, "Sicherung erstellen …");
var backupPath = backup.CreateBackup(DbPath); 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 // 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. // Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword)) if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
@@ -363,7 +365,9 @@ public static class AppBootstrapper
services.AddTransient<TrashViewModel>(); services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
provider.GetRequiredService<LiteDbContext>();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier, // Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden. // 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, StartTime = l.StartTime,
Homework = l.Homework, Homework = l.Homework,
Reflection = l.Reflection, Reflection = l.Reflection,
PlanningIdeas = l.PlanningIdeas,
Phases = ToAiPhases(l.Phases, pathNames), Phases = ToAiPhases(l.Phases, pathNames),
}) })
.ToList(); .ToList();
@@ -270,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal)) if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
diffs.Add("Reflexion geändert"); 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 pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
var existingPhases = ToAiPhases(existing.Phases, pathNames); var existingPhases = ToAiPhases(existing.Phases, pathNames);
if (!PhasesEqual(existingPhases, proposed.Phases)) if (!PhasesEqual(existingPhases, proposed.Phases))
@@ -586,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = ai.StartTime, StartTime = ai.StartTime,
Homework = ai.Homework, Homework = ai.Homework,
Reflection = ai.Reflection, Reflection = ai.Reflection,
PlanningIdeas = ai.PlanningIdeas,
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits // Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant" // durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht). // zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
@@ -597,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
Activity = p.Activity, Activity = p.Activity,
Material = p.Material, Material = p.Material,
Shorthand = p.Shorthand, 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) AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
? pathId : null, ? pathId : null,
}).ToList(), }).ToList(),
@@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval); _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; if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try try
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
string icsText; string icsText;
try try
{ {
icsText = await _http.GetStringAsync(url).ConfigureAwait(false); icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
+9 -3
View File
@@ -24,14 +24,20 @@ public record TimeEntryDto(
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date, Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description); 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 LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
public record LessonDto( public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic, Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, LessonStatus Status, List<string> Competencies, List<LessonPhaseDto> Phases, string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
List<LessonAttachmentDto> Attachments); List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten /// <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) - /// 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."); 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( public async Task<WriteResultDto> UpdateLesson(
[Description("ID der Einzelstunde.")] Guid lessonId, [Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null, [Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = 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 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("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null, [Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
@@ -204,6 +205,7 @@ public class LessonPlanTools(
var changes = new StringBuilder(); var changes = new StringBuilder();
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; } 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 (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 (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 (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; } 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("Tätigkeit/Sozialform.")] string activity = "",
[Description("Material.")] string material = "", [Description("Material.")] string material = "",
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "", [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) CancellationToken ct = default)
{ {
var lesson = lessons.GetById(lessonId); var lesson = lessons.GetById(lessonId);
@@ -240,7 +243,7 @@ public class LessonPlanTools(
var phase = new LessonPhaseStep var phase = new LessonPhaseStep
{ {
Name = name, DurationMinutes = durationMinutes, Activity = activity, Name = name, DurationMinutes = durationMinutes, Activity = activity,
Material = material, Shorthand = shorthand, Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt,
}; };
lesson.Phases.Add(phase); lesson.Phases.Add(phase);
lessons.Save(lesson); lessons.Save(lesson);
@@ -256,6 +259,7 @@ public class LessonPlanTools(
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null, [Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null, [Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = 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) CancellationToken ct = default)
{ {
var lesson = lessons.GetById(lessonId); 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 (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 (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 (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) if (changes.Length == 0)
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig."); return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
@@ -409,7 +414,7 @@ public class LessonPlanTools(
} }
private static LessonDto ToDto(Lesson l) => new( 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.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)).ToList(), 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()); 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() public List<UntisHubJobRow> GetRows()
{ {
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear()) 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) .OrderBy(g => g.Name)
.ToList(); .ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow); return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
@@ -72,8 +72,14 @@ public sealed class UntisHubService(
public static List<UntisHubJobRow> BuildRows( public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow) 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) => 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>(); var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups) foreach (var group in eligibleGroups)
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes)); 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; if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try try
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
string icsText; string icsText;
try try
{ {
icsText = await _http.GetStringAsync(url).ConfigureAwait(false); icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly GradingService _grading; private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear; 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 Guid _groupId;
private string _groupName = ""; private string _groupName = "";
@@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject
public void Refresh() public void Refresh()
{ {
var today = DateOnly.FromDateTime(DateTime.Today); 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); LoadNextLesson(today);
LoadNextExam(today); LoadNextExam(today);
LoadYearComparison(); LoadYearComparison();
@@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true; [ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId; [ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private bool _excludedFromUntisHub;
[ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = ""; [ObservableProperty] private string _gradeLevelError = "";
@@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsDifferentiated = group.IsDifferentiated; IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning; RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId; WebUntisLessonId = group.WebUntisLessonId;
ExcludedFromUntisHub = group.ExcludedFromUntisHub;
OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText)); OnPropertyChanged(nameof(SaveButtonText));
} }
@@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsDifferentiated = IsDifferentiated; Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning; Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId; Result.WebUntisLessonId = WebUntisLessonId;
Result.ExcludedFromUntisHub = ExcludedFromUntisHub;
_groups.Save(Result); _groups.Save(Result);
} }
} }
@@ -737,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject
[ObservableProperty] private int? _lessonNumber; [ObservableProperty] private int? _lessonNumber;
[ObservableProperty] private string _topic = ""; [ObservableProperty] private string _topic = "";
[ObservableProperty] private string _startTimeText = ""; [ObservableProperty] private string _startTimeText = "";
[ObservableProperty] private string _planningIdeas = "";
[ObservableProperty] private string _homework = ""; [ObservableProperty] private string _homework = "";
[ObservableProperty] private bool _homeworkChecked; [ObservableProperty] private bool _homeworkChecked;
[ObservableProperty] private bool _homeworkCheckDismissed; [ObservableProperty] private bool _homeworkCheckDismissed;
@@ -823,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject
LessonNumber = editingLesson.LessonNumber; LessonNumber = editingLesson.LessonNumber;
Topic = editingLesson.Topic; Topic = editingLesson.Topic;
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? ""; StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
PlanningIdeas = editingLesson.PlanningIdeas ?? "";
Homework = editingLesson.Homework ?? ""; Homework = editingLesson.Homework ?? "";
HomeworkChecked = editingLesson.HomeworkChecked; HomeworkChecked = editingLesson.HomeworkChecked;
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed; HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
@@ -888,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject
Activity = source?.Activity ?? "", Activity = source?.Activity ?? "",
Material = source?.Material ?? "", Material = source?.Material ?? "",
Shorthand = source?.Shorthand ?? "", Shorthand = source?.Shorthand ?? "",
MaterialPrompt = source?.MaterialPrompt,
}; };
item.OnChanged = RecomputeTimes; item.OnChanged = RecomputeTimes;
item.OnRemove = RemovePhase; item.OnRemove = RemovePhase;
@@ -1075,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject
Result.LessonNumber = LessonNumber; Result.LessonNumber = LessonNumber;
Result.Topic = Topic.Trim(); Result.Topic = Topic.Trim();
Result.StartTime = startTime; Result.StartTime = startTime;
Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim();
Result.Phases = Phases.Select(p => p.ToModel()).ToList(); Result.Phases = Phases.Select(p => p.ToModel()).ToList();
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim(); Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
Result.HomeworkChecked = HomeworkChecked; Result.HomeworkChecked = HomeworkChecked;
@@ -1103,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject
[ObservableProperty] private string _material = ""; [ObservableProperty] private string _material = "";
[ObservableProperty] private string _shorthand = ""; [ObservableProperty] private string _shorthand = "";
[ObservableProperty] private string _computedTimeDisplay = ""; [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 /// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung. /// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
@@ -1162,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject
Activity = Activity.Trim(), Activity = Activity.Trim(),
Material = Material.Trim(), Material = Material.Trim(),
Shorthand = Shorthand.Trim(), Shorthand = Shorthand.Trim(),
MaterialPrompt = MaterialPrompt,
AlternativePathId = AlternativePathId, AlternativePathId = AlternativePathId,
}; };
} }
@@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private readonly IParticipationAspectRepository _aspects; private readonly IParticipationAspectRepository _aspects;
private readonly IDocumentationRepository? _documentation; 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 Guid _groupId;
private SeatingPlan? _currentPlan; private SeatingPlan? _currentPlan;
private bool _isReadOnly; private bool _isReadOnly;
@@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
?? StudentSeatOption.Empty; ?? StudentSeatOption.Empty;
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, 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(); UpdateAssignmentSummary();
RefreshSeatLessonData(); RefreshSeatLessonData();
@@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
partial void OnIsEditModeChanged(bool value) partial void OnIsEditModeChanged(bool value)
{ {
if (value) QuickMode = "";
OnPropertyChanged(nameof(CanEditLayout)); OnPropertyChanged(nameof(CanEditLayout));
foreach (var seat in Seats) foreach (var seat in Seats)
{ {
@@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session)
public partial class SeatCellViewModel : ObservableObject 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 readonly Action<SeatCellViewModel> _onChanged;
private bool _suppressChange; private bool _suppressChange;
private readonly Action<SeatCellViewModel, string> _toggleSituationTag; 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 /// 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 /// 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> /// 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; private readonly Action<SeatCellViewModel, bool> _tally;
@@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnSelectedOptionChanged(StudentSeatOption value) partial void OnSelectedOptionChanged(StudentSeatOption value)
{ {
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(IsOccupied));
OnPropertyChanged(nameof(StudentName)); OnPropertyChanged(nameof(StudentName));
OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(ShowLessonOverview));
@@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnCanEditChanged(bool value) partial void OnCanEditChanged(bool value)
{ {
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(ShowLessonOverview));
OnPropertyChanged(nameof(ShowSeat)); OnPropertyChanged(nameof(ShowSeat));
OnPropertyChanged(nameof(CanToggleHidden)); OnPropertyChanged(nameof(CanToggleHidden));
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
/// </summary> /// </summary>
public class TeachingModeViewModel public class TeachingModeViewModel
{ {
public TeachingTimelineViewModel Timeline { get; }
public string GroupName { get; } public string GroupName { get; }
public LessonViewerViewModel LessonInfo { get; } public LessonViewerViewModel LessonInfo { get; }
public SeatingPlanTabViewModel SeatingPlan { get; } public SeatingPlanTabViewModel SeatingPlan { get; }
@@ -38,9 +39,11 @@ public class TeachingModeViewModel
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation) SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
{ {
GroupName = group.Name; GroupName = group.Name;
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths); LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
SeatingPlan = seatingPlan; SeatingPlan = seatingPlan;
SeatingPlan.IsTeachingMode = true;
SeatingPlan.Initialize(group.Id, !group.IsActive); SeatingPlan.Initialize(group.Id, !group.IsActive);
SeatingPlan.SelectOrCreateSessionForLesson(lesson); SeatingPlan.SelectOrCreateSessionForLesson(lesson);
@@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject
if (_previousLesson is null) return; if (_previousLesson is null) return;
_previousLesson.HomeworkChecked = value; _previousLesson.HomeworkChecked = value;
if (value) _previousLesson.HomeworkCheckDismissed = false; 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] [RelayCommand]
private void SaveCurrentHomework() private void SaveCurrentHomework()
{ {
_lesson.Homework = CurrentHomework; _lesson.Homework = CurrentHomework;
_lessons.Save(_lesson); var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.Homework = CurrentHomework;
_lessons.Save(latest);
SaveStatus = "Gespeichert."; 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."/> 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> </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> </StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -1,5 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 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:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels" xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView" x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
@@ -42,6 +43,19 @@
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<WrapPanel> <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 --> <!-- Nächste Stunde -->
<Border Classes="card"> <Border Classes="card">
<StackPanel> <StackPanel>
@@ -1,8 +1,14 @@
using Avalonia.Controls; using Avalonia.Controls;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupOverviewTabView : UserControl public partial class GroupOverviewTabView : UserControl
{ {
public GroupOverviewTabView() => InitializeComponent(); 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}}"/> IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </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"/> <Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
@@ -66,7 +73,7 @@
</Grid> </Grid>
<!-- Tabellenkopf --> <!-- 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="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Pfad" 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."/> 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"> <DataTemplate x:DataType="vm:PhaseStepEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" <Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8"> 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" <TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
VerticalAlignment="Top" Margin="0,0,6,0"/> VerticalAlignment="Top" Margin="0,0,6,0"/>
<CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}" <CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
@@ -110,11 +117,14 @@
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top" FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE" 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)."/> 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"/> 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"/> 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"/> VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
</Grid> </Grid>
</Border> </Border>
@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
@@ -48,6 +49,20 @@ public partial class LessonDialog : Window
Close(false); 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) private async void OnAddAttachment(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not LessonDialogViewModel vm) return; if (DataContext is not LessonDialogViewModel vm) return;
@@ -24,8 +24,8 @@
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<Grid ColumnDefinitions="260,*"> <Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" <Border Grid.Column="0" Width="260" IsVisible="{Binding !IsTeachingMode}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16"> BorderThickness="0,0,1,0" Padding="16">
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12"> <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 RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24">
<Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18"> <Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3"> <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 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" <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"> IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/> <TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}" <ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210"/> DisplayMemberBinding="{Binding DisplayName}" MinWidth="210" IsEnabled="{Binding !IsTeachingMode}"/>
</StackPanel> </StackPanel>
</Border>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4"> <StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"> <StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/> <Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}" <Border IsVisible="{Binding !IsTeachingMode}">
IsVisible="{Binding IsEditable}"/> <ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}"/>
</Border>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/> <TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right" <TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/> FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
<TextBlock Text="Klicken: bewerten" HorizontalAlignment="Right" <Border IsVisible="{Binding !IsTeachingMode}">
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/> <TextBlock Text="{Binding QuickModeDisplay}" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -158,10 +167,20 @@
IsVisible="{Binding HasDayHighlightBadge}" IsVisible="{Binding HasDayHighlightBadge}"
ToolTip.Tip="Tagesflagge"/> ToolTip.Tip="Tagesflagge"/>
</StackPanel> </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 <!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
vollen Bewertungsdialog zu öffnen --> vollen Bewertungsdialog zu öffnen -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5" <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
IsVisible="{Binding ShowLessonOverview}"> IsVisible="{Binding ShowNormalActions}">
<Button Padding="6,2" FontSize="10" <Button Padding="6,2" FontSize="10"
Command="{Binding TallyRaisedHandCommand}" Command="{Binding TallyRaisedHandCommand}"
ToolTip.Tip="Meldung zählen"> ToolTip.Tip="Meldung zählen">
@@ -174,7 +193,7 @@
</Button> </Button>
</StackPanel> </StackPanel>
<Expander Header=" Situation" FontSize="10" <Expander Header=" Situation" FontSize="10"
IsVisible="{Binding CanRecordLesson}"> IsVisible="{Binding ShowSituationActions}">
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0"> <ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate> <ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
@@ -1,5 +1,6 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 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:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow" x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow"
@@ -8,8 +9,23 @@
Width="1400" Height="850" MinWidth="1000" MinHeight="600" Width="1400" Height="850" MinWidth="1000" MinHeight="600"
WindowState="Maximized" CanResize="True" WindowStartupLocation="CenterScreen"> 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 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"> <StackPanel Grid.Column="0" Spacing="3">
<TextBlock FontSize="20" FontWeight="SemiBold"> <TextBlock FontSize="20" FontWeight="SemiBold">
<Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/> <Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/>
@@ -26,19 +42,22 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</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 <!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". --> (siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}" <Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/> ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
<Button Content="Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}" <Button Content="Anwesenheit kontrollieren" Command="{Binding SeatingPlan.CheckAttendanceCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/> <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}" <Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/> ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/> <Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
<Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/> <Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/>
</StackPanel> </WrapPanel>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="360,16,*"> <Grid Grid.Row="1" ColumnDefinitions="360,16,*">
@@ -48,44 +67,78 @@
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/> <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> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem"> <DataTemplate x:DataType="vm:TeachingPhaseViewModel">
<StackPanel Margin="0,0,0,10"> <Border Classes="phase" Classes.active="{Binding IsActive}" Classes.overflow="{Binding IsOverflow}" CornerRadius="6" Padding="10,8" Margin="0,0,0,8">
<StackPanel IsVisible="{Binding $parent[ItemsControl].((vm:LessonViewerViewModel)DataContext).HasAlternatives}"> <StackPanel Spacing="5">
<TextBlock Text="{Binding Label}" FontSize="12" FontWeight="SemiBold" Opacity="0.75" Margin="0,6,0,2"/> <TextBlock Text="{Binding Source.Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.55" TextWrapping="Wrap" Margin="0,0,0,6" <TextBlock Text="{Binding TimeDisplay}" FontSize="12"/>
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> <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> </StackPanel>
<ItemsControl ItemsSource="{Binding Phases}"> </Border>
<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>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </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 <!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
@@ -1,5 +1,9 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; 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;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -8,7 +12,53 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class TeachingModeWindow : Window 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) protected override void OnDataContextChanged(EventArgs e)
{ {
@@ -35,6 +85,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm) private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
{ {
if (tabVm.StudentRows.Count == 0) return; if (tabVm.StudentRows.Count == 0) return;
tabVm.RefreshCurrentGrid();
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList()); var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm }; var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this); await dialog.ShowDialog(this);
@@ -44,6 +95,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm) private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
{ {
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return; if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
tabVm.RefreshCurrentGrid();
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay); var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm }; var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this); await dialog.ShowDialog(this);
@@ -185,19 +185,10 @@ public partial class TimetableView : UserControl
await dialog.ShowDialog<bool>(owner); await dialog.ShowDialog<bool>(owner);
} }
private async Task ShowTeachingMode(Lesson lesson) private Task ShowTeachingMode(Lesson lesson)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; TeachingModeWindow.Open(lesson);
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId); return Task.CompletedTask;
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);
} }
private async Task ShowLessonViewerDialog(Lesson lesson) private async Task ShowLessonViewerDialog(Lesson lesson)
+17 -4
View File
@@ -7,8 +7,21 @@
ShowInTaskbar="False" ShowInTaskbar="False"
WindowDecorations="None" WindowDecorations="None"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen">
<Image Source="/Assets/SplashScreen.png" <Viewbox Stretch="UniformToFill">
Stretch="UniformToFill" <Canvas Width="1086" Height="1448">
HorizontalAlignment="Stretch" <Image Source="/Assets/SplashScreen.png" Width="1086" Height="1448"/>
VerticalAlignment="Stretch"/> <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> </Window>
@@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views;
public partial class SplashWindow : Window public partial class SplashWindow : Window
{ {
public SplashWindow() => InitializeComponent(); public SplashWindow() => InitializeComponent();
public void SetProgress(int value, string status)
{
StartupProgress.Value = value;
StartupStatus.Text = status;
}
} }
+14
View File
@@ -38,6 +38,20 @@ public sealed class EventApplierTests
Assert.Null(db.Students.FindById(student.Id)); Assert.Null(db.Students.FindById(student.Id));
} }
[Fact]
public async Task ApplyAsync_UntisHubJobStateSave_SchreibtEntitaetDirektInDieCollection()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
await applier.ApplyAsync(MakeEvent(nameof(UntisHubJobState), state.Id.ToString(), "Save", state));
var saved = db.UntisHubJobStates.FindById(state.Id);
Assert.NotNull(saved);
Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind);
}
[Fact] [Fact]
public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht() public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht()
{ {
+1
View File
@@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SubstitutionEntry>(context => context.SubstitutionEntries); Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains); Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<Vorgang>(context => context.Vorgaenge); Simple<Vorgang>(context => context.Vorgaenge);
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert. // Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
+74
View File
@@ -2097,6 +2097,37 @@ vollständig enthält.
Zeilen), damit ein KI-Client für eine dort gelistete Gruppe nicht zusätzlich `get_groups` Zeilen), damit ein KI-Client für eine dort gelistete Gruppe nicht zusätzlich `get_groups`
aufrufen muss, nur um den Fehlzeitenabgleich für sie auszulösen. aufrufen muss, nur um den Fehlzeitenabgleich für sie auszulösen.
**Nachtrag (Nutzer-Feedback, 2026-09-13):** `UntisHubJobState` (die Fälligkeits-Zeitstempel
hinter dem Hub) lief bislang außerhalb des Sync — jedes Gerät führte seine eigene Buchhaltung.
In der Praxis zeigte das auf allen Instanzen dieselben offenen Punkte, was dem Zweck des Hubs
widerspricht (möglichst wenig WebUntis-Traffic: ein bereits auf Gerät A erledigter Abgleich soll
auf Gerät B nicht erneut als fällig erscheinen und zu einem zweiten, unnötigen Abruf verleiten).
Anders als die drei bewusst unsynchronisierten Report-Caches (`UntisAbsenceCacheRepository` &
Co., siehe oben) enthält `UntisHubJobState` keine WebUntis-Rohdaten, sondern nur Zeitstempel +
Kurztext — unkritisch für Sync.
- `UntisHubJobStateRepository.Save` ruft jetzt `db.OnChange` wie die übrigen ~28 synchronisierten
Repositories auf; `EventApplier` bekommt dafür einen zusätzlichen `Simple<UntisHubJobState>`-
Eintrag. Kein `Delete` nötig (Interface kennt keins) — Zeilen werden nur überschrieben.
- Geräte-Pairing (`SnapshotService`) war bereits unberührt, da es die komplette DB-Datei kopiert.
- Race-Härtung: `UntisHubService.RecordRun` legt bei einem (Kind, GroupId), das lokal noch nie
lief, eine neue `Id` an; laufen zwei Geräte offline denselben, noch nie ausgeführten Job
unabhängig voneinander, entstehen dadurch kurzzeitig zwei Datensätze für dasselbe Paar (kein
Unique-Index darauf). `UntisHubService.BuildRows` wählt deshalb jetzt den Datensatz mit dem
jüngsten `LastRunAt` statt eines beliebigen — der verwaiste zweite Datensatz bleibt harmlos in
der DB stehen (gleiches akzeptiertes v1-Verhalten wie bei anderen Entitäten ohne serverseitige
Merge-Logik, siehe TODO 10.3).
**Nachtrag (Nutzer-Feedback, 2026-09-13):** Manche Lerngruppen mit `WebUntisLessonId` (z.B.
Klassenrat, AGs) sollen trotzdem nie im Untis-Hub auftauchen. Neues Feld
`LearningGroup.ExcludedFromUntisHub` (Default `false`, siehe
[LehrerApp.Core/Models/LearningGroup.cs](LehrerApp.Core/Models/LearningGroup.cs)) —
`UntisHubService.GetRows()` filtert damit zusätzlich zu `WebUntisLessonId is not null`. Checkbox
"Nicht im Untis-Hub verfolgen" im Gruppendialog
([AddGroupDialog.axaml](LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml)). Kein eigener
Sync-Aufwand nötig, da `LearningGroup` bereits als Ganzes synchronisiert wird
(`EventApplier.Simple<LearningGroup>`); `get_untis_hub_status` (MCP) profitiert automatisch mit,
da es nur an `GetRows()` delegiert.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
@@ -2885,6 +2916,40 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
- Kein zusätzliches Feature wie Windows-Autostart für LehrerApp.Desktop selbst oder ein - Kein zusätzliches Feature wie Windows-Autostart für LehrerApp.Desktop selbst oder ein
Tray-Icon (auf Nachfrage bewusst nicht Teil dieser Änderung) — die Bridge löst das Problem Tray-Icon (auf Nachfrage bewusst nicht Teil dieser Änderung) — die Bridge löst das Problem
rein auf Protokollebene, unabhängig davon, wann der Nutzer LehrerApp tatsächlich startet. rein auf Protokollebene, unabhängig davon, wann der Nutzer LehrerApp tatsächlich startet.
- [x] **4.5.35** Freitextfeld "Planungsideen" je Stunde (2026-09-14, Nutzer-Feedback): erste, noch
grobe Ideen zu einer Stunde entstehen erfahrungsgemäß lange bevor der Verlaufsplan (4.2.2)
feingeplant wird — dafür gab es bisher kein Feld, nur `Reflection` (nach der Stunde) und
`Unit.Notes` (auf Einheiten- statt Stundenebene). Neues `Lesson.PlanningIdeas` (`string?`),
im [LessonDialog](LehrerApp.Desktop/Views/Groups/LessonDialog.axaml) direkt unter "Thema" als
mehrzeiliges Freitextfeld, noch vor dem Verlaufsplan — bewusste Platzierung, damit die Idee
zuerst und die Feinplanung erst danach kommt. Keine Migration nötig (LiteDB, neues Feld).
Auch als Kontext für die KI-Planungsunterstützung (4.5.9) durchgereicht, wie von der
Lehrkraft gewünscht ("auch nützlich für die KI-gestützte Weiterplanung"): `AiLesson`/
`AiUnitContext` (Core/AiPlanning) bekommen ein neues, zu `Homework`/`Reflection` symmetrisches
Feld `planningIdeas` (gelesen in `AiPlanningService.BuildContext`, übernommen in
`ApplyResponse`, im Planungsdiff berücksichtigt in `DescribeChanges`); `ai-backend/plan.php`
bekommt das Feld in Ein- und Ausgabeschema samt Hinweis an die KI, es als starken Kontext zu
nutzen und unverändert zurückzugeben, außer die Anweisung verlangt ausdrücklich eine
Überarbeitung. Für die MCP-gestützte Weiterplanung (4.5.28) zusätzlich in `LessonDto`
(Services/Mcp/Tools/Dto.cs) exponiert und über einen neuen optionalen `planningIdeas`-
Parameter von `update_lesson` (LessonPlanTools.cs) schreibbar — bewusst kein neues, eigenes
MCP-Tool nur für dieses eine Feld, passt in das bestehende kleinteilige Muster.
- [x] **4.5.36** Materialerstellungs-Prompt bleibt nach dem Übernehmen erhalten (2026-09-14,
Nachtrag zu 4.5.20, Nutzer-Feedback): der in 4.5.20 gebaute Prompt war bisher nur im
`AiAssistDialog` (Review vor dem Speichern) kopierbar — einmal übernommen, war er weg, obwohl
die Lehrkraft ihn oft erst später in einer externen KI-Sitzung tatsächlich einlöst. Neues
`LessonPhaseStep.MaterialPrompt` (`string?`, keine Migration nötig): `AiPlanningService.
ApplyResponse` befüllt es automatisch mit dem von `BuildMaterialPrompt` erzeugten Text, sobald
die Phase einen `MaterialSuggestion`-Vorschlag hatte — bewusst weiterhin kein separates
"Material"-Domänenmodell (siehe 4.5.26), nur ein zusätzliches Feld an der bestehenden Zeile.
Im [LessonDialog](LehrerApp.Desktop/Views/Groups/LessonDialog.axaml) erscheint dafür in der
Verlaufsplan-Tabelle ein neuer Button "📋" je Phasenzeile, nur sichtbar wenn ein Prompt
gespeichert ist (`PhaseStepEditItem.HasMaterialPrompt`) — derselbe Zwischenablage-Mechanismus
wie im `AiAssistDialog` (`LessonDialog.axaml.cs:OnCopyMaterialPrompt`). Für die MCP-gestützte
Weiterplanung zusätzlich in `LessonPhaseDto.MaterialPrompt` gelesen und über einen neuen
optionalen Parameter an `add_lesson_phase`/`update_lesson_phase` (LessonPlanTools.cs)
schreibbar, damit auch ein extern (z.B. in Claude Desktop) formulierter Prompt zur
Wiederverwendung gespeichert werden kann, nicht nur ein vom eigenen KI-Backend erzeugter.
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich, **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
@@ -4801,3 +4866,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
**→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte **→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte
pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder
gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht. gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht.
### Startoptimierung (September 2026)
- [x] Backup, Backup-Pruefung und Datenbankinitialisierung laufen ausserhalb des UI-Threads.
- [x] Splashscreen mit echtem, phasenbasiertem Ladebalken und aktuellem Arbeitsschritt.
- [x] Optionale iCal-Erstabgleiche starten nach dem Hauptfenster; lokale Daten sind sofort nutzbar.
Laufende Erstabgleiche werden vor der Freigabe der Datenbank beendet; HTTP-Abrufe beim Beenden abgebrochen.
Die Zeit bis zum Hauptfenster wird im App-Log protokolliert.
+2 -1
View File
@@ -39,7 +39,8 @@ Unterricht einsetzen kann, auch wenn sie nicht ihre eigene Idee war.
"phases": [ "phases": [
{ "name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>", "material": "<Material>", "shorthand": "<Kurzsymbol>" } { "name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>", "material": "<Material>", "shorthand": "<Kurzsymbol>" }
], ],
"homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>" "homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>",
"planningIdeas": "<grobe Ideen der Lehrkraft vor der Feinplanung oder null, nur Lesekontext>"
} }
} }
+9 -2
View File
@@ -65,7 +65,8 @@ Die Eingabe hat exakt diese Struktur:
} }
], ],
"homework": "<Hausaufgabe oder null>", "homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>" "reflection": "<Reflexion oder null>",
"planningIdeas": "<Grobe Ideen/Rohentwurf der Lehrkraft vor der Feinplanung oder null>"
} }
] ]
} }
@@ -111,6 +112,11 @@ Stunde zur Prüfung, alles andere würde ihr gar nicht angezeigt.
"AB" Arbeitsblatt) — kein Fließtext. "AB" Arbeitsblatt) — kein Fließtext.
- "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen - "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen
(i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit). (i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit).
- "planningIdeas" einer Stunde enthält oft schon vor der Feinplanung festgehaltene grobe Ideen der
Lehrkraft (Stichpunkte, erste Gedanken, mögliche Aufhänger) — nutze das, wo vorhanden, als starken
Hinweis für Thema/Verlaufsplan dieser Stunde, ohne jedes Stichwort wörtlich übernehmen zu müssen.
Gib das Feld in deiner Antwort unverändert zurück, außer die Anweisung der Lehrkraft verlangt
ausdrücklich eine Überarbeitung dieser Ideen selbst.
- "materialSuggestion" je Phase (nur in deiner Antwort, nicht in der Eingabe): ein kurzer Vorschlag - "materialSuggestion" je Phase (nur in deiner Antwort, nicht in der Eingabe): ein kurzer Vorschlag
(1-2 Sätze), WAS ein zu dieser Phase passendes Medium/Material konkret zeigen oder enthalten (1-2 Sätze), WAS ein zu dieser Phase passendes Medium/Material konkret zeigen oder enthalten
sollte (z.B. Aufbau eines Tafelbilds, Inhalt eines Arbeitsblatts) — nicht nur "ein Tafelbild sollte (z.B. Aufbau eines Tafelbilds, Inhalt eines Arbeitsblatts) — nicht nur "ein Tafelbild
@@ -142,7 +148,8 @@ Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in gena
} }
], ],
"homework": "<Hausaufgabe oder null>", "homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>" "reflection": "<Reflexion oder null>",
"planningIdeas": "<unverändert aus der Eingabe übernommen, siehe oben, oder null>"
} }
], ],
"summary": "<kurze menschenlesbare Zusammenfassung, was du getan hast>" "summary": "<kurze menschenlesbare Zusammenfassung, was du getan hast>"
+9
View File
@@ -129,6 +129,15 @@ statt der typisierten `Lessons`-Collection — nach jeder Modelländerung kennt
`Lesson`-Klasse die alten Feldnamen nicht mehr, ein Zugriff darüber hätte sie beim Deserialisieren `Lesson`-Klasse die alten Feldnamen nicht mehr, ein Zugriff darüber hätte sie beim Deserialisieren
bereits verworfen, bevor sie gelesen werden können. bereits verworfen, bevor sie gelesen werden können.
`LessonPhaseStep.MaterialPrompt` (4.5.36, Nachtrag zu 4.5.20) ist kein von Hand gepflegtes Feld:
`AiPlanningService.ApplyResponse` befüllt es automatisch mit dem vollständigen, lokal erzeugten
Prompt (`BuildMaterialPrompt`), sobald die KI beim letzten "Übernehmen" für diese Phase einen
Medienvorschlag gemacht hatte — ursprünglich (4.5.20) bewusst NICHT persistiert, weil der Vorschlag
nur für die Review-Anzeige gedacht war; Nutzer-Feedback nach erstem Einsatz war, dass der Prompt
auch nach dem Übernehmen noch abrufbar bleiben soll. Kein neues "Material"-Domänenmodell dafür (das
gibt es weiterhin nicht, siehe 4.5.26) — der Prompt hängt einfach als zusätzliches Freitextfeld an
der bereits bestehenden `LessonPhaseStep`-Zeile.
## Eindeutige Schlüssel ## Eindeutige Schlüssel
Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes: Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes: