Merge pull request 'feature/meldingen-hardening' (#7) from feature/meldingen-hardening into main
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
This commit is contained in:
commit
79d2b8a16c
24 changed files with 1292 additions and 82 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
@ -41,6 +50,12 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
|
||||||
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
|
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
|
||||||
Protocol level; slides classified stricter than the level the deck is shown at
|
Protocol level; slides classified stricter than the level the deck is shown at
|
||||||
are withheld when presenting and exporting.
|
are withheld when presenting and exporting.
|
||||||
|
- **Export release ceiling** — an optional maximum TLP level that may be
|
||||||
|
exported. When set, a deck classified *above* it cannot be exported in any
|
||||||
|
format; the gate is enforced at the single export chokepoint and fails closed
|
||||||
|
(no file is written when blocked, and the export dialog explains why).
|
||||||
|
Classifying a deck stays optional — the ceiling only stops decks that exceed
|
||||||
|
it, and it is off by default.
|
||||||
- **Dual-screen presenter** — on a second display the beamer shows the slide
|
- **Dual-screen presenter** — on a second display the beamer shows the slide
|
||||||
while the laptop shows the presenter view (current/next slide, notes, timer),
|
while the laptop shows the presenter view (current/next slide, notes, timer),
|
||||||
kept in sync over method channels.
|
kept in sync over method channels.
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ Built with Flutter for macOS, Windows, and Linux.
|
||||||
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
|
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
|
||||||
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
|
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
|
||||||
- **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.
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ OciDeck is an offline desktop application. Areas of particular interest:
|
||||||
- Importing presentations from a URL.
|
- Importing presentations from a URL.
|
||||||
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
|
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
|
||||||
mermaid, MathJax) to render offline.
|
mermaid, MathJax) to render offline.
|
||||||
|
- The export classification gate (`ClassificationPolicy`) — any way to export a
|
||||||
|
deck classified above the configured release ceiling.
|
||||||
|
|
||||||
## Supported versions
|
## Supported versions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
||||||
services/ # markdown, file, export, image, caption, description,
|
services/ # markdown, file, export, classification_policy, image, caption,
|
||||||
# image_dedup (md5 duplicates), image_reference (.md rewrites),
|
# description, image_dedup (md5 duplicates),
|
||||||
# recovery, rasterizer, marp_html, annotation_codec
|
# image_reference (.md rewrites), recovery, rasterizer,
|
||||||
|
# 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)
|
||||||
|
|
@ -66,6 +67,15 @@ the key thing to understand before touching rendering:
|
||||||
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
|
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
|
||||||
renderer by design.
|
renderer by design.
|
||||||
|
|
||||||
|
Both worlds converge at one chokepoint: `services/export_service.dart`
|
||||||
|
(`ExportService.export()`) is the only place that writes an export, so the
|
||||||
|
**classification gate** lives there rather than in the export dialog. A
|
||||||
|
`ClassificationPolicy` enforces an optional *release ceiling* and refuses,
|
||||||
|
**fail-closed**, to export a deck classified above it — no format can bypass it.
|
||||||
|
The ceiling is stored in app settings (`maxReleaseExportTlpKey`, off by default);
|
||||||
|
the dialog also runs the same check up front so a blocked export is explained
|
||||||
|
before any work starts.
|
||||||
|
|
||||||
## Presenter
|
## Presenter
|
||||||
|
|
||||||
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
|
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
|
||||||
|
|
@ -74,6 +84,13 @@ the key thing to understand before touching rendering:
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -114,12 +114,38 @@ A deck has an overall TLP level (shown as a marking on the slides). Each slide c
|
||||||
deck can be shown safely to audiences with different clearances. Order, least to
|
deck can be shown safely to audiences with different clearances. Order, least to
|
||||||
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
|
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
|
||||||
|
|
||||||
|
Classifying a deck is **optional**. As an extra guardrail, an organisation can
|
||||||
|
set a **release ceiling** — a maximum level that may leave the machine; see
|
||||||
|
*Exporting* below.
|
||||||
|
|
||||||
## Presenting
|
## Presenting
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -148,6 +174,11 @@ Export to:
|
||||||
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
||||||
assets, to hand the whole deck to someone else.
|
assets, to hand the whole deck to someone else.
|
||||||
|
|
||||||
|
**Release ceiling (optional).** When a maximum TLP level is configured, exporting
|
||||||
|
a deck classified *above* it is blocked for every format, and the export dialog
|
||||||
|
explains why. The ceiling is off by default and classifying a deck stays
|
||||||
|
optional — it only stops decks that exceed the configured level.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
OciDeck aims for WCAG 2.1 in the editor:
|
OciDeck aims for WCAG 2.1 in the editor:
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Delete',
|
'Verwijderen': 'Delete',
|
||||||
'Herstellen': 'Restore',
|
'Herstellen': 'Restore',
|
||||||
'Opslaan en sluiten': 'Save and close',
|
'Opslaan en sluiten': 'Save and close',
|
||||||
|
'Niet opslaan': "Don't save",
|
||||||
'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?',
|
'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?',
|
||||||
'Niet-opgeslagen wijzigingen': 'Unsaved changes',
|
'Niet-opgeslagen wijzigingen': 'Unsaved changes',
|
||||||
'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:':
|
'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:':
|
||||||
|
|
@ -1161,6 +1162,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Elimina',
|
'Verwijderen': 'Elimina',
|
||||||
'Herstellen': 'Ripristina',
|
'Herstellen': 'Ripristina',
|
||||||
'Opslaan en sluiten': 'Salva e chiudi',
|
'Opslaan en sluiten': 'Salva e chiudi',
|
||||||
|
'Niet opslaan': 'Non salvare',
|
||||||
'Importeren via URL': 'Importa da URL',
|
'Importeren via URL': 'Importa da URL',
|
||||||
'Ophalen': 'Recupera',
|
'Ophalen': 'Recupera',
|
||||||
'Titelpagina': 'Slide titolo',
|
'Titelpagina': 'Slide titolo',
|
||||||
|
|
@ -1372,6 +1374,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Löschen',
|
'Verwijderen': 'Löschen',
|
||||||
'Herstellen': 'Wiederherstellen',
|
'Herstellen': 'Wiederherstellen',
|
||||||
'Opslaan en sluiten': 'Speichern und schließen',
|
'Opslaan en sluiten': 'Speichern und schließen',
|
||||||
|
'Niet opslaan': 'Nicht speichern',
|
||||||
'Importeren via URL': 'Von URL importieren',
|
'Importeren via URL': 'Von URL importieren',
|
||||||
'Ophalen': 'Abrufen',
|
'Ophalen': 'Abrufen',
|
||||||
'Titelpagina': 'Titelfolie',
|
'Titelpagina': 'Titelfolie',
|
||||||
|
|
@ -1584,6 +1587,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Supprimer',
|
'Verwijderen': 'Supprimer',
|
||||||
'Herstellen': 'Restaurer',
|
'Herstellen': 'Restaurer',
|
||||||
'Opslaan en sluiten': 'Enregistrer et fermer',
|
'Opslaan en sluiten': 'Enregistrer et fermer',
|
||||||
|
'Niet opslaan': 'Ne pas enregistrer',
|
||||||
'Importeren via URL': 'Importer depuis une URL',
|
'Importeren via URL': 'Importer depuis une URL',
|
||||||
'Ophalen': 'Récupérer',
|
'Ophalen': 'Récupérer',
|
||||||
'Titelpagina': 'Diapositive de titre',
|
'Titelpagina': 'Diapositive de titre',
|
||||||
|
|
@ -1795,6 +1799,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Eliminar',
|
'Verwijderen': 'Eliminar',
|
||||||
'Herstellen': 'Restaurar',
|
'Herstellen': 'Restaurar',
|
||||||
'Opslaan en sluiten': 'Guardar y cerrar',
|
'Opslaan en sluiten': 'Guardar y cerrar',
|
||||||
|
'Niet opslaan': 'No guardar',
|
||||||
'Importeren via URL': 'Importar desde URL',
|
'Importeren via URL': 'Importar desde URL',
|
||||||
'Ophalen': 'Obtener',
|
'Ophalen': 'Obtener',
|
||||||
'Titelpagina': 'Diapositiva de título',
|
'Titelpagina': 'Diapositiva de título',
|
||||||
|
|
@ -2007,6 +2012,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Fuortsmite',
|
'Verwijderen': 'Fuortsmite',
|
||||||
'Herstellen': 'Weromsette',
|
'Herstellen': 'Weromsette',
|
||||||
'Opslaan en sluiten': 'Bewarje en slute',
|
'Opslaan en sluiten': 'Bewarje en slute',
|
||||||
|
'Niet opslaan': 'Net bewarje',
|
||||||
'Importeren via URL': 'Ymportearje fan URL',
|
'Importeren via URL': 'Ymportearje fan URL',
|
||||||
'Ophalen': 'Ophelje',
|
'Ophalen': 'Ophelje',
|
||||||
'Titelpagina': 'Titelslide',
|
'Titelpagina': 'Titelslide',
|
||||||
|
|
@ -2219,6 +2225,7 @@ const _dutchSourceStrings = {
|
||||||
'Verwijderen': 'Kita',
|
'Verwijderen': 'Kita',
|
||||||
'Herstellen': 'Restorá',
|
'Herstellen': 'Restorá',
|
||||||
'Opslaan en sluiten': 'Warda i sera',
|
'Opslaan en sluiten': 'Warda i sera',
|
||||||
|
'Niet opslaan': 'No warda',
|
||||||
'Importeren via URL': 'Importá for di URL',
|
'Importeren via URL': 'Importá for di URL',
|
||||||
'Ophalen': 'Tuma',
|
'Ophalen': 'Tuma',
|
||||||
'Titelpagina': 'Slide di título',
|
'Titelpagina': 'Slide di título',
|
||||||
|
|
@ -2429,6 +2436,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 +2615,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 +2942,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 +3269,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 +3600,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 +3927,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 +4247,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
46
lib/models/rehearsal.dart
Normal 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!;
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -364,11 +376,21 @@ class AppSettings {
|
||||||
final String selectedAppAppearanceProfileName;
|
final String selectedAppAppearanceProfileName;
|
||||||
final List<String> recentFiles;
|
final List<String> recentFiles;
|
||||||
|
|
||||||
|
/// Optioneel vrijgaveplafond voor de classificatie-gate, opgeslagen als
|
||||||
|
/// TLP-sleutel (zie `TlpLevelX.key`). `null` = geen plafond, alles mag worden
|
||||||
|
/// geëxporteerd (standaard). Classificeren blijft optioneel; dit plafond
|
||||||
|
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
|
||||||
|
final String? maxReleaseExportTlpKey;
|
||||||
|
|
||||||
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
||||||
/// text scaling. The slide canvas itself is never scaled: slides are a
|
/// text scaling. The slide canvas itself is never scaled: slides are a
|
||||||
/// 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,
|
||||||
|
|
@ -378,7 +400,9 @@ class AppSettings {
|
||||||
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||||
this.selectedAppAppearanceProfileName = 'Basic',
|
this.selectedAppAppearanceProfileName = 'Basic',
|
||||||
this.recentFiles = const [],
|
this.recentFiles = const [],
|
||||||
|
this.maxReleaseExportTlpKey,
|
||||||
this.uiTextScale = 1.0,
|
this.uiTextScale = 1.0,
|
||||||
|
this.presentationTargetSeconds = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
ThemeProfile get themeProfile {
|
ThemeProfile get themeProfile {
|
||||||
|
|
@ -430,9 +454,12 @@ class AppSettings {
|
||||||
List<AppAppearanceProfile>? appAppearanceProfiles,
|
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||||
String? selectedAppAppearanceProfileName,
|
String? selectedAppAppearanceProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
|
String? maxReleaseExportTlpKey,
|
||||||
double? uiTextScale,
|
double? uiTextScale,
|
||||||
|
int? presentationTargetSeconds,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
bool clearExportDirectory = false,
|
bool clearExportDirectory = false,
|
||||||
|
bool clearMaxReleaseExportTlp = false,
|
||||||
}) {
|
}) {
|
||||||
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
|
|
@ -464,7 +491,12 @@ class AppSettings {
|
||||||
selectedAppAppearanceProfileName ??
|
selectedAppAppearanceProfileName ??
|
||||||
this.selectedAppAppearanceProfileName,
|
this.selectedAppAppearanceProfileName,
|
||||||
recentFiles: recentFiles ?? this.recentFiles,
|
recentFiles: recentFiles ?? this.recentFiles,
|
||||||
|
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
|
||||||
|
? null
|
||||||
|
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
|
||||||
uiTextScale: uiTextScale ?? this.uiTextScale,
|
uiTextScale: uiTextScale ?? this.uiTextScale,
|
||||||
|
presentationTargetSeconds:
|
||||||
|
presentationTargetSeconds ?? this.presentationTargetSeconds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
lib/services/classification_policy.dart
Normal file
58
lib/services/classification_policy.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import '../models/deck.dart';
|
||||||
|
|
||||||
|
/// Uitkomst van de export-gate: mag deze export door, en zo niet, waarom niet.
|
||||||
|
class ExportDecision {
|
||||||
|
/// Of de export is toegestaan.
|
||||||
|
final bool allowed;
|
||||||
|
|
||||||
|
/// Reden waarom de export geweigerd is (`null` wanneer toegestaan). Bedoeld om
|
||||||
|
/// 1-op-1 aan de gebruiker te tonen.
|
||||||
|
final String? reason;
|
||||||
|
|
||||||
|
const ExportDecision._(this.allowed, this.reason);
|
||||||
|
|
||||||
|
const ExportDecision.allow() : this._(true, null);
|
||||||
|
const ExportDecision.block(String reason) : this._(false, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centrale, pure beslisser voor classificatie bij export.
|
||||||
|
///
|
||||||
|
/// Classificeren is **optioneel**: een deck zonder TLP-niveau ([TlpLevel.none])
|
||||||
|
/// exporteert altijd. Maar zodra een organisatie een vrijgaveplafond instelt, is
|
||||||
|
/// dit de enige plek die bepaalt of een geclassificeerd deck naar buiten mag.
|
||||||
|
///
|
||||||
|
/// De gate hangt aan het export-chokepoint ([ExportService.export]), zodat geen
|
||||||
|
/// enkel formaat (PDF/PPTX/HTML) eromheen kan. Fail-closed: bij twijfel weigert
|
||||||
|
/// de gate in plaats van stilletjes te exporteren.
|
||||||
|
class ClassificationPolicy {
|
||||||
|
/// Hoogste TLP-niveau dat geëxporteerd mag worden — het vrijgaveplafond.
|
||||||
|
///
|
||||||
|
/// `null` = geen plafond, alles mag (standaard). Een deck dat híérboven is
|
||||||
|
/// geclassificeerd wordt geweigerd in plaats van naar buiten gebracht. Let op:
|
||||||
|
/// een plafond van [TlpLevel.none] staat alléén ongeclassificeerde decks toe.
|
||||||
|
final TlpLevel? maxReleaseLevel;
|
||||||
|
|
||||||
|
const ClassificationPolicy({this.maxReleaseLevel});
|
||||||
|
|
||||||
|
/// Bouw het beleid uit de opgeslagen instelling: een TLP-sleutel (zie
|
||||||
|
/// [TlpLevelX.key]) of `null` wanneer er geen plafond is ingesteld.
|
||||||
|
factory ClassificationPolicy.fromKey(String? key) => ClassificationPolicy(
|
||||||
|
maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Of er überhaupt een gate actief is.
|
||||||
|
bool get hasGate => maxReleaseLevel != null;
|
||||||
|
|
||||||
|
/// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden.
|
||||||
|
ExportDecision evaluate(TlpLevel deckLevel) {
|
||||||
|
final ceiling = maxReleaseLevel;
|
||||||
|
if (ceiling != null && deckLevel.index > ceiling.index) {
|
||||||
|
return ExportDecision.block(
|
||||||
|
'Export geblokkeerd door classificatiebeleid: dit deck is '
|
||||||
|
'${deckLevel.label}, hoger dan het toegestane vrijgaveniveau '
|
||||||
|
'${ceiling.label}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const ExportDecision.allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import 'package:path/path.dart' as p;
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import 'classification_policy.dart';
|
||||||
import 'marp_html_service.dart';
|
import 'marp_html_service.dart';
|
||||||
|
|
||||||
enum ExportFormat { pdf, pptx, html }
|
enum ExportFormat { pdf, pptx, html }
|
||||||
|
|
@ -103,7 +105,17 @@ class ExportService {
|
||||||
List<String>? notes,
|
List<String>? notes,
|
||||||
String? markdown,
|
String? markdown,
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
ClassificationPolicy policy = const ClassificationPolicy(),
|
||||||
}) async {
|
}) async {
|
||||||
|
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat
|
||||||
|
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
|
||||||
|
// UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een
|
||||||
|
// weigering wordt er niets gebouwd of weggeschreven.
|
||||||
|
final decision = policy.evaluate(tlp);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return ExportResult.fail(decision.reason!);
|
||||||
|
}
|
||||||
if (format == ExportFormat.html) {
|
if (format == ExportFormat.html) {
|
||||||
if (markdown == null || markdown.trim().isEmpty) {
|
if (markdown == null || markdown.trim().isEmpty) {
|
||||||
return ExportResult.fail('Geen inhoud om te exporteren.');
|
return ExportResult.fail('Geen inhoud om te exporteren.');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;'
|
||||||
|
|
|
||||||
115
lib/services/rehearsal_controller.dart
Normal file
115
lib/services/rehearsal_controller.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,10 +54,27 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
? selectedAppearance
|
? selectedAppearance
|
||||||
: 'Basic',
|
: 'Basic',
|
||||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||||
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stel het vrijgaveplafond voor de export-gate in (een TLP-sleutel), of
|
||||||
|
/// `null` om de gate uit te zetten. Persisteert in hetzelfde prefs-domein.
|
||||||
|
Future<void> setMaxReleaseExportTlp(String? key) async {
|
||||||
|
state = key == null
|
||||||
|
? state.copyWith(clearMaxReleaseExportTlp: true)
|
||||||
|
: state.copyWith(maxReleaseExportTlpKey: key);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null) {
|
||||||
|
await prefs.remove('maxReleaseExportTlp');
|
||||||
|
} else {
|
||||||
|
await prefs.setString('maxReleaseExportTlp', key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setUiTextScale(double scale) async {
|
Future<void> setUiTextScale(double scale) async {
|
||||||
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
||||||
state = state.copyWith(uiTextScale: clamped);
|
state = state.copyWith(uiTextScale: clamped);
|
||||||
|
|
@ -65,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,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../models/deck.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
import '../services/caption_service.dart';
|
import '../services/caption_service.dart';
|
||||||
import '../services/description_service.dart';
|
import '../services/description_service.dart';
|
||||||
|
import '../services/classification_policy.dart';
|
||||||
import '../services/export_service.dart';
|
import '../services/export_service.dart';
|
||||||
import '../services/recovery_service.dart';
|
import '../services/recovery_service.dart';
|
||||||
import '../state/deck_provider.dart';
|
import '../state/deck_provider.dart';
|
||||||
|
|
@ -38,6 +39,9 @@ part 'shell/welcome_screen.dart';
|
||||||
part 'shell/status_bar.dart';
|
part 'shell/status_bar.dart';
|
||||||
part 'shell/shell_overlays.dart';
|
part 'shell/shell_overlays.dart';
|
||||||
|
|
||||||
|
/// Keuze uit de "niet-opgeslagen wijzigingen"-dialoog bij het sluiten.
|
||||||
|
enum _CloseChoice { cancel, discard, save }
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
const AppShell({super.key});
|
const AppShell({super.key});
|
||||||
|
|
||||||
|
|
@ -127,14 +131,21 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
|
||||||
@override
|
@override
|
||||||
void onWindowClose() async {
|
void onWindowClose() async {
|
||||||
if (ref.read(tabsProvider).anyDirty) {
|
if (ref.read(tabsProvider).anyDirty) {
|
||||||
final shouldSave = await _confirmSaveBeforeClose(
|
final choice = await _confirmSaveBeforeClose(
|
||||||
context.l10n.d(
|
context.l10n.d(
|
||||||
'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.',
|
'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!shouldSave) return;
|
switch (choice) {
|
||||||
final saved = await _saveAllDirtyTabs();
|
case _CloseChoice.cancel:
|
||||||
if (saved) await _destroy();
|
return;
|
||||||
|
case _CloseChoice.discard:
|
||||||
|
// Wijzigingen verwerpen: herstelbestanden weg, niets opslaan.
|
||||||
|
await _destroy();
|
||||||
|
case _CloseChoice.save:
|
||||||
|
final saved = await _saveAllDirtyTabs();
|
||||||
|
if (saved) await _destroy();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await _destroy();
|
await _destroy();
|
||||||
}
|
}
|
||||||
|
|
@ -146,9 +157,9 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
|
||||||
await windowManager.destroy();
|
await windowManager.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _confirmSaveBeforeClose(String message) async {
|
Future<_CloseChoice> _confirmSaveBeforeClose(String message) async {
|
||||||
if (!mounted) return false;
|
if (!mounted) return _CloseChoice.cancel;
|
||||||
return await showDialog<bool>(
|
return await showDialog<_CloseChoice>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
|
|
@ -158,18 +169,25 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
|
||||||
content: Text(message),
|
content: Text(message),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, _CloseChoice.cancel),
|
||||||
child: Text(l10n.t('cancel')),
|
child: Text(l10n.t('cancel')),
|
||||||
),
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, _CloseChoice.discard),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text(l10n.d('Niet opslaan')),
|
||||||
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, _CloseChoice.save),
|
||||||
child: Text(l10n.d('Opslaan en sluiten')),
|
child: Text(l10n.d('Opslaan en sluiten')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
) ??
|
) ??
|
||||||
false;
|
_CloseChoice.cancel;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _saveAllDirtyTabs() async {
|
Future<bool> _saveAllDirtyTabs() async {
|
||||||
|
|
@ -185,16 +203,23 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
|
||||||
Future<void> _onCloseTab(int index) async {
|
Future<void> _onCloseTab(int index) async {
|
||||||
final tab = ref.read(tabsProvider).tabs[index];
|
final tab = ref.read(tabsProvider).tabs[index];
|
||||||
if (tab.isDirty) {
|
if (tab.isDirty) {
|
||||||
final shouldSave = await _confirmSaveBeforeClose(
|
final choice = await _confirmSaveBeforeClose(
|
||||||
context.l10n.d(
|
context.l10n.d(
|
||||||
'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.',
|
'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!shouldSave) return;
|
switch (choice) {
|
||||||
final saved = await tab.deckNotifier.save(
|
case _CloseChoice.cancel:
|
||||||
initialDirectory: ref.read(settingsProvider).homeDirectory,
|
return;
|
||||||
);
|
case _CloseChoice.discard:
|
||||||
if (!saved) return;
|
// Wijzigingen verwerpen: closeTab() ruimt ook het herstelbestand op.
|
||||||
|
break;
|
||||||
|
case _CloseChoice.save:
|
||||||
|
final saved = await tab.deckNotifier.save(
|
||||||
|
initialDirectory: ref.read(settingsProvider).homeDirectory,
|
||||||
|
);
|
||||||
|
if (!saved) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ref.read(tabsProvider.notifier).closeTab(index);
|
ref.read(tabsProvider.notifier).closeTab(index);
|
||||||
}
|
}
|
||||||
|
|
@ -527,6 +552,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) {
|
||||||
|
|
@ -560,6 +589,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
policy: ClassificationPolicy.fromKey(
|
||||||
|
ref.read(settingsProvider).maxReleaseExportTlpKey,
|
||||||
|
),
|
||||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||||
// Inline chart data so the HTML export can render charts standalone,
|
// Inline chart data so the HTML export can render charts standalone,
|
||||||
// even when a chart links an external CSV.
|
// even when a chart links an external CSV.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
import '../../services/classification_policy.dart';
|
||||||
import '../../services/export_service.dart';
|
import '../../services/export_service.dart';
|
||||||
import '../../services/slide_rasterizer.dart';
|
import '../../services/slide_rasterizer.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
@ -18,6 +19,9 @@ class ExportDialog extends StatefulWidget {
|
||||||
final ExportService exportService;
|
final ExportService exportService;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Classificatie-gate. Standaard geen plafond (alles mag).
|
||||||
|
final ClassificationPolicy policy;
|
||||||
|
|
||||||
/// Folder all exports are written to. Null = next to the source deck.
|
/// Folder all exports are written to. Null = next to the source deck.
|
||||||
final String? exportDirectory;
|
final String? exportDirectory;
|
||||||
|
|
||||||
|
|
@ -32,6 +36,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required this.projectPath,
|
required this.projectPath,
|
||||||
required this.exportService,
|
required this.exportService,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.policy = const ClassificationPolicy(),
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
this.markdown = '',
|
this.markdown = '',
|
||||||
});
|
});
|
||||||
|
|
@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required String? projectPath,
|
required String? projectPath,
|
||||||
required ExportService exportService,
|
required ExportService exportService,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
ClassificationPolicy policy = const ClassificationPolicy(),
|
||||||
String? exportDirectory,
|
String? exportDirectory,
|
||||||
String markdown = '',
|
String markdown = '',
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -57,6 +63,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
policy: policy,
|
||||||
exportDirectory: exportDirectory,
|
exportDirectory: exportDirectory,
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
),
|
),
|
||||||
|
|
@ -131,6 +138,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
notes: [for (final s in widget.slides) s.notes],
|
notes: [for (final s in widget.slides) s.notes],
|
||||||
markdown: widget.markdown,
|
markdown: widget.markdown,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
tlp: widget.tlp,
|
||||||
|
policy: widget.policy,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -231,6 +240,25 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-flight classificatie-gate: blokkeert de export al vóór een poging,
|
||||||
|
// zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde
|
||||||
|
// regel nog eens als backstop, dus dit is puur UX — niet de beveiliging.
|
||||||
|
final decision = widget.policy.evaluate(widget.tlp);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.block, color: Colors.red, size: 36),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
decision.reason!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.red[800]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
178
lib/widgets/presentation/rehearsal_summary.dart
Normal file
178
lib/widgets/presentation/rehearsal_summary.dart
Normal 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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
62
test/classification_policy_test.dart
Normal file
62
test/classification_policy_test.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ClassificationPolicy', () {
|
||||||
|
test(
|
||||||
|
'without a ceiling every level is allowed (classificeren optioneel)',
|
||||||
|
() {
|
||||||
|
const policy = ClassificationPolicy();
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
for (final level in TlpLevel.values) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a ceiling allows levels at or below it', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
expect(policy.hasGate, isTrue);
|
||||||
|
for (final level in [
|
||||||
|
TlpLevel.none,
|
||||||
|
TlpLevel.clear,
|
||||||
|
TlpLevel.green,
|
||||||
|
TlpLevel.amber,
|
||||||
|
]) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling blocks levels above it, with a clear reason', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
for (final level in [TlpLevel.amberStrict, TlpLevel.red]) {
|
||||||
|
final decision = policy.evaluate(level);
|
||||||
|
expect(decision.allowed, isFalse, reason: level.name);
|
||||||
|
expect(decision.reason, contains(level.label));
|
||||||
|
expect(decision.reason, contains(TlpLevel.amber.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling of none only allows unclassified decks', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.none);
|
||||||
|
expect(policy.evaluate(TlpLevel.none).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.clear).allowed, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromKey', () {
|
||||||
|
test('null key means no gate', () {
|
||||||
|
final policy = ClassificationPolicy.fromKey(null);
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.red).allowed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a TLP key sets the ceiling', () {
|
||||||
|
final policy = ClassificationPolicy.fromKey(TlpLevel.green.key);
|
||||||
|
expect(policy.maxReleaseLevel, TlpLevel.green);
|
||||||
|
expect(policy.evaluate(TlpLevel.green).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.amber).allowed, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
import 'package:ocideck/services/export_service.dart';
|
import 'package:ocideck/services/export_service.dart';
|
||||||
import 'package:ocideck/services/marp_html_service.dart';
|
import 'package:ocideck/services/marp_html_service.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -56,6 +58,41 @@ void main() {
|
||||||
|
|
||||||
String deckPath() => p.join(tmp.path, 'deck.md');
|
String deckPath() => p.join(tmp.path, 'deck.md');
|
||||||
|
|
||||||
|
test(
|
||||||
|
'classificatie-gate blocks an over-classified export, writes nothing',
|
||||||
|
() async {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.red,
|
||||||
|
policy: policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.success, isFalse);
|
||||||
|
expect(r.outputPath, isNull);
|
||||||
|
expect(r.error, contains('classificatiebeleid'));
|
||||||
|
// Fail-closed: no file may be produced when the gate refuses.
|
||||||
|
final produced = tmp.listSync().whereType<File>().where(
|
||||||
|
(f) => p.extension(f.path) == '.pdf',
|
||||||
|
);
|
||||||
|
expect(produced, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('classificatie-gate allows an export at or below the ceiling', () async {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
policy: policy,
|
||||||
|
);
|
||||||
|
expect(r.success, isTrue, reason: r.error);
|
||||||
|
});
|
||||||
|
|
||||||
test('exports a PDF that starts with the PDF magic header', () async {
|
test('exports a PDF that starts with the PDF magic header', () async {
|
||||||
final images = [_png(), _png()];
|
final images = [_png(), _png()];
|
||||||
final r = await service.export(deckPath(), ExportFormat.pdf, images);
|
final r = await service.export(deckPath(), ExportFormat.pdf, images);
|
||||||
|
|
|
||||||
90
test/rehearsal_controller_test.dart
Normal file
90
test/rehearsal_controller_test.dart
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue