From b719c439919bdd68e9eabd293ea8d4f8fdea05e9 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Sat, 13 Jun 2026 07:03:08 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 9 + README.md | 1 + docs/ARCHITECTURE.md | 9 +- docs/SHORTCUTS.md | 5 +- docs/USER_GUIDE.md | 26 +- lib/l10n/app_localizations.dart | 162 ++++++++++ lib/models/rehearsal.dart | 46 +++ lib/models/settings.dart | 22 +- lib/services/file_service.dart | 2 +- lib/services/marp_html_service.dart | 2 +- lib/services/rehearsal_controller.dart | 115 +++++++ lib/state/settings_provider.dart | 11 + lib/widgets/app_shell.dart | 4 + lib/widgets/dialogs/settings_dialog.dart | 63 ++++ .../presentation/fullscreen_presenter.dart | 302 ++++++++++++++---- .../presentation/rehearsal_summary.dart | 178 +++++++++++ .../slides/previews/table_preview.dart | 3 +- test/rehearsal_controller_test.dart | 90 ++++++ 18 files changed, 987 insertions(+), 63 deletions(-) create mode 100644 lib/models/rehearsal.dart create mode 100644 lib/services/rehearsal_controller.dart create mode 100644 lib/widgets/presentation/rehearsal_summary.dart create mode 100644 test/rehearsal_controller_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc2651..1498d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### 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 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 diff --git a/README.md b/README.md index 9b8c510..8a7f49c 100644 --- a/README.md +++ b/README.md @@ -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. - **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. +- **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. - **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). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97935d3..bc156e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ lib/ services/ # markdown, file, export, classification_policy, image, caption, # description, image_dedup (md5 duplicates), # image_reference (.md rewrites), recovery, rasterizer, - # marp_html, annotation_codec + # marp_html, annotation_codec, rehearsal_controller state/ # Riverpod providers: deck, editor, settings, tabs, clipboard widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter l10n/ # AppLocalizations (8 languages) @@ -84,6 +84,13 @@ before any work starts. and the **annotation tools** (pen/highlighter/eraser/laser). - Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide 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 diff --git a/docs/SHORTCUTS.md b/docs/SHORTCUTS.md index b7c0c0c..aa936fa 100644 --- a/docs/SHORTCUTS.md +++ b/docs/SHORTCUTS.md @@ -35,10 +35,11 @@ View & timing: | 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 | | `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 | | `L` | Loop (restart after the last slide) on/off | | `M` | Advance automatically after a slide's audio finishes | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d3d8b99..33a7bf8 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 [`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 -in-app cheatsheet. +`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `K` for the +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) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e8b0bdd..be0b7b6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2429,6 +2429,28 @@ const _dutchSourceStrings = { const _dutchSourceStringAdditions = { 'en': { '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', '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.', @@ -2586,6 +2608,29 @@ const _dutchSourceStringAdditions = { }, 'it': { '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', '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.', @@ -2890,6 +2935,30 @@ const _dutchSourceStringAdditions = { }, 'de': { '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', '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.', @@ -3193,6 +3262,30 @@ const _dutchSourceStringAdditions = { }, 'fr': { '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', '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.', @@ -3500,6 +3593,29 @@ const _dutchSourceStringAdditions = { }, 'es': { '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', '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.', @@ -3804,6 +3920,29 @@ const _dutchSourceStringAdditions = { }, 'fy': { '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', '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.', @@ -4101,6 +4240,29 @@ const _dutchSourceStringAdditions = { }, 'pap': { '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', '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.', diff --git a/lib/models/rehearsal.dart b/lib/models/rehearsal.dart new file mode 100644 index 0000000..56eb375 --- /dev/null +++ b/lib/models/rehearsal.dart @@ -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 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!; +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7c93f58..60f71e5 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -8,6 +8,7 @@ class ThemeProfile { final bool checklistStrikeThrough; final String tableTextColor; final String tableHeaderTextColor; + final String tableHeaderBackgroundColor; final String titleBackgroundColor; final String titleTextColor; final String sectionBackgroundColor; @@ -58,6 +59,7 @@ class ThemeProfile { this.checklistStrikeThrough = true, String? tableTextColor, this.tableHeaderTextColor = '#FFFFFF', + String? tableHeaderBackgroundColor, this.titleBackgroundColor = '#1C2B47', this.titleTextColor = '#FFFFFF', this.sectionBackgroundColor = '#2E7D64', @@ -74,7 +76,9 @@ class ThemeProfile { this.footerPosition = 'right', this.closingSlideEnabled = false, this.closingSlideMarkdown = '# Bedankt\n\nVragen?', - }) : tableTextColor = tableTextColor ?? textColor; + }) : tableTextColor = tableTextColor ?? textColor, + tableHeaderBackgroundColor = + tableHeaderBackgroundColor ?? accentColor; static const logoPositions = [ 'top-left', @@ -95,6 +99,7 @@ class ThemeProfile { bool? checklistStrikeThrough, String? tableTextColor, String? tableHeaderTextColor, + String? tableHeaderBackgroundColor, String? titleBackgroundColor, String? titleTextColor, String? sectionBackgroundColor, @@ -126,6 +131,8 @@ class ThemeProfile { checklistStrikeThrough ?? this.checklistStrikeThrough, tableTextColor: tableTextColor ?? this.tableTextColor, tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor, + tableHeaderBackgroundColor: + tableHeaderBackgroundColor ?? this.tableHeaderBackgroundColor, titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor, titleTextColor: titleTextColor ?? this.titleTextColor, sectionBackgroundColor: @@ -158,6 +165,7 @@ class ThemeProfile { 'checklistStrikeThrough': checklistStrikeThrough, 'tableTextColor': tableTextColor, 'tableHeaderTextColor': tableHeaderTextColor, + 'tableHeaderBackgroundColor': tableHeaderBackgroundColor, 'titleBackgroundColor': titleBackgroundColor, 'titleTextColor': titleTextColor, 'sectionBackgroundColor': sectionBackgroundColor, @@ -197,6 +205,10 @@ class ThemeProfile { '#222222', tableHeaderTextColor: json['tableHeaderTextColor'] as String? ?? '#FFFFFF', + tableHeaderBackgroundColor: + json['tableHeaderBackgroundColor'] as String? ?? + json['accentColor'] as String? ?? + '#2E7D64', titleBackgroundColor: json['titleBackgroundColor'] as String? ?? '#1C2B47', 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%. 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({ this.languageCode = 'nl', this.homeDirectory, @@ -386,6 +402,7 @@ class AppSettings { this.recentFiles = const [], this.maxReleaseExportTlpKey, this.uiTextScale = 1.0, + this.presentationTargetSeconds = 0, }); ThemeProfile get themeProfile { @@ -439,6 +456,7 @@ class AppSettings { List? recentFiles, String? maxReleaseExportTlpKey, double? uiTextScale, + int? presentationTargetSeconds, bool clearHomeDirectory = false, bool clearExportDirectory = false, bool clearMaxReleaseExportTlp = false, @@ -477,6 +495,8 @@ class AppSettings { ? null : (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey), uiTextScale: uiTextScale ?? this.uiTextScale, + presentationTargetSeconds: + presentationTargetSeconds ?? this.presentationTargetSeconds, ); } } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 681892d..4dd9c6a 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -883,7 +883,7 @@ th, td { } thead th, tr:first-child th { - background: ${profile.accentColor}; + background: ${profile.tableHeaderBackgroundColor}; color: ${profile.tableHeaderTextColor}; } $logoCss diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index 108122c..3f3f9e8 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -626,7 +626,7 @@ class MarpHtmlService { '.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;' 'padding-left:16px;opacity:.85}' '.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}' '.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;' diff --git a/lib/services/rehearsal_controller.dart b/lib/services/rehearsal_controller.dart new file mode 100644 index 0000000..ff00b6b --- /dev/null +++ b/lib/services/rehearsal_controller.dart @@ -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 _spent = {}; + + /// Eerste positie waarop een slide werd gezien (voor stabiele volgorde). + final Map _firstIndex = {}; + + /// Volgorde van eerste verschijning, voor een leesbare samenvatting. + final List _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.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, + ); + } +} diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index 493e677..a555e61 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -56,6 +56,8 @@ class SettingsNotifier extends StateNotifier { recentFiles: prefs.getStringList('recentFiles') ?? [], maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'), 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 { 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 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 addRecentFile(String path) async { final updated = [ path, diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 699621a..567e99a 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -528,6 +528,10 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { themeProfile: deck.themeProfile, initialIndex: initial, tlp: deck.tlp, + targetDuration: () { + final secs = ref.read(settingsProvider).presentationTargetSeconds; + return secs > 0 ? Duration(seconds: secs) : null; + }(), annotations: deck.annotations, onAnnotationsChanged: deckNotifier.setAnnotations, onSlideChanged: (updated) { diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index 36c68b3..5e9e085 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -450,6 +450,18 @@ class _SettingsDialogState extends ConsumerState { ), ), 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')), Row( children: [ @@ -544,6 +556,49 @@ class _SettingsDialogState extends ConsumerState { ); } + /// 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( + 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() { final l10n = context.l10n; final profiles = ref.watch(settingsProvider).appAppearanceProfiles; @@ -1047,6 +1102,14 @@ class _SettingsDialogState extends ConsumerState { _themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v), ), const SizedBox(height: 12), + _colorSetting( + l10n.d('Tabel kopachtergrond'), + _themeProfile.tableHeaderBackgroundColor, + (v) => _themeProfile = _themeProfile.copyWith( + tableHeaderBackgroundColor: v, + ), + ), + const SizedBox(height: 12), _colorSetting( l10n.d('Titelachtergrond'), _themeProfile.titleBackgroundColor, diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index 57a0fbe..cdade86 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -13,6 +13,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/markdown_service.dart'; +import '../../services/rehearsal_controller.dart'; import '../../utils/log.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; @@ -20,6 +21,7 @@ import '../slides/inline_markdown.dart'; import '../slides/slide_preview.dart'; import 'annotation_overlay.dart'; import 'audience_window.dart'; +import 'rehearsal_summary.dart'; /// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint). enum _Blank { none, black, white } @@ -31,6 +33,10 @@ class FullscreenPresenter extends StatefulWidget { final int initialIndex; 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 /// laptop shows the presenter view, the slide goes to [audienceWindow]. Null /// for the classic single-screen mode. @@ -49,6 +55,7 @@ class FullscreenPresenter extends StatefulWidget { required this.themeProfile, required this.initialIndex, this.tlp = TlpLevel.none, + this.targetDuration, this.audienceWindow, this.initialAnnotations = const {}, this.onAnnotationsChanged, @@ -65,6 +72,7 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + Duration? targetDuration, Map> annotations = const {}, void Function(Map>)? onAnnotationsChanged, ValueChanged? onSlideChanged, @@ -94,6 +102,7 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + targetDuration: targetDuration, annotations: annotations, onAnnotationsChanged: onAnnotationsChanged, onSlideChanged: onSlideChanged, @@ -106,6 +115,7 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + targetDuration: targetDuration, annotations: annotations, onAnnotationsChanged: onAnnotationsChanged, onSlideChanged: onSlideChanged, @@ -120,6 +130,7 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + Duration? targetDuration, Map> annotations = const {}, void Function(Map>)? onAnnotationsChanged, ValueChanged? onSlideChanged, @@ -139,6 +150,7 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + targetDuration: targetDuration, initialAnnotations: annotations, onAnnotationsChanged: onAnnotationsChanged, onSlideChanged: onSlideChanged, @@ -165,6 +177,7 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + Duration? targetDuration, Map> annotations = const {}, void Function(Map>)? onAnnotationsChanged, ValueChanged? onSlideChanged, @@ -341,13 +354,19 @@ class _FullscreenPresenterState extends State { double _gridRowExtent = 220; final ScrollController _gridScroll = ScrollController(); - /// Starttijd voor de verstreken-tijd-teller (resetbaar met R). - late DateTime _startTime; + /// Oefenklok: verstreken tijd, aftelling en per-slide-tijd. Sessie-only, + /// puur meten (geen pacing). Resetbaar met R. + late RehearsalController _rehearsal; /// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief). String _typed = ''; 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. bool _helpOpen = false; @@ -400,7 +419,7 @@ class _FullscreenPresenterState extends State { void initState() { super.initState(); _index = widget.initialIndex; - _startTime = DateTime.now(); + _rehearsal = RehearsalController(target: widget.targetDuration); _focusNode = FocusNode(); _ink = { for (final e in widget.initialAnnotations.entries) @@ -757,6 +776,7 @@ class _FullscreenPresenterState extends State { Future _exit() async { _advanceTimer?.cancel(); + await _maybeShowRehearsalSummary(); final aw = widget.audienceWindow; if (aw != null) { // Dual mode: the main window was never put in full screen; just tear down @@ -769,6 +789,14 @@ class _FullscreenPresenterState extends State { if (mounted) Navigator.pop(context); } + /// Toon na afloop de oefenrun-samenvatting, mits er genoeg gemeten is. + /// Sessie-only: niets wordt opgeslagen. + Future _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): /// visueel verandert de hele slide, maar zonder aankondiging merkt een /// schermlezer-gebruiker de wissel niet op. @@ -815,7 +843,40 @@ class _FullscreenPresenterState extends State { } 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() { @@ -957,6 +1018,9 @@ class _FullscreenPresenterState extends State { 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. if (_gridOpen) return _handleGridKey(key); @@ -1008,6 +1072,9 @@ class _FullscreenPresenterState extends State { case LogicalKeyboardKey.keyR: _resetTimer(); return KeyEventResult.handled; + case LogicalKeyboardKey.keyK: + _beginTargetInput(); + return KeyEventResult.handled; case LogicalKeyboardKey.keyB: _toggleBlank(_Blank.black); return KeyEventResult.handled; @@ -1062,6 +1129,41 @@ class _FullscreenPresenterState extends State { } } + /// 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. KeyEventResult _handleGridKey(LogicalKeyboardKey key) { final last = widget.slides.length - 1; @@ -1105,6 +1207,12 @@ class _FullscreenPresenterState extends State { 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 Widget build(BuildContext context) { final total = widget.slides.length; @@ -1116,6 +1224,11 @@ class _FullscreenPresenterState extends State { // Keep the beamer window in step with whatever index/blank we now show. _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( focusNode: _focusNode, autofocus: true, @@ -1142,6 +1255,13 @@ class _FullscreenPresenterState extends State { bottom: 60, child: Center(child: _buildTypedBadge(total)), ), + if (_targetInput) + Positioned( + left: 0, + right: 0, + bottom: 60, + child: Center(child: _buildTargetBadge()), + ), if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()), ], ), @@ -1270,6 +1390,42 @@ class _FullscreenPresenterState extends State { ); } + /// 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). Widget _buildHelpOverlay() { final l10n = context.l10n; @@ -1284,7 +1440,8 @@ class _FullscreenPresenterState extends State { ('B · W', l10n.d('Zwart · wit scherm')), ('D · T · E', l10n.d('Pen · markeerstift · gum')), ('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')), ('L', l10n.d('Herhalen (loop) aan/uit')), ('M', l10n.d('Na media automatisch doorgaan')), @@ -1584,9 +1741,41 @@ class _FullscreenPresenterState extends State { ); } + /// 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() { 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( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -1594,59 +1783,64 @@ class _FullscreenPresenterState extends State { borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFF262626)), ), - child: Row( + child: Column( children: [ - // Verstreken tijd - Expanded( - 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, + // Bovenrij: verstreken tijd, knoppen, wandklok. + Row( children: [ - Text( - l10n.d('Klok'), - style: const TextStyle(color: Colors.white38, fontSize: 10), + Expanded( + child: _metric(l10n.d('Verstreken'), _fmtElapsed(elapsed)), ), - const SizedBox(height: 2), - Text( - _fmtClock(DateTime.now()), - style: const TextStyle( - color: Colors.white70, - fontSize: 24, - fontWeight: FontWeight.w600, - fontFeatures: [FontFeature.tabularFigures()], + Tooltip( + message: l10n.d('Doeltijd / aftellen (K)'), + child: IconButton( + onPressed: _beginTargetInput, + icon: const Icon(Icons.timer_outlined, size: 18), + color: Colors.white38, + visualDensity: VisualDensity.compact, ), ), + 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, + ), ], ), ], diff --git a/lib/widgets/presentation/rehearsal_summary.dart b/lib/widgets/presentation/rehearsal_summary.dart new file mode 100644 index 0000000..d3afa76 --- /dev/null +++ b/lib/widgets/presentation/rehearsal_summary.dart @@ -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 showRehearsalSummary( + BuildContext context, { + required RehearsalRun run, + required List slides, +}) { + return showDialog( + 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 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 _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')), + ), + ], + ); + } +} diff --git a/lib/widgets/slides/previews/table_preview.dart b/lib/widgets/slides/previews/table_preview.dart index 6611473..e4d87f4 100644 --- a/lib/widgets/slides/previews/table_preview.dart +++ b/lib/widgets/slides/previews/table_preview.dart @@ -35,6 +35,7 @@ class _TablePreview extends StatelessWidget { final accent = _hexColor(profile.accentColor); final textColor = _hexColor(profile.tableTextColor); final headerTextColor = _hexColor(profile.tableHeaderTextColor); + final headerBackground = _hexColor(profile.tableHeaderBackgroundColor); final borderColor = accent.withValues(alpha: 0.35); Widget cell(String value, {required bool header}) { @@ -61,7 +62,7 @@ class _TablePreview extends StatelessWidget { TableRow buildRow(List row, {required bool header}) { return TableRow( - decoration: BoxDecoration(color: header ? accent : null), + decoration: BoxDecoration(color: header ? headerBackground : null), children: List.generate(colCount, (c) { final value = c < row.length ? row[c] : ''; return TableCell( diff --git a/test/rehearsal_controller_test.dart b/test/rehearsal_controller_test.dart new file mode 100644 index 0000000..cc7294e --- /dev/null +++ b/test/rehearsal_controller_test.dart @@ -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 + }); +}