Codepflege: CLAUDE.md und UI-Konsolidierung (Kapitel 13.4)

CLAUDE.md mit Projektkonventionen für künftige Claude-Code-Sitzungen.
Wiederkehrende UI-Muster konsolidiert: neue PageHeader-Control für
Titel/Untertitel in den Listen- und Detailansichten, globale Styles für
Dialog-Titel und Leerlisten-Hinweise statt inline wiederholter Werte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 16:14:40 +02:00
co-authored by Claude Sonnet 5
parent 48d07a2b99
commit 629b8d1bf0
28 changed files with 244 additions and 52 deletions
+126
View File
@@ -0,0 +1,126 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
LehrerApp is a German-language desktop application for teachers (Notenverwaltung, Anwesenheit,
Hausaufgaben, Klausuren, Unterrichtsplanung, Mitarbeitsbewertung). The primary product is the
Avalonia desktop client backed by a local LiteDB file; an optional ASP.NET Core API enables
multi-device sync via an encrypted event queue.
The living roadmap and feature spec is `TODO.md` — check it before starting new work and update
it (with an implementation note, following the existing style) when a checklist item is finished.
`docs/Datenmodell.md` documents the meaning of domain fields where naming alone is ambiguous
(e.g. why `GroupMembership` has no `SchoolYear` of its own).
## Commands
```bash
# Build everything
dotnet build LehrerApp.sln
# Run all tests
dotnet test LehrerApp.sln
# Run one test project
dotnet test LehrerApp.Tests/LehrerApp.Tests.csproj
# Run a single test (xUnit fully-qualified name filter)
dotnet test --filter "FullyQualifiedName~GradingServiceTests.RoundToGrade_Kaufmaennisch"
# Run the desktop app
dotnet run --project LehrerApp.Desktop
# Run the sync API locally (needs JWT_SECRET — copy .env.example to .env first)
dotnet run --project LehrerApp.Api
```
The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from the environment).
## Architecture
### Project layout
- **LehrerApp.Core** — domain models (`Models/`), repository interfaces (`Interfaces/IRepositories.cs`),
and framework-free business services (`Services/`): `GradingService` (rounding, grading-key
validation, report-grade calculation), `SchoolYearService`, `AppLogger`, `BackupService`,
`AppLockService`. No dependency on LiteDB or Avalonia — this is what makes these services
testable without a UI or a real database.
- **LehrerApp.Data** — LiteDB implementation. `LiteDbContext` owns the single `LiteDatabase`
connection and all collection accessors; `Repositories/AllRepositories.cs` implements every
`I*Repository` interface from Core. `DatabaseEncryptionService` handles password-protecting the
DB file.
- **LehrerApp.Desktop** — the Avalonia MVVM client. `AppBootstrapper.BuildServices()` is the
single DI composition root (repositories and `LiteDbContext` are singletons — one LiteDB file
per user/process). `ViewModels/` and `Views/` are split into `Groups/`, `Students/`, `Settings/`
(mirrors the domain, not a strict 1:1 with models).
- **LehrerApp.Sync** — sync client library used by Desktop: `EventQueue` (local outbox),
`ConflictResolver` (last-write-wins with timestamp tie-breaking), `SyncEngine` (push/pull
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
### MVVM conventions (Desktop)
- ViewModels use CommunityToolkit.Mvvm source generators: `[ObservableProperty] private T _foo;`
and `[RelayCommand]`. A generated `partial void On{Foo}Changed(...)` is emitted for every
`[ObservableProperty]` — do not name a property/callback so it collides with that generated
name (e.g. a field named `OnFooChanged` next to `[ObservableProperty] Foo` fails with `CS0102`).
- Field-level validation: each invalid input gets its own `{Field}Error` string property
(not one collective `ValidationMessage`), rendered directly under that field in XAML via
`IsVisible="{Binding {Field}Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"`.
`Save()` clears all `*Error` properties up front, accumulates failures into a local `valid`
flag instead of returning on the first one, then does `if (!valid) return;` — so multiple
errors can show at once. A message that only makes sense as one combined statement about
several fields (e.g. a weighting scheme's three percentages summing to 100) legitimately stays
a single message near those fields; that's a judgement call, not a bug.
- Enum-backed UI: don't bind a `ComboBox` directly to a raw enum (Avalonia falls back to
`ToString()`, i.e. English member names). Expose a `string[] Options` of German labels plus a
`{Enum}Name` wrapper string property that the ComboBox binds to, converting to/from the actual
enum property — see `NiveauDisplay`/`GradeCategoryDisplay` for the established pattern.
- Dialogs are `Window` subclasses shown via `await dialog.ShowDialog<bool>(owner)`, with
`Close(true)`/`Close(false)` on confirm/cancel and the result read from a `Result` property on
the dialog's ViewModel.
### Avalonia XAML gotchas
- Local attribute values always win over `Style` selectors for the same property. Conditional
`Classes`-based styling only works if the base value also comes from a `Style` selector, not a
local attribute on the element.
- `[SomeAttached.Property] = value` is not valid inside a C# object initializer for an attached
property — use `ToolTip.SetTip(control, value)` as a separate statement.
- `x:DataType` on a `Window`/`UserControl` enables compiled bindings, which validate binding paths
at build time — a clean build is meaningful evidence that new bindings are wired correctly, not
just that the XML parses.
### LiteDB details
- Only a property literally named `Id` is auto-recognized as the primary key. A model with a
differently-named identity property (e.g. `EventId`) needs an explicit `[BsonId]`, or LiteDB
silently assigns an unrelated `_id` and lookups/deletes by that property quietly do nothing.
- `DateTime` round-trips through LiteDB BSON with `Kind` converted `Utc → Local` and the ticks
shifted to preserve the same instant. Since `DateTime` comparison operators compare raw ticks
and ignore `Kind`, comparing a round-tripped value against a freshly-created one is unsafe
outside UTC+0 — always `.ToUniversalTime()` both sides first (see `ConflictResolver`).
LiteDB's `LiteDatabase.Rebuild(new RebuildOptions { Password = ... })` for in-place re-encryption
is broken in the pinned LiteDB version (5.0.21) — it throws even against an unencrypted source
file. `DatabaseEncryptionService` instead copies every collection into a fresh database opened
with the target password.
- `LiteDbContext` tracks a schema version in a `meta` collection (`RunVersionedMigrations`) so
migration steps run once, not on every startup. Add new migrations as another
`if (version < N) { ...; version = N; }` block rather than making old steps re-check their own
idempotency forever.
- `new LiteDbContext(stream)` opens an in-memory database from a `MemoryStream` — used pervasively
in tests for a fast, disk-free `LiteDbContext`.
### Central Package Management
`Directory.Packages.props` pins all NuGet versions; individual `.csproj` files must reference
packages without a `Version` attribute (`<PackageReference Include="Foo" />`).
+11
View File
@@ -5,5 +5,16 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<!-- Wiederkehrende Textmuster (13.4.3): Dialog-Titel und Leerlisten-Hinweis wurden bisher
in jeder View einzeln mit denselben Werten inline gesetzt. -->
<Style Selector="TextBlock.dialogtitle">
<Setter Property="FontSize" Value="18"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="TextBlock.emptyhint">
<Setter Property="Opacity" Value="0.4"/>
<Setter Property="FontSize" Value="13"/>
</Style>
</Application.Styles> </Application.Styles>
</Application> </Application>
@@ -18,6 +18,7 @@ public partial class StudentListViewModel : ObservableObject
[ObservableProperty] private StudentListItem? _selectedStudent; [ObservableProperty] private StudentListItem? _selectedStudent;
public ObservableCollection<StudentListItem> Students { get; } = []; public ObservableCollection<StudentListItem> Students { get; } = [];
public string CountSummary => $"{Students.Count} Schüler gesamt";
public StudentListViewModel(IStudentRepository students) public StudentListViewModel(IStudentRepository students)
{ {
@@ -40,6 +41,7 @@ public partial class StudentListViewModel : ObservableObject
: all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) : all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); || s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
foreach (var s in f) Students.Add(new StudentListItem(s)); foreach (var s in f) Students.Add(new StudentListItem(s));
OnPropertyChanged(nameof(CountSummary));
} }
public Func<Task>? OnAddStudent { get; set; } public Func<Task>? OnAddStudent { get; set; }
@@ -38,7 +38,7 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine Stunden heute" Opacity="0.4" FontSize="13" <TextBlock Text="Keine Stunden heute" Classes="emptyhint"
IsVisible="{Binding !TodaysLessons.Count}"/> IsVisible="{Binding !TodaysLessons.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -62,7 +62,7 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine offenen Aufgaben" Opacity="0.4" FontSize="13" <TextBlock Text="Keine offenen Aufgaben" Classes="emptyhint"
IsVisible="{Binding !OpenTasks.Count}"/> IsVisible="{Binding !OpenTasks.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -195,7 +195,7 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine offenen Entschuldigungen." Opacity="0.4" FontSize="13" <TextBlock Text="Keine offenen Entschuldigungen." Classes="emptyhint"
IsVisible="{Binding !OpenExcuses.Count}"/> IsVisible="{Binding !OpenExcuses.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -228,7 +228,7 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Noch keine Lerngruppen." Opacity="0.4" FontSize="13" <TextBlock Text="Noch keine Lerngruppen." Classes="emptyhint"
IsVisible="{Binding !CurrentGroups.Count}"/> IsVisible="{Binding !CurrentGroups.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -9,7 +9,7 @@
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14"> <StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="{Binding DialogTitle}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<!-- Typ: Klasse oder Kurs --> <!-- Typ: Klasse oder Kurs -->
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@@ -9,7 +9,7 @@
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14"> <StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Neuer Bewertungszeitpunkt" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="Neuer Bewertungszeitpunkt" Classes="dialogtitle"/>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
@@ -11,7 +11,7 @@
<!-- Überschrift + Suche --> <!-- Überschrift + Suche -->
<StackPanel Grid.Row="0" Spacing="12" Margin="0,0,0,12"> <StackPanel Grid.Row="0" Spacing="12" Margin="0,0,0,12">
<TextBlock Text="Schüler zur Gruppe hinzufügen" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="Schüler zur Gruppe hinzufügen" Classes="dialogtitle"/>
<TextBox Text="{Binding SearchText}" <TextBox Text="{Binding SearchText}"
PlaceholderText="Schüler suchen …" PlaceholderText="Schüler suchen …"
x:Name="SearchBox"/> x:Name="SearchBox"/>
@@ -9,7 +9,7 @@
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Sammelnote erfassen" FontSize="18" FontWeight="SemiBold"/> <TextBlock Grid.Row="0" Text="Sammelnote erfassen" Classes="dialogtitle"/>
<StackPanel Grid.Row="1" Spacing="6" Margin="0,12,0,10"> <StackPanel Grid.Row="1" Spacing="6" Margin="0,12,0,10">
<Grid ColumnDefinitions="120,*,90" ColumnSpacing="6"> <Grid ColumnDefinitions="120,*,90" ColumnSpacing="6">
@@ -7,7 +7,7 @@
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12"> <StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="Klausur wirklich löschen?" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="Klausur wirklich löschen?" Classes="dialogtitle"/>
<TextBlock Text="{Binding}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap"/> <TextBlock Text="{Binding}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="Dabei werden auch alle erfassten Ergebnisse dieser Klausur dauerhaft gelöscht." <TextBlock Text="Dabei werden auch alle erfassten Ergebnisse dieser Klausur dauerhaft gelöscht."
TextWrapping="Wrap" Opacity="0.7"/> TextWrapping="Wrap" Opacity="0.7"/>
@@ -7,7 +7,7 @@
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12"> <StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="Lerngruppe wirklich löschen?" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="Lerngruppe wirklich löschen?" Classes="dialogtitle"/>
<TextBlock Text="{Binding}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap"/> <TextBlock Text="{Binding}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="Dabei werden auch Einschreibungen, Klausuren, Noten, Unterrichtsplanung und Mitarbeitseinträge dieser Lerngruppe dauerhaft gelöscht. Wenn du die Gruppe nur ausblenden möchtest, nutze stattdessen das Archiv." <TextBlock Text="Dabei werden auch Einschreibungen, Klausuren, Noten, Unterrichtsplanung und Mitarbeitseinträge dieser Lerngruppe dauerhaft gelöscht. Wenn du die Gruppe nur ausblenden möchtest, nutze stattdessen das Archiv."
TextWrapping="Wrap" Opacity="0.7"/> TextWrapping="Wrap" Opacity="0.7"/>
@@ -12,7 +12,7 @@
<ScrollViewer Grid.Row="0"> <ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0"> <StackPanel Spacing="14" Margin="0,0,12,0">
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<TextBlock Text="{Binding DialogTitle}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<TextBlock Text="{Binding SubjectDisplay}" FontSize="12" Opacity="0.6"/> <TextBlock Text="{Binding SubjectDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel> </StackPanel>
@@ -13,7 +13,7 @@
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding ExamTitle}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center"/> <TextBlock Text="{Binding ExamTitle}" Classes="dialogtitle" VerticalAlignment="Center"/>
<Border Background="{DynamicResource SystemControlBackgroundAccentBrush}" CornerRadius="4" Padding="8,2" <Border Background="{DynamicResource SystemControlBackgroundAccentBrush}" CornerRadius="4" Padding="8,2"
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/> <TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
@@ -70,7 +70,7 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Noch keine bewerteten Ergebnisse." Opacity="0.4" FontSize="13" <TextBlock Text="Noch keine bewerteten Ergebnisse." Classes="emptyhint"
IsVisible="{Binding !GradedCount}"/> IsVisible="{Binding !GradedCount}"/>
<Separator/> <Separator/>
@@ -3,6 +3,7 @@
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"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core" xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView" x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
x:DataType="vm:GroupDetailViewModel"> x:DataType="vm:GroupDetailViewModel">
@@ -13,10 +14,7 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"> <shared:PageHeader Grid.Column="0" Title="{Binding GroupTitle}" Subtitle="{Binding GroupSubtitle}"/>
<TextBlock Text="{Binding GroupTitle}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding GroupSubtitle}" FontSize="12" Opacity="0.5"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13"> <TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13">
<Run Text="{Binding StudentCount}"/> <Run Text="{Binding StudentCount}"/>
@@ -1,6 +1,7 @@
<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:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView" x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
x:DataType="vm:GroupListViewModel"> x:DataType="vm:GroupListViewModel">
@@ -11,10 +12,7 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0"> <shared:PageHeader Grid.Column="0" Title="Lerngruppen" Subtitle="{Binding ListSummary}"/>
<TextBlock Text="Lerngruppen" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding ListSummary}" FontSize="12" Opacity="0.5"/>
</StackPanel>
<ComboBox Grid.Column="1" ItemsSource="{Binding SchoolYears}" <ComboBox Grid.Column="1" ItemsSource="{Binding SchoolYears}"
SelectedItem="{Binding SelectedSchoolYear}" SelectedItem="{Binding SelectedSchoolYear}"
Width="100" Margin="0,0,8,0" VerticalAlignment="Center"/> Width="100" Margin="0,0,8,0" VerticalAlignment="Center"/>
@@ -9,7 +9,7 @@
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24"> <Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Mitarbeitsnote berechnen" FontSize="18" FontWeight="SemiBold"/> <TextBlock Grid.Row="0" Text="Mitarbeitsnote berechnen" Classes="dialogtitle"/>
<Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*" Margin="0,12,0,10"> <Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*" Margin="0,12,0,10">
<TextBlock Grid.Column="0" Text="Zeitraum:" VerticalAlignment="Center" Margin="0,0,8,0"/> <TextBlock Grid.Column="0" Text="Zeitraum:" VerticalAlignment="Center" Margin="0,0,8,0"/>
@@ -13,7 +13,7 @@
<StackPanel Grid.Row="0" Spacing="2"> <StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding GroupLabel}" FontSize="13" Opacity="0.5"/> <TextBlock Text="{Binding GroupLabel}" FontSize="13" Opacity="0.5"/>
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StudentName}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Grid.Column="0" Text="{Binding StudentName}" Classes="dialogtitle"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Content="◂ Zurück" Command="{Binding PreviousStudentCommand}"/> <Button Content="◂ Zurück" Command="{Binding PreviousStudentCommand}"/>
<TextBlock Text="{Binding ProgressText}" VerticalAlignment="Center" Opacity="0.6" FontSize="12"/> <TextBlock Text="{Binding ProgressText}" VerticalAlignment="Center" Opacity="0.6" FontSize="12"/>
@@ -10,7 +10,7 @@
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="2"> <StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding GroupLabel}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="{Binding GroupLabel}" Classes="dialogtitle"/>
<TextBlock Text="{Binding SchemeSummary}" FontSize="12" Opacity="0.6"/> <TextBlock Text="{Binding SchemeSummary}" FontSize="12" Opacity="0.6"/>
</StackPanel> </StackPanel>
@@ -9,7 +9,7 @@
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="{Binding StudentName}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Grid.Row="0" Text="{Binding StudentName}" Classes="dialogtitle"/>
<Button Grid.Row="1" Content=" Note hinzufügen" Command="{Binding AddEntryCommand}" <Button Grid.Row="1" Content=" Note hinzufügen" Command="{Binding AddEntryCommand}"
HorizontalAlignment="Left" Margin="0,10,0,10"/> HorizontalAlignment="Left" Margin="0,10,0,10"/>
@@ -52,7 +52,7 @@
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
<TextBlock Grid.Row="2" Text="Noch keine Noten erfasst." Opacity="0.4" FontSize="13" <TextBlock Grid.Row="2" Text="Noch keine Noten erfasst." Classes="emptyhint"
HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,20,0,0" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,20,0,0"
IsVisible="{Binding !Entries.Count}"/> IsVisible="{Binding !Entries.Count}"/>
@@ -10,7 +10,7 @@
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="10"> <StackPanel Grid.Row="0" Spacing="10">
<StackPanel> <StackPanel>
<TextBlock Text="{Binding Name}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="{Binding Name}" Classes="dialogtitle"/>
<TextBlock Text="{Binding GradingSystemLabel}" FontSize="12" Opacity="0.5"/> <TextBlock Text="{Binding GradingSystemLabel}" FontSize="12" Opacity="0.5"/>
</StackPanel> </StackPanel>
@@ -1,13 +1,13 @@
<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:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView" x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
x:DataType="vm:SettingsViewModel"> x:DataType="vm:SettingsViewModel">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="Einstellungen" FontSize="22" FontWeight="SemiBold" <shared:PageHeader Grid.Row="0" Title="Einstellungen" Margin="32,28,32,0"/>
Margin="32,28,32,0"/>
<TabbedPage Grid.Row="1" TabPlacement="Top"> <TabbedPage Grid.Row="1" TabPlacement="Top">
@@ -88,7 +88,7 @@
IsVisible="{Binding CatalogValidation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding CatalogValidation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<!-- Leerer Zustand --> <!-- Leerer Zustand -->
<TextBlock Text="Kein Fach ausgewählt." Opacity="0.4" FontSize="13" <TextBlock Text="Kein Fach ausgewählt." Classes="emptyhint"
IsVisible="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNull}}"/> IsVisible="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNull}}"/>
<!-- Domain-Liste --> <!-- Domain-Liste -->
@@ -166,7 +166,7 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<TextBlock Text="Noch keine Vorlagen angelegt." Opacity="0.4" FontSize="13" <TextBlock Text="Noch keine Vorlagen angelegt." Classes="emptyhint"
IsVisible="{Binding !GradingKeyTemplateList.Count}"/> IsVisible="{Binding !GradingKeyTemplateList.Count}"/>
<ItemsControl ItemsSource="{Binding GradingKeyTemplateList}"> <ItemsControl ItemsSource="{Binding GradingKeyTemplateList}">
@@ -298,7 +298,7 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Noch keine Backups vorhanden." Opacity="0.35" FontSize="12" <TextBlock Text="Noch keine Backups vorhanden." Classes="emptyhint"
IsVisible="{Binding !Backups.Count}"/> IsVisible="{Binding !Backups.Count}"/>
</StackPanel> </StackPanel>
@@ -7,7 +7,7 @@
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12"> <StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="{Binding Title}" Classes="dialogtitle"/>
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" Opacity="0.8"/> <TextBlock Text="{Binding Message}" TextWrapping="Wrap" Opacity="0.8"/>
</StackPanel> </StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -0,0 +1,13 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.Views.Shared.PageHeader"
x:CompileBindings="False">
<StackPanel>
<TextBlock Text="{Binding Title, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="12" Opacity="0.5"
IsVisible="{Binding Subtitle, RelativeSource={RelativeSource AncestorType=UserControl},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Shared;
/// Seiten-/Detail-Kopfzeile (Titel + optionaler Untertitel) — bisher in jeder
/// Listen-/Detailansicht einzeln mit denselben Werten nachgebaut (13.4.3).
public partial class PageHeader : UserControl
{
public static readonly StyledProperty<string> TitleProperty =
AvaloniaProperty.Register<PageHeader, string>(nameof(Title), defaultValue: "");
public static readonly StyledProperty<string?> SubtitleProperty =
AvaloniaProperty.Register<PageHeader, string?>(nameof(Subtitle));
public string Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public string? Subtitle
{
get => GetValue(SubtitleProperty);
set => SetValue(SubtitleProperty, value);
}
public PageHeader() => InitializeComponent();
}
@@ -12,7 +12,7 @@
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock Text="Neuen Schüler anlegen" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="Neuen Schüler anlegen" Classes="dialogtitle"/>
<!-- Name --> <!-- Name -->
<Grid ColumnDefinitions="*,10,*"> <Grid ColumnDefinitions="*,10,*">
@@ -94,7 +94,7 @@
</ItemsControl> </ItemsControl>
<TextBlock Text="Noch keine Kontakte. Über Kontakt hinzufügen." <TextBlock Text="Noch keine Kontakte. Über Kontakt hinzufügen."
Opacity="0.35" FontSize="12" Classes="emptyhint"
IsVisible="{Binding !Contacts.Count}"/> IsVisible="{Binding !Contacts.Count}"/>
</StackPanel> </StackPanel>
@@ -11,7 +11,7 @@
<Grid RowDefinitions="*,Auto" Margin="24,20"> <Grid RowDefinitions="*,Auto" Margin="24,20">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="16"> <StackPanel Spacing="16">
<TextBlock Text="{Binding DialogTitle}" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<Grid ColumnDefinitions="*,10,*"> <Grid ColumnDefinitions="*,10,*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
@@ -1,6 +1,7 @@
<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:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Students.StudentDetailView" x:Class="LehrerApp.Desktop.Views.Students.StudentDetailView"
x:DataType="vm:StudentDetailViewModel"> x:DataType="vm:StudentDetailViewModel">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
@@ -10,9 +11,7 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" IsVisible="{Binding !IsEditing}"> <shared:PageHeader Grid.Column="0" IsVisible="{Binding !IsEditing}" Title="{Binding StudentTitle}"/>
<TextBlock Text="{Binding StudentTitle}" FontSize="22" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="0" Spacing="6" IsVisible="{Binding IsEditing}"> <StackPanel Grid.Column="0" Spacing="6" IsVisible="{Binding IsEditing}">
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/> <TextBox Grid.Column="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/>
@@ -181,7 +180,7 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Noch keine Noten für diesen Schüler erfasst." Opacity="0.4" <TextBlock Text="Noch keine Noten für diesen Schüler erfasst." Classes="emptyhint"
IsVisible="{Binding !GradeHistory.Count}"/> IsVisible="{Binding !GradeHistory.Count}"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -1,6 +1,7 @@
<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:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Students.StudentListView" x:Class="LehrerApp.Desktop.Views.Students.StudentListView"
x:DataType="vm:StudentListViewModel"> x:DataType="vm:StudentListViewModel">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
@@ -8,13 +9,7 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0"> <shared:PageHeader Grid.Column="0" Title="Schüler" Subtitle="{Binding CountSummary}"/>
<TextBlock Text="Schüler" FontSize="22" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding Students.Count}"/>
<Run Text=" Schüler gesamt"/>
</TextBlock>
</StackPanel>
<CheckBox Grid.Column="1" Content="Inaktive anzeigen" <CheckBox Grid.Column="1" Content="Inaktive anzeigen"
IsChecked="{Binding ShowInactive}" IsChecked="{Binding ShowInactive}"
VerticalAlignment="Center" Margin="0,0,12,0"/> VerticalAlignment="Center" Margin="0,0,12,0"/>
+26 -5
View File
@@ -580,12 +580,33 @@ Fächer- und Kompetenzverwaltung existiert bereits in
sinnvoll zu testen. sinnvoll zu testen.
### 13.4 Codepflege ### 13.4 Codepflege
- [ ] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`). - [x] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`) — [CLAUDE.md](CLAUDE.md).
- [ ] **13.4.2** `AllRepositories.cs` und `IRepositories.cs` in Themendateien aufteilen, - [ ] **13.4.2** `AllRepositories.cs` und `IRepositories.cs` in Themendateien aufteilen,
sobald weitere Repositories dazukommen. sobald weitere Repositories dazukommen. Aktuell (319 bzw. 154 Zeilen für ~20 Repositories)
- [ ] **13.4.3** Wiederkehrende UI-Muster (Suchleiste, Kopfzeile, leere Liste) als noch nicht unübersichtlich genug, um die Bedingung auszulösen — bewusst zurückgestellt.
wiederverwendbare Controls in `Views/Shared/`. - [x] **13.4.3** Wiederkehrende UI-Muster als wiederverwendbare Controls/Styles.
- [ ] **13.4.4** Konverter und Styles zentralisieren (aktuell teils inline in den Views). Bestandsaufnahme vor der Umsetzung ergab drei echte Duplikate (Suchleiste dagegen nur 3×
und jedes Mal eine simple `TextBox` ohne gemeinsames Chrome — Extraktion lohnt sich dort
nicht):
- Seiten-Kopfzeile (Titel 22px SemiBold + optionaler Untertitel) war identisch in
`GroupListView`, `StudentListView`, `GroupDetailView`, `StudentDetailView`,
`SettingsView` nachgebaut → neue
[PageHeader](LehrerApp.Desktop/Views/Shared/PageHeader.axaml)-Control, dort eingesetzt.
- Dialog-Titel (`FontSize="18" FontWeight="SemiBold"` als erstes Element) kam in 16
Dialogen jeweils inline vor → globale Style-Klasse `TextBlock.dialogtitle`
([App.axaml](LehrerApp.Desktop/App.axaml)).
- Leerlisten-Hinweis ("Noch keine …") kam an 10 Stellen vor, dabei war Opacity/FontSize
bereits leicht auseinandergedriftet (0.35 vs. 0.4, 12 vs. 13) → globale Style-Klasse
`TextBlock.emptyhint`, normalisiert alle Stellen auf einen Wert.
Absichtlich nicht angefasst: die vier Views mit eigenen lokalen `<Style Selector=>`-Blöcken
(MainWindow, DashboardView, StudentDetailView, ExamEvaluationDialog) sind jeweils
einzigartige Spezial-Visualisierungen (Kalenderzellen, Sparkline, Balkendiagramm,
Toast/Drawer) ohne Duplikate untereinander.
- [x] **13.4.4** Konverter und Styles zentralisieren. Bei der Bestandsaufnahme für 13.4.3 zeigte
sich: es gibt keine einzige eigene `IValueConverter`-Implementierung im Projekt — überall
werden bereits konsistent Avalonias eingebaute statische Konverter (`StringConverters`,
`BoolConverters`, `ObjectConverters`) verwendet. Nichts zu zentralisieren; die
Style-Duplikate wurden im Zuge von 13.4.3 behoben (siehe oben).
--- ---