Add presentation timer / rehearsal mode to the presenter

The presenter view now doubles as a rehearsal clock that measures without
coaching: a countdown against a target time, the time spent on the current
slide, and an end-of-run summary (total vs. target and per-slide times, with
copy-to-clipboard). Timing lives in a plain, unit-tested RehearsalController fed
via an idempotent observe() on every build, so it captures every navigation
path. The default target is stored in AppSettings; live adjustment is the K key
(typed as MMSS). All rehearsal state is session-only -- nothing is written to
disk or into the .md file.

- New: models/rehearsal.dart, services/rehearsal_controller.dart,
  widgets/presentation/rehearsal_summary.dart, plus a controller unit test.
- Presenter: countdown + per-slide timer in the clock bar, K to set the target,
  R resets the run, end-of-run summary dialog, and help/cheatsheet entries.
- Settings: presentationTargetSeconds (default target) with a dropdown in the
  General tab, threaded into FullscreenPresenter.present().
- l10n: new Dutch source strings translated in all seven languages.
- Docs: README, CHANGELOG, USER_GUIDE, SHORTCUTS, ARCHITECTURE.

Also bundles a pre-existing in-progress change already in the working tree: wire
the existing ThemeProfile.tableHeaderBackgroundColor into table rendering
(preview, HTML export, file_service) and the settings dialog, plus its
translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-13 07:03:08 +02:00
parent f93417dc3c
commit b719c43991
18 changed files with 987 additions and 63 deletions

View file

@ -8,6 +8,15 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Presentation timer / rehearsal mode** — the presenter view now doubles as a
rehearsal clock that measures without coaching. A **countdown** runs against a
target time (default under *Settings → General → Presentation*, or set live with
`K` as `MMSS`; `0` turns it off) and turns red when you go over. The clock bar
also shows the time spent on the **current slide**, accumulated per slide across
the run. `R` resets the run (elapsed and per-slide timings, keeping the target).
Leaving the presenter after a run shows a **summary** (total vs. target, time per
slide) with copy-to-clipboard. Session-only: nothing is persisted to disk or the
`.md` file.
- **Duplicate clean-up in the image library** — a footer button finds - **Duplicate clean-up in the image library** — a footer button finds
byte-identical images by md5 checksum, keeps one file per group (preferring byte-identical images by md5 checksum, keeps one file per group (preferring
the one used in slides, then the oldest), merges the tags/descriptions and the one used in slides, then the oldest), merges the tags/descriptions and

View file

@ -14,6 +14,7 @@ Built with Flutter for macOS, Windows, and Linux.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math. - **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. An optional **export release ceiling** can block exporting any deck classified above a chosen level — enforced for every format, off by default. - **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. An optional **export release ceiling** can block exporting any deck classified above a chosen level — enforced for every format, off by default.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview. - **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
- **Presentation timer / rehearsal mode** — the presenter view doubles as a rehearsal clock: a countdown against a target time (set in Settings or live with `K`), the time spent on the current slide, and an end-of-run summary (total vs. target and per-slide times, copyable). It measures only — no pacing coaching — and is session-only, never written to disk.
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync. - **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar. - **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file). - **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file).

View file

@ -18,7 +18,7 @@ lib/
services/ # markdown, file, export, classification_policy, image, caption, services/ # markdown, file, export, classification_policy, image, caption,
# description, image_dedup (md5 duplicates), # description, image_dedup (md5 duplicates),
# image_reference (.md rewrites), recovery, rasterizer, # image_reference (.md rewrites), recovery, rasterizer,
# marp_html, annotation_codec # marp_html, annotation_codec, rehearsal_controller
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
l10n/ # AppLocalizations (8 languages) l10n/ # AppLocalizations (8 languages)
@ -84,6 +84,13 @@ before any work starts.
and the **annotation tools** (pen/highlighter/eraser/laser). and the **annotation tools** (pen/highlighter/eraser/laser).
- Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide - Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide
changes never flash black (important for screen recording). changes never flash black (important for screen recording).
- **Rehearsal timing** lives in `services/rehearsal_controller.dart` — a plain,
unit-testable controller (injectable clock) that the presenter feeds via a
cheap, idempotent `observe(id, index)` on every build, so it captures every
navigation path. It measures only: elapsed, remaining against a target, and
per-slide time — no pacing logic. State is **session-only** (no prefs, no `.md`);
`_exit` shows a summary (`rehearsal_summary.dart`) and discards it. The default
target lives in `AppSettings.presentationTargetSeconds`.
### Dual-screen mode ### Dual-screen mode

View file

@ -35,10 +35,11 @@ View & timing:
| Shortcut | Action | | Shortcut | Action |
| --- | --- | | --- | --- |
| `P` | Toggle presenter view (notes, clock, next slide) | | `P` | Toggle presenter view (notes, clock, countdown, per-slide timer, next slide) |
| `S` | Move the presentation to another screen | | `S` | Move the presentation to another screen |
| `B` · `W` | Black · white screen | | `B` · `W` | Black · white screen |
| `R` | Reset the elapsed-time counter | | `K` | Set the target time / countdown (type `MMSS`, `Enter` to confirm, `0` = off) |
| `R` | Reset the time & rehearsal run (elapsed and per-slide timings; the target stays) |
| `A` | Auto-advance on/off | | `A` | Auto-advance on/off |
| `L` | Loop (restart after the last slide) on/off | | `L` | Loop (restart after the last slide) on/off |
| `M` | Advance automatically after a slide's audio finishes | | `M` | Advance automatically after a slide's audio finishes |

View file

@ -122,8 +122,30 @@ set a **release ceiling** — a maximum level that may leave the machine; see
Start the fullscreen presenter from the toolbar. See Start the fullscreen presenter from the toolbar. See
[`SHORTCUTS.md`](SHORTCUTS.md) for the full key list; highlights: arrows to move, [`SHORTCUTS.md`](SHORTCUTS.md) for the full key list; highlights: arrows to move,
`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `H` for the `G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `K` for the
in-app cheatsheet. countdown, `R` to reset the timing, `H` for the in-app cheatsheet.
### Rehearsing and timing
The presenter view (`P`) is also a rehearsal clock — it measures, it does not
nag. The clock bar shows four things:
- **Elapsed** — time since the run started (or since the last `R`).
- **Remaining** — a countdown against a **target time**. It turns red and shows a
minus sign once you go over; there is no "speed up" coaching, just the number.
- **This slide** — how long you have spent on the current slide. Time accumulates
per slide across the whole run, even if you jump back and forth.
- **Clock** — the wall-clock time.
Set the target time up front under *Settings → General → Presentation*, or change
it live while presenting with **`K`** (type the minutes and seconds as `MMSS`,
`Enter` to confirm, `0` to switch the countdown off). **`R`** resets the run —
elapsed time and per-slide timings — while keeping the target.
When you leave the presenter after a run of at least ten seconds, a **summary**
shows the total time against the target and the time spent per slide, with a
button to copy the times to the clipboard. This is **session-only**: nothing is
written to disk or into the `.md` file.
### Two screens (beamer + laptop) ### Two screens (beamer + laptop)

View file

@ -2429,6 +2429,28 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = { const _dutchSourceStringAdditions = {
'en': { 'en': {
'Toegankelijkheid': 'Accessibility', 'Toegankelijkheid': 'Accessibility',
'Presentatie': 'Presentation',
'Tabel kopachtergrond': 'Table header background',
'Doeltijd': 'Target time',
'Doeltijd (aftellen)': 'Target time (countdown)',
'Geen aftelling': 'No countdown',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Default target time for the presenter countdown. Fine-tune it while presenting with the K key.',
'uit': 'off',
'Doeltijd / aftellen (K)': 'Target / countdown (K)',
'Doeltijd / aftellen instellen (MMSS)': 'Set target / countdown (MMSS)',
'Tijd & oefenrun resetten': 'Reset time & rehearsal',
'Resterend': 'Remaining',
'Over de tijd': 'Over time',
'Binnen de tijd': 'Within time',
'Deze slide': 'This slide',
'Oefenrun': 'Rehearsal',
'Totaal': 'Total',
'Totale tijd': 'Total time',
'Geen slides gemeten.': 'No slides measured.',
'Tijden gekopieerd naar klembord.': 'Times copied to clipboard.',
'Kopieer': 'Copy',
'Sluiten': 'Close',
'Tekstgrootte van de interface': 'Interface text size', 'Tekstgrootte van de interface': 'Interface text size',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Enlarges all editor text up to 200%. The slides themselves are not affected.', 'Enlarges all editor text up to 200%. The slides themselves are not affected.',
@ -2586,6 +2608,29 @@ const _dutchSourceStringAdditions = {
}, },
'it': { 'it': {
'Toegankelijkheid': 'Accessibilità', 'Toegankelijkheid': 'Accessibilità',
'Presentatie': 'Presentazione',
'Tabel kopachtergrond': 'Sfondo intestazione tabella',
'Doeltijd': 'Tempo obiettivo',
'Doeltijd (aftellen)': 'Tempo obiettivo (conto alla rovescia)',
'Geen aftelling': 'Nessun conto alla rovescia',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Tempo obiettivo predefinito per il conto alla rovescia del presentatore. Regolalo durante la presentazione con il tasto K.',
'uit': 'off',
'Doeltijd / aftellen (K)': 'Obiettivo / conto alla rovescia (K)',
'Doeltijd / aftellen instellen (MMSS)':
'Imposta obiettivo / conto alla rovescia (MMSS)',
'Tijd & oefenrun resetten': 'Azzera tempo e prova',
'Resterend': 'Rimanente',
'Over de tijd': 'Tempo superato',
'Binnen de tijd': 'Entro il tempo',
'Deze slide': 'Questa slide',
'Oefenrun': 'Prova',
'Totaal': 'Totale',
'Totale tijd': 'Tempo totale',
'Geen slides gemeten.': 'Nessuna slide misurata.',
'Tijden gekopieerd naar klembord.': 'Tempi copiati negli appunti.',
'Kopieer': 'Copia',
'Sluiten': 'Chiudi',
'Tekstgrootte van de interface': 'Dimensione del testo dell\'interfaccia', 'Tekstgrootte van de interface': 'Dimensione del testo dell\'interfaccia',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Ingrandisce tutto il testo dell\'editor fino al 200%. Le slide non cambiano.', 'Ingrandisce tutto il testo dell\'editor fino al 200%. Le slide non cambiano.',
@ -2890,6 +2935,30 @@ const _dutchSourceStringAdditions = {
}, },
'de': { 'de': {
'Toegankelijkheid': 'Barrierefreiheit', 'Toegankelijkheid': 'Barrierefreiheit',
'Presentatie': 'Präsentation',
'Tabel kopachtergrond': 'Tabellenkopf-Hintergrund',
'Doeltijd': 'Zielzeit',
'Doeltijd (aftellen)': 'Zielzeit (Countdown)',
'Geen aftelling': 'Kein Countdown',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Standard-Zielzeit für den Countdown im Präsentationsmodus. Während der Präsentation mit der Taste K feinjustierbar.',
'uit': 'aus',
'Doeltijd / aftellen (K)': 'Zielzeit / Countdown (K)',
'Doeltijd / aftellen instellen (MMSS)':
'Zielzeit / Countdown einstellen (MMSS)',
'Tijd & oefenrun resetten': 'Zeit & Probelauf zurücksetzen',
'Resterend': 'Verbleibend',
'Over de tijd': 'Zeit überschritten',
'Binnen de tijd': 'Innerhalb der Zeit',
'Deze slide': 'Diese Folie',
'Oefenrun': 'Probelauf',
'Totaal': 'Gesamt',
'Totale tijd': 'Gesamtzeit',
'Geen slides gemeten.': 'Keine Folien gemessen.',
'Tijden gekopieerd naar klembord.':
'Zeiten in die Zwischenablage kopiert.',
'Kopieer': 'Kopieren',
'Sluiten': 'Schließen',
'Tekstgrootte van de interface': 'Textgröße der Oberfläche', 'Tekstgrootte van de interface': 'Textgröße der Oberfläche',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Vergrößert sämtlichen Text der Bearbeitungsumgebung auf bis zu 200 %. Die Folien selbst ändern sich nicht.', 'Vergrößert sämtlichen Text der Bearbeitungsumgebung auf bis zu 200 %. Die Folien selbst ändern sich nicht.',
@ -3193,6 +3262,30 @@ const _dutchSourceStringAdditions = {
}, },
'fr': { 'fr': {
'Toegankelijkheid': 'Accessibilité', 'Toegankelijkheid': 'Accessibilité',
'Presentatie': 'Présentation',
'Tabel kopachtergrond': 'Fond d\'en-tête de tableau',
'Doeltijd': 'Temps cible',
'Doeltijd (aftellen)': 'Temps cible (compte à rebours)',
'Geen aftelling': 'Pas de compte à rebours',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Temps cible par défaut pour le compte à rebours du présentateur. Ajustez-le pendant la présentation avec la touche K.',
'uit': 'off',
'Doeltijd / aftellen (K)': 'Cible / compte à rebours (K)',
'Doeltijd / aftellen instellen (MMSS)':
'Définir cible / compte à rebours (MMSS)',
'Tijd & oefenrun resetten': 'Réinitialiser temps et répétition',
'Resterend': 'Restant',
'Over de tijd': 'Temps dépassé',
'Binnen de tijd': 'Dans les temps',
'Deze slide': 'Cette diapo',
'Oefenrun': 'Répétition',
'Totaal': 'Total',
'Totale tijd': 'Temps total',
'Geen slides gemeten.': 'Aucune diapo mesurée.',
'Tijden gekopieerd naar klembord.':
'Temps copiés dans le presse-papiers.',
'Kopieer': 'Copier',
'Sluiten': 'Fermer',
'Tekstgrootte van de interface': 'Taille du texte de l\'interface', 'Tekstgrootte van de interface': 'Taille du texte de l\'interface',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Agrandit tout le texte de l\'éditeur jusqu\'à 200 %. Les diapositives ne changent pas.', 'Agrandit tout le texte de l\'éditeur jusqu\'à 200 %. Les diapositives ne changent pas.',
@ -3500,6 +3593,29 @@ const _dutchSourceStringAdditions = {
}, },
'es': { 'es': {
'Toegankelijkheid': 'Accesibilidad', 'Toegankelijkheid': 'Accesibilidad',
'Presentatie': 'Presentación',
'Tabel kopachtergrond': 'Fondo de encabezado de tabla',
'Doeltijd': 'Tiempo objetivo',
'Doeltijd (aftellen)': 'Tiempo objetivo (cuenta atrás)',
'Geen aftelling': 'Sin cuenta atrás',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Tiempo objetivo predeterminado para la cuenta atrás del presentador. Ajústalo durante la presentación con la tecla K.',
'uit': 'off',
'Doeltijd / aftellen (K)': 'Objetivo / cuenta atrás (K)',
'Doeltijd / aftellen instellen (MMSS)':
'Definir objetivo / cuenta atrás (MMSS)',
'Tijd & oefenrun resetten': 'Restablecer tiempo y ensayo',
'Resterend': 'Restante',
'Over de tijd': 'Tiempo excedido',
'Binnen de tijd': 'Dentro del tiempo',
'Deze slide': 'Esta diapositiva',
'Oefenrun': 'Ensayo',
'Totaal': 'Total',
'Totale tijd': 'Tiempo total',
'Geen slides gemeten.': 'No se midieron diapositivas.',
'Tijden gekopieerd naar klembord.': 'Tiempos copiados al portapapeles.',
'Kopieer': 'Copiar',
'Sluiten': 'Cerrar',
'Tekstgrootte van de interface': 'Tamaño del texto de la interfaz', 'Tekstgrootte van de interface': 'Tamaño del texto de la interfaz',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Amplía todo el texto del editor hasta un 200 %. Las diapositivas no cambian.', 'Amplía todo el texto del editor hasta un 200 %. Las diapositivas no cambian.',
@ -3804,6 +3920,29 @@ const _dutchSourceStringAdditions = {
}, },
'fy': { 'fy': {
'Toegankelijkheid': 'Tagonklikens', 'Toegankelijkheid': 'Tagonklikens',
'Presentatie': 'Presintaasje',
'Tabel kopachtergrond': 'Tabelkop-eftergrûn',
'Doeltijd': 'Doeltiid',
'Doeltijd (aftellen)': 'Doeltiid (ôftelle)',
'Geen aftelling': 'Gjin ôftelling',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Standert doeltiid foar it ôftellen yn de presinter. Under it presintearjen fyn ôf te stellen mei de toets K.',
'uit': 'út',
'Doeltijd / aftellen (K)': 'Doeltiid / ôftelle (K)',
'Doeltijd / aftellen instellen (MMSS)':
'Doeltiid / ôftelle ynstelle (MMSS)',
'Tijd & oefenrun resetten': 'Tiid & oefenrin weromsette',
'Resterend': 'Oerbleaun',
'Over de tijd': 'Oer de tiid',
'Binnen de tijd': 'Binnen de tiid',
'Deze slide': 'Dizze slide',
'Oefenrun': 'Oefenrin',
'Totaal': 'Totaal',
'Totale tijd': 'Totale tiid',
'Geen slides gemeten.': 'Gjin slides metten.',
'Tijden gekopieerd naar klembord.': 'Tiden nei it klamboerd kopiearre.',
'Kopieer': 'Kopiearje',
'Sluiten': 'Slute',
'Tekstgrootte van de interface': 'Tekstgrutte fan de ynterface', 'Tekstgrootte van de interface': 'Tekstgrutte fan de ynterface',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Fergruttet alle tekst fan de bewurkomjouwing oant maksimaal 200%. De slides sels feroarje net.', 'Fergruttet alle tekst fan de bewurkomjouwing oant maksimaal 200%. De slides sels feroarje net.',
@ -4101,6 +4240,29 @@ const _dutchSourceStringAdditions = {
}, },
'pap': { 'pap': {
'Toegankelijkheid': 'Aksesibilidat', 'Toegankelijkheid': 'Aksesibilidat',
'Presentatie': 'Presentashon',
'Tabel kopachtergrond': 'Fondo di kabes di tabel',
'Doeltijd': 'Tempo meta',
'Doeltijd (aftellen)': 'Tempo meta (kuenta atras)',
'Geen aftelling': 'Sin kuenta atras',
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.':
'Tempo meta default pa e kuenta atras di e presentadó. Ahustá durante presentashon ku e tekla K.',
'uit': 'pagá',
'Doeltijd / aftellen (K)': 'Meta / kuenta atras (K)',
'Doeltijd / aftellen instellen (MMSS)':
'Konfigurá meta / kuenta atras (MMSS)',
'Tijd & oefenrun resetten': 'Reset tempo i ensayo',
'Resterend': 'Restante',
'Over de tijd': 'Pasá di tempo',
'Binnen de tijd': 'Den tempo',
'Deze slide': 'E slide aki',
'Oefenrun': 'Ensayo',
'Totaal': 'Total',
'Totale tijd': 'Tempo total',
'Geen slides gemeten.': 'No a midi slide.',
'Tijden gekopieerd naar klembord.': 'Tempo kopiá na klipbord.',
'Kopieer': 'Kopia',
'Sluiten': 'Sera',
'Tekstgrootte van de interface': 'Tamaño di teksto di e interfaz', 'Tekstgrootte van de interface': 'Tamaño di teksto di e interfaz',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.': 'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Ta hasi tur teksto di e editor mas grandi te 200%. E slidenan mes no ta kambia.', 'Ta hasi tur teksto di e editor mas grandi te 200%. E slidenan mes no ta kambia.',

46
lib/models/rehearsal.dart Normal file
View file

@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
/// Tijd die tijdens een oefenrun aan één slide is besteed.
///
/// Sessie-only: niets hiervan wordt op schijf bewaard (geen prefs, geen `.md`).
/// Het bestand blijft inhoud; oefentijden leven alleen in het draaiende
/// presenter-venster.
@immutable
class SlideTiming {
/// Stabiele slide-id binnen de sessie ([Slide.id]).
final String slideId;
/// 0-gebaseerde positie waarop de slide voor het eerst werd getoond. Dient
/// alleen voor een stabiele weergavevolgorde in de samenvatting.
final int index;
/// Opgetelde wandkloktijd op deze slide over de hele run (een slide kan
/// meerdere keren bezocht zijn).
final Duration spent;
const SlideTiming({
required this.slideId,
required this.index,
required this.spent,
});
}
/// Samenvatting van één oefenrun in de huidige sessie: totale tijd, de
/// (optionele) doeltijd en de tijd per slide. Puur beschrijvend er zit
/// geen pacing-advies in, alleen gemeten tijd.
@immutable
class RehearsalRun {
final Duration total;
final Duration? target;
final List<SlideTiming> perSlide;
const RehearsalRun({
required this.total,
required this.target,
required this.perSlide,
});
/// Verschil t.o.v. de doeltijd: positief = over de tijd, negatief = ruim
/// binnen. Null wanneer er geen doeltijd was.
Duration? get delta => target == null ? null : total - target!;
}

View file

@ -8,6 +8,7 @@ class ThemeProfile {
final bool checklistStrikeThrough; final bool checklistStrikeThrough;
final String tableTextColor; final String tableTextColor;
final String tableHeaderTextColor; final String tableHeaderTextColor;
final String tableHeaderBackgroundColor;
final String titleBackgroundColor; final String titleBackgroundColor;
final String titleTextColor; final String titleTextColor;
final String sectionBackgroundColor; final String sectionBackgroundColor;
@ -58,6 +59,7 @@ class ThemeProfile {
this.checklistStrikeThrough = true, this.checklistStrikeThrough = true,
String? tableTextColor, String? tableTextColor,
this.tableHeaderTextColor = '#FFFFFF', this.tableHeaderTextColor = '#FFFFFF',
String? tableHeaderBackgroundColor,
this.titleBackgroundColor = '#1C2B47', this.titleBackgroundColor = '#1C2B47',
this.titleTextColor = '#FFFFFF', this.titleTextColor = '#FFFFFF',
this.sectionBackgroundColor = '#2E7D64', this.sectionBackgroundColor = '#2E7D64',
@ -74,7 +76,9 @@ class ThemeProfile {
this.footerPosition = 'right', this.footerPosition = 'right',
this.closingSlideEnabled = false, this.closingSlideEnabled = false,
this.closingSlideMarkdown = '# Bedankt\n\nVragen?', this.closingSlideMarkdown = '# Bedankt\n\nVragen?',
}) : tableTextColor = tableTextColor ?? textColor; }) : tableTextColor = tableTextColor ?? textColor,
tableHeaderBackgroundColor =
tableHeaderBackgroundColor ?? accentColor;
static const logoPositions = [ static const logoPositions = [
'top-left', 'top-left',
@ -95,6 +99,7 @@ class ThemeProfile {
bool? checklistStrikeThrough, bool? checklistStrikeThrough,
String? tableTextColor, String? tableTextColor,
String? tableHeaderTextColor, String? tableHeaderTextColor,
String? tableHeaderBackgroundColor,
String? titleBackgroundColor, String? titleBackgroundColor,
String? titleTextColor, String? titleTextColor,
String? sectionBackgroundColor, String? sectionBackgroundColor,
@ -126,6 +131,8 @@ class ThemeProfile {
checklistStrikeThrough ?? this.checklistStrikeThrough, checklistStrikeThrough ?? this.checklistStrikeThrough,
tableTextColor: tableTextColor ?? this.tableTextColor, tableTextColor: tableTextColor ?? this.tableTextColor,
tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor, tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor,
tableHeaderBackgroundColor:
tableHeaderBackgroundColor ?? this.tableHeaderBackgroundColor,
titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor, titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor,
titleTextColor: titleTextColor ?? this.titleTextColor, titleTextColor: titleTextColor ?? this.titleTextColor,
sectionBackgroundColor: sectionBackgroundColor:
@ -158,6 +165,7 @@ class ThemeProfile {
'checklistStrikeThrough': checklistStrikeThrough, 'checklistStrikeThrough': checklistStrikeThrough,
'tableTextColor': tableTextColor, 'tableTextColor': tableTextColor,
'tableHeaderTextColor': tableHeaderTextColor, 'tableHeaderTextColor': tableHeaderTextColor,
'tableHeaderBackgroundColor': tableHeaderBackgroundColor,
'titleBackgroundColor': titleBackgroundColor, 'titleBackgroundColor': titleBackgroundColor,
'titleTextColor': titleTextColor, 'titleTextColor': titleTextColor,
'sectionBackgroundColor': sectionBackgroundColor, 'sectionBackgroundColor': sectionBackgroundColor,
@ -197,6 +205,10 @@ class ThemeProfile {
'#222222', '#222222',
tableHeaderTextColor: tableHeaderTextColor:
json['tableHeaderTextColor'] as String? ?? '#FFFFFF', json['tableHeaderTextColor'] as String? ?? '#FFFFFF',
tableHeaderBackgroundColor:
json['tableHeaderBackgroundColor'] as String? ??
json['accentColor'] as String? ??
'#2E7D64',
titleBackgroundColor: titleBackgroundColor:
json['titleBackgroundColor'] as String? ?? '#1C2B47', json['titleBackgroundColor'] as String? ?? '#1C2B47',
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF', titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
@ -375,6 +387,10 @@ class AppSettings {
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%. /// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
final double uiTextScale; final double uiTextScale;
/// Standaard doeltijd (in seconden) voor de aftelling/oefenklok in de
/// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K).
final int presentationTargetSeconds;
const AppSettings({ const AppSettings({
this.languageCode = 'nl', this.languageCode = 'nl',
this.homeDirectory, this.homeDirectory,
@ -386,6 +402,7 @@ class AppSettings {
this.recentFiles = const [], this.recentFiles = const [],
this.maxReleaseExportTlpKey, this.maxReleaseExportTlpKey,
this.uiTextScale = 1.0, this.uiTextScale = 1.0,
this.presentationTargetSeconds = 0,
}); });
ThemeProfile get themeProfile { ThemeProfile get themeProfile {
@ -439,6 +456,7 @@ class AppSettings {
List<String>? recentFiles, List<String>? recentFiles,
String? maxReleaseExportTlpKey, String? maxReleaseExportTlpKey,
double? uiTextScale, double? uiTextScale,
int? presentationTargetSeconds,
bool clearHomeDirectory = false, bool clearHomeDirectory = false,
bool clearExportDirectory = false, bool clearExportDirectory = false,
bool clearMaxReleaseExportTlp = false, bool clearMaxReleaseExportTlp = false,
@ -477,6 +495,8 @@ class AppSettings {
? null ? null
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey), : (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
uiTextScale: uiTextScale ?? this.uiTextScale, uiTextScale: uiTextScale ?? this.uiTextScale,
presentationTargetSeconds:
presentationTargetSeconds ?? this.presentationTargetSeconds,
); );
} }
} }

View file

@ -883,7 +883,7 @@ th, td {
} }
thead th, tr:first-child th { thead th, tr:first-child th {
background: ${profile.accentColor}; background: ${profile.tableHeaderBackgroundColor};
color: ${profile.tableHeaderTextColor}; color: ${profile.tableHeaderTextColor};
} }
$logoCss $logoCss

View file

@ -626,7 +626,7 @@ class MarpHtmlService {
'.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;' '.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;'
'padding-left:16px;opacity:.85}' 'padding-left:16px;opacity:.85}'
'.slide table{border-collapse:collapse}' '.slide table{border-collapse:collapse}'
'.slide th{background:${t.sectionBackgroundColor};color:${t.tableHeaderTextColor};' '.slide th{background:${t.tableHeaderBackgroundColor};color:${t.tableHeaderTextColor};'
'border:1px solid #ccc;padding:6px 12px;font-size:20px}' 'border:1px solid #ccc;padding:6px 12px;font-size:20px}'
'.slide td{color:${t.tableTextColor};border:1px solid #ccc;padding:6px 12px;font-size:20px}' '.slide td{color:${t.tableTextColor};border:1px solid #ccc;padding:6px 12px;font-size:20px}'
'@media print{body{background:#fff}.slide{margin:0;box-shadow:none;' '@media print{body{background:#fff}.slide{margin:0;box-shadow:none;'

View file

@ -0,0 +1,115 @@
import '../models/rehearsal.dart';
/// Meet verstreken tijd, resterende tijd t.o.v. een doeltijd, en de tijd per
/// slide tijdens het presenteren/oefenen.
///
/// Bewust *alleen een klok*: het registreert wandkloktijd en rekent geen
/// pacing-advies uit ("ga sneller/langzamer"). Alles is sessie-only; er wordt
/// niets gepersisteerd.
///
/// De klokbron is injecteerbaar ([now]) zodat de timing in tests deterministisch
/// is productie gebruikt [DateTime.now].
class RehearsalController {
RehearsalController({DateTime Function()? now, Duration? target})
: _now = now ?? DateTime.now,
_target = (target != null && target > Duration.zero) ? target : null {
final t = _now();
_runStart = t;
_slideEntered = t;
}
final DateTime Function() _now;
Duration? _target;
late DateTime _runStart;
late DateTime _slideEntered;
String? _currentId;
/// Opgetelde tijd per slide-id over de hele run.
final Map<String, Duration> _spent = {};
/// Eerste positie waarop een slide werd gezien (voor stabiele volgorde).
final Map<String, int> _firstIndex = {};
/// Volgorde van eerste verschijning, voor een leesbare samenvatting.
final List<String> _order = [];
/// Huidige doeltijd, of null als er geen aftelling loopt.
Duration? get target => _target;
/// Zet de doeltijd. Een waarde van nul of minder zet de aftelling uit.
set target(Duration? value) =>
_target = (value != null && value > Duration.zero) ? value : null;
/// Verstreken tijd sinds de (her)start van de run.
Duration get elapsed => _now().difference(_runStart);
/// Resterende tijd t.o.v. de doeltijd; null zonder doeltijd. Kan negatief
/// worden wanneer je over de tijd gaat.
Duration? get remaining {
final t = _target;
return t == null ? null : t - elapsed;
}
/// Tijd op de huidige slide sinds binnenkomst.
Duration get currentSlideElapsed => _now().difference(_slideEntered);
/// Heeft de run genoeg gegevens om een zinvolle samenvatting te tonen?
/// Voorkomt een dialoog bij het meteen weer sluiten van de presentatie.
bool get hasMeaningfulData => _order.isNotEmpty && elapsed.inSeconds >= 10;
/// Registreer dat slide [id] op positie [index] nu zichtbaar is.
///
/// Idempotent: alleen een échte wissel sluit de vorige slide af. Wordt elke
/// build aangeroepen, dus moet goedkoop blijven.
void observe(String id, int index) {
if (_currentId == id) return;
final t = _now();
final prev = _currentId;
if (prev != null) {
_spent[prev] = (_spent[prev] ?? Duration.zero) + t.difference(_slideEntered);
}
_currentId = id;
_slideEntered = t;
if (!_firstIndex.containsKey(id)) {
_firstIndex[id] = index;
_order.add(id);
}
}
/// Reset de hele run (verstreken tijd én per-slide-tijden). De doeltijd blijft
/// staan. De eerstvolgende [observe] registreert de huidige slide opnieuw.
void reset() {
final t = _now();
_runStart = t;
_slideEntered = t;
_spent.clear();
_firstIndex.clear();
_order.clear();
_currentId = null;
}
/// Sluit de lopende slide af en geef de samenvatting van deze run terug.
/// Niet-destructief: je kunt erna gewoon doorpresenteren.
RehearsalRun finish() {
final t = _now();
final spent = Map<String, Duration>.from(_spent);
final cur = _currentId;
if (cur != null) {
spent[cur] = (spent[cur] ?? Duration.zero) + t.difference(_slideEntered);
}
final perSlide = [
for (final id in _order)
SlideTiming(
slideId: id,
index: _firstIndex[id] ?? 0,
spent: spent[id] ?? Duration.zero,
),
];
return RehearsalRun(
total: t.difference(_runStart),
target: _target,
perSlide: perSlide,
);
}
}

View file

@ -56,6 +56,8 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
recentFiles: prefs.getStringList('recentFiles') ?? [], recentFiles: prefs.getStringList('recentFiles') ?? [],
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'), maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0), uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0)
.clamp(0, 86400),
); );
} }
@ -80,6 +82,15 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
await prefs.setDouble('uiTextScale', clamped); await prefs.setDouble('uiTextScale', clamped);
} }
/// Stel de standaard doeltijd (in seconden) voor de presenter-aftelling in.
/// 0 = geen aftelling. Begrensd tot een etmaal tegen onzin-invoer.
Future<void> setPresentationTargetSeconds(int seconds) async {
final clamped = seconds.clamp(0, 86400);
state = state.copyWith(presentationTargetSeconds: clamped);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('presentationTargetSeconds', clamped);
}
Future<void> addRecentFile(String path) async { Future<void> addRecentFile(String path) async {
final updated = [ final updated = [
path, path,

View file

@ -528,6 +528,10 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
themeProfile: deck.themeProfile, themeProfile: deck.themeProfile,
initialIndex: initial, initialIndex: initial,
tlp: deck.tlp, tlp: deck.tlp,
targetDuration: () {
final secs = ref.read(settingsProvider).presentationTargetSeconds;
return secs > 0 ? Duration(seconds: secs) : null;
}(),
annotations: deck.annotations, annotations: deck.annotations,
onAnnotationsChanged: deckNotifier.setAnnotations, onAnnotationsChanged: deckNotifier.setAnnotations,
onSlideChanged: (updated) { onSlideChanged: (updated) {

View file

@ -450,6 +450,18 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_sectionTitle(l10n.d('Presentatie')),
_presentationTargetField(),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
l10n.d(
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
const SizedBox(height: 16),
_sectionTitle(l10n.t('presentationFolder')), _sectionTitle(l10n.t('presentationFolder')),
Row( Row(
children: [ children: [
@ -544,6 +556,49 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
); );
} }
/// Dropdown met veelgebruikte doeltijden voor de presenter-aftelling. De
/// opgeslagen waarde snapt naar de dichtstbijzijnde optie; fijnregelen kan
/// live met toets K tijdens het presenteren.
Widget _presentationTargetField() {
final l10n = context.l10n;
const steps = [0, 300, 600, 900, 1200, 1500, 1800, 2700, 3600, 5400];
final current = ref.watch(
settingsProvider.select((s) => s.presentationTargetSeconds),
);
final value = steps.reduce(
(a, b) => (a - current).abs() <= (b - current).abs() ? a : b,
);
return InputDecorator(
decoration: InputDecoration(
labelText: l10n.d('Doeltijd (aftellen)'),
isDense: true,
prefixIcon: const Icon(Icons.timer_outlined, size: 18),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: value,
isExpanded: true,
isDense: true,
items: [
for (final step in steps)
DropdownMenuItem(
value: step,
child: Text(
step == 0 ? l10n.d('Geen aftelling') : '${step ~/ 60} min',
),
),
],
onChanged: (seconds) {
if (seconds == null) return;
ref
.read(settingsProvider.notifier)
.setPresentationTargetSeconds(seconds);
},
),
),
);
}
Widget _appearanceTab() { Widget _appearanceTab() {
final l10n = context.l10n; final l10n = context.l10n;
final profiles = ref.watch(settingsProvider).appAppearanceProfiles; final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
@ -1047,6 +1102,14 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v), _themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_colorSetting(
l10n.d('Tabel kopachtergrond'),
_themeProfile.tableHeaderBackgroundColor,
(v) => _themeProfile = _themeProfile.copyWith(
tableHeaderBackgroundColor: v,
),
),
const SizedBox(height: 12),
_colorSetting( _colorSetting(
l10n.d('Titelachtergrond'), l10n.d('Titelachtergrond'),
_themeProfile.titleBackgroundColor, _themeProfile.titleBackgroundColor,

View file

@ -13,6 +13,7 @@ import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../services/markdown_service.dart'; import '../../services/markdown_service.dart';
import '../../services/rehearsal_controller.dart';
import '../../utils/log.dart'; import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart'; import '../../utils/url_launcher_util.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@ -20,6 +21,7 @@ import '../slides/inline_markdown.dart';
import '../slides/slide_preview.dart'; import '../slides/slide_preview.dart';
import 'annotation_overlay.dart'; import 'annotation_overlay.dart';
import 'audience_window.dart'; import 'audience_window.dart';
import 'rehearsal_summary.dart';
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint). /// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
enum _Blank { none, black, white } enum _Blank { none, black, white }
@ -31,6 +33,10 @@ class FullscreenPresenter extends StatefulWidget {
final int initialIndex; final int initialIndex;
final TlpLevel tlp; final TlpLevel tlp;
/// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling.
/// Sessie-only; live aanpasbaar in de presenter (toets K).
final Duration? targetDuration;
/// When set, this presenter drives a separate audience (beamer) window: the /// When set, this presenter drives a separate audience (beamer) window: the
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null /// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
/// for the classic single-screen mode. /// for the classic single-screen mode.
@ -49,6 +55,7 @@ class FullscreenPresenter extends StatefulWidget {
required this.themeProfile, required this.themeProfile,
required this.initialIndex, required this.initialIndex,
this.tlp = TlpLevel.none, this.tlp = TlpLevel.none,
this.targetDuration,
this.audienceWindow, this.audienceWindow,
this.initialAnnotations = const {}, this.initialAnnotations = const {},
this.onAnnotationsChanged, this.onAnnotationsChanged,
@ -65,6 +72,7 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile, required ThemeProfile themeProfile,
required int initialIndex, required int initialIndex,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
Duration? targetDuration,
Map<String, List<InkStroke>> annotations = const {}, Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged, void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged, ValueChanged<Slide>? onSlideChanged,
@ -94,6 +102,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile, themeProfile: themeProfile,
initialIndex: initialIndex, initialIndex: initialIndex,
tlp: tlp, tlp: tlp,
targetDuration: targetDuration,
annotations: annotations, annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged, onSlideChanged: onSlideChanged,
@ -106,6 +115,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile, themeProfile: themeProfile,
initialIndex: initialIndex, initialIndex: initialIndex,
tlp: tlp, tlp: tlp,
targetDuration: targetDuration,
annotations: annotations, annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged, onSlideChanged: onSlideChanged,
@ -120,6 +130,7 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile, required ThemeProfile themeProfile,
required int initialIndex, required int initialIndex,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
Duration? targetDuration,
Map<String, List<InkStroke>> annotations = const {}, Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged, void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged, ValueChanged<Slide>? onSlideChanged,
@ -139,6 +150,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile, themeProfile: themeProfile,
initialIndex: initialIndex, initialIndex: initialIndex,
tlp: tlp, tlp: tlp,
targetDuration: targetDuration,
initialAnnotations: annotations, initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged, onSlideChanged: onSlideChanged,
@ -165,6 +177,7 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile, required ThemeProfile themeProfile,
required int initialIndex, required int initialIndex,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
Duration? targetDuration,
Map<String, List<InkStroke>> annotations = const {}, Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged, void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged, ValueChanged<Slide>? onSlideChanged,
@ -341,13 +354,19 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
double _gridRowExtent = 220; double _gridRowExtent = 220;
final ScrollController _gridScroll = ScrollController(); final ScrollController _gridScroll = ScrollController();
/// Starttijd voor de verstreken-tijd-teller (resetbaar met R). /// Oefenklok: verstreken tijd, aftelling en per-slide-tijd. Sessie-only,
late DateTime _startTime; /// puur meten (geen pacing). Resetbaar met R.
late RehearsalController _rehearsal;
/// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief). /// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief).
String _typed = ''; String _typed = '';
Timer? _typedTimer; Timer? _typedTimer;
/// Doeltijd-invoermodus (toets K): cijfers worden als MMSS gelezen i.p.v. als
/// slidenummer. [_targetTyped] houdt de invoer tot Enter/Esc.
bool _targetInput = false;
String _targetTyped = '';
/// Sneltoets-overzicht (cheatsheet) zichtbaar. /// Sneltoets-overzicht (cheatsheet) zichtbaar.
bool _helpOpen = false; bool _helpOpen = false;
@ -400,7 +419,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
void initState() { void initState() {
super.initState(); super.initState();
_index = widget.initialIndex; _index = widget.initialIndex;
_startTime = DateTime.now(); _rehearsal = RehearsalController(target: widget.targetDuration);
_focusNode = FocusNode(); _focusNode = FocusNode();
_ink = { _ink = {
for (final e in widget.initialAnnotations.entries) for (final e in widget.initialAnnotations.entries)
@ -757,6 +776,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
Future<void> _exit() async { Future<void> _exit() async {
_advanceTimer?.cancel(); _advanceTimer?.cancel();
await _maybeShowRehearsalSummary();
final aw = widget.audienceWindow; final aw = widget.audienceWindow;
if (aw != null) { if (aw != null) {
// Dual mode: the main window was never put in full screen; just tear down // Dual mode: the main window was never put in full screen; just tear down
@ -769,6 +789,14 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} }
/// Toon na afloop de oefenrun-samenvatting, mits er genoeg gemeten is.
/// Sessie-only: niets wordt opgeslagen.
Future<void> _maybeShowRehearsalSummary() async {
if (!mounted || !_rehearsal.hasMeaningfulData) return;
final run = _rehearsal.finish();
await showRehearsalSummary(context, run: run, slides: widget.slides);
}
/// Meld de slidewissel aan schermlezers (WCAG 4.1.3, statusberichten): /// Meld de slidewissel aan schermlezers (WCAG 4.1.3, statusberichten):
/// visueel verandert de hele slide, maar zonder aankondiging merkt een /// visueel verandert de hele slide, maar zonder aankondiging merkt een
/// schermlezer-gebruiker de wissel niet op. /// schermlezer-gebruiker de wissel niet op.
@ -815,7 +843,40 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
} }
void _resetTimer() { void _resetTimer() {
setState(() => _startTime = DateTime.now()); setState(() => _rehearsal.reset());
}
/// Open de doeltijd-invoer (toets K): cijfers worden voortaan als MMSS
/// gelezen. Een lege invoer laat de huidige doeltijd ongemoeid.
void _beginTargetInput() {
_clearTyped();
setState(() {
_targetInput = true;
_targetTyped = '';
});
}
void _cancelTargetInput() {
setState(() {
_targetInput = false;
_targetTyped = '';
});
}
/// Lees [_targetTyped] als MMSS en zet de doeltijd. Leeg = ongewijzigd,
/// nul = aftelling uit.
void _commitTarget() {
final raw = _targetTyped;
setState(() {
_targetInput = false;
_targetTyped = '';
});
if (raw.isEmpty) return;
final n = int.tryParse(raw) ?? 0;
final secs = (n ~/ 100) * 60 + (n % 100);
setState(
() => _rehearsal.target = secs <= 0 ? null : Duration(seconds: secs),
);
} }
void _toggleHelp() { void _toggleHelp() {
@ -957,6 +1018,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// Doeltijd-invoer vangt cijfers/Enter/Esc tot de invoer klaar is.
if (_targetInput) return _handleTargetKey(key);
// Terwijl het raster open is, sturen de pijltjes een aparte cursor aan. // Terwijl het raster open is, sturen de pijltjes een aparte cursor aan.
if (_gridOpen) return _handleGridKey(key); if (_gridOpen) return _handleGridKey(key);
@ -1008,6 +1072,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
case LogicalKeyboardKey.keyR: case LogicalKeyboardKey.keyR:
_resetTimer(); _resetTimer();
return KeyEventResult.handled; return KeyEventResult.handled;
case LogicalKeyboardKey.keyK:
_beginTargetInput();
return KeyEventResult.handled;
case LogicalKeyboardKey.keyB: case LogicalKeyboardKey.keyB:
_toggleBlank(_Blank.black); _toggleBlank(_Blank.black);
return KeyEventResult.handled; return KeyEventResult.handled;
@ -1062,6 +1129,41 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
} }
} }
/// Toetsen terwijl de doeltijd wordt ingevoerd (MMSS). Alles wordt
/// opgeslokt zodat losse cijfers niet als slidesprong gelden.
KeyEventResult _handleTargetKey(LogicalKeyboardKey key) {
final digit = _digits[key];
if (digit != null) {
setState(() {
_targetTyped += digit;
if (_targetTyped.length > 4) {
_targetTyped = _targetTyped.substring(_targetTyped.length - 4);
}
});
return KeyEventResult.handled;
}
switch (key) {
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
case LogicalKeyboardKey.keyK:
_commitTarget();
case LogicalKeyboardKey.backspace:
if (_targetTyped.isNotEmpty) {
setState(
() => _targetTyped = _targetTyped.substring(
0,
_targetTyped.length - 1,
),
);
}
case LogicalKeyboardKey.escape:
_cancelTargetInput();
default:
break;
}
return KeyEventResult.handled;
}
/// Toetsen terwijl het rasteroverzicht open is. /// Toetsen terwijl het rasteroverzicht open is.
KeyEventResult _handleGridKey(LogicalKeyboardKey key) { KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
final last = widget.slides.length - 1; final last = widget.slides.length - 1;
@ -1105,6 +1207,12 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss'; return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss';
} }
/// Resterende tijd, met minteken zodra je over de doeltijd gaat.
String _fmtRemaining(Duration d) {
final body = _fmtElapsed(d.abs());
return d.isNegative ? '-$body' : body;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final total = widget.slides.length; final total = widget.slides.length;
@ -1116,6 +1224,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
// Keep the beamer window in step with whatever index/blank we now show. // Keep the beamer window in step with whatever index/blank we now show.
_syncAudience(); _syncAudience();
// Per-slide-timing: registreer de huidige slide. Idempotent en goedkoop,
// dus veilig om elke build aan te roepen vangt álle navigatiepaden.
final clampedIndex = _index.clamp(0, total - 1);
_rehearsal.observe(widget.slides[clampedIndex].id, clampedIndex);
return Focus( return Focus(
focusNode: _focusNode, focusNode: _focusNode,
autofocus: true, autofocus: true,
@ -1142,6 +1255,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
bottom: 60, bottom: 60,
child: Center(child: _buildTypedBadge(total)), child: Center(child: _buildTypedBadge(total)),
), ),
if (_targetInput)
Positioned(
left: 0,
right: 0,
bottom: 60,
child: Center(child: _buildTargetBadge()),
),
if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()), if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()),
], ],
), ),
@ -1270,6 +1390,42 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
); );
} }
/// Badge tijdens het invoeren van de doeltijd ("Doeltijd 20:00 · Enter").
/// Cijfers schuiven van rechts in als MM:SS (zoals een magnetron).
Widget _buildTargetBadge() {
final padded = _targetTyped.padLeft(4, '0');
final preview = '${padded.substring(0, 2)}:${padded.substring(2)}';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFF59E0B), width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.timer_outlined, color: Color(0xFFF59E0B), size: 20),
const SizedBox(width: 10),
Text(
preview,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
fontFeatures: [FontFeature.tabularFigures()],
),
),
const SizedBox(width: 12),
Text(
'${context.l10n.d('Doeltijd')} · Enter · 0 = ${context.l10n.d('uit')}',
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
],
),
);
}
/// Sneltoets-overzicht (cheatsheet). /// Sneltoets-overzicht (cheatsheet).
Widget _buildHelpOverlay() { Widget _buildHelpOverlay() {
final l10n = context.l10n; final l10n = context.l10n;
@ -1284,7 +1440,8 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
('B · W', l10n.d('Zwart · wit scherm')), ('B · W', l10n.d('Zwart · wit scherm')),
('D · T · E', l10n.d('Pen · markeerstift · gum')), ('D · T · E', l10n.d('Pen · markeerstift · gum')),
('X · C', l10n.d('Laser · annotaties wissen')), ('X · C', l10n.d('Laser · annotaties wissen')),
('R', l10n.d('Verstreken tijd resetten')), ('K', l10n.d('Doeltijd / aftellen instellen (MMSS)')),
('R', l10n.d('Tijd & oefenrun resetten')),
('A', l10n.d('Automatische modus aan/uit')), ('A', l10n.d('Automatische modus aan/uit')),
('L', l10n.d('Herhalen (loop) aan/uit')), ('L', l10n.d('Herhalen (loop) aan/uit')),
('M', l10n.d('Na media automatisch doorgaan')), ('M', l10n.d('Na media automatisch doorgaan')),
@ -1584,9 +1741,41 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
); );
} }
/// Eén tijdwaarde met bijschrift voor de klokbalk.
Widget _metric(
String label,
String value, {
Color? color,
CrossAxisAlignment align = CrossAxisAlignment.start,
double size = 24,
}) {
return Column(
crossAxisAlignment: align,
children: [
Text(
label,
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
color: color ?? Colors.white,
fontSize: size,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
);
}
Widget _buildClockBar() { Widget _buildClockBar() {
final l10n = context.l10n; final l10n = context.l10n;
final elapsed = DateTime.now().difference(_startTime); final elapsed = _rehearsal.elapsed;
final remaining = _rehearsal.remaining;
final slideElapsed = _rehearsal.currentSlideElapsed;
final overtime = remaining != null && remaining.isNegative;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -1594,59 +1783,64 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF262626)), border: Border.all(color: const Color(0xFF262626)),
), ),
child: Row( child: Column(
children: [ children: [
// Verstreken tijd // Bovenrij: verstreken tijd, knoppen, wandklok.
Expanded( Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d('Verstreken'),
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
const SizedBox(height: 2),
Text(
_fmtElapsed(elapsed),
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
// Reset-knop
Tooltip(
message: l10n.d('Tijd resetten (R)'),
child: IconButton(
onPressed: _resetTimer,
icon: const Icon(Icons.restart_alt, size: 18),
color: Colors.white38,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
// Wandklok
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Expanded(
l10n.d('Klok'), child: _metric(l10n.d('Verstreken'), _fmtElapsed(elapsed)),
style: const TextStyle(color: Colors.white38, fontSize: 10),
), ),
const SizedBox(height: 2), Tooltip(
Text( message: l10n.d('Doeltijd / aftellen (K)'),
_fmtClock(DateTime.now()), child: IconButton(
style: const TextStyle( onPressed: _beginTargetInput,
color: Colors.white70, icon: const Icon(Icons.timer_outlined, size: 18),
fontSize: 24, color: Colors.white38,
fontWeight: FontWeight.w600, visualDensity: VisualDensity.compact,
fontFeatures: [FontFeature.tabularFigures()],
), ),
), ),
Tooltip(
message: l10n.d('Tijd resetten (R)'),
child: IconButton(
onPressed: _resetTimer,
icon: const Icon(Icons.restart_alt, size: 18),
color: Colors.white38,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
_metric(
l10n.d('Klok'),
_fmtClock(DateTime.now()),
color: Colors.white70,
align: CrossAxisAlignment.end,
),
],
),
const Divider(height: 18, color: Color(0xFF262626)),
// Onderrij: aftelling (resterend/over) en tijd op huidige slide.
Row(
children: [
Expanded(
child: _metric(
overtime ? l10n.d('Over de tijd') : l10n.d('Resterend'),
remaining == null ? ':' : _fmtRemaining(remaining),
color: remaining == null
? Colors.white24
: (overtime
? const Color(0xFFEF4444)
: const Color(0xFF22C55E)),
size: 20,
),
),
_metric(
l10n.d('Deze slide'),
_fmtElapsed(slideElapsed),
color: Colors.white70,
align: CrossAxisAlignment.end,
size: 20,
),
], ],
), ),
], ],

View file

@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
import '../../models/rehearsal.dart';
import '../../models/slide.dart';
/// Toon de samenvatting van een oefenrun (sessie-only). Beschrijvend: totale
/// tijd, doeltijd en de tijd per slide geen pacing-oordeel.
Future<void> showRehearsalSummary(
BuildContext context, {
required RehearsalRun run,
required List<Slide> slides,
}) {
return showDialog<void>(
context: context,
builder: (_) => _RehearsalSummaryDialog(run: run, slides: slides),
);
}
String _fmt(Duration d) {
final neg = d.isNegative;
final a = d.abs();
final mm = (a.inMinutes % 60).toString().padLeft(2, '0');
final ss = (a.inSeconds % 60).toString().padLeft(2, '0');
final body = a.inHours > 0 ? '${a.inHours}:$mm:$ss' : '$mm:$ss';
return neg ? '-$body' : body;
}
class _RehearsalSummaryDialog extends StatelessWidget {
final RehearsalRun run;
final List<Slide> slides;
const _RehearsalSummaryDialog({required this.run, required this.slides});
String _label(SlideTiming t) {
final slide = slides.firstWhere(
(s) => s.id == t.slideId,
orElse: () => slides.isNotEmpty ? slides.first : (throw StateError('')),
);
final title = slide.title.trim();
return title.isEmpty ? '${t.index + 1}.' : '${t.index + 1}. $title';
}
Future<void> _copy(BuildContext context) async {
final l10n = context.l10n;
final buf = StringBuffer()
..writeln('${l10n.d('Totaal')}: ${_fmt(run.total)}');
if (run.target != null) {
buf.writeln('${l10n.d('Doeltijd')}: ${_fmt(run.target!)}');
}
buf.writeln('');
for (final t in run.perSlide) {
buf.writeln('${_label(t)}\t${_fmt(t.spent)}');
}
await Clipboard.setData(ClipboardData(text: buf.toString()));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.d('Tijden gekopieerd naar klembord.'))),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final delta = run.delta;
return AlertDialog(
title: Text(l10n.d('Oefenrun')),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Totaal vs. doeltijd.
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.d('Totale tijd'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
_fmt(run.total),
style: const TextStyle(
fontWeight: FontWeight.w700,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
if (run.target != null) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.d('Doeltijd')),
Text(
_fmt(run.target!),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
if (delta != null) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
delta.isNegative
? l10n.d('Binnen de tijd')
: l10n.d('Over de tijd'),
),
Text(
_fmt(delta),
style: TextStyle(
fontWeight: FontWeight.w600,
color: delta.isNegative
? Colors.green.shade700
: Colors.red.shade700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
],
],
const Divider(height: 24),
Flexible(
child: run.perSlide.isEmpty
? Text(l10n.d('Geen slides gemeten.'))
: ListView.builder(
shrinkWrap: true,
itemCount: run.perSlide.length,
itemBuilder: (_, i) {
final t = run.perSlide[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
_label(t),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
Text(
_fmt(t.spent),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => _copy(context),
child: Text(l10n.d('Kopieer')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.d('Sluiten')),
),
],
);
}
}

View file

@ -35,6 +35,7 @@ class _TablePreview extends StatelessWidget {
final accent = _hexColor(profile.accentColor); final accent = _hexColor(profile.accentColor);
final textColor = _hexColor(profile.tableTextColor); final textColor = _hexColor(profile.tableTextColor);
final headerTextColor = _hexColor(profile.tableHeaderTextColor); final headerTextColor = _hexColor(profile.tableHeaderTextColor);
final headerBackground = _hexColor(profile.tableHeaderBackgroundColor);
final borderColor = accent.withValues(alpha: 0.35); final borderColor = accent.withValues(alpha: 0.35);
Widget cell(String value, {required bool header}) { Widget cell(String value, {required bool header}) {
@ -61,7 +62,7 @@ class _TablePreview extends StatelessWidget {
TableRow buildRow(List<String> row, {required bool header}) { TableRow buildRow(List<String> row, {required bool header}) {
return TableRow( return TableRow(
decoration: BoxDecoration(color: header ? accent : null), decoration: BoxDecoration(color: header ? headerBackground : null),
children: List.generate(colCount, (c) { children: List.generate(colCount, (c) {
final value = c < row.length ? row[c] : ''; final value = c < row.length ? row[c] : '';
return TableCell( return TableCell(

View file

@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/services/rehearsal_controller.dart';
void main() {
// Bestuurbare klok zodat de timing deterministisch is.
late DateTime now;
DateTime clock() => now;
setUp(() => now = DateTime(2026, 1, 1, 10, 0, 0));
void advance(Duration d) => now = now.add(d);
test('elapsed loopt met de klok mee', () {
final c = RehearsalController(now: clock);
expect(c.elapsed, Duration.zero);
advance(const Duration(seconds: 90));
expect(c.elapsed, const Duration(seconds: 90));
});
test('per-slide-tijd telt op per slide en houdt volgorde aan', () {
final c = RehearsalController(now: clock);
c.observe('a', 0);
advance(const Duration(seconds: 30));
c.observe('b', 1);
advance(const Duration(seconds: 20));
c.observe('a', 0); // terug naar a
advance(const Duration(seconds: 10));
final run = c.finish();
expect(run.total, const Duration(seconds: 60));
expect(run.perSlide.map((t) => t.slideId).toList(), ['a', 'b']);
expect(run.perSlide[0].spent, const Duration(seconds: 40)); // 30 + 10
expect(run.perSlide[1].spent, const Duration(seconds: 20));
});
test('observe is idempotent: dezelfde slide sluit niet af', () {
final c = RehearsalController(now: clock);
c.observe('a', 0);
advance(const Duration(seconds: 5));
c.observe('a', 0); // geen wissel
advance(const Duration(seconds: 5));
expect(c.currentSlideElapsed, const Duration(seconds: 10));
});
test('aftelling: resterend wordt negatief na de doeltijd', () {
final c = RehearsalController(now: clock, target: const Duration(minutes: 1));
advance(const Duration(seconds: 40));
expect(c.remaining, const Duration(seconds: 20));
advance(const Duration(seconds: 30));
expect(c.remaining, const Duration(seconds: -10));
});
test('geen doeltijd → geen resterende tijd; nul-target zet aftelling uit', () {
final c = RehearsalController(now: clock);
expect(c.remaining, isNull);
c.target = Duration.zero;
expect(c.target, isNull);
c.target = const Duration(minutes: 5);
expect(c.target, const Duration(minutes: 5));
});
test('reset wist run en per-slide-tijden, behoudt doeltijd', () {
final c = RehearsalController(now: clock, target: const Duration(minutes: 1));
c.observe('a', 0);
advance(const Duration(seconds: 30));
c.reset();
expect(c.elapsed, Duration.zero);
expect(c.target, const Duration(minutes: 1));
// Na reset is er nog geen geregistreerde slide.
advance(const Duration(seconds: 5));
expect(c.finish().perSlide, isEmpty);
});
test('hasMeaningfulData vereist een slide én ≥10s', () {
final c = RehearsalController(now: clock);
expect(c.hasMeaningfulData, isFalse);
c.observe('a', 0);
advance(const Duration(seconds: 9));
expect(c.hasMeaningfulData, isFalse);
advance(const Duration(seconds: 1));
expect(c.hasMeaningfulData, isTrue);
});
test('delta beschrijft over/onder de doeltijd', () {
final c = RehearsalController(now: clock, target: const Duration(minutes: 1));
c.observe('a', 0);
advance(const Duration(seconds: 70));
final run = c.finish();
expect(run.delta, const Duration(seconds: 10)); // over de tijd
});
}