feat: add seating plan drag and quick assessment

This commit is contained in:
2026-08-17 01:49:05 +02:00
parent ad40d11d6e
commit 50f3b2d6a9
13 changed files with 891 additions and 36 deletions
@@ -243,6 +243,11 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
ParticipationTab.RefreshCurrentGrid();
};
}
public void LoadGroup(Guid id)
@@ -108,11 +108,11 @@ public partial class ParticipationTabViewModel : ObservableObject
public void LoadSessions()
{
var selectedId = SelectedSession?.Id;
Sessions.Clear();
foreach (var s in _sessions.GetByGroup(_groupId))
Sessions.Add(new ParticipationSessionItem(s));
if (SelectedSession is null && Sessions.Any())
SelectedSession = Sessions[0];
SelectedSession = Sessions.FirstOrDefault(s => s.Id == selectedId) ?? Sessions.FirstOrDefault();
}
partial void OnSelectedSessionChanged(ParticipationSessionItem? value)
@@ -12,6 +12,9 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private readonly ISeatingPlanRepository _plans;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly IParticipationAspectRepository _aspects;
private Guid _groupId;
private SeatingPlan? _currentPlan;
private bool _isReadOnly;
@@ -25,19 +28,26 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
public ObservableCollection<StudentSeatOption> StudentOptions { get; } = [];
public ObservableCollection<StudentSeatOption> UnassignedStudents { get; } = [];
public bool HasPlans => Plans.Count > 0;
public bool HasSelectedPlan => _currentPlan is not null;
public bool IsEditable => !_isReadOnly;
public Func<SeatingPlan?, Task<SeatingPlan?>>? OnEditPlan { get; set; }
public Func<SeatingPlanSummary, Task<bool>>? OnConfirmDelete { get; set; }
public Func<SeatAssessmentViewModel, Task>? OnAssessStudent { get; set; }
public Action? OnAssessmentChanged { get; set; }
public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students,
IGroupMembershipRepository memberships)
IGroupMembershipRepository memberships, IParticipationSessionRepository sessions,
IParticipationRepository participation, IParticipationAspectRepository aspects)
{
_plans = plans;
_students = students;
_memberships = memberships;
_sessions = sessions;
_participation = participation;
_aspects = aspects;
}
public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) =>
@@ -87,6 +97,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
Seats.Clear();
if (plan is null)
{
UnassignedStudents.Clear();
PlanColumns = 1;
PlanTitle = "";
PlanSubtitle = "";
@@ -124,6 +135,49 @@ public partial class SeatingPlanTabViewModel : ObservableObject
other.SetSelectionSilently(StudentSeatOption.Empty);
}
SaveSeatAssignments();
}
private void UpdateAssignmentSummary()
{
var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue);
var total = StudentOptions.Count - 1;
AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet";
var assignedIds = Seats.Where(s => s.SelectedOption.StudentId.HasValue)
.Select(s => s.SelectedOption.StudentId!.Value).ToHashSet();
UnassignedStudents.Clear();
foreach (var option in StudentOptions.Where(o => o.StudentId.HasValue && !assignedIds.Contains(o.StudentId.Value)))
UnassignedStudents.Add(option);
}
public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target)
{
if (!IsEditable || source == target || !source.SelectedOption.StudentId.HasValue) return;
var targetOption = target.SelectedOption;
target.SetSelectionSilently(source.SelectedOption);
source.SetSelectionSilently(targetOption);
SaveSeatAssignments();
}
public void AssignStudent(StudentSeatOption student, SeatCellViewModel target)
{
if (!IsEditable || !student.StudentId.HasValue) return;
foreach (var other in Seats.Where(s => s != target && s.SelectedOption.StudentId == student.StudentId))
other.SetSelectionSilently(StudentSeatOption.Empty);
target.SetSelectionSilently(student);
SaveSeatAssignments();
}
public void ClearSeat(SeatCellViewModel seat)
{
if (!IsEditable || !seat.SelectedOption.StudentId.HasValue) return;
seat.SetSelectionSilently(StudentSeatOption.Empty);
SaveSeatAssignments();
}
private void SaveSeatAssignments()
{
if (_currentPlan is null) return;
_currentPlan.Assignments = Seats
.Where(s => s.SelectedOption.StudentId.HasValue)
.Select(s => new SeatAssignment
@@ -136,11 +190,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject
UpdateAssignmentSummary();
}
private void UpdateAssignmentSummary()
public async Task AssessStudent(SeatCellViewModel seat)
{
var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue);
var total = StudentOptions.Count - 1;
AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet";
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable);
await OnAssessStudent(assessment);
OnAssessmentChanged?.Invoke();
}
[RelayCommand(CanExecute = nameof(CanEdit))]
@@ -203,11 +259,14 @@ public partial class SeatCellViewModel : ObservableObject
private bool _suppressChange;
[ObservableProperty] private StudentSeatOption _selectedOption;
[ObservableProperty] private bool _isDropTarget;
public int Row { get; }
public int Column { get; }
public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}";
public ObservableCollection<StudentSeatOption> Options { get; }
public bool CanEdit { get; }
public bool IsOccupied => SelectedOption.StudentId.HasValue;
public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz";
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit)
@@ -222,6 +281,8 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnSelectedOptionChanged(StudentSeatOption value)
{
OnPropertyChanged(nameof(IsOccupied));
OnPropertyChanged(nameof(StudentName));
if (!_suppressChange) _onChanged(this);
}
@@ -233,6 +294,281 @@ public partial class SeatCellViewModel : ObservableObject
}
}
public partial class SeatAssessmentViewModel : ObservableObject
{
private readonly IParticipationRepository _entries;
private readonly ParticipationEntry? _entry;
private readonly bool _canEdit;
[ObservableProperty] private int _selectedAspectIndex;
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
public string StudentName { get; }
public string SessionDisplay { get; }
public bool CanEdit => _canEdit && _entry is not null;
public string ReadOnlyHint => _entry is null
? "Für heute existiert keine Sitzung. In einer archivierten Gruppe kann keine neue angelegt werden."
: "Archivierte Lerngruppe Bewertung nur ansehen.";
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
IParticipationRepository entries, IParticipationAspectRepository aspects,
Guid groupId, Guid studentId, string studentName, bool canEdit)
{
_entries = entries;
_canEdit = canEdit;
StudentName = studentName;
var today = DateOnly.FromDateTime(DateTime.Today);
var session = sessions.GetByGroup(groupId).FirstOrDefault(s => s.Date == today);
if (session is null && canEdit)
{
session = new ParticipationSession
{
GroupId = groupId,
Date = today,
Comment = "Sitzplan",
};
sessions.Save(session);
}
SessionDisplay = session is null ? "Keine Sitzung für heute" : $"{session.Date:dd.MM.yyyy} · {session.Comment}";
_entry = session is null ? null
: entries.GetBySessionAndStudent(session.Id, studentId)
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
var aspectDefinitions = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)).ToList();
if (aspectDefinitions.Count == 0) aspectDefinitions = DefaultParticipationAspects.All.Select(a => new ParticipationAspect
{
Key = a.Key, Label = a.Label, ValueType = a.ValueType, MaxPoints = a.MaxPoints,
}).ToList();
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
{
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value;
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating));
}
BuildAttendanceChoices();
BuildHomeworkChoices();
RefreshStatusChoices();
SelectAspect(0);
}
private void BuildAttendanceChoices()
{
AttendanceChoices.Add(new("✓", "Anwesend", "Strg+1", AttendanceStatus.Present, SetAttendance));
AttendanceChoices.Add(new("?", "Entschuldigung offen", "Strg+2", AttendanceStatus.ExcusePending, SetAttendance));
AttendanceChoices.Add(new("⊘", "Entschuldigt", "Strg+5", AttendanceStatus.Excused, SetAttendance));
AttendanceChoices.Add(new("◇", "Schulveranstaltung", "Strg+7", AttendanceStatus.OtherSchoolEvent, SetAttendance));
AttendanceChoices.Add(new("✕", "Geschwänzt", "Strg+9", AttendanceStatus.Truant, SetAttendance));
AttendanceChoices.Add(new("!", "Unentschuldigt", "Strg+0", AttendanceStatus.Unexcused, SetAttendance));
AttendanceChoices.Add(new("·", "Nicht kontrolliert", "Strg+X", null, SetAttendance));
}
private void BuildHomeworkChoices()
{
HomeworkChoices.Add(new("✓", "Gemacht", "⌥1", HomeworkStatus.Completed, SetHomework));
HomeworkChoices.Add(new("◐", "Teilweise", "⌥3", HomeworkStatus.PartiallyCompleted, SetHomework));
HomeworkChoices.Add(new("◕", "Rest nachgereicht", "⌥4", HomeworkStatus.PartialSubmittedLate, SetHomework));
HomeworkChoices.Add(new("◒", "Rest fehlt", "⌥5", HomeworkStatus.PartialMissingOverdue, SetHomework));
HomeworkChoices.Add(new("!", "Nicht gemacht", "⌥7", HomeworkStatus.MissingOpen, SetHomework));
HomeworkChoices.Add(new("↺", "Nachgereicht", "⌥8", HomeworkStatus.SubmittedLate, SetHomework));
HomeworkChoices.Add(new("✕", "Nicht nachgereicht", "⌥0", HomeworkStatus.MissingOverdue, SetHomework));
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
}
public void SelectAspect(int index)
{
if (index < 0 || index >= AspectRows.Count) return;
SelectedAspectIndex = index;
foreach (var row in AspectRows) row.IsActive = row.Index == index;
}
public void MoveAspect(int delta)
{
if (AspectRows.Count == 0) return;
SelectAspect(Math.Clamp(SelectedAspectIndex + delta, 0, AspectRows.Count - 1));
}
public void SetRatingByNumber(int number)
{
if (!CanEdit) return;
var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex);
if (row is null) return;
if (row.ValueType == AspectValueType.Points)
row.ApplyValue(Math.Clamp(number, 0, row.MaxPoints));
else
{
var steps = ParticipationRatingScale.Steps(row.ValueType);
if (number >= 1 && number <= steps.Count) row.ApplyValue(steps[number - 1].Value);
}
}
public void AdjustCurrentRating(int delta)
{
if (!CanEdit) return;
var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex);
row?.Adjust(delta);
}
public void ClearCurrentRating()
{
if (!CanEdit) return;
AspectRows.ElementAtOrDefault(SelectedAspectIndex)?.ApplyValue(null);
}
public void ApplyAttendanceShortcut(int? digit, bool clear)
{
if (!CanEdit) return;
var status = clear ? null : digit switch
{
1 => AttendanceStatus.Present, 2 => AttendanceStatus.ExcusePending,
5 => AttendanceStatus.Excused, 7 => AttendanceStatus.OtherSchoolEvent,
9 => AttendanceStatus.Truant, 0 => AttendanceStatus.Unexcused,
_ => (AttendanceStatus?)null,
};
if (clear || digit is 0 or 1 or 2 or 5 or 7 or 9) SetAttendance(status);
}
public void ApplyHomeworkShortcut(int? digit, bool clear)
{
if (!CanEdit) return;
var status = clear ? null : digit switch
{
1 => HomeworkStatus.Completed, 3 => HomeworkStatus.PartiallyCompleted,
4 => HomeworkStatus.PartialSubmittedLate, 5 => HomeworkStatus.PartialMissingOverdue,
7 => HomeworkStatus.MissingOpen, 8 => HomeworkStatus.SubmittedLate,
0 => HomeworkStatus.MissingOverdue, _ => (HomeworkStatus?)null,
};
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
}
private void ApplyRating(string key, int? value)
{
if (!CanEdit || _entry is null) return;
var existing = _entry.Ratings.FirstOrDefault(r => r.Key == key);
if (value is null)
{
if (existing is not null) _entry.Ratings.Remove(existing);
}
else if (existing is null) _entry.Ratings.Add(new AspectRating { Key = key, Value = value.Value });
else existing.Value = value.Value;
_entries.Save(_entry);
}
private void SetAttendance(AttendanceStatus? status)
{
if (!CanEdit || _entry is null) return;
_entry.Attendance = status;
_entries.Save(_entry);
RefreshStatusChoices();
}
private void SetHomework(HomeworkStatus? status)
{
if (!CanEdit || _entry is null) return;
_entry.Homework = status;
_entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(status);
_entries.Save(_entry);
RefreshStatusChoices();
}
private void RefreshStatusChoices()
{
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
}
}
public partial class SeatAssessmentAspectRow : ObservableObject
{
private readonly Action<string, int?> _apply;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private int? _value;
public int Index { get; }
public string Key { get; }
public string Label { get; }
public AspectValueType ValueType { get; }
public int MaxPoints { get; }
public string Shortcut => Index switch { 0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => "" };
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
public ObservableCollection<SeatRatingChoice> Choices { get; } = [];
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
Action<string, int?> apply)
{
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
var steps = ValueType == AspectValueType.Points
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
: ParticipationRatingScale.Steps(ValueType).ToList();
foreach (var (step, i) in steps.Select((s, i) => (s, i)))
Choices.Add(new SeatRatingChoice(step.Item2, ValueType == AspectValueType.Points ? step.Item1.ToString() : (i + 1).ToString(),
step.Item1, step.Item1 == value, ApplyValue));
}
public void ApplyValue(int? value)
{
Value = value;
OnPropertyChanged(nameof(DisplayValue));
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
_apply(Key, value);
}
public void Adjust(int delta)
{
if (ValueType == AspectValueType.Points)
{
ApplyValue(Math.Clamp((Value ?? (delta > 0 ? -1 : MaxPoints + 1)) + delta, 0, MaxPoints));
return;
}
var steps = ParticipationRatingScale.Steps(ValueType).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var index = Value.HasValue ? steps.IndexOf(Value.Value) : (delta > 0 ? -1 : steps.Count);
ApplyValue(steps[Math.Clamp(index + delta, 0, steps.Count - 1)]);
}
[RelayCommand] private void Clear() => ApplyValue(null);
[RelayCommand] private void Increment() => Adjust(1);
[RelayCommand] private void Decrement() => Adjust(-1);
}
public partial class SeatRatingChoice(string label, string shortcut, int value, bool isSelected,
Action<int?> apply) : ObservableObject
{
public string Label { get; } = label;
public string Shortcut { get; } = shortcut;
public int Value { get; } = value;
[ObservableProperty] private bool _isSelected = isSelected;
[RelayCommand] private void Apply() => apply(Value);
}
public partial class SeatAttendanceChoice(string symbol, string label, string shortcut,
AttendanceStatus? status, Action<AttendanceStatus?> apply) : ObservableObject
{
public string Symbol { get; } = symbol;
public string Label { get; } = label;
public string Shortcut { get; } = shortcut;
public AttendanceStatus? Status { get; } = status;
[ObservableProperty] private bool _isSelected;
[RelayCommand] private void Apply() => apply(Status);
}
public partial class SeatHomeworkChoice(string symbol, string label, string shortcut,
HomeworkStatus? status, Action<HomeworkStatus?> apply) : ObservableObject
{
public string Symbol { get; } = symbol;
public string Label { get; } = label;
public string Shortcut { get; } = shortcut;
public HomeworkStatus? Status { get; } = status;
[ObservableProperty] private bool _isSelected;
[RelayCommand] private void Apply() => apply(Status);
}
public partial class SeatingPlanDialogViewModel : ObservableObject
{
private readonly ISeatingPlanRepository _plans;
@@ -256,8 +592,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
_groupId = groupId;
_editingPlan = editingPlan;
if (editingPlan is null) return;
Name = editingPlan.Name;
Room = editingPlan.Room;
Name = editingPlan.Name ?? "";
Room = editingPlan.Room ?? "";
Rows = editingPlan.Rows;
Columns = editingPlan.Columns;
}
@@ -281,8 +617,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
if (!valid) return;
var plan = _editingPlan ?? new SeatingPlan { GroupId = _groupId };
plan.Name = Name.Trim();
plan.Room = Room.Trim();
plan.Name = Name?.Trim() ?? "";
plan.Room = Room?.Trim() ?? "";
plan.Rows = decimal.ToInt32(Rows);
plan.Columns = decimal.ToInt32(Columns);
try
@@ -0,0 +1,133 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatAssessmentDialog"
x:DataType="vm:SeatAssessmentViewModel"
Title="Sitzplatz-Schnelleingabe" Width="660" Height="700"
MinWidth="560" MinHeight="560" CanResize="True"
WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="Button.choice">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,5"/>
</Style>
<Style Selector="Button.choice.selected">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorLight2}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="Border.aspectrow">
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.aspectrow.active">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="Background" Value="{DynamicResource SystemAccentColorLight3}"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*,Auto" Margin="20">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
<StackPanel>
<TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SessionDisplay}" FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="Schließen" Click="OnClose" VerticalAlignment="Center"/>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="16" IsEnabled="{Binding CanEdit}">
<Border Background="#FFF3CD" CornerRadius="6" Padding="10"
IsVisible="{Binding CanEdit, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="{Binding ReadOnlyHint}" Foreground="#92400E" TextWrapping="Wrap"/>
</Border>
<StackPanel Spacing="7">
<TextBlock Text="MITARBEIT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<ItemsControl ItemsSource="{Binding AspectRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatAssessmentAspectRow">
<Border Classes="aspectrow" Classes.active="{Binding IsActive}"
CornerRadius="7" Padding="9" Margin="0,3">
<Grid ColumnDefinitions="150,*">
<StackPanel VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold">
<Run Text="["/><Run Text="{Binding Shortcut}"/><Run Text="] "/><Run Text="{Binding Label}"/>
</TextBlock>
<TextBlock Text="{Binding DisplayValue}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
<ItemsControl ItemsSource="{Binding Choices}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatRatingChoice">
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
Command="{Binding ApplyCommand}" Margin="2">
<StackPanel>
<TextBlock Text="{Binding Label}" HorizontalAlignment="Center" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Shortcut}" HorizontalAlignment="Center" FontSize="9" Opacity="0.5"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="choice" Content="" Command="{Binding DecrementCommand}" Margin="2"/>
<Button Classes="choice" Content="+" Command="{Binding IncrementCommand}" Margin="2"/>
<Button Classes="choice" Content="Löschen" Command="{Binding ClearCommand}" Margin="2"/>
</WrapPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="7">
<TextBlock Text="ANWESENHEIT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding AttendanceLabel}" FontSize="12"/>
<ItemsControl ItemsSource="{Binding AttendanceChoices}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatAttendanceChoice">
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
Command="{Binding ApplyCommand}" Margin="2">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding Symbol}" FontWeight="Bold"/>
<TextBlock Text="{Binding Label}"/>
<TextBlock Text="{Binding Shortcut}" FontSize="9" Opacity="0.5" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="7">
<TextBlock Text="HAUSAUFGABEN" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding HomeworkLabel}" FontSize="12"/>
<ItemsControl ItemsSource="{Binding HomeworkChoices}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatHomeworkChoice">
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
Command="{Binding ApplyCommand}" Margin="2">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding Symbol}" FontWeight="Bold"/>
<TextBlock Text="{Binding Label}"/>
<TextBlock Text="{Binding Shortcut}" FontSize="9" Opacity="0.5" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="2" Margin="0,12,0,0" FontSize="10" Opacity="0.55" TextWrapping="Wrap"
Text="Mitarbeit: Q/W/E/R/T Aspekt · 15 Bewertung · +/ anpassen · Backspace löschen · ←/→ Aspekt | Anwesenheit: Strg+1/2/5/7/9/0, Strg+X | Hausaufgaben: ⌥+1/3/4/5/7/8/0, ⌥+X | Esc schließen"/>
</Grid>
</Window>
@@ -0,0 +1,80 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatAssessmentDialog : Window
{
public SeatAssessmentDialog()
{
InitializeComponent();
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Focus();
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not SeatAssessmentViewModel vm) return;
var digit = DigitFromEvent(e);
var clear = e.Key == Key.X || e.PhysicalKey == PhysicalKey.X;
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
if (clear || digit is not null) vm.ApplyAttendanceShortcut(digit, clear);
e.Handled = clear || digit is not null;
return;
}
if (e.KeyModifiers.HasFlag(KeyModifiers.Alt))
{
if (clear || digit is not null) vm.ApplyHomeworkShortcut(digit, clear);
e.Handled = clear || digit is not null;
return;
}
if (digit is not null)
{
vm.SetRatingByNumber(digit.Value);
e.Handled = true;
return;
}
switch (e.Key)
{
case Key.Q: vm.SelectAspect(0); e.Handled = true; break;
case Key.W: vm.SelectAspect(1); e.Handled = true; break;
case Key.E: vm.SelectAspect(2); e.Handled = true; break;
case Key.R: vm.SelectAspect(3); e.Handled = true; break;
case Key.T: vm.SelectAspect(4); e.Handled = true; break;
case Key.Left: vm.MoveAspect(-1); e.Handled = true; break;
case Key.Right: vm.MoveAspect(1); e.Handled = true; break;
case Key.OemPlus or Key.Add: vm.AdjustCurrentRating(1); e.Handled = true; break;
case Key.OemMinus or Key.Subtract: vm.AdjustCurrentRating(-1); e.Handled = true; break;
case Key.Back: vm.ClearCurrentRating(); e.Handled = true; break;
case Key.Escape: Close(); e.Handled = true; break;
}
}
private static int? DigitFromEvent(KeyEventArgs e) => e.PhysicalKey switch
{
PhysicalKey.Digit0 => 0, PhysicalKey.Digit1 => 1, PhysicalKey.Digit2 => 2,
PhysicalKey.Digit3 => 3, PhysicalKey.Digit4 => 4, PhysicalKey.Digit5 => 5,
PhysicalKey.Digit6 => 6, PhysicalKey.Digit7 => 7, PhysicalKey.Digit8 => 8,
PhysicalKey.Digit9 => 9,
_ => e.Key switch
{
Key.D0 or Key.NumPad0 => 0, Key.D1 or Key.NumPad1 => 1,
Key.D2 or Key.NumPad2 => 2, Key.D3 or Key.NumPad3 => 3,
Key.D4 or Key.NumPad4 => 4, Key.D5 or Key.NumPad5 => 5,
Key.D6 or Key.NumPad6 => 6, Key.D7 or Key.NumPad7 => 7,
Key.D8 or Key.NumPad8 => 8, Key.D9 or Key.NumPad9 => 9,
_ => null,
},
};
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -3,6 +3,21 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanTabView"
x:DataType="vm:SeatingPlanTabViewModel">
<UserControl.Styles>
<Style Selector="Border.seat">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style Selector="Border.seat.occupied">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
</Style>
<Style Selector="Border.seat.droptarget">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorLight2}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="260,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16">
@@ -51,8 +66,11 @@
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding AssignmentSummary}" VerticalAlignment="Bottom"
FontSize="12" Opacity="0.6"/>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom">
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5"/>
</StackPanel>
</Grid>
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto"
@@ -70,24 +88,60 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatCellViewModel">
<Border Width="190" MinHeight="76" Margin="5" Padding="10"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7">
<Border Classes="seat" Classes.occupied="{Binding IsOccupied}"
Classes.droptarget="{Binding IsDropTarget}"
Width="190" MinHeight="76" Margin="5" Padding="10"
CornerRadius="7"
DragDrop.AllowDrop="True"
DragDrop.DragEnter="OnSeatDragEnter"
DragDrop.DragLeave="OnSeatDragLeave"
DragDrop.DragOver="OnSeatDragOver"
DragDrop.Drop="OnSeatDrop"
PointerPressed="OnDragSourcePressed"
PointerMoved="OnDragSourceMoved"
PointerReleased="OnDragSourceReleased"
Tapped="OnSeatTapped">
<StackPanel Spacing="5">
<TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/>
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
IsEnabled="{Binding CanEdit}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentSeatOption">
<TextBlock Text="{Binding DisplayName}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/>
<TextBlock Grid.Column="1" Text="⠿" Opacity="0.45" IsVisible="{Binding IsOccupied}"/>
</Grid>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border DragDrop.AllowDrop="True" DragDrop.DragOver="OnUnassignedDragOver"
DragDrop.Drop="OnUnassignedDrop"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="12" Margin="5,8">
<StackPanel Spacing="8">
<TextBlock Text="NICHT ZUGEORDNET" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="Schüler hierher ziehen, um einen Platz zu leeren."
FontSize="11" Opacity="0.5"/>
<ItemsControl ItemsSource="{Binding UnassignedStudents}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentSeatOption">
<Border Background="{DynamicResource SystemAccentColorLight2}" CornerRadius="5"
Padding="9,5" Margin="0,0,6,6"
PointerPressed="OnDragSourcePressed"
PointerMoved="OnDragSourceMoved"
PointerReleased="OnDragSourceReleased">
<TextBlock Text="{Binding DisplayName}" FontSize="12"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Alle Schüler sind zugeordnet." Classes="emptyhint"
IsVisible="{Binding !UnassignedStudents.Count}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
@@ -1,4 +1,7 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
@@ -7,6 +10,15 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatingPlanTabView : UserControl
{
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;
public SeatingPlanTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
@@ -16,9 +28,128 @@ public partial class SeatingPlanTabView : UserControl
{
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 { IsEditable: 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;
_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)
{
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 { IsEditable: true }
&& ((_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)
{
if (_draggedSeat is not null)
{
_pendingDropTarget = null;
_pendingClearSeat = true;
e.DragEffects = DragDropEffects.Move;
}
}
private async void OnSeatTapped(object? sender, TappedEventArgs e)
{
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 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;