Änderungen für Release 2.0.0 sind umgesetzt #41

Merged
lrsauvette merged 30 commits from develop into main 2026-08-02 22:17:03 +00:00
Owner
No description provided.
Both pages previously lacked the header-row + primary-action pairing
used consistently on Verwaltung and Anträge, and Anwesenheit's
settings were split across two separate cards with hand-rolled
Radio/Checkbox rows instead of standard widgets.

Wahlen:
- Adds a simple page title header.
- Moves 'Wahl starten' into a header row next to 'Ämter wählen'.
- Always shows the 'Vergangene Wahlen (N)' count, avoiding layout
  shift when the list is empty.
- Restyles the Neuwahl/Erstwahl chip to match Anträge's chip
  language (tinted background + border instead of solid color).
- Wraps the page in a CustomScrollView with a SliverFillRemaining
  for the past-elections list, fixing a vertical RenderFlex overflow
  when the window is too short for the fixed header content.
- Moves 'Online-Wahl erstellen' next to 'Wahl starten' in the Ämter
  wählen header row (as a button, replacing the separate card
  section), using a Wrap so both reflow on narrow widths.
- Adds overflow/maxLines safety to the Neuwahl/Erstwahl checkbox
  title to avoid a minor horizontal overflow with long section names.

Anwesenheit (ErzeugungPage):
- Adds a page title header with the primary action button
  ('Anwesenheitsliste erzeugen') moved up next to it, instead of a
  detached full-width button at the bottom.
- Merges the Sitzungstyp and Datum cards into a single settings
  card, replacing the hand-rolled Radio/GestureDetector row with a
  standard SegmentedButton.
- Replaces the manual 'select all' Row+Checkbox with a proper
  CheckboxListTile, matching the list items below it.

WahlergebnisPage: replaces the stacked FloatingActionButtons and the
AppBar action icon with a single header-row action Wrap (Gesamt-
wahlzettel / Live-Abstimmung / Wahlergebnis erzeugen), matching the
button-row pattern used on the Anträge and Wahlen tab pages.
Renames the 'Live-Abstimmung' button to 'Live-Wahl'.

WahlEditor: moves the 'Wählen' action out of the AppBar into a body
header row paired with a 'Sektionen (N)' label, consistent with how
primary actions are now paired with their section headers elsewhere
in the app.
Moves 'Online-Wahl erstellen' and 'Wahl starten' into the same Wrap
row as the 'Wahlen' title, right-aligned, instead of on a separate
row below it. Also removes the redundant 'Ämter wählen' section
label that sat above the cards.
Reviewed-on: #29
Adds a per-user Dark Mode preference, persisted in the app's
JsonStore, that forces a dark Material 3 colour scheme based on
the same indigo seed colour as the light theme.

- New ThemeModeStore (lib/persistence/theme_mode_store.dart) that
  holds a single bool ('dark mode on/off') and exposes the matching
  ThemeMode for MaterialApp. Default is 'off' → ThemeMode.system,
  so the app still follows the OS setting when the user has not
  opted in.
- MaterialApp now provides a darkTheme in addition to the existing
  theme, and selects themeMode based on the store value.
- New 'Dunkles Farbschema' SwitchListTile in the 'Darstellung'
  section of the settings page.
- Unit tests for the store (default, enable + persistence, disable +
  persistence).
Reviewed-on: #31
Reviewed-on: #32
Adds a per-student counter that tracks how many SVVV sessions the
student has participated in, surfaced as a new sortable column in
the Verwaltung table beside the Nachname column. The new column
can be hidden via a switch in the Einstellungen → Darstellung
section.

Behaviour:
- Each Student carries an int counter `svvvSessionsAttended`
  (default 0, persisted with the student record).
- Generating an Anwesenheitsliste for an SVVV-Sitzung in the
  ErzeugungPage automatically increments the counter for every
  selected student. SVR sessions and cancelled generations are
  not counted. Increment happens before the save dialog, so even
  if the user cancels the file save the session is still
  recorded.
- Backwards-compatible JSON: students persisted before this
  change load with counter = 0.
- A new `ShowSvvvStatsStore` (bool, default = visible) persists
  the column visibility. New `SVVV-Sitzungsstatistik` SwitchListTile
  in the Einstellungen page toggles it.

Tests:
- New unit tests for `ShowSvvvStatsStore` (default, hide + persist,
  show + persist).
- New unit tests for the Student model covering the new field's
  default, JSON roundtrip, backwards-compatible deserialisation,
  and copyWith.
Reviewed-on: #33
Adds a round-trippable backup of the entire app data directory to
a single .zip file, exposed in the existing 'Import / Export'
section of the Einstellungen page.

Backup layout (inside the .zip):
- manifest.json     format version, app identifier, UTC timestamp
- data/             every JSON key from the JsonStore
- antraege/         Antrag attachment directories
- vorlagen/         user-added ODT templates

Import replaces the three subdirectories in-place — anything not
present in the archive is deleted — so the user-visible state is
fully recreated. A destructive red confirm dialog gates the
import, and the success snackbar reminds the user to restart
the app.

Safety:
- formatVersion check on import so future schema changes can
  refuse old backups cleanly (with no data loss, since the
  delete-then-write only happens after the manifest validates).
- Path-traversal check: a resolved output path must live inside
  the intended subdirectory (data/, antraege/, vorlagen/);
  anything that escapes is rejected with a BackupException.
- Files with a top-level segment outside the three allowed
  directories are silently ignored.

Platform: export/import require a real filesystem, so the two
buttons display an unsupported-platform snackbar on web/Android/
iOS (the existing CSV export uses the same guard).

Tests (8 new): export counts, full roundtrip preserves data and
deletes leftovers, missing-file error, missing-manifest error,
wrong-format-version error, files outside allowed dirs ignored,
path-traversal inside allowed dirs rejected.
Reviewed-on: #34
Adds a new 'Kasse' tab to the app for tracking SV income and
expenses, with CRUD, verification, filtering and CSV import/export.

Models
- KassenEintrag: datum, typ (Einnahme/Ausgabe), betrag,
  beschreibung, kategorieId, notiz, zahlungsweg (Bargeld/Bank/
  Lastschrift), belegNummer, verantwortlicher, verifiziert,
  verifiziertAm.
- KassenKategorie: simple bezeichnung-only category with a
  'standard' flag reserved for future predefined entries.

Persistence
- KassenEintragStore: add / update / remove / addAll / replaceAll /
  setVerifiziert plus computed summeEinnahmen / summeAusgaben /
  saldo. Backed by the existing JsonStore.
- KassenKategorieStore: add / update / remove / reorder and
  lookup helpers used by the page and CSV import. Starts empty.

UI
- KassePage: new navigation-bar tab. Toolbar with CSV import /
  export and 'Neuer Eintrag'. Summary row at the top shows
  Einnahmen / Ausgaben / Saldo (red for negative). Filter row
  with Alle / Offen / Verifiziert segments plus a search field.
  Sortable DataTable with Datum, Typ, Beschreibung, Kategorie,
  Betrag columns and per-row actions: verify toggle (green
  verified icon when on), edit, delete (with confirmation).
- KassenEintragDialog: SegmentedButton for typ, fields for
  beschreibung / betrag (numeric, accepts comma) / datum
  picker / kategorie / zahlungsweg / beleg-nr / verantwortlicher
  / notiz. Validation for betrag > 0 and non-empty beschreibung.
- KassenKategorienPage: ReorderableListView with rename-on-tap
  and delete with confirmation. Empty-state hint when no
  categories exist.
- New 'Kassen-Kategorien verwalten' card in the Einstellungen
  page → Stammdaten section.

CSV import / export
- CsvIoKasse with the standard header:
  datum, typ, betrag, beschreibung, kategorie, notiz,
  zahlungsweg, beleg_nummer, verantwortlicher, verifiziert.
- Comma-quoted fields, German decimal comma supported on
  import, dot decimal on export.
- Header detection requires at least 2 matching column names,
  so a data row whose value happens to equal a header name
  (e.g. 'Kategorie') is not mistaken for a header.
- Import auto-creates missing categories by bezeichnung and
  resolves them to IDs after creation.

Tests (20 new, total 63 passing):
- KassenEintragStore: add/update/remove, addAll vs replaceAll,
  sums, setVerifiziert toggles verifiziertAm, throws on unknown
  id, JsonStore roundtrip preserves type / verified state.
- KassenKategorieStore: empty start, ignore-blank, CRUD,
  case-insensitive findByBezeichnung, reorder + persist,
  bezeichnungFuer fallbacks.
- CsvIoKasse: header order, export-import roundtrip,
  position-based parsing without header, CSV-quoting with
  embedded comma, German decimal comma, empty input,
  toEintrag generates fresh IDs.
The three-cell summary row (Einnahmen/Ausgaben/Saldo) had two
issues:

1. The two 1-px vertical Container dividers were squeezed
   against the Expanded cells, eating horizontal space and
   contributing to the overflow. Replaced with SizedBox
   separators.

2. The inner Row inside _summaryZahl (icon + label) was sized
   to its natural width with no flex fallback, so the label
   'Einnahmen' overflowed when the column got narrower (e.g.
   with the global text scale cranked up). Wrapped the label
   Text in Flexible with overflow ellipsis and softWrap=false.

The amount Text now also lives inside a FittedBox(scaleDown)
so large Saldo values never overflow the available width
either.
Reviewed-on: #35
Foundations for the multi-user / role-based auth (issue/26).

- Benutzer model with BenutzerRolle enum (admin, coSsp, svr, kw,
  kp, keine) and a compact label extension.
- Permission enum + Permissions.hat(rolle, p) static map encoding
  the issue's permission table (5-role matrix with KP-only Kasse
  verify/delete, two separate kasse/schueler CSV permissions).
- BenutzerStore persisted in the JsonStore: add / update / remove,
  case-insensitive findByName, verifyCredentials (rejects users
  with role 'keine'), changeOwnPassword, adminSetPassword,
  adminResetPassword. Runs a one-time migration that replaces the
  old 'SVR-V' / 'stv. SVR-V' amtKürzel with 'Co-SVR-V' on load.
- AdminRecoveryStore holds the sha256 hash of the global recovery
  code used to reset the admin password. Code generator stays
  identical to the old PasswordStore (10 chars, no ambiguous
  glyphs).
- 20 new tests covering CRUD, lookup, password flows, role
  rejection, amtKürzel migration and JsonStore roundtrip.
Replaces the single-password AuthNotifier (login returned bool)
with a user-aware one. AuthState now carries the currently logged
in Benutzer; login returns the Benutzer or null depending on
whether the credentials check (and role-non-keine check) pass.

- login(name, password) -> Benutzer? validates through
  BenutzerStore.verifyCredentials, starts the 30-minute inactivity
  timer, sets currentUser.
- logout() cancels the timer and clears currentUser.
- resetAdminPasswordWithRecoveryCode(code, newPassword) uses
  AdminRecoveryStore.verify and BenutzerStore.adminResetPassword
  to reset the admin password.
- recordActivity clears the sessionWarning flag.

11 new tests cover initial state, login success/failure, role
rejection, logout, recordActivity, and the recovery flow's
success / wrong-code / no-admin paths.
Two small helpers for the upcoming permission gates:

- usePermission(ref, p) -> bool: true if the logged-in user has
  the given permission. Returns false when nobody is logged in.
- PermissionGate({permission, child, fallback}): renders [child]
  only when the user has the permission; otherwise renders
  [fallback] (default SizedBox.shrink).

Strategy throughout the rest of the issue: hide unauthorized
actions entirely (no snackbar fallback). Test covers the full
role x permission matrix for admin / coSsp / svr / kw / kp /
keine and asserts eigenesPasswortAendern is granted to every
logged-in role.
Updates the canonical Amt.standard list to a single Co-SVR-V
entry instead of the two former variants, and adds one-time
migrations in AmtStore and StudentStore so existing data is
mapped to the new kürzel on load.

- Amt.standard: drops SVR-V and stv. SVR-V, adds
  Co-SVR-V (bezeichnung: Co-SVR-Vorsitzende:r). Other entries
  reordered so MdSVR/MdBDK/SVL/KW/KP sit right after.
- AmtStore._load: when a persisted list contains the old kürzel,
  it is replaced with Co-SVR-V (idempotent).
- StudentStore._load: same replacement for each student's
  aemter list, with deduplication (a student with both SVR-V
  and stv. SVR-V in aemter ends up with a single Co-SVR-V).
- BenutzerStore migration (added in the previous commit) does
  the same for user.amtKuerzel.

Tests cover the migration in both stores plus idempotency.
Replaces the single-password SetPasswordPage with a multi-user
FirstAdminSetupPage that creates the first admin account and shows
the global recovery code. The LoginPage now shows a user dropdown
when more than one user exists (single-user shortcut remains).

AuthGate routing:
- empty benutzerStore → FirstAdminSetupPage
- non-empty & not logged in → LoginPage
- logged in → AppShell (unchanged listener pattern)

AuthGate also fires a one-time cleanup of the legacy
sv_helfer_pw_hash / sv_helfer_pw_recovery keys so the data dir
stays clean after the migration.

LoginPage: when the user clicks 'Admin-Passwort vergessen?'
the dialog now asks for the recovery code + a new password, and
calls AuthNotifier.resetAdminPasswordWithRecoveryCode.
Non-admin passwords are no longer recoverable via the dialog
(only Admin can reset, per issue spec).
New BenutzerPage (admin only) accessible from Einstellungen →
Sicherheit. Provides:

- List of all Benutzer with name, role label and amt
  bezeichnung.
- Add user dialog: name, initial password, role dropdown
  (keine hidden, since it has no permissions), amt dropdown.
- Edit user dialog: change name, role, amt (password not edited
  here).
- Passwort zurücksetzen dialog: admin sets a new password for a
  user, with confirmation.
- Delete user with confirmation, blocked for the user themselves
  and for the last admin (both produce a snackbar explaining why).
- Admin-Wiederherstellungscode neu erzeugen action: regenerates
  the global recovery code and shows it once with confirmation
  checkbox.

The 'Benutzer verwalten' card in Einstellungen is wrapped in a
PermissionGate(permission: nutzerVerwalten), so non-admins never
see the entry.
- Extracts the password-change dialog into a standalone
  PasswortAendernDialog so it can be opened from anywhere
  (currently the AppBar). Uses BenutzerStore.changeOwnPassword.
- AppShell changes:
  - AppBar shows a compact chip with the current user's name
    and role label, plus a 'Passwort ändern' icon button next
    to the existing logout button.
  - Tabs are filtered by the user's role via the new
    _visibleTabs helper: Verwaltung needs schuelerSehen,
    Anträge antraegeErstellen, Wahlen wahlenErstellen,
    Anwesenheit anwesenheitslisteErzeugen, Kasse kasseLesen,
    Einstellungen always. Hidden tabs are not rendered at all
    (no empty body, no hidden IndexedStack child).
Wraps the action buttons in every feature page with the
PermissionGate widget so users only see what they're allowed to do.

EinstellungenPage:
- Schuljahr ändern + Datenspeicherort cards: blocked unless
  stammdatenAendern / speicherortAendern respectively.
- Ämter verwalten + Antragstypen verwalten: stammdatenAendern.
- Kassen-Kategorien verwalten: kasseKategorieVerwalten (note: KW
  and KP can manage kassen categories via this entry).
- CSV import / export (Schüler): schuelerCsvImportExport.
- Vollsicherung exportieren: backupExportieren.
- Sicherung importieren: backupImportieren.
- Passwort ändern: opens the new PasswortAendernDialog.

VerwaltungPage: Hinzufügen / Edit / Delete buttons all gated by
schuelerVerwalten. svLeitung still sees the table and student
list (read-only) via schuelerSehen on the tab.

WahlenPage: Wahl starten + Online-Wahl erstellen gated by
wahlenErstellen. Löschen-Button gated by datenLoeschen. Tap-on-row
to edit gated by wahlenErstellen.

AntraegePage: Live-Abstimmung gated by antraegeAbstimmen, Neuer
Antrag by antraegeErstellen, Löschen by datenLoeschen, Edit-on-tap
by antraegeErstellen.

ErzeugungPage: Anwesenheitsliste erzeugen gated by
anwesenheitslisteErzeugen.

KassePage: CSV import / export gated by kasseCsvImportExport,
Neuer Eintrag by kasseSchreiben, verify by kasseVerifizieren,
edit by kasseSchreiben, delete by kasseLoeschenVerifiziert.

The old _PasswortAendernDialog class is removed from
EinstellungenPage; the new shared PasswortAendernDialog replaces
it.
The old PasswordStore (single-password sha256 hash + global
recovery code) and AppConfig.adminPassword constant are no longer
referenced anywhere — credentials now live in BenutzerStore
(multi-user) and the global recovery code in AdminRecoveryStore.

AuthGate already cleans up the legacy sv_helfer_pw_hash and
sv_helfer_pw_recovery JsonStore keys on first launch after the
upgrade, so old installs lose their auth data cleanly.
The _EditBenutzerDialog used default-e?.rolle ?? BenutzerRolle.keine,
but the dropdown items filter out BenutzerRolle.keine, so the
initialValue had no matching item and the form threw an assertion
when adding a new user.

New users now default to BenutzerRolle.coSsp (always present in
the items list). Existing users with BenutzerRolle.keine also
fall back to coSsp, which is the right behaviour since such
records can never log in anyway.
EinstellungenPage tweaks:

- The 'Stammdaten' header and its 16px gap are now wrapped in a
  conditional that checks whether any of the three cards below
  (Ämter, Antragstypen, Kassen-Kategorien) would be visible for
  the current user. The same is done for 'Import / Export' (CSV
  im-/export, Vollsicherung im-/export). When the user has no
  permission for any of a section's cards, the entire section
  header is omitted so the page only shows relevant labels.

- The SVVV-Sitzungsstatistik card under 'Darstellung' is now
  hidden for users without schuelerSehen (KW, KP, etc.). Since
  those roles don't have a Verwaltung tab, the toggle had no
  effect for them anyway.
Reviewed-on: #36
Refreshes the README to cover everything shipped on issue/26 and
the surrounding issues:

- Adds a top-level 'Anmeldung und Rollen' section explaining the
  five roles (Administrator, Co-Schülersprecher:in,
  SVR-Mitglied, Kassenwart:in, Kassenprüfer:in), the role-based
  permission model, the first-admin setup flow and the global
  recovery code.
- New 'Kasse' section covering Einnahmen/Ausgaben, summary
  card, Verifizierung (KP-only), filter, CSV im/export and
  Kassen-Kategorien.
- New 'Datensicherung' subsection describing the .zip full
  backup (export + import).
- Adds the SVVV-Sitzungsstatistik toggle to the Verwaltung
  description and expands the Einstellungen section with the
  themed Display, Sicherheit → Benutzer verwalten and the new
  Kassen-Kategorien sub-page.
- Updates the Ämter table to the new Co-SVR-V entry (replaces
  the old SVR-V / stv. SVR-V pair) and notes that the toggle
  for changing the data storage location is admin-only.
- Expands the bottom security note to mention the multi-user
  role model and the shared recovery code.
Reviewed-on: #37
The previous implementation used a Timer.periodic that ticked
every 30 seconds and checked whether the idle time had crossed
the 30-minute threshold. In practice, the Listener wrapping
AppShell still received pointer events on the regions outside
the warning dialog while the dialog was showing, which reset
state.lastActivity. The periodic tick then saw lastActivity
close to 'now' and never reached the logout condition — the
warning dialog appeared but the user was never logged out.

Replaces the periodic timer with a single Timer that is always
scheduled to fire exactly at the next transition (warning at
28 min, logout at 30 min). On every recordActivity() the timer
is rescheduled, so any synchronous activity reliably pushes the
logout out. The warning branch sets sessionWarning = true and
then schedules a follow-up timer for the remaining 2 minutes;
that follow-up is the one that actually calls logout().

3 new tests using package:fake_async cover the full sequence:
warning at 28 min, logout at 30 min, recordActivity reset,
and logout() cancelling the pending timer.
Reviewed-on: #40
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lrsauvette/sv-helfer!41
No description provided.