using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using LehrerApp.Desktop.ViewModels.Groups; using System.Collections; using System.Globalization; namespace LehrerApp.Desktop.Views.Groups; public partial class ExamGradingDialog : Window { private static readonly IBrush InvalidBackground = new SolidColorBrush(Color.Parse("#FFCDD2")); public ExamGradingDialog() => InitializeComponent(); protected override void OnOpened(EventArgs e) { base.OnOpened(e); if (DataContext is ExamGradingDialogViewModel vm) BuildColumns(vm); } private void BuildColumns(ExamGradingDialogViewModel vm) { var grid = this.FindControl("ResultGrid"); if (grid is null) return; grid.Columns.Clear(); grid.Columns.Add(new DataGridTextColumn { Header = "Schüler", Binding = new Binding("Name"), IsReadOnly = true, Width = new DataGridLength(170, DataGridLengthUnitType.Pixel), }); for (var i = 0; i < vm.Tasks.Count; i++) { var task = vm.Tasks[i]; grid.Columns.Add(new DataGridTemplateColumn { Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}", Width = new DataGridLength(1, DataGridLengthUnitType.Star), CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count), }); } grid.Columns.Add(new DataGridTextColumn { Header = "Summe", Binding = new Binding("TotalPointsDisplay"), IsReadOnly = true, Width = new DataGridLength(70, DataGridLengthUnitType.Pixel), }); grid.Columns.Add(new DataGridTextColumn { Header = "Note", Binding = new Binding("GradeDisplay"), IsReadOnly = true, Width = new DataGridLength(70, DataGridLengthUnitType.Pixel), }); grid.Columns.Add(new DataGridTemplateColumn { Header = "Abw.", Width = new DataGridLength(50, DataGridLengthUnitType.Pixel), CellTemplate = BuildAbsentCellTemplate(), }); grid.Columns.Add(new DataGridTemplateColumn { Header = "Kommentar", Width = new DataGridLength(180, DataGridLengthUnitType.Pixel), CellTemplate = BuildCommentCellTemplate(grid), }); } private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid, int taskCount) { var cellName = $"Pts_{taskIndex}"; return new FuncDataTemplate((row, _) => { if (row is null) return new TextBlock(); var cell = row.Cells.ElementAtOrDefault(taskIndex); if (cell is null) return new TextBlock(); var tb = new TextBox { Name = cellName, Text = FormatPoints(cell.Value), BorderThickness = new Thickness(0), Background = Brushes.Transparent, Padding = new Thickness(6, 4), HorizontalContentAlignment = HorizontalAlignment.Center, }; ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte"); UpdateInvalidVisual(tb, cell.IsInvalid); cell.PropertyChanged += (_, pe) => { if (pe.PropertyName == nameof(PointsCell.IsInvalid)) UpdateInvalidVisual(tb, cell.IsInvalid); }; tb.LostFocus += (_, _) => CommitPointsCell(tb, cell); tb.KeyDown += (_, e) => HandlePointsCellKeyDown(e, grid, row, taskIndex, taskCount); return tb; }); } private static void CommitPointsCell(TextBox tb, PointsCell cell) { var text = tb.Text?.Trim().Replace(',', '.'); double? parsed = string.IsNullOrEmpty(text) ? null : double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : cell.Value; cell.TrySetValue(parsed); tb.Text = FormatPoints(cell.Value); } private static void UpdateInvalidVisual(TextBox tb, bool invalid) => tb.Background = invalid ? InvalidBackground : Brushes.Transparent; private static string FormatPoints(double? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? ""; private IDataTemplate BuildAbsentCellTemplate() { return new FuncDataTemplate((row, _) => { if (row is null) return new TextBlock(); var cb = new CheckBox { IsChecked = row.Absent, HorizontalAlignment = HorizontalAlignment.Center }; cb.IsCheckedChanged += (_, _) => row.Absent = cb.IsChecked == true; return cb; }); } private IDataTemplate BuildCommentCellTemplate(DataGrid grid) { const string cellName = "Comment"; return new FuncDataTemplate((row, _) => { if (row is null) return new TextBlock(); var tb = new TextBox { Name = cellName, Text = row.Comment, BorderThickness = new Thickness(0), Background = Brushes.Transparent, Padding = new Thickness(6, 4), }; tb.LostFocus += (_, _) => row.Comment = tb.Text ?? ""; tb.KeyDown += (_, e) => HandleCommentCellKeyDown(e, grid, row, cellName); return tb; }); } /// Zwei Korrektur-Workflows (Nutzer-Feedback): "Arbeit für Arbeit" (ein Schüler komplett, /// dann der nächste) und "Aufgabe für Aufgabe" (eine Aufgabe für alle Schüler, dann die /// nächste) — beide bewusst ohne Moduswechsel über zwei verschiedene Tasten, weil sich beide /// Reihenfolgen ohnehin schon auf die Tabelle abbilden (Zeile vs. Spalte). Enter läuft /// zeilenweise (am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile), Pfeil-hoch/-runter /// spaltenweise (am Spaltenende weiter zur Nachbaraufgabe) — beides bricht am Tabellenende /// einfach ab, kein Rundlauf zurück zum Anfang. private void HandlePointsCellKeyDown(KeyEventArgs e, DataGrid grid, ExamResultRow row, int taskIndex, int taskCount) { if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return; e.Handled = true; if (grid.ItemsSource is not IList items) return; var rowIdx = items.IndexOf(row); if (rowIdx < 0) return; int targetRowIdx; int targetTaskIndex; if (e.Key == Key.Enter) { targetTaskIndex = taskIndex + 1; targetRowIdx = rowIdx; if (targetTaskIndex >= taskCount) { targetTaskIndex = 0; targetRowIdx = rowIdx + 1; } } else { targetTaskIndex = taskIndex; targetRowIdx = e.Key == Key.Up ? rowIdx - 1 : rowIdx + 1; if (targetRowIdx < 0) { targetRowIdx = items.Count - 1; targetTaskIndex = taskIndex - 1; } else if (targetRowIdx >= items.Count) { targetRowIdx = 0; targetTaskIndex = taskIndex + 1; } } if (targetTaskIndex < 0 || targetTaskIndex >= taskCount || targetRowIdx < 0 || targetRowIdx >= items.Count) return; FocusPointsCell(grid, items[targetRowIdx]!, targetTaskIndex); } /// Enter/Pfeil-Hoch/Pfeil-Runter springen im Kommentarfeld zur gleichen Spalte in der /// Nachbarzeile (Tab funktioniert bereits über die normale Fokus-Reihenfolge). private void HandleCommentCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName) { if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return; e.Handled = true; if (grid.ItemsSource is not IList items) return; var idx = items.IndexOf(rowItem); if (idx < 0) return; var targetIdx = e.Key == Key.Up ? idx - 1 : idx + 1; if (targetIdx < 0 || targetIdx >= items.Count) return; var targetItem = items[targetIdx]!; grid.ScrollIntoView(targetItem, grid.CurrentColumn); Dispatcher.UIThread.Post(() => { var targetTb = grid.GetVisualDescendants().OfType() .FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName); if (targetTb is null) return; targetTb.Focus(); targetTb.SelectAll(); }, DispatcherPriority.Loaded); } /// Spalte 0 ist "Schüler", danach folgen die Aufgaben-Spalten in Reihenfolge — daher der /// Offset von 1 beim Auflösen des Spaltenobjekts für ScrollIntoView (muss auch horizontal /// zur Nachbaraufgabe scrollen, nicht nur vertikal zur Nachbarzeile). private void FocusPointsCell(DataGrid grid, object targetItem, int targetTaskIndex) { var targetColumn = grid.Columns.ElementAtOrDefault(1 + targetTaskIndex); if (targetColumn is null) return; grid.ScrollIntoView(targetItem, targetColumn); var cellName = $"Pts_{targetTaskIndex}"; Dispatcher.UIThread.Post(() => { var targetTb = grid.GetVisualDescendants().OfType() .FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName); if (targetTb is null) return; targetTb.Focus(); targetTb.SelectAll(); }, DispatcherPriority.Loaded); } private void OnClose(object? sender, RoutedEventArgs e) => Close(); }