QuestPDF als PDF-Bibliothek eingeführt (11.3), bewusst nur in LehrerApp.Desktop referenziert - zieht SkiaSharp-Native-Binaries mit, die im Server-Docker-Image nichts verloren haben. Neuer PdfExportService mit gemeinsamem Kopf-/Fußzeilen-Layout (Titel, Stand-Datum, Seitenzahlen) und schlanken Druck-DTOs statt ViewModels, damit die Erzeugung ohne Avalonia-Bezug testbar bleibt. Speicherdialog läuft über den bestehenden ExportService (neues PDF-Format). Fünf Druckvorlagen (11.4) als "Als PDF"-Button direkt in der jeweiligen Ansicht: - Sitzplan: Raster mit Tafel-Banner, Tischabständen als echte Lücken, ausgeblendeten Plätzen als leere Rasterposition. - Notenliste: druckt exakt die aktuell angezeigte Matrix (Zeitraum/Darstellung/Sortierung). - Klausur-Notenspiegel: Kennzahlen, Notenverteilung und Aufgabenanalyse mit Balkendiagrammen, auffällig schwache Aufgaben rot markiert. - Kompetenzbericht: Analyse-Tabelle für den ausgewählten Schüler (erledigt zugleich 8.3.3). - Schülerdokumentation: alle Einträge chronologisch, vertrauliche/Entwurfs-Einträge markiert. Layout an gerenderten Muster-PDFs visuell geprüft, nicht nur per Unit-Test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
257 lines
10 KiB
C#
257 lines
10 KiB
C#
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using LehrerApp.Core.Models;
|
|
using LehrerApp.Desktop.Services;
|
|
using LehrerApp.Desktop.ViewModels.Groups;
|
|
using LehrerApp.Desktop.Views.Shared;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace LehrerApp.Desktop.Views.Groups;
|
|
|
|
public partial class SeatingPlanTabView : UserControl
|
|
{
|
|
private const double AutoScrollEdgeSize = 72;
|
|
private const double AutoScrollStep = 14;
|
|
|
|
private object? _dragCandidate;
|
|
private PointerPressedEventArgs? _dragTrigger;
|
|
private Avalonia.Point _pressPosition;
|
|
private SeatCellViewModel? _draggedSeat;
|
|
private StudentSeatOption? _draggedStudent;
|
|
private SeatCellViewModel? _pendingDropTarget;
|
|
private bool _pendingClearSeat;
|
|
private DateTime _ignoreTapUntil;
|
|
private readonly DispatcherTimer _autoScrollTimer;
|
|
private Avalonia.Vector _autoScrollDirection;
|
|
|
|
public SeatingPlanTabView()
|
|
{
|
|
InitializeComponent();
|
|
_autoScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
|
_autoScrollTimer.Tick += OnAutoScrollTick;
|
|
}
|
|
|
|
protected override void OnDataContextChanged(EventArgs e)
|
|
{
|
|
base.OnDataContextChanged(e);
|
|
if (DataContext is SeatingPlanTabViewModel vm)
|
|
{
|
|
vm.OnEditPlan = ShowPlanDialog;
|
|
vm.OnConfirmDelete = ShowDeleteConfirmDialog;
|
|
vm.OnAssessStudent = ShowAssessmentDialog;
|
|
}
|
|
}
|
|
|
|
private void OnDragSourcePressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (sender is not Control control ||
|
|
!e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return;
|
|
var candidate = control.DataContext;
|
|
if (candidate is SeatCellViewModel { IsOccupied: false } or null) return;
|
|
if (DataContext is not SeatingPlanTabViewModel { CanEditLayout: true }) return;
|
|
_dragCandidate = candidate;
|
|
_dragTrigger = e;
|
|
_pressPosition = e.GetPosition(this);
|
|
}
|
|
|
|
private async void OnDragSourceMoved(object? sender, PointerEventArgs e)
|
|
{
|
|
if (_dragCandidate is null || _dragTrigger is null ||
|
|
!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
|
var current = e.GetPosition(this);
|
|
if (Math.Abs(current.X - _pressPosition.X) < 6 && Math.Abs(current.Y - _pressPosition.Y) < 6) return;
|
|
|
|
_draggedSeat = _dragCandidate as SeatCellViewModel;
|
|
_draggedStudent = _dragCandidate as StudentSeatOption;
|
|
_pendingDropTarget = null;
|
|
_pendingClearSeat = false;
|
|
_dragCandidate = null;
|
|
var trigger = _dragTrigger;
|
|
_dragTrigger = null;
|
|
var data = new DataTransfer();
|
|
data.Add(DataTransferItem.CreateText("LehrerApp-Sitzplatz"));
|
|
var effect = await DragDrop.DoDragDropAsync(trigger, data, DragDropEffects.Move);
|
|
var draggedSeat = _draggedSeat;
|
|
var draggedStudent = _draggedStudent;
|
|
var dropTarget = _pendingDropTarget;
|
|
var clearSeat = _pendingClearSeat;
|
|
_draggedSeat = null;
|
|
_draggedStudent = null;
|
|
_pendingDropTarget = null;
|
|
_pendingClearSeat = false;
|
|
StopAutoScroll();
|
|
_ignoreTapUntil = DateTime.UtcNow.AddMilliseconds(250);
|
|
|
|
// Never mutate an ItemsControl while Avalonia is still processing its native
|
|
// drop event. Assigning an unassigned student removes the dragged source item.
|
|
if ((effect & DragDropEffects.Move) == 0 || DataContext is not SeatingPlanTabViewModel vm) return;
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (clearSeat && draggedSeat is not null) vm.ClearSeat(draggedSeat);
|
|
else if (dropTarget is not null && draggedSeat is not null) vm.MoveSeat(draggedSeat, dropTarget);
|
|
else if (dropTarget is not null && draggedStudent is not null) vm.AssignStudent(draggedStudent, dropTarget);
|
|
}, DispatcherPriority.Background);
|
|
}
|
|
|
|
private void OnDragSourceReleased(object? sender, PointerReleasedEventArgs e)
|
|
{
|
|
_dragCandidate = null;
|
|
_dragTrigger = null;
|
|
}
|
|
|
|
private void OnSeatDragEnter(object? sender, DragEventArgs e)
|
|
{
|
|
if (sender is Border { DataContext: SeatCellViewModel target } && CanDropOn(target))
|
|
target.IsDropTarget = true;
|
|
}
|
|
|
|
private void OnSeatDragLeave(object? sender, DragEventArgs e)
|
|
{
|
|
if (sender is Border { DataContext: SeatCellViewModel target }) target.IsDropTarget = false;
|
|
}
|
|
|
|
private void OnSeatDragOver(object? sender, DragEventArgs e)
|
|
{
|
|
e.DragEffects = sender is Border { DataContext: SeatCellViewModel target } && CanDropOn(target)
|
|
? DragDropEffects.Move : DragDropEffects.None;
|
|
}
|
|
|
|
private void OnSeatDrop(object? sender, DragEventArgs e)
|
|
{
|
|
StopAutoScroll();
|
|
if (sender is not Border { DataContext: SeatCellViewModel target }) return;
|
|
target.IsDropTarget = false;
|
|
if (!CanDropOn(target))
|
|
{
|
|
e.DragEffects = DragDropEffects.None;
|
|
return;
|
|
}
|
|
_pendingDropTarget = target;
|
|
_pendingClearSeat = false;
|
|
e.DragEffects = DragDropEffects.Move;
|
|
}
|
|
|
|
private bool CanDropOn(SeatCellViewModel target) =>
|
|
DataContext is SeatingPlanTabViewModel { CanEditLayout: true } && !target.IsHidden
|
|
&& ((_draggedSeat is not null && _draggedSeat != target) || _draggedStudent?.StudentId is not null);
|
|
|
|
private void OnUnassignedDragOver(object? sender, DragEventArgs e) =>
|
|
e.DragEffects = _draggedSeat is not null ? DragDropEffects.Move : DragDropEffects.None;
|
|
|
|
private void OnUnassignedDrop(object? sender, DragEventArgs e)
|
|
{
|
|
StopAutoScroll();
|
|
if (_draggedSeat is not null)
|
|
{
|
|
_pendingDropTarget = null;
|
|
_pendingClearSeat = true;
|
|
e.DragEffects = DragDropEffects.Move;
|
|
}
|
|
}
|
|
|
|
private void OnRoomDragOver(object? sender, DragEventArgs e)
|
|
{
|
|
if (_draggedSeat is null && _draggedStudent is null)
|
|
{
|
|
StopAutoScroll();
|
|
return;
|
|
}
|
|
|
|
var position = e.GetPosition(RoomScrollViewer);
|
|
_autoScrollDirection = new Avalonia.Vector(
|
|
GetAutoScrollDirection(position.X, RoomScrollViewer.Bounds.Width),
|
|
GetAutoScrollDirection(position.Y, RoomScrollViewer.Bounds.Height));
|
|
|
|
if (_autoScrollDirection == default)
|
|
StopAutoScroll();
|
|
else if (!_autoScrollTimer.IsEnabled)
|
|
_autoScrollTimer.Start();
|
|
}
|
|
|
|
private void OnRoomDragLeave(object? sender, DragEventArgs e) => StopAutoScroll();
|
|
|
|
private void OnAutoScrollTick(object? sender, EventArgs e)
|
|
{
|
|
var maxX = Math.Max(0, RoomScrollViewer.Extent.Width - RoomScrollViewer.Viewport.Width);
|
|
var maxY = Math.Max(0, RoomScrollViewer.Extent.Height - RoomScrollViewer.Viewport.Height);
|
|
var offset = RoomScrollViewer.Offset;
|
|
RoomScrollViewer.Offset = new Avalonia.Vector(
|
|
Math.Clamp(offset.X + _autoScrollDirection.X * AutoScrollStep, 0, maxX),
|
|
Math.Clamp(offset.Y + _autoScrollDirection.Y * AutoScrollStep, 0, maxY));
|
|
}
|
|
|
|
private static double GetAutoScrollDirection(double position, double viewportSize)
|
|
{
|
|
if (position < AutoScrollEdgeSize) return -1;
|
|
if (position > viewportSize - AutoScrollEdgeSize) return 1;
|
|
return 0;
|
|
}
|
|
|
|
private void StopAutoScroll()
|
|
{
|
|
_autoScrollDirection = default;
|
|
_autoScrollTimer.Stop();
|
|
}
|
|
|
|
private async void OnSeatTapped(object? sender, TappedEventArgs e)
|
|
{
|
|
if (e.Source is Control source &&
|
|
(source is Button or Expander || source.FindAncestorOfType<Button>() is not null ||
|
|
source.FindAncestorOfType<Expander>() is not null)) return;
|
|
if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat }
|
|
|| !seat.IsOccupied || DataContext is not SeatingPlanTabViewModel vm) return;
|
|
await vm.AssessStudent(seat);
|
|
}
|
|
|
|
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
var topLevel = TopLevel.GetTopLevel(this);
|
|
if (topLevel is null || DataContext is not SeatingPlanTabViewModel { HasSelectedPlan: true } vm) return;
|
|
|
|
var data = new SeatingPlanPrintData(
|
|
vm.PlanTitle, vm.PlanSubtitle, vm.PlanColumns, vm.IsBoardAtBottom, vm.ColumnGapWidths,
|
|
vm.Seats.Select(s => new SeatPrintCell(s.Row, s.Column,
|
|
s.IsOccupied ? s.SelectedOption.DisplayName : null, s.IsHidden)).ToList());
|
|
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildSeatingPlanPdf(data);
|
|
|
|
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
|
|
ExportFile.Pdf("Sitzplan als PDF speichern", $"Sitzplan_{vm.PlanTitle}", pdf));
|
|
}
|
|
|
|
private async Task ShowAssessmentDialog(SeatAssessmentViewModel vm)
|
|
{
|
|
var dialog = new SeatAssessmentDialog { DataContext = vm };
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
if (owner is not null) await dialog.ShowDialog(owner);
|
|
}
|
|
|
|
private async Task<SeatingPlan?> ShowPlanDialog(SeatingPlan? plan)
|
|
{
|
|
if (DataContext is not SeatingPlanTabViewModel vm) return null;
|
|
var dialogVm = vm.CreateDialogViewModel(plan);
|
|
var dialog = new SeatingPlanDialog { DataContext = dialogVm };
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
if (owner is null || !await dialog.ShowDialog<bool>(owner)) return null;
|
|
return dialogVm.Result;
|
|
}
|
|
|
|
private async Task<bool> ShowDeleteConfirmDialog(SeatingPlanSummary plan)
|
|
{
|
|
var dialog = new ConfirmDialog
|
|
{
|
|
DataContext = new ConfirmDialogInfo
|
|
{
|
|
Title = "Sitzplan löschen?",
|
|
Message = $"Der Sitzplan „{plan.Name}“ und seine Zuordnungen werden gelöscht.",
|
|
ConfirmText = "Löschen",
|
|
},
|
|
};
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
return owner is not null && await dialog.ShowDialog<bool>(owner);
|
|
}
|
|
}
|