Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
4859d66d95 Merge pull request 'Meldingen-hardening: verwerp-optie bij sluiten, classificatie-gate, presentatietimer' (#7) from feature/meldingen-hardening into main
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
2026-06-13 05:54:42 +00:00
Brenno de Winter
483264b652 Add "discard" option to the unsaved-changes close dialog
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
CI / Format · Analyze · Test (pull_request) Waiting to run
Closing a presentation only offered Cancel or "Save and close", so
unsaved edits could only be dropped by force-quitting. That left an
autosave recovery snapshot behind, creating version ambiguity between
the saved file on disk and the restored unsaved copy on next launch.

Add a third "Niet opslaan" (discard) choice to the close/quit dialog.
Discarding closes without saving; closeTab() and _destroy() already
clear the recovery files, so no shadow version remains and there is a
single unambiguous version afterwards.

Register the "Niet opslaan" string for all 8 supported languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:49:07 +02:00
Brenno de Winter
b719c43991 Add presentation timer / rehearsal mode to the presenter
The presenter view now doubles as a rehearsal clock that measures without
coaching: a countdown against a target time, the time spent on the current
slide, and an end-of-run summary (total vs. target and per-slide times, with
copy-to-clipboard). Timing lives in a plain, unit-tested RehearsalController fed
via an idempotent observe() on every build, so it captures every navigation
path. The default target is stored in AppSettings; live adjustment is the K key
(typed as MMSS). All rehearsal state is session-only -- nothing is written to
disk or into the .md file.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:03:08 +02:00
Brenno de Winter
f93417dc3c Add fail-closed export classification gate (release ceiling)
Enforce an optional TLP release ceiling at the single export chokepoint
so no format (PDF/PPTX/HTML) can bypass it. Classifying a deck stays
optional; the gate only blocks decks classified above the configured
ceiling, and is off by default.

- ClassificationPolicy + ExportDecision: pure, tested decision logic
  (release ceiling, fail-closed; null = no gate).
- ExportService.export() evaluates the policy first and refuses without
  building or writing anything when blocked.
- Persist the ceiling as maxReleaseExportTlpKey in app settings/prefs
  (default off) with a setter on SettingsNotifier.
- Export dialog runs the same check up front and explains a blocked
  export before any work starts; app shell builds the policy from
  settings.
- Tests: classification_policy_test plus export_service chokepoint tests
  asserting a blocked export fails and writes no file.
- Docs: CHANGELOG, README, USER_GUIDE, ARCHITECTURE, SECURITY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:26:29 +02:00
0b3d0be30f Merge pull request 'feature/meldingen-hardening' (#6) from feature/meldingen-hardening into main
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
Reviewed-on: #6
2026-06-11 20:40:08 +00:00
Brenno de Winter
b270e71755 Apply repo formatter across the tree
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
CI / Format · Analyze · Test (pull_request) Has been cancelled
Run `make format` so the whole repo is consistent under the project formatter.
Whitespace only; no logic changes. Touches a few widgets and tests that were
unformatted on main (dart-format version drift), so `make check` is fully green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:17:07 +02:00
Brenno de Winter
97b825f1b9 Bundle pre-existing in-progress changes
In-progress local work that predated this branch, committed alongside it:
localization updates (app_localizations.dart), consent/deck/tabs providers,
the Android Gradle build config, and their accompanying tests. Grouped here so
the structural changes on this branch stay separable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:57 +02:00
Brenno de Winter
6b2ba4df89 Split slide_preview.dart and app_shell.dart into part files (#1)
Break the two largest widget files into part/part-of libraries grouped by
concern, with no public API or behaviour change (private widgets keep working
because parts share the library namespace; all imports stay in the main file).

  slide_preview.dart  4748 -> 426 lines + slides/previews/{text,bullets,
                      checklist,table,media,code,chart,overlays}.dart
  app_shell.dart      1930 -> 996 lines + shell/{shell_actions,tab_bar,
                      welcome_screen,status_bar,shell_overlays}.dart

fullscreen_presenter.dart is intentionally left as-is: ~1.6k of its lines are a
single interactive _FullscreenPresenterState (38 setState calls), which a
mechanical split cannot reduce and extensions can't host (protected setState).
Shrinking it needs a behaviour-affecting sub-widget extraction, tracked
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:49 +02:00
Brenno de Winter
ee9e2bfc58 Add logger; replace silent catch(_) with logged fallbacks (#2)
Introduce lib/utils/log.dart (logError / logWarning over dart:developer) and
route all 53 previously-bare `catch (_)` blocks through it. Behaviour is
unchanged: every fallback still fails soft (a broken sidecar, unreadable file
or unsupported platform must never crash a presentation) but the cause is now
observable. logError is used for unexpected parse/IO failures, logWarning for
expected best-effort fallbacks; no deck or file contents are ever logged.

Note: file_service, markdown_service, marp_html_service, fullscreen_presenter,
image_carousel_picker and url_launcher_util also carried pre-existing local
changes, bundled here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:39 +02:00
Brenno de Winter
f08055c7ae Upgrade vendored JS and add deps-check CVE gate (#3)
Upgrade the JavaScript bundles inlined into the offline HTML export:
DOMPurify 3.1.7 -> 3.4.9 (clears 10 OSV advisories), marked 12.0.2 -> 18.0.5,
highlight.js 11.9.0 -> 11.11.1. mermaid 10.9.6 and MathJax 3.2.2 are kept
(no known CVEs) and now guarded rather than chased.

Pin every bundle in assets/web_export/MANIFEST.json (npm name, version, source,
sha256, licence) and add tool/check_bundled_js.dart: it verifies each file
still matches the manifest hash and queries the OSV database for known
vulnerabilities. Wired as `make deps-check`, into `check-full`, and into CI
next to the licence check. THIRD_PARTY_NOTICES.md updated for the now-standalone
DOMPurify bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:29 +02:00
6bf85773b0 Merge pull request 'Live presenter annotations + keep styling out of saved .md' (#5) from feature/live-annotations-clean-md into main
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
2026-06-11 17:31:10 +00:00
Brenno de Winter
2c4a6f7358 Sync presenter annotations live, keep styling out of saved .md
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
CI / Format · Analyze · Test (pull_request) Has been cancelled
Presentation fixes:
- Mirror the in-progress pen/highlighter stroke to the audience window
  live (new 'inkLive' channel) so highlights appear as they are drawn,
  not only after the pen lifts.
- Cover the macOS menu bar on the beamer: raise the audience window
  above .mainMenu level so the Apple/Wi-Fi strip no longer shows during
  a presentation.

Styling no longer lives in the file:
- generateDeck no longer embeds the ThemeProfile; a saved .md holds only
  content. The profile is inlined only for the transient audience-window
  payload (inlineStyleProfile: true), never to disk.
- On open, the app applies the active style profile (FileService.openDeck
  / activeProfileFor, DeckNotifier.loadDeck); applyMarkdown preserves the
  current profile.

Quality pass / tests green:
- Complete the consent-screen translations (English plus 7 missing
  strings per other language).
- Pass the consent gate in widget/ui-scale tests by seeding the consent
  key, so the app shell renders.
- Update markdown round-trip tests for the new default and add coverage
  for live stroke streaming and styling-free saves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:25:05 +02:00
Brenno de Winter
47b2555dc5 Fix blank consent screen: drop nested MaterialApp
The consent gate wrapped ConsentDialog in a second MaterialApp with no
localizationsDelegates. That started a fresh Localizations scope without
the AppLocalizations delegate, so context.l10n inside the dialog resolved
to nothing and the consent screen rendered with no text.

Render the consent screen as a plain Scaffold inside the app's existing
MaterialApp, which already supplies both the theme and the localization
delegates. Text now renders in all languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:27:24 +02:00
Brenno de Winter
56932a2dda Add consent screen translations for all 8 languages
Complete localization for privacy/consent features in all supported languages:
- Italian (Italiano)
- German (Deutsch)
- French (Français)
- Spanish (Español)
- Frisian (Frysk)
- Papiamento

Each language block now contains 12 consent-related translations covering:
- Welcome and privacy explanation
- License information
- User agreement text
- Revoke consent option
- Required acceptance message

Consent screens are now fully localized for all 8 supported languages.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-11 14:15:14 +02:00
Brenno de Winter
4f595d1340 Fix: Use correct EUPL 1.2 license instead of MIT
The project uses EUPL v1.2, not MIT. Updated consent dialog to display
the actual EUPL license text and point to the official EUPL license page.

- Changed 'Licentie (MIT)' to 'Licentie (EUPL 1.2)'
- Updated _getLicenseText() with EUPL v1.2 summary
- Fixed license link to https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-11 14:02:24 +02:00
Brenno de Winter
c6190dc31b Add consent/privacy screen at startup and license display
Implement three privacy features:
1. Consent gate at app startup - users must accept privacy terms before using OciDeck
2. License visibility - MIT license displayed in consent dialog
3. Consent revocation - privacy settings tab allows users to withdraw consent and return to consent screen

Changes:
- New ConsentProvider for managing consent state with SharedPreferences persistence
- New ConsentDialog with privacy explanation and MIT license (expandable)
- Added Privacy tab to settings dialog with revoke consent button
- Updated localization strings for Dutch/English consent screens

Consent flow:
- On first launch or after revocation, consent screen blocks app access
- Users can read privacy terms, view license, and accept to proceed
- Consent can be revoked anytime from Settings → Privacy tab
- After revocation, app returns to consent screen on next launch

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-11 14:01:06 +02:00
Brenno de Winter
e86d30e75a Merge branch 'fix/two-bullets-independent-scaling': Afbeeldingenbibliotheek: md5-duplicaten opruimen en filter zonder tags
Duplicaten opruimen (md5) met samenvoegen van tags en opmerkingen en het
omzetten van slideverwijzingen — ook in presentaties op schijf die niet
geopend zijn. Filter om alleen afbeeldingen zonder tags te tonen. De
verwijder-waarschuwing dekt nu ook niet-geopende presentaties. Plus de
eerder uitstaande toegankelijkheids- en tabelplak-verbeteringen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:45:55 +02:00
Brenno de Winter
68725341a7 Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
  file per group (preferring the most-used, then the oldest), merges
  the tags/descriptions and captions of the copies, repoints slides in
  open decks and in .md presentations on disk, and deletes the copies
  after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
  easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
  reference the image (marked "not open"), next to the open decks.

Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
  keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
  the whole grid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
Brenno de Winter
280934d331 Deliver hover events to non-key windows on macOS
Flutter's macOS engine only sends mouse-moved events to the key window
by default. The borderless audience (beamer) window deliberately never
becomes key, so chart tooltips never appeared on the second screen, and
hover styling stuck around whenever a window lost key status before the
exit event arrived. Track the mouse whenever the app is active instead,
for both the main window and every secondary window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:42 +02:00
815f5f2cee Merge pull request 'Checklist clear-all actie, grotere voortgangsgrafiek en leesbaardere bullet auto-fit' (#4) from fix/two-bullets-independent-scaling into main
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
2026-06-09 21:38:10 +00:00
105 changed files with 12414 additions and 6852 deletions

View file

@ -34,3 +34,8 @@ jobs:
# Fail the build if any dependency is not open source.
- name: Licence compliance (make licenses)
run: make licenses
# Fail the build if a vendored JS bundle drifted from its manifest or a
# pinned version has a known vulnerability (queries the OSV database).
- name: Bundled JS security (make deps-check)
run: make deps-check

View file

@ -8,6 +8,27 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- **Presentation timer / rehearsal mode** — the presenter view now doubles as a
rehearsal clock that measures without coaching. A **countdown** runs against a
target time (default under *Settings → General → Presentation*, or set live with
`K` as `MMSS`; `0` turns it off) and turns red when you go over. The clock bar
also shows the time spent on the **current slide**, accumulated per slide across
the run. `R` resets the run (elapsed and per-slide timings, keeping the target).
Leaving the presenter after a run shows a **summary** (total vs. target, time per
slide) with copy-to-clipboard. Session-only: nothing is persisted to disk or the
`.md` file.
- **Duplicate clean-up in the image library** — a footer button finds
byte-identical images by md5 checksum, keeps one file per group (preferring
the one used in slides, then the oldest), merges the tags/descriptions and
captions of the copies onto it, repoints slides that used a copy — in open
decks and in `.md` presentations on disk that are not currently open — and
deletes the copies after confirmation.
- **Untagged-images filter in the image library** — a toggle next to the search
box shows only images without a description/tags, making it easy to see which
ones still need attention.
- **Delete warning covers decks on disk** — deleting an image from the library
now also warns when presentations that are not currently open still
reference it.
- **Source-code slides** — a "code sheet" with per-language syntax highlighting,
stored as a fenced code block. Background, text colour and monospace font are
part of the style profile, with a syntax-colouring toggle; turning it off renders
@ -29,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
Protocol level; slides classified stricter than the level the deck is shown at
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
while the laptop shows the presenter view (current/next slide, notes, timer),
kept in sync over method channels.
@ -37,6 +64,26 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
live to the beamer, and persisted in a `<name>.ink.json` sidecar.
- **App theming** — customizable app appearance profiles, including a dark
interface.
- **Paste a table into a table cell** — pasting a spreadsheet selection (Excel,
Numbers, LibreOffice Calc, Google Sheets), CSV (comma or semicolon), or a
markdown table into any cell of the table editor fills the whole grid from
that cell, growing rows and columns as needed. Works with `Ctrl/Cmd+V` and
`Shift+Insert` on macOS, Windows, and Linux; plain text still pastes into the
single cell.
- **Slide-type chooser previews** — the add-slide dialog shows a miniature
wireframe of each layout (in the spirit of other presentation tools) instead
of an abstract icon, and is fully keyboard-operable (`Tab`/`Enter`/`Esc`).
- **Accessibility (WCAG 2.1)**:
- An **interface text size** setting (100200%, Settings → General →
Accessibility) that scales all editor text; slides themselves keep their
fixed design size.
- The panel divider is focusable and **keyboard-resizable** (arrow keys), with
a visible focus state, and presents itself to screen readers as a slider.
- **Screen-reader support**: slide thumbnails announce one concise label
("Slide 3/12: title") instead of their full content; charts expose their
type, title, and underlying values as a text alternative; the presenter
announces each slide change.
- Improved contrast for hint/label text in the editors.
- Project documentation: contributing guide, security policy, architecture and
build notes, user guide, keyboard-shortcut reference, third-party notices, and
the EUPL-1.2 licence text.
@ -47,11 +94,36 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
separate from the slide title.
- Slide text auto-sizing now measures with the deck's own font, so text grows to
use the available space more accurately instead of staying smaller than needed.
- The two bullet columns now scale **independently**, so a column with few items
is no longer shrunk down to the size of a crowded one beside it.
- The two bullet columns are measured **independently** and then rendered at a
**shared size** set by the busiest column, so the two columns always look
typographically related. Dense two-column slides spend less height on the
title, headings, and gaps so the list items themselves render larger.
- Slide transitions in the presenter no longer flash a black frame (neighbour
images are precached and `gaplessPlayback` is enabled) — important for
recording.
- **Spider/radar charts** now use the available space: axis labels are measured
and placed snugly around the polygon (up to three lines, full remaining
width), so the diagram renders considerably larger and long labels stay
readable instead of being truncated.
- Bullet auto-fit now stops growing at ≈32 pt (on a 16:9 deck) — the upper end
of the 2432 pt range presentation-design guidance recommends for body text —
so slides with few bullets no longer render body text that competes with the
title.
- After resizing the slide panel (dragging the divider or resizing the window),
the list scrolls the slide being edited back into view.
### Fixed
- Hover on charts (tooltips, legend highlight) now works on a second screen:
macOS only delivered mouse-moved events to the key window, so the borderless
beamer window never saw them; the stuck hover state after the pointer left a
window is gone for the same reason.
- Bar-chart x-axis labels could run through each other: the spacing maths now
matches how bar groups are actually laid out, and the final label shrinks to
the real gap when it sits closer than a full step.
- A crash in the slide list ("A _RenderLayoutBuilder was mutated…") when its
keyed items were rebuilt during layout — both the resize-detection inside the
panel and the shell's width computation now avoid LayoutBuilders above the
reorderable list.
## [1.0.0]

View file

@ -1,4 +1,4 @@
.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated licenses check check-full help
.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated deps-check licenses check check-full help
help:
@echo "OciDeck quality targets:"
@ -11,6 +11,7 @@ help:
@echo " make test-services Caption/description/image service tests."
@echo " make test-presenter Fullscreen presenter interaction tests."
@echo " make deps-outdated Advisory dependency freshness report."
@echo " make deps-check Verify vendored JS bundles vs manifest + OSV CVEs."
@echo " make licenses Verify all dependencies use open-source licences."
# Install Flutter/Dart dependencies.
@ -106,6 +107,18 @@ deps-outdated:
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
flutter pub outdated
# Security gate for the vendored JS bundles inlined into the HTML export.
# Verifies each file still matches assets/web_export/MANIFEST.json (sha256) and
# queries the OSV database for known vulnerabilities in the pinned versions.
deps-check:
@echo "== OciDeck check: bundled JavaScript =="
@echo "Command: dart run tool/check_bundled_js.dart"
@echo "Covers: integrity (sha256 vs manifest) + known CVEs (OSV) for marked,"
@echo " highlight.js, DOMPurify, mermaid and MathJax."
@echo "Failure means: a bundle drifted from the manifest, or a pinned version"
@echo " now has a known vulnerability — upgrade it and refresh the manifest."
dart run tool/check_bundled_js.dart
# Open-source licence compliance check for all resolved dependencies.
licenses:
@echo "== OciDeck check: licences =="
@ -120,6 +133,6 @@ check: format-check analyze test
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
# Extended local check with advisory dependency freshness after the required gate.
check-full: check licenses deps-outdated
check-full: check licenses deps-check deps-outdated
@echo "== OciDeck extended check complete =="
@echo "Validated: required quality gate, licence compliance, and dependency freshness."
@echo "Validated: required quality gate, licence compliance, bundled-JS CVEs, and dependency freshness."

View file

@ -12,13 +12,15 @@ 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.
- **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.
- **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.
- **Presentation timer / rehearsal mode** — the presenter view doubles as a rehearsal clock: a countdown against a target time (set in Settings or live with `K`), the time spent on the current slide, and an end-of-run summary (total vs. target and per-slide times, copyable). It measures only — no pacing coaching — and is session-only, never written to disk.
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata.
- **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).
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Accessibility** — WCAG 2.1-oriented: interface text scaling up to 200%, keyboard-operable panel divider and dialogs, screen-reader labels for slides and charts (charts read out their data), and slide-change announcements while presenting.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
@ -69,7 +71,9 @@ lib/
services/ # Markdown, export, file, image, caption, recovery, rasterizer
state/ # Riverpod providers (deck, editor, settings, tabs, clipboard)
widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter
l10n/ # AppLocalizations (8 languages)
theme/ # App theming
utils/ # Small shared helpers (clipboard table parsing, URL launching)
```
State is managed with [Riverpod](https://riverpod.dev/).

View file

@ -38,6 +38,8 @@ OciDeck is an offline desktop application. Areas of particular interest:
- Importing presentations from a URL.
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
mermaid, MathJax) to render offline.
- The export classification gate (`ClassificationPolicy`) — any way to export a
deck classified above the configured release ceiling.
## Supported versions

View file

@ -14,10 +14,16 @@ Shipped inside the app and embedded into the **offline HTML export**
| --- | --- | --- |
| [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT |
| [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause |
| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT (bundles [DOMPurify](https://github.com/cure53/DOMPurify), Apache-2.0 / MPL-2.0) |
| [DOMPurify](https://github.com/cure53/DOMPurify) | Sanitises the rendered Markdown before it hits the DOM in the export | Apache-2.0 / MPL-2.0 |
| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT |
| [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 |
| [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 |
The exact pinned version, source URL and SHA-256 of every vendored JS bundle
live in [`assets/web_export/MANIFEST.json`](assets/web_export/MANIFEST.json).
`make deps-check` verifies each file still matches that manifest and queries the
[OSV](https://osv.dev) database for known vulnerabilities.
## Vendored (forked) plugins
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /

View file

@ -1,9 +1,23 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// Release signing is read from android/key.properties (kept out of version
// control). When it is absent we fall back to the debug key so that
// `flutter run --release` keeps working during development — but a build meant
// for distribution must provide a real keystore here.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.example.ocideck"
compileSdk = flutter.compileSdkVersion
@ -25,11 +39,27 @@ android {
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Use the real release keystore when configured; otherwise fall back
// to the debug key so `flutter run --release` still works locally.
// Do NOT distribute a build signed with the debug key.
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}

View file

@ -0,0 +1,46 @@
{
"_comment": "Pinned inventory of the vendored JavaScript bundles inlined into the offline HTML export (see lib/services/marp_html_service.dart). Each entry records the npm package + exact version so `make deps-check` can query the OSV vulnerability database, and a sha256 so the same check can prove the on-disk file still matches this manifest (tamper / accidental-replacement guard). When you intentionally upgrade a bundle, update its version, source and sha256 here in the same commit.",
"ecosystem": "npm",
"bundles": [
{
"file": "marked.min.js",
"npm": "marked",
"version": "18.0.5",
"sha256": "2dc4769dfde29f51c7aca1a539c6407c789c8ea644cf8b7d01ded28a9c1d800b",
"source": "https://cdn.jsdelivr.net/npm/marked@18.0.5/lib/marked.umd.js",
"license": "MIT"
},
{
"file": "highlight.min.js",
"npm": "highlight.js",
"version": "11.11.1",
"sha256": "c4a399dd6f488bc97a3546e3476747b3e714c99c57b9473154c6fb8d259b9381",
"source": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js",
"license": "BSD-3-Clause"
},
{
"file": "purify.min.js",
"npm": "dompurify",
"version": "3.4.9",
"sha256": "3c16cc90eb152b823b71b8585cd79e7fb7cd7a380157a800dfbd9459aad5f726",
"source": "https://cdn.jsdelivr.net/npm/dompurify@3.4.9/dist/purify.min.js",
"license": "Apache-2.0 OR MPL-2.0"
},
{
"file": "mermaid.min.js",
"npm": "mermaid",
"version": "10.9.6",
"sha256": "eda3a0ad572bbe69a318c1be0163e8233dd824f3f12939e5168feba207767151",
"source": "https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js",
"license": "MIT"
},
{
"file": "tex-svg.js",
"npm": "mathjax",
"version": "3.2.2",
"sha256": "d4295dc33744836935c1399feece5159577b34c5c8ffb9f1c6324cd82e03a882",
"source": "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js",
"license": "Apache-2.0"
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
assets/web_export/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -15,12 +15,15 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
```
lib/
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
services/ # markdown, file, export, image, caption, description,
# recovery, rasterizer, marp_html, annotation_codec
services/ # markdown, file, export, classification_policy, image, caption,
# description, image_dedup (md5 duplicates),
# image_reference (.md rewrites), recovery, rasterizer,
# marp_html, annotation_codec, rehearsal_controller
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
l10n/ # AppLocalizations (8 languages)
theme/ # app theming
utils/ # small shared helpers (clipboard table parsing, URL launching)
```
## Data model
@ -64,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
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
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
@ -72,6 +84,13 @@ the key thing to understand before touching rendering:
and the **annotation tools** (pen/highlighter/eraser/laser).
- Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide
changes never flash black (important for screen recording).
- **Rehearsal timing** lives in `services/rehearsal_controller.dart` — a plain,
unit-testable controller (injectable clock) that the presenter feeds via a
cheap, idempotent `observe(id, index)` on every build, so it captures every
navigation path. It measures only: elapsed, remaining against a target, and
per-slide time — no pacing logic. State is **session-only** (no prefs, no `.md`);
`_exit` shows a summary (`rehearsal_summary.dart`) and discards it. The default
target lives in `AppSettings.presentationTargetSeconds`.
### Dual-screen mode
@ -90,10 +109,12 @@ hence the vendored multi-window fork below.
## Sidecars (separate layers)
To keep the `.md` pure Marp, three kinds of data live beside it (see
To keep the `.md` pure Marp, four kinds of data live beside it (see
`FILE_FORMAT.md` §6):
- **Captions**`.ocideck_captions.json` (per image, in `images/`).
- **Descriptions/tags**`.ocideck_descriptions.json` (searchable image
metadata, used by the library's search and the untagged filter).
- **Annotations**`<name>.ink.json` (`services/annotation_codec.dart`).
- **Linked chart data**`data/*.csv` (the living source for a chart).
@ -105,8 +126,12 @@ Two upstream plugins are forked into `third_party/` and wired via `pubspec.yaml`
- **`desktop_multi_window`** (MixinNetwork) — published 0.3.0 dropped the native
window-geometry API. The fork adds macOS `window_setFrame`,
`window_coverScreen` (borderless fill of a chosen screen), and `window_close`,
exposed on `WindowController`. This is what makes the dual-screen audience
window possible.
exposed on `WindowController`. It also tracks the mouse for **non-key
windows** (matched by `macos/Runner/MainFlutterWindow.swift` for the main
window): macOS only delivers mouse-moved events to the key window by default,
and the borderless audience window deliberately never becomes key, so chart
tooltips and hover states would otherwise never appear on the beamer. This is
what makes the dual-screen audience window possible.
- **`screen_retriever_macos`** (leanflutter) — a packaging fix for recent
Xcode/CocoaPods.

View file

@ -382,6 +382,13 @@ Bijschriften worden op **twee** plaatsen bewaard:
```
Een lege caption verwijdert de sleutel; een leeg bestand wordt verwijderd.
Naast de captions bestaat er een tweede, gelijkvormige sidecar
`.ocideck_descriptions.json` voor **beschrijvingen/tags**: doorzoekbare
vrije tekst per afbeelding, gebruikt door het zoekveld en het
"zonder tags"-filter van de afbeeldingenbibliotheek (en samengevoegd bij het
opruimen van md5-duplicaten). Zelfde formaat en dezelfde leeg-opruimregels als
de captions-sidecar.
### 6.2 Annotatielaag (`<naam>.ink.json`)
Vrije-hand-annotaties (pen, markeerstift) die tijdens het presenteren worden

View file

@ -12,6 +12,11 @@
| `Ctrl/Cmd + Shift + Z` | Redo |
| `Ctrl + Y` | Redo (alternative) |
| `Ctrl/Cmd + H` | Find & replace |
| `Ctrl/Cmd + V` (in a table cell) | Paste a spreadsheet/CSV/markdown selection as a table (also `Shift + Insert`) |
| `Tab` to the panel divider, then `←` / `→` | Resize the slide panel |
In the **add-slide dialog**, `Tab` moves between the type cards, `Enter` picks
the focused one, and `Esc` cancels.
## Fullscreen presenter
@ -30,10 +35,11 @@ View & timing:
| Shortcut | Action |
| --- | --- |
| `P` | Toggle presenter view (notes, clock, next slide) |
| `P` | Toggle presenter view (notes, clock, countdown, per-slide timer, next slide) |
| `S` | Move the presentation to another screen |
| `B` · `W` | Black · white screen |
| `R` | Reset the elapsed-time counter |
| `K` | Set the target time / countdown (type `MMSS`, `Enter` to confirm, `0` = off) |
| `R` | Reset the time & rehearsal run (elapsed and per-slide timings; the target stays) |
| `A` | Auto-advance on/off |
| `L` | Loop (restart after the last slide) on/off |
| `M` | Advance automatically after a slide's audio finishes |

View file

@ -21,8 +21,10 @@ Marp tools.
Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
**audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or
spider/radar), and **free Markdown**. Each type has a dedicated editor on the left
and a live preview on the right.
spider/radar), and **free Markdown**. Each card in the chooser shows a miniature
wireframe of the layout, and the dialog works entirely with the keyboard
(`Tab`/`Enter` to choose, `Esc` to cancel). Each type has a dedicated editor on
the left and a live preview on the right.
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
`[links](…)`). Free-Markdown slides also render fenced code with syntax
@ -38,6 +40,16 @@ green on black for a classic CRT-terminal look. The text is sized to fill the
panel — larger when there's room, smaller for long fragments. Stored as a fenced
code block in the Markdown.
### Tables
The first row is the header. Press `Enter` inside a cell for a new line within
that cell. To bring in existing data, **paste a table into any cell** with
`Ctrl/Cmd+V` (or `Shift+Insert`): a selection copied from a spreadsheet (Excel,
Numbers, LibreOffice Calc, Google Sheets), CSV text (comma- or
semicolon-separated), or a markdown table fills the grid from that cell onward,
adding rows and columns as needed. Ordinary text — even a sentence with a comma
in it — still pastes into just the one cell.
### Charts
Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then
@ -56,10 +68,34 @@ row/column. Each series and (for pie/radar) each label can be given its own colo
- **Reading values** — hovering a legend entry highlights its series (or pie
slice). On a line chart the tooltip belongs to the dot under the cursor and
shows every overlapping dot at once; on a spider/radar chart hovering a point
shows its value in a tooltip too.
shows its value in a tooltip too. For screen readers every chart also carries
a text alternative with its type, title, and the values per series.
- Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
HTML export.
## Image library
Image fields open a library that shows every image found in the deck's
directories, with a grid and a coverflow view, search, and a preview pane. Per
image you can store a **caption** (source/credit line, shown on the slide) and a
searchable **description** — in practice your tags. The search box matches file
names and descriptions.
- **Filter untagged images** — the label toggle next to the search box shows
only images that have no description/tags yet, so you can see at a glance
which ones still need attention.
- **Clean up duplicates** — the button in the footer finds byte-identical
images by md5 checksum. Per group one file is kept (preferring the one used
in slides, then the oldest), tags and captions of the copies are merged onto
it, slides that referenced a copy are repointed to the kept file, and the
copies are deleted — after a confirmation that lists exactly what will
happen. References are updated in the open decks *and* in `.md`
presentations found on disk in the search directories, so presentations
that are not currently open keep working too.
- **Deleting an image** warns when it is still in use — in open decks (per
slide) and in presentations on disk that are not currently open (per file,
marked "not open").
## Per-slide options
Below each editor you can set:
@ -78,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
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
Start the fullscreen presenter from the toolbar. See
[`SHORTCUTS.md`](SHORTCUTS.md) for the full key list; highlights: arrows to move,
`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `H` for the
in-app cheatsheet.
`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `K` for the
countdown, `R` to reset the timing, `H` for the in-app cheatsheet.
### Rehearsing and timing
The presenter view (`P`) is also a rehearsal clock — it measures, it does not
nag. The clock bar shows four things:
- **Elapsed** — time since the run started (or since the last `R`).
- **Remaining** — a countdown against a **target time**. It turns red and shows a
minus sign once you go over; there is no "speed up" coaching, just the number.
- **This slide** — how long you have spent on the current slide. Time accumulates
per slide across the whole run, even if you jump back and forth.
- **Clock** — the wall-clock time.
Set the target time up front under *Settings → General → Presentation*, or change
it live while presenting with **`K`** (type the minutes and seconds as `MMSS`,
`Enter` to confirm, `0` to switch the countdown off). **`R`** resets the run —
elapsed time and per-slide timings — while keeping the target.
When you leave the presenter after a run of at least ten seconds, a **summary**
shows the total time against the target and the time spent per slide, with a
button to copy the times to the clipboard. This is **session-only**: nothing is
written to disk or into the `.md` file.
### Two screens (beamer + laptop)
@ -112,6 +174,26 @@ Export to:
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
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
OciDeck aims for WCAG 2.1 in the editor:
- **Interface text size** — Settings → General → Accessibility offers 100200%
text scaling for the whole editing environment, on top of what the operating
system asks for. Slides keep their fixed 16:9 design size, so what you see is
still exactly what you present and export.
- **Keyboard** — the panel divider between the slide list and the editor can be
focused with `Tab` and resized with `←`/`→`; the add-slide dialog is fully
keyboard-operable.
- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12:
title", including the skipped state), charts read out their data as a text
alternative, and the fullscreen presenter announces every slide change.
## Theming and language
- **Style profiles** control deck colours (including the source-code background,

View file

@ -3,8 +3,10 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'l10n/app_localizations.dart';
import 'state/settings_provider.dart';
import 'state/consent_provider.dart';
import 'theme/app_theme.dart';
import 'widgets/app_shell.dart';
import 'widgets/dialogs/consent_dialog.dart';
class OciDeckApp extends ConsumerWidget {
const OciDeckApp({super.key});
@ -17,11 +19,28 @@ class OciDeckApp extends ConsumerWidget {
final appearance = ref.watch(
settingsProvider.select((s) => s.appAppearanceProfile),
);
final uiTextScale = ref.watch(
settingsProvider.select((s) => s.uiTextScale),
);
AppLocalizations.setActiveLanguageCode(languageCode);
return MaterialApp(
title: 'OciDeck',
theme: AppTheme.fromProfile(appearance),
debugShowCheckedModeBanner: false,
// Interface text scaling (WCAG 1.4.4): the user's setting multiplies
// whatever the OS already asks for. Slides themselves opt out they
// are a fixed design canvas (see SlidePreviewWidget).
builder: (context, child) {
final media = MediaQuery.of(context);
return MediaQuery(
data: media.copyWith(
textScaler: TextScaler.linear(
(media.textScaler.scale(1.0) * uiTextScale).clamp(1.0, 2.0),
),
),
child: child!,
);
},
locale: AppLocalizations.materialLocaleFor(languageCode),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: const [
@ -30,7 +49,30 @@ class OciDeckApp extends ConsumerWidget {
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const AppShell(),
home: const _ConsentGate(),
);
}
}
class _ConsentGate extends ConsumerWidget {
const _ConsentGate();
@override
Widget build(BuildContext context, WidgetRef ref) {
final consent = ref.watch(consentProvider);
if (consent.isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (!consent.hasAccepted) {
// Plain Scaffold inside the app's existing MaterialApp — that one already
// supplies the theme and the AppLocalizations delegate, so context.l10n
// resolves here. A nested MaterialApp would start a fresh Localizations
// scope without our delegate and the consent text would render blank.
return const Scaffold(body: Center(child: ConsentDialog()));
}
return const AppShell();
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import '../utils/log.dart';
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
/// data files stay tidily in one place separate from images/media.
const String chartDataDirName = 'data';
@ -155,7 +157,8 @@ class ChartSpec {
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
],
);
} catch (_) {
} catch (e, s) {
logError('ChartSpec.parse: decode chart JSON block', e, s);
return const ChartSpec();
}
}

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

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

View file

@ -8,6 +8,7 @@ class ThemeProfile {
final bool checklistStrikeThrough;
final String tableTextColor;
final String tableHeaderTextColor;
final String tableHeaderBackgroundColor;
final String titleBackgroundColor;
final String titleTextColor;
final String sectionBackgroundColor;
@ -58,6 +59,7 @@ class ThemeProfile {
this.checklistStrikeThrough = true,
String? tableTextColor,
this.tableHeaderTextColor = '#FFFFFF',
String? tableHeaderBackgroundColor,
this.titleBackgroundColor = '#1C2B47',
this.titleTextColor = '#FFFFFF',
this.sectionBackgroundColor = '#2E7D64',
@ -74,7 +76,9 @@ class ThemeProfile {
this.footerPosition = 'right',
this.closingSlideEnabled = false,
this.closingSlideMarkdown = '# Bedankt\n\nVragen?',
}) : tableTextColor = tableTextColor ?? textColor;
}) : tableTextColor = tableTextColor ?? textColor,
tableHeaderBackgroundColor =
tableHeaderBackgroundColor ?? accentColor;
static const logoPositions = [
'top-left',
@ -95,6 +99,7 @@ class ThemeProfile {
bool? checklistStrikeThrough,
String? tableTextColor,
String? tableHeaderTextColor,
String? tableHeaderBackgroundColor,
String? titleBackgroundColor,
String? titleTextColor,
String? sectionBackgroundColor,
@ -126,6 +131,8 @@ class ThemeProfile {
checklistStrikeThrough ?? this.checklistStrikeThrough,
tableTextColor: tableTextColor ?? this.tableTextColor,
tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor,
tableHeaderBackgroundColor:
tableHeaderBackgroundColor ?? this.tableHeaderBackgroundColor,
titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor,
titleTextColor: titleTextColor ?? this.titleTextColor,
sectionBackgroundColor:
@ -158,6 +165,7 @@ class ThemeProfile {
'checklistStrikeThrough': checklistStrikeThrough,
'tableTextColor': tableTextColor,
'tableHeaderTextColor': tableHeaderTextColor,
'tableHeaderBackgroundColor': tableHeaderBackgroundColor,
'titleBackgroundColor': titleBackgroundColor,
'titleTextColor': titleTextColor,
'sectionBackgroundColor': sectionBackgroundColor,
@ -197,6 +205,10 @@ class ThemeProfile {
'#222222',
tableHeaderTextColor:
json['tableHeaderTextColor'] as String? ?? '#FFFFFF',
tableHeaderBackgroundColor:
json['tableHeaderBackgroundColor'] as String? ??
json['accentColor'] as String? ??
'#2E7D64',
titleBackgroundColor:
json['titleBackgroundColor'] as String? ?? '#1C2B47',
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
@ -364,6 +376,21 @@ class AppSettings {
final String selectedAppAppearanceProfileName;
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.02.0), on top of the system
/// 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%.
final double uiTextScale;
/// Standaard doeltijd (in seconden) voor de aftelling/oefenklok in de
/// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K).
final int presentationTargetSeconds;
const AppSettings({
this.languageCode = 'nl',
this.homeDirectory,
@ -373,6 +400,9 @@ class AppSettings {
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [],
this.maxReleaseExportTlpKey,
this.uiTextScale = 1.0,
this.presentationTargetSeconds = 0,
});
ThemeProfile get themeProfile {
@ -424,8 +454,12 @@ class AppSettings {
List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName,
List<String>? recentFiles,
String? maxReleaseExportTlpKey,
double? uiTextScale,
int? presentationTargetSeconds,
bool clearHomeDirectory = false,
bool clearExportDirectory = false,
bool clearMaxReleaseExportTlp = false,
}) {
final nextProfiles = themeProfiles ?? this.themeProfiles;
return AppSettings(
@ -457,6 +491,12 @@ class AppSettings {
selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles,
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
? null
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
uiTextScale: uiTextScale ?? this.uiTextScale,
presentationTargetSeconds:
presentationTargetSeconds ?? this.presentationTargetSeconds,
);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import '../models/annotation.dart';
import '../models/slide.dart';
import '../utils/log.dart';
/// Serializes the annotation layer into a sidecar payload that is fully
/// decoupled from the Marp markdown.
@ -96,7 +97,8 @@ class AnnotationCodec {
used.add(target);
result[slides[target].id] = strokes;
}
} catch (_) {
} catch (e, s) {
logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s);
return {};
}
return result;

View file

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
/// Bestandsnaam: .ocideck_captions.json
class CaptionService {
@ -17,7 +19,8 @@ class CaptionService {
final data = jsonDecode(await file.readAsString()) as Map;
final caption = data[p.basename(resolvedPath)];
return caption is String ? caption : null;
} catch (_) {
} catch (e) {
logWarning('CaptionService.getCaption: read caption sidecar', e);
return null;
}
}
@ -36,7 +39,9 @@ class CaptionService {
data = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map,
);
} catch (_) {}
} catch (e, s) {
logError('CaptionService.saveCaption: parse existing sidecar', e, s);
}
}
final key = p.basename(resolvedPath);
if (caption.trim().isEmpty) {

View 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();
}
}

View file

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Stores short, searchable image descriptions as a JSON sidecar in the image's
/// own directory. File name: .ocideck_descriptions.json, keyed by base name.
///
@ -19,7 +21,11 @@ class DescriptionService {
final data = jsonDecode(await file.readAsString()) as Map;
final value = data[p.basename(imagePath)];
return value is String ? value : null;
} catch (_) {
} catch (e) {
logWarning(
'DescriptionService.getDescription: read description sidecar',
e,
);
return null;
}
}
@ -33,7 +39,13 @@ class DescriptionService {
data = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map,
);
} catch (_) {}
} catch (e, s) {
logError(
'DescriptionService.saveDescription: parse existing sidecar',
e,
s,
);
}
}
final key = p.basename(imagePath);
if (description.trim().isEmpty) {
@ -71,7 +83,9 @@ class DescriptionService {
result[p.join(dir, entry.key as String)] = entry.value as String;
}
}
} catch (_) {}
} catch (e) {
logWarning('DescriptionService.loadFor: read description sidecar', e);
}
}
return result;
}

View file

@ -8,7 +8,9 @@ import 'package:path/path.dart' as p;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import '../models/deck.dart';
import '../models/settings.dart';
import 'classification_policy.dart';
import 'marp_html_service.dart';
enum ExportFormat { pdf, pptx, html }
@ -103,7 +105,17 @@ class ExportService {
List<String>? notes,
String? markdown,
ThemeProfile? themeProfile,
TlpLevel tlp = TlpLevel.none,
ClassificationPolicy policy = const ClassificationPolicy(),
}) 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 (markdown == null || markdown.trim().isEmpty) {
return ExportResult.fail('Geen inhoud om te exporteren.');

View file

@ -10,6 +10,7 @@ import '../l10n/app_localizations.dart';
import '../models/settings.dart';
import '../models/chart.dart';
import '../models/slide.dart';
import '../utils/log.dart';
import 'annotation_codec.dart';
import 'caption_service.dart';
import 'image_service.dart';
@ -58,6 +59,25 @@ class FileService {
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
/// The user's active style profile, resolved for [projectPath]. Styling is no
/// longer read from the markdown (the file holds only content); the app
/// applies the current profile whenever a deck is opened.
ThemeProfile activeProfileFor({String? projectPath}) =>
resolveThemeProfile(_themeProfile(), projectPath: projectPath);
/// Resolve a project-relative [path] to an absolute path strictly inside
/// [projectPath], or null for absolute paths or `../` escapes. Used for file
/// references an untrusted deck controls (e.g. a chart's linked CSV) so it
/// can't read arbitrary files outside its own folder.
static String? _projectFile(String? projectPath, String path) {
if (projectPath == null || path.trim().isEmpty || p.isAbsolute(path)) {
return null;
}
final abs = p.normalize(p.join(projectPath, path));
if (abs != projectPath && !p.isWithin(projectPath, abs)) return null;
return abs;
}
ThemeProfile resolveThemeProfile(
ThemeProfile profile, {
String? projectPath,
@ -106,7 +126,11 @@ class FileService {
List<FileSystemEntity> entries;
try {
entries = await dir.list(followLinks: false).toList();
} catch (_) {
} catch (e) {
logWarning(
'FileService.scanPresentations: directory listing failed',
e,
);
return;
}
for (final entity in entries) {
@ -118,7 +142,8 @@ class FileService {
String content;
try {
content = await entity.readAsString();
} catch (_) {
} catch (e) {
logWarning('FileService.scanPresentations: file not readable', e);
continue;
}
final deck = await openDeck(entity.path, content: content);
@ -167,8 +192,12 @@ class FileService {
if (!await file.exists()) return null;
raw = await file.readAsString();
}
final deck = _md.parseDeck(raw, filePath: filePath);
if (deck == null) return null;
final parsed = _md.parseDeck(raw, filePath: filePath);
if (parsed == null) return null;
// The file carries only content; apply the active style profile on open.
final deck = parsed.copyWith(
themeProfile: activeProfileFor(projectPath: parsed.projectPath),
);
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
// Re-attach the separate annotation layer from its sidecar, if present.
if (content == null) {
@ -180,8 +209,9 @@ class FileService {
hydrated.slides,
);
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
} catch (_) {
} catch (e) {
// A broken sidecar must never block opening the deck.
logWarning('FileService.openDeck: annotation sidecar unreadable', e);
}
}
}
@ -219,11 +249,11 @@ class FileService {
slides.add(s);
continue;
}
final abs = p.isAbsolute(spec.source!)
? spec.source!
: p.join(deck.projectPath!, spec.source!);
final file = File(abs);
if (!await file.exists()) {
// A chart's CSV link must stay inside the project (no absolute paths or
// `../` escapes) otherwise an untrusted deck could read arbitrary files.
final abs = _projectFile(deck.projectPath, spec.source!);
final file = abs == null ? null : File(abs);
if (file == null || !await file.exists()) {
slides.add(s);
continue;
}
@ -231,7 +261,8 @@ class FileService {
final csv = await file.readAsString();
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
changed = true;
} catch (_) {
} catch (e) {
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
slides.add(s);
}
}
@ -315,9 +346,18 @@ class FileService {
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
String? addAsset(String path, String subdir) {
if (path.trim().isEmpty) return null;
final abs = p.isAbsolute(path)
? path
: (deck.projectPath != null ? p.join(deck.projectPath!, path) : path);
final String abs;
if (p.isAbsolute(path)) {
// Absolute paths come from the picker (the user explicitly chose them).
abs = path;
} else if (deck.projectPath != null) {
// A relative asset must not escape the project via `../`.
final resolved = _projectFile(deck.projectPath, path);
if (resolved == null) return null;
abs = resolved;
} else {
abs = path;
}
final file = File(abs);
if (!file.existsSync()) return null;
final rel = p.posix.join(subdir, p.basename(abs));
@ -405,7 +445,8 @@ class FileService {
profile,
logoRel == null ? null : '../$logoRel',
);
} catch (_) {
} catch (e) {
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
return null;
}
}
@ -419,7 +460,8 @@ class FileService {
final Archive archive;
try {
archive = ZipDecoder().decodeBytes(zipBytes);
} catch (_) {
} catch (e, s) {
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
return null;
}
@ -438,14 +480,33 @@ class FileService {
final destDir = _uniqueDir(destParentDir, folderName);
await destDir.create(recursive: true);
for (final f in archive.files) {
if (!f.isFile) continue;
final out = File(p.join(destDir.path, f.name));
await out.parent.create(recursive: true);
await out.writeAsBytes(f.content as List<int>, flush: true);
// Resolve an archive entry name to a path strictly inside [destDir], or
// null when it would escape (zip-slip: `../`, absolute paths, ).
String? safeOutPath(String entryName) {
final resolved = p.normalize(p.join(destDir.path, entryName));
if (resolved != destDir.path && !p.isWithin(destDir.path, resolved)) {
return null;
}
return resolved;
}
return p.join(destDir.path, mdEntry.name);
var extracted = 0;
for (final f in archive.files) {
if (!f.isFile) continue;
final outPath = safeOutPath(f.name);
if (outPath == null) continue; // skip path-traversal entries
final content = f.content as List<int>;
// Bound total extracted size so a small zip can't fill the disk (zip bomb).
extracted += content.length;
if (extracted > _maxDownloadBytes) break;
final out = File(outPath);
await out.parent.create(recursive: true);
await out.writeAsBytes(content, flush: true);
}
// The main markdown must itself resolve inside the extraction folder.
final mdPath = safeOutPath(mdEntry.name);
return mdPath;
}
Directory _uniqueDir(String parent, String name) {
@ -461,26 +522,65 @@ class FileService {
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
/// markdown-bestand terug.
/// Cap on how much we download / extract, to bound memory and disk use.
static const _maxDownloadBytes = 64 * 1024 * 1024; // 64 MB
/// Hosts an import must never reach (loopback, private and link-local ranges)
/// so a deck URL can't be used to probe the local machine or intranet (SSRF).
static bool _isBlockedHost(String host) {
final h = host.toLowerCase();
if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true;
final addr = InternetAddress.tryParse(host);
if (addr == null) return false; // a hostname; can't classify offline
if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true;
final raw = addr.rawAddress;
if (addr.type == InternetAddressType.IPv4) {
final a = raw[0], b = raw[1];
if (a == 0 || a == 10 || a == 127) {
return true; // this-host/private/loopback
}
if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a == 192 && b == 168) return true; // 192.168.0.0/16
if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local
} else if ((raw[0] & 0xfe) == 0xfc) {
return true; // fc00::/7 unique-local
}
return false;
}
Future<String?> importFromUrl(String url, String destParentDir) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !uri.hasScheme) return null;
// Only fetch over web schemes, and never reach private/loopback hosts.
final scheme = uri.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') return null;
if (_isBlockedHost(uri.host)) return null;
final List<int> bytes;
try {
final client = HttpClient();
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 15);
try {
final request = await client.getUrl(uri);
final response = await request.close();
// Don't auto-follow redirects: a 3xx could point at a private host and
// bypass the SSRF check above.
request.followRedirects = false;
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != 200) return null;
if (response.contentLength > _maxDownloadBytes) return null;
final builder = BytesBuilder(copy: false);
await for (final chunk in response) {
builder.add(chunk);
if (builder.length > _maxDownloadBytes) return null; // runaway body
}
bytes = builder.takeBytes();
} finally {
client.close(force: true);
}
} catch (_) {
} catch (e) {
logError('FileService.importFromUrl: download failed', e);
return null;
}
@ -499,7 +599,8 @@ class FileService {
final String markdown;
try {
markdown = utf8.decode(bytes);
} catch (_) {
} catch (e, s) {
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
return null;
}
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
@ -620,8 +721,9 @@ class FileService {
'assets/themes/ocideck.css',
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
} catch (_) {
} catch (e) {
// Asset not bundled in this build context; skip
logWarning('FileService._writeTheme: theme asset not bundled', e);
}
}
@ -781,7 +883,7 @@ th, td {
}
thead th, tr:first-child th {
background: ${profile.accentColor};
background: ${profile.tableHeaderBackgroundColor};
color: ${profile.tableHeaderTextColor};
}
$logoCss

View file

@ -0,0 +1,103 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/log.dart';
/// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum,
/// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op
/// grootte gegroepeerd; alleen gelijke groottes worden daadwerkelijk gehasht,
/// dus grote bibliotheken blijven snel.
class ImageDedupService {
/// Groepeer [imagePaths] op identieke inhoud (md5). Elke teruggegeven groep
/// bevat twee of meer paden naar byte-voor-byte gelijke bestanden, in de
/// volgorde waarin ze in [imagePaths] stonden. Onleesbare bestanden worden
/// stilletjes overgeslagen.
Future<List<List<String>>> findDuplicateGroups(
Iterable<String> imagePaths,
) async {
// Stap 1: op bestandsgrootte groeperen verschillend groot is nooit gelijk.
final bySize = <int, List<String>>{};
for (final path in imagePaths) {
try {
final size = File(path).statSync().size;
bySize.putIfAbsent(size, () => []).add(path);
} catch (e) {
logWarning('ImageDedupService.findDuplicateGroups: stat for size', e);
}
}
// Stap 2: alleen binnen gelijke groottes de md5 berekenen.
final groups = <List<String>>[];
for (final candidates in bySize.values) {
if (candidates.length < 2) continue;
final byHash = <String, List<String>>{};
for (final path in candidates) {
try {
final digest = await md5.bind(File(path).openRead()).single;
byHash.putIfAbsent(digest.toString(), () => []).add(path);
} catch (e) {
logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e);
}
}
for (final group in byHash.values) {
if (group.length >= 2) groups.add(group);
}
}
return groups;
}
/// Kies binnen een groep duplicaten het pad dat behouden blijft. Voorkeur:
/// het meest in slides gebruikte bestand, daarna het oudste (vermoedelijk
/// het origineel), daarna de volgorde in de groep.
String chooseKeeper(
List<String> group, {
int Function(String path)? usageCountOf,
}) {
DateTime modifiedOf(String path) {
try {
return File(path).statSync().modified;
} catch (e) {
logWarning('ImageDedupService.chooseKeeper: stat modified time', e);
return DateTime.fromMillisecondsSinceEpoch(0);
}
}
var keeper = group.first;
var keeperUsages = usageCountOf?.call(keeper) ?? 0;
var keeperModified = modifiedOf(keeper);
for (final candidate in group.skip(1)) {
final usages = usageCountOf?.call(candidate) ?? 0;
final modified = modifiedOf(candidate);
final wins =
usages > keeperUsages ||
(usages == keeperUsages && modified.isBefore(keeperModified));
if (wins) {
keeper = candidate;
keeperUsages = usages;
keeperModified = modified;
}
}
return keeper;
}
/// Voeg metadata-teksten (tags/beschrijvingen of opmerkingen/captions) van
/// duplicaten samen tot één waarde: unieke, niet-lege teksten gescheiden
/// door [separator]. Een tekst die al letterlijk in een eerdere voorkomt
/// (zoals dezelfde tag op beide duplicaten) wordt niet herhaald.
String mergeMetadata(Iterable<String?> values, {String separator = ' · '}) {
final merged = <String>[];
for (final value in values) {
final text = value?.trim() ?? '';
if (text.isEmpty) continue;
final isDuplicate = merged.any(
(existing) => existing.toLowerCase().contains(text.toLowerCase()),
);
if (!isDuplicate) merged.add(text);
}
return merged.join(separator);
}
}
final imageDedupServiceProvider = Provider<ImageDedupService>(
(_) => ImageDedupService(),
);

View file

@ -0,0 +1,176 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Vindt en herschrijft afbeeldingsverwijzingen (`![](pad)`) in
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
/// ook presentaties mee die nu niet geopend zijn.
class ImageReferenceService {
/// Zelfde mappen als FileService.scanPresentations overslaat.
static const _ignoredDirs = {
'images',
'logos',
'themes',
'node_modules',
'build',
'.git',
'.dart_tool',
};
static const _maxDepth = 4;
/// Markdown-afbeelding: `![alt of bg-directive](pad)`.
static final _imageRef = RegExp(r'!\[([^\]]*)\]\(([^)\n]+)\)');
/// Zoek recursief alle `.md`-bestanden onder [searchDirs] (begrensd op
/// diepte, asset- en verborgen mappen worden overgeslagen). Dubbele treffers
/// via overlappende zoekpaden worden één keer teruggegeven.
Future<List<String>> findDeckFiles(Iterable<String> searchDirs) async {
final found = <String>{};
Future<void> walk(Directory dir, int depth) async {
List<FileSystemEntity> entries;
try {
entries = await dir.list(followLinks: false).toList();
} catch (e) {
logWarning('ImageReferenceService.findDeckFiles: list directory', e);
return;
}
for (final entity in entries) {
if (entity is File) {
if (entity.path.toLowerCase().endsWith('.md')) {
found.add(p.normalize(entity.path));
}
} else if (entity is Directory && depth < _maxDepth) {
final name = p.basename(entity.path);
if (_ignoredDirs.contains(name) || name.startsWith('.')) continue;
await walk(entity, depth + 1);
}
}
}
for (final dirPath in searchDirs) {
if (dirPath.isEmpty) continue;
final root = Directory(dirPath);
if (!root.existsSync()) continue;
await walk(root, 0);
}
return found.toList();
}
/// Tel per pad uit [targets] hoe vaak het in [deckFiles] wordt genoemd.
/// Paden in de markdown worden opgelost relatief aan de map van het
/// `.md`-bestand. Paden zonder verwijzingen ontbreken in het resultaat.
Future<Map<String, int>> countReferences(
Iterable<String> deckFiles,
Iterable<String> targets,
) async {
final wanted = {for (final t in targets) p.normalize(t)};
final counts = <String, int>{};
for (final deckFile in deckFiles) {
String content;
try {
content = await File(deckFile).readAsString();
} catch (e) {
logWarning('ImageReferenceService.countReferences: read deck file', e);
continue;
}
final mdDir = p.dirname(deckFile);
for (final match in _imageRef.allMatches(content)) {
final resolved = _resolve(match.group(2)!, mdDir);
if (resolved == null) continue;
for (final target in wanted) {
if (p.equals(resolved, target)) {
counts[target] = (counts[target] ?? 0) + 1;
break;
}
}
}
}
return counts;
}
/// Per deckbestand: hoe vaak [target] erin wordt genoemd. Bestanden zonder
/// treffer ontbreken in het resultaat. Gebruikt voor de waarschuwing bij
/// verwijderen, zodat ook niet-geopende presentaties zichtbaar zijn.
Future<Map<String, int>> referencingFiles(
Iterable<String> deckFiles,
String target,
) async {
final wanted = p.normalize(target);
final result = <String, int>{};
for (final deckFile in deckFiles) {
String content;
try {
content = await File(deckFile).readAsString();
} catch (e) {
logWarning('ImageReferenceService.referencingFiles: read deck file', e);
continue;
}
final mdDir = p.dirname(deckFile);
var count = 0;
for (final match in _imageRef.allMatches(content)) {
final resolved = _resolve(match.group(2)!, mdDir);
if (resolved != null && p.equals(resolved, wanted)) count++;
}
if (count > 0) result[deckFile] = count;
}
return result;
}
/// Herschrijf in [deckFile] elke verwijzing naar [fromAbsolute] zodat die
/// naar [toAbsolute] wijst. Alleen het pad binnen `![]()` verandert; de
/// rest van het bestand blijft byte-voor-byte gelijk. Geeft true terug
/// wanneer het bestand daadwerkelijk is gewijzigd.
Future<bool> replaceReferences(
String deckFile,
String fromAbsolute,
String toAbsolute,
) async {
final file = File(deckFile);
String content;
try {
content = await file.readAsString();
} catch (e) {
logWarning('ImageReferenceService.replaceReferences: read deck file', e);
return false;
}
final mdDir = p.dirname(deckFile);
var changed = false;
final updated = content.replaceAllMapped(_imageRef, (m) {
final ref = m.group(2)!;
final resolved = _resolve(ref, mdDir);
if (resolved == null || !p.equals(resolved, fromAbsolute)) {
return m.group(0)!;
}
changed = true;
// Blijf relatief schrijven als de verwijzing dat al was en het nieuwe
// pad binnen de projectmap ligt; anders absoluut.
final replacement =
!p.isAbsolute(ref.trim()) && p.isWithin(mdDir, toAbsolute)
? p.relative(toAbsolute, from: mdDir)
: toAbsolute;
return '![${m.group(1)}]($replacement)';
});
if (!changed) return false;
try {
await file.writeAsString(updated);
} catch (e) {
logWarning('ImageReferenceService.replaceReferences: write deck file', e);
return false;
}
return true;
}
String? _resolve(String ref, String mdDir) {
final cleaned = ref.trim();
if (cleaned.isEmpty || cleaned.contains('://')) return null;
return p.normalize(
p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned),
);
}
}
final imageReferenceServiceProvider = Provider<ImageReferenceService>(
(_) => ImageReferenceService(),
);

View file

@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import '../l10n/app_localizations.dart';
import '../models/slide.dart';
import '../utils/log.dart';
class ImageService {
final String Function() _languageCode;
@ -46,7 +47,8 @@ class ImageService {
if (bytes.isEmpty) return false;
await Pasteboard.writeImage(bytes);
return true;
} catch (_) {
} catch (e) {
logError('ImageService.copyImageBytesToClipboard: write image', e);
return false;
}
}
@ -60,7 +62,8 @@ class ImageService {
final file = File(path);
if (!await file.exists()) return false;
return copyImageBytesToClipboard(await file.readAsBytes());
} catch (_) {
} catch (e) {
logWarning('ImageService.copyImageToClipboard: read image file', e);
return false;
}
}

View file

@ -5,13 +5,26 @@ import '../models/chart.dart';
import '../models/deck.dart';
import '../models/settings.dart';
import '../models/slide.dart';
import '../utils/log.dart';
const _uuid = Uuid();
class MarkdownService {
// Generation
String generateDeck(Deck deck, {bool inlineChartData = false}) {
/// Serialise a deck to Marp markdown.
///
/// The styling (the [ThemeProfile]) is deliberately NOT written to the file:
/// a saved `.md` holds only the content (the "base"), and the app applies the
/// active style profile when it opens the deck. [inlineStyleProfile] re-adds
/// the profile for transient, non-file payloads currently only the markdown
/// streamed to the audience (beamer) window, which has no other way to learn
/// the styling. It must stay false for anything written to disk.
String generateDeck(
Deck deck, {
bool inlineChartData = false,
bool inlineStyleProfile = false,
}) {
final buf = StringBuffer();
buf.writeln('---');
buf.writeln('marp: true');
@ -42,9 +55,11 @@ class MarkdownService {
if (deck.tlp != TlpLevel.none) {
buf.writeln('tlp: ${deck.tlp.key}');
}
buf.writeln(
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
);
if (inlineStyleProfile) {
buf.writeln(
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
);
}
buf.writeln('---');
buf.writeln();
@ -514,7 +529,8 @@ class MarkdownService {
static String _decodeText(String encoded) {
try {
return utf8.decode(base64Url.decode(encoded.trim()));
} catch (_) {
} catch (e, s) {
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
return '';
}
}
@ -524,7 +540,9 @@ class MarkdownService {
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
final raw = jsonDecode(decoded);
if (raw is List) return raw.map((v) => v.toString()).toList();
} catch (_) {}
} catch (e, s) {
logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s);
}
return const [];
}
@ -632,7 +650,8 @@ class MarkdownService {
Deck? parseDeck(String markdown, {String? filePath}) {
try {
return _doParse(markdown, filePath: filePath);
} catch (_) {
} catch (e, s) {
logError('MarkdownService.parseDeck: parse markdown', e, s);
return null;
}
}
@ -678,11 +697,22 @@ class MarkdownService {
} else if (line.startsWith('tlp:')) {
tlp = TlpLevelX.fromKey(line.substring(4));
} else if (line.startsWith('ocideck_style_profile:')) {
final encoded = line.substring(22).trim();
final decoded = utf8.decode(base64Url.decode(encoded));
themeProfile = ThemeProfile.fromJson(
Map<String, Object?>.from(jsonDecode(decoded) as Map),
);
// Best-effort: a corrupt profile token must not fail the whole
// parse (which would blank the audience window). Keep the default.
try {
final encoded = line.substring(22).trim();
final decoded = utf8.decode(base64Url.decode(encoded));
themeProfile = ThemeProfile.fromJson(
Map<String, Object?>.from(jsonDecode(decoded) as Map),
);
} catch (e, s) {
logError(
'MarkdownService._doParse: decode ocideck_style_profile',
e,
s,
);
// Leave themeProfile at its default.
}
}
}
content = content.substring(end + 5).trim();

View file

@ -6,6 +6,7 @@ import 'package:flutter/services.dart' show rootBundle;
import '../models/chart.dart';
import '../models/settings.dart';
import '../utils/log.dart';
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
///
@ -38,6 +39,7 @@ class MarpHtmlService {
/// colours and font so the export matches the in-app / PDF look.
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
final marked = await loadAsset('$_assetDir/marked.min.js');
final purify = await loadAsset('$_assetDir/purify.min.js');
final hljs = await loadAsset('$_assetDir/highlight.min.js');
final hljsCss = await loadAsset('$_assetDir/highlight.css');
final mathjax = await loadAsset('$_assetDir/tex-svg.js');
@ -61,6 +63,7 @@ class MarpHtmlService {
'<style>$css\n$hljsCss</style>'
'<script>$_mathjaxConfig</script>'
'${inline(marked)}'
'${inline(purify)}'
'${inline(hljs)}'
'${inline(mathjax)}'
'${inline(mermaid)}'
@ -97,11 +100,16 @@ class MarpHtmlService {
}
/// Neutralise any `</script` inside inlined content so it can't break out of
/// the surrounding <script> element. Safe for both JS (string contexts) and
/// the embedded Markdown payloads.
static String _guard(String s) => s
.replaceAll('</script', r'<\/script')
.replaceAll('</SCRIPT', r'<\/SCRIPT');
/// the surrounding <script> element. Case-insensitive `</ScRiPt>` must not
/// slip through. Safe for both JS (string contexts) and the embedded Markdown
/// payloads.
static final RegExp _scriptClose = RegExp(
r'</(script)',
caseSensitive: false,
);
static String _guard(String s) =>
s.replaceAllMapped(_scriptClose, (m) => '<\\/${m.group(1)}');
// Charts inline SVG
@ -563,7 +571,9 @@ class MarpHtmlService {
final range = (rawHi - rawLo).abs();
final r = range <= 0 ? 1.0 : range;
final rawStep = r / 4;
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
final mag = math
.pow(10, (math.log(rawStep) / math.ln10).floor())
.toDouble();
final norm = rawStep / mag;
final niceNorm = norm < 1.5
? 1.0
@ -616,7 +626,7 @@ class MarpHtmlService {
'.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;'
'padding-left:16px;opacity:.85}'
'.slide table{border-collapse:collapse}'
'.slide th{background:${t.sectionBackgroundColor};color:${t.tableHeaderTextColor};'
'.slide th{background:${t.tableHeaderBackgroundColor};color:${t.tableHeaderTextColor};'
'border:1px solid #ccc;padding:6px 12px;font-size:20px}'
'.slide td{color:${t.tableTextColor};border:1px solid #ccc;padding:6px 12px;font-size:20px}'
'@media print{body{background:#fff}.slide{margin:0;box-shadow:none;'
@ -640,7 +650,8 @@ class MarpHtmlService {
return "@font-face{font-family:'EB Garamond';font-weight:400 800;"
"font-style:normal;src:url(data:font/ttf;base64,$b64) "
"format('truetype');}";
} catch (_) {
} catch (e) {
logWarning('MarpHtmlService._ebGaramondFontFace: load font asset', e);
return ''; // Fall back to the CSS font stack if the asset is missing.
}
}
@ -672,7 +683,11 @@ body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Ar
var holder=sec.querySelector('script[type="text/markdown"]');
var src=holder?holder.textContent:'';
var div=document.createElement('div');div.className='content';
div.innerHTML=window.marked?marked.parse(src):src;
var html=window.marked?marked.parse(src):src;
// Sanitise rendered Markdown before it touches the DOM: a deck must not be
// able to run script/onerror/javascript: payloads when the export is opened.
// If the sanitiser somehow isn't present, fail closed to plain text.
if(window.DOMPurify){div.innerHTML=DOMPurify.sanitize(html);}else{div.textContent=src;}
sec.innerHTML='';sec.appendChild(div);
});
document.querySelectorAll('code.language-mermaid').forEach(function(code){

View file

@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../utils/log.dart';
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
class RecoverySnapshot {
final String id;
@ -72,7 +74,8 @@ class RecoveryService {
dir,
snapshot.id,
).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
} catch (_) {
} catch (e) {
logWarning('RecoveryService.save: write recovery snapshot', e);
// Autosave mag nooit de app verstoren.
}
}
@ -81,7 +84,9 @@ class RecoveryService {
try {
final file = _file(await _dir(), id);
if (file.existsSync()) await file.delete();
} catch (_) {}
} catch (e) {
logWarning('RecoveryService.discard: delete recovery file', e);
}
}
Future<List<RecoverySnapshot>> loadAll() async {
@ -93,12 +98,15 @@ class RecoveryService {
try {
final data = jsonDecode(await entry.readAsString());
out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data)));
} catch (_) {}
} catch (e, s) {
logError('RecoveryService.loadAll: decode recovery snapshot', e, s);
}
}
}
out.sort((a, b) => b.savedAt.compareTo(a.savedAt));
return out;
} catch (_) {
} catch (e) {
logWarning('RecoveryService.loadAll: list recovery dir', e);
return const [];
}
}
@ -110,10 +118,14 @@ class RecoveryService {
if (entry is File && entry.path.endsWith('.json')) {
try {
await entry.delete();
} catch (_) {}
} catch (e) {
logWarning('RecoveryService.clearAll: delete recovery file', e);
}
}
}
} catch (_) {}
} catch (e) {
logWarning('RecoveryService.clearAll: list recovery dir', e);
}
}
}

View file

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

View file

@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _consentKey = 'app_consent_accepted';
final consentProvider = NotifierProvider<ConsentNotifier, ConsentState>(() {
return ConsentNotifier();
});
class ConsentState {
final bool hasAccepted;
final bool isLoading;
const ConsentState({required this.hasAccepted, this.isLoading = false});
ConsentState copyWith({bool? hasAccepted, bool? isLoading}) {
return ConsentState(
hasAccepted: hasAccepted ?? this.hasAccepted,
isLoading: isLoading ?? this.isLoading,
);
}
}
class ConsentNotifier extends Notifier<ConsentState> {
@override
ConsentState build() {
_initialize();
return const ConsentState(hasAccepted: false, isLoading: true);
}
Future<void> _initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final hasAccepted = prefs.getBool(_consentKey) ?? false;
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
} catch (e) {
// Can't read the flag: fail closed (gate stays up) but don't hang loading.
debugPrint('ConsentNotifier: could not read consent flag: $e');
state = state.copyWith(isLoading: false);
}
}
Future<void> acceptConsent() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_consentKey, true);
state = state.copyWith(hasAccepted: true);
} catch (e) {
// Persisting failed; let the user through this session, but the gate will
// reappear next launch. Surface the failure instead of swallowing it.
debugPrint('ConsentNotifier: could not persist consent: $e');
state = state.copyWith(hasAccepted: true);
}
}
Future<void> revokeConsent() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_consentKey, false);
state = state.copyWith(hasAccepted: false);
} catch (e) {
debugPrint('ConsentNotifier: could not persist consent revocation: $e');
state = state.copyWith(hasAccepted: false);
}
}
}

View file

@ -127,13 +127,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
state = DeckState(deck: deck, isDirty: true);
}
/// Load a deck that was already parsed (used by the tab manager).
/// Load a deck that was already parsed (used by the tab manager). Styling is
/// not taken from the deck/markdown but from the active style profile, so an
/// opened or recovered deck always picks up the current look.
void loadDeck(Deck deck, {String? filePath}) {
final resolvedDeck = deck.copyWith(
themeProfile: _file.resolveThemeProfile(
deck.themeProfile,
projectPath: deck.projectPath,
),
themeProfile: _file.activeProfileFor(projectPath: deck.projectPath),
);
_clearHistory();
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
@ -202,7 +201,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
void removeSlide(int index) {
final deck = state.deck;
if (deck == null || deck.slides.length <= 1) return;
if (deck == null ||
deck.slides.length <= 1 ||
index < 0 ||
index >= deck.slides.length) {
return;
}
final slides = List<Slide>.from(deck.slides)..removeAt(index);
_mutate(deck.copyWith(slides: slides));
}
@ -255,7 +259,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void duplicateSlide(int index) {
final deck = state.deck;
if (deck == null) return;
if (deck == null || index < 0 || index >= deck.slides.length) return;
final slides = List<Slide>.from(deck.slides);
slides.insert(index + 1, Slide.duplicate(slides[index]));
_mutate(deck.copyWith(slides: slides));
@ -273,7 +277,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void updateSlide(int index, Slide updated) {
final deck = state.deck;
if (deck == null) return;
if (deck == null || index < 0 || index >= deck.slides.length) return;
final slides = List<Slide>.from(deck.slides);
slides[index] = updated;
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
@ -394,6 +398,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
title: sub(s.title),
subtitle: sub(s.subtitle),
bullets: [for (final b in s.bullets) sub(b)],
bullets2: [for (final b in s.bullets2) sub(b)],
columnTitle1: sub(s.columnTitle1),
columnTitle2: sub(s.columnTitle2),
quote: sub(s.quote),
quoteAuthor: sub(s.quoteAuthor),
customMarkdown: sub(s.customMarkdown),
@ -415,6 +422,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
s.title,
s.subtitle,
...s.bullets,
...s.bullets2,
s.columnTitle1,
s.columnTitle2,
s.quote,
s.quoteAuthor,
s.customMarkdown,
@ -499,8 +509,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
/// Returns false if parsing fails (content is preserved).
bool applyMarkdown(String markdown) {
final deck = _md.parseDeck(markdown, filePath: state.filePath);
if (deck == null) return false;
final parsed = _md.parseDeck(markdown, filePath: state.filePath);
if (parsed == null) return false;
// The markdown carries only content; keep the deck's current styling rather
// than resetting it to the default profile the parser returns.
final current = state.deck;
final deck = current == null
? parsed
: parsed.copyWith(themeProfile: current.themeProfile);
_mutate(deck); // discrete stap ook ongedaan te maken
return true;
}

View file

@ -54,9 +54,43 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
? selectedAppearance
: 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [],
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0)
.clamp(0, 86400),
);
}
/// 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 {
final clamped = scale.clamp(1.0, 2.0).toDouble();
state = state.copyWith(uiTextScale: clamped);
final prefs = await SharedPreferences.getInstance();
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 {
final updated = [
path,

View file

@ -101,9 +101,26 @@ class TabsNotifier extends StateNotifier<TabsState> {
for (final sub in _subs.values) {
sub.cancel();
}
// The tabs' notifiers are not disposed here: at teardown the widget tree is
// still unmounting and may read a tab one last time. The process is ending
// anyway. The per-close path (_disposeTab) is what prevents the real leak.
super.dispose();
}
/// Tear down a tab that is being removed: stop listening to it and dispose
/// its notifiers so their listeners and undo/redo history are released. The
/// dispose is deferred to a microtask so any widget still referencing this
/// tab while it unmounts has finished before the notifiers go away.
void _disposeTab(TabInfo tab) {
_subs.remove(tab.id)?.cancel();
final deckNotifier = tab.deckNotifier;
final editorNotifier = tab.editorNotifier;
Future.microtask(() {
deckNotifier.dispose();
editorNotifier.dispose();
});
}
TabInfo _createTab() {
final id = _nextId++;
final recoveryId = _uuid.v4();
@ -163,7 +180,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
if (replaceEmpty) {
_subs.remove(state.tabs.first.id)?.cancel();
_disposeTab(state.tabs.first);
state = state.copyWith(tabs: restored, selectedIndex: 0);
} else {
final tabs = [...state.tabs, ...restored];
@ -282,7 +299,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
}
final tab = state.tabs[index];
_recovery.discard(tab.recoveryId);
_subs.remove(tab.id)?.cancel();
_disposeTab(tab);
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);

41
lib/utils/log.dart Normal file
View file

@ -0,0 +1,41 @@
/// Lightweight logging for failures the app deliberately swallows.
///
/// Many call sites here used to be bare `catch (_) {}` blocks. Swallowing was
/// usually the *right* behaviour a broken sidecar, an unreadable file or an
/// unsupported platform must never crash a presentation but the failure then
/// vanished without a trace, which made real bugs invisible. Routing these
/// through [logError]/[logWarning] keeps the fail-soft behaviour while making
/// the cause observable.
///
/// Records go to the `dart:developer` logging stream (DevTools / the VM
/// service), not stdout, so release builds stay quiet. Pass only an operation
/// description and the caught error object never deck or file *contents*,
/// which can be personal data.
library;
import 'dart:developer' as developer;
const _name = 'ocideck';
// Severity levels mirror package:logging (WARNING = 900, SEVERE = 1000).
const int _levelWarning = 900;
const int _levelError = 1000;
/// An unexpected failure that was handled by falling back. [op] is a short
/// description of what was attempted, e.g. `'openDeck: read annotation sidecar'`.
void logError(String op, Object error, [StackTrace? stack]) {
developer.log(
op,
name: _name,
error: error,
stackTrace: stack,
level: _levelError,
);
}
/// An expected-but-notable condition where the app fell back to a default
/// (e.g. an absent optional file, an unsupported platform capability). Lower
/// severity than [logError]; [error] is optional.
void logWarning(String op, [Object? error]) {
developer.log(op, name: _name, error: error, level: _levelWarning);
}

View file

@ -0,0 +1,123 @@
/// Recognises tabular clipboard content so a paste into one table cell can
/// fill a whole grid.
///
/// Spreadsheets (Excel, Numbers, LibreOffice Calc, Google Sheets) put
/// tab-separated text on the clipboard on macOS, Linux and Windows alike, so
/// TSV is the primary format. CSV with a comma or semicolon (the Dutch/European
/// list separator) and markdown tables are recognised as well.
library;
/// Parses [text] as a table, or returns null when it does not look tabular
/// in that case the paste should go into the single cell as usual.
///
/// Detection is deliberately conservative for ambiguous formats: a tab is
/// always a column break (no one types tabs into a cell), but commas and
/// semicolons only count when every line yields the same column count, so a
/// pasted sentence with a comma stays plain text.
List<List<String>>? parseClipboardTable(String text) {
final normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
if (normalized.trim().isEmpty) return null;
final markdown = _parseMarkdownTable(normalized);
if (markdown != null) return markdown;
if (normalized.contains('\t')) {
return _trim(_splitDelimited(normalized, '\t'));
}
// CSV variants: require at least two rows with a consistent column count of
// two or more (checked before any padding, so prose with stray commas does
// not qualify); prefer the separator that yields the wider table.
List<List<String>>? best;
for (final delimiter in const [';', ',']) {
if (!normalized.contains(delimiter)) continue;
final rows = _splitDelimited(normalized, delimiter);
while (rows.isNotEmpty && rows.last.every((c) => c.trim().isEmpty)) {
rows.removeLast();
}
if (rows.length < 2) continue;
final cols = rows.first.length;
if (cols < 2 || rows.any((r) => r.length != cols)) continue;
if (best == null || cols > best.first.length) best = rows;
}
return best;
}
/// Markdown table: every non-empty line framed by pipes. The `|---|---|`
/// separator row is dropped.
List<List<String>>? _parseMarkdownTable(String text) {
final lines = [
for (final line in text.split('\n'))
if (line.trim().isNotEmpty) line.trim(),
];
if (lines.isEmpty || lines.any((l) => !l.startsWith('|'))) return null;
final rows = <List<String>>[];
for (final line in lines) {
var body = line.substring(1);
if (body.endsWith('|')) body = body.substring(0, body.length - 1);
final cells = body.split('|').map((c) => c.trim()).toList();
// Alignment/separator row (|---|:--:|) carries no data.
if (cells.every((c) => RegExp(r'^:?-{2,}:?$').hasMatch(c))) continue;
rows.add(cells);
}
if (rows.isEmpty || rows.first.length < 2) return null;
return _trim(rows);
}
/// Splits [text] into rows/cells on newlines and [delimiter], honouring
/// double-quoted fields ("" escapes a quote) so cells from spreadsheets may
/// contain the delimiter or even line breaks.
List<List<String>> _splitDelimited(String text, String delimiter) {
final rows = <List<String>>[];
var row = <String>[];
final cell = StringBuffer();
var quoted = false;
for (var i = 0; i < text.length; i++) {
final ch = text[i];
if (quoted) {
if (ch == '"') {
if (i + 1 < text.length && text[i + 1] == '"') {
cell.write('"');
i++;
} else {
quoted = false;
}
} else {
cell.write(ch);
}
} else if (ch == '"' && cell.isEmpty) {
quoted = true;
} else if (ch == delimiter) {
row.add(cell.toString());
cell.clear();
} else if (ch == '\n') {
row.add(cell.toString());
cell.clear();
rows.add(row);
row = <String>[];
} else {
cell.write(ch);
}
}
row.add(cell.toString());
rows.add(row);
return rows;
}
/// Drops empty trailing rows (from the trailing newline spreadsheets add) and
/// pads every row to the same column count. Returns null when the result is a
/// single lone cell that is not a table.
List<List<String>>? _trim(List<List<String>> rows) {
final kept = List<List<String>>.from(rows);
while (kept.isNotEmpty && kept.last.every((c) => c.trim().isEmpty)) {
kept.removeLast();
}
if (kept.isEmpty) return null;
final cols = kept.fold<int>(0, (m, r) => r.length > m ? r.length : m);
if (cols < 2 && kept.length < 2) return null;
return [
for (final row in kept)
[for (var c = 0; c < cols; c++) c < row.length ? row[c] : ''],
];
}

View file

@ -1,8 +1,14 @@
import 'package:url_launcher/url_launcher.dart';
import 'log.dart';
/// Schemes a deck link may open. Anything else (file:, javascript:, custom app
/// schemes, ) is refused so a deck can't hand the OS a dangerous or
/// unexpected URI.
const _allowedUrlSchemes = {'https', 'http', 'mailto'};
/// Open een link uit slide-tekst in de externe browser. Kale domeinen
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige
/// of niet-openbare URLs.
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige,
/// niet-openbare of niet-toegestane URLs.
Future<void> openExternalUrl(String url) async {
var u = url.trim();
if (u.isEmpty) return;
@ -11,11 +17,13 @@ Future<void> openExternalUrl(String url) async {
}
final uri = Uri.tryParse(u);
if (uri == null) return;
if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return;
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (_) {
} catch (e) {
logWarning('openExternalUrl: launching external URL failed', e);
// Nooit de presentatie laten crashen op een kapotte link.
}
}

File diff suppressed because it is too large Load diff

View file

@ -15,23 +15,19 @@ class AddSlideDialog extends StatelessWidget {
}
static const _types = [
(SlideType.title, Icons.title, 'Titelpagina'),
(SlideType.section, Icons.bookmark_outline, 'Tussentitel'),
(SlideType.bullets, Icons.format_list_bulleted, 'Alleen Bullets'),
(SlideType.twoBullets, Icons.view_column_outlined, 'Twee Bulletkolommen'),
(
SlideType.bulletsImage,
Icons.view_agenda_outlined,
'Bullets + Afbeelding',
),
(SlideType.twoImages, Icons.auto_stories_outlined, 'Twee Afbeeldingen'),
(SlideType.image, Icons.image_outlined, 'Grote Afbeelding'),
(SlideType.video, Icons.movie_outlined, 'Video'),
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
(SlideType.code, Icons.terminal, 'Broncode'),
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
(SlideType.title, 'Titelpagina'),
(SlideType.section, 'Tussentitel'),
(SlideType.bullets, 'Alleen Bullets'),
(SlideType.twoBullets, 'Twee Bulletkolommen'),
(SlideType.bulletsImage, 'Bullets + Afbeelding'),
(SlideType.twoImages, 'Twee Afbeeldingen'),
(SlideType.image, 'Grote Afbeelding'),
(SlideType.video, 'Video'),
(SlideType.quote, 'Quote'),
(SlideType.table, 'Tabel'),
(SlideType.chart, 'Grafiek'),
(SlideType.code, 'Broncode'),
(SlideType.freeMarkdown, 'Vrije Markdown'),
];
@override
@ -42,73 +38,272 @@ class AddSlideDialog extends StatelessWidget {
const SingleActivator(LogicalKeyboardKey.escape): () =>
Navigator.pop(context),
},
child: Focus(
autofocus: true,
child: AlertDialog(
title: Text(l10n.d('Slide type kiezen')),
content: SizedBox(
width: 400,
child: AlertDialog(
title: Text(l10n.d('Slide type kiezen')),
content: SizedBox(
width: 440,
// Reading-order tabbing through the cards; the first one takes
// focus so the dialog is fully keyboard-operable right away.
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: _types.map((entry) {
final (type, icon, label) = entry;
return _TypeCard(
icon: icon,
label: l10n.d(label),
onTap: () => Navigator.pop(context, type),
);
}).toList(),
children: [
for (var i = 0; i < _types.length; i++)
_TypeCard(
type: _types[i].$1,
label: l10n.d(_types[i].$2),
autofocus: i == 0,
onTap: () => Navigator.pop(context, _types[i].$1),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.t('cancel')),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.t('cancel')),
),
],
),
);
}
}
class _TypeCard extends StatelessWidget {
final IconData icon;
final SlideType type;
final String label;
final VoidCallback onTap;
final bool autofocus;
const _TypeCard({
required this.icon,
required this.type,
required this.label,
required this.onTap,
this.autofocus = false,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 110,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFCBD5E1)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 28, color: AppTheme.navy),
const SizedBox(height: 8),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 11),
),
],
return Semantics(
button: true,
child: InkWell(
onTap: onTap,
autofocus: autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: AppTheme.accent.withValues(alpha: 0.14),
hoverColor: AppTheme.accent.withValues(alpha: 0.06),
child: Container(
width: 100,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFCBD5E1)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// A stylised wireframe of the layout, so the card shows what
// the slide will look like instead of an abstract icon.
ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CustomPaint(
painter: SlideTypePreviewPainter(type: type),
),
),
),
),
const SizedBox(height: 6),
Text(
label,
textAlign: TextAlign.center,
maxLines: 2,
style: const TextStyle(fontSize: 11, height: 1.15),
),
],
),
),
),
);
}
}
/// Paints a miniature 16:9 wireframe of a slide layout, in the spirit of the
/// layout pickers in other presentation tools: title bars, text lines, image
/// placeholders. All geometry lives on a 160×90 design canvas and is scaled
/// to whatever size the card provides.
@visibleForTesting
class SlideTypePreviewPainter extends CustomPainter {
final SlideType type;
/// Wireframe palette: dark bars for titles, soft bars for body text.
static const _canvas = Color(0xFFF8FAFC);
static const _ink = Color(0xFF334155);
static const _soft = Color(0xFFB6C2D2);
static const _fill = Color(0xFFE2E8F0);
static const _accent = AppTheme.accent;
const SlideTypePreviewPainter({required this.type});
@override
void paint(Canvas canvas, Size size) {
final u = size.width / 160;
canvas.scale(u);
canvas.drawRect(const Rect.fromLTWH(0, 0, 160, 90), _paint(_canvas));
switch (type) {
case SlideType.title:
_bar(canvas, 30, 34, 100, 12, _ink);
_bar(canvas, 45, 53, 70, 7, _accent);
case SlideType.section:
_bar(canvas, 16, 36, 5, 24, _accent);
_bar(canvas, 30, 38, 86, 11, _ink);
_bar(canvas, 30, 54, 52, 6, _soft);
case SlideType.bullets:
_bar(canvas, 14, 12, 84, 9, _ink);
_bullets(canvas, 14, 34, 110, 4);
case SlideType.twoBullets:
_bar(canvas, 14, 12, 84, 9, _ink);
_bullets(canvas, 14, 32, 56, 3);
_bullets(canvas, 90, 32, 56, 3);
case SlideType.bulletsImage:
_bar(canvas, 14, 12, 66, 9, _ink);
_bullets(canvas, 14, 32, 60, 3);
_imageBox(canvas, 90, 26, 56, 50);
case SlideType.twoImages:
_imageBox(canvas, 12, 16, 64, 46);
_imageBox(canvas, 84, 16, 64, 46);
_bar(canvas, 20, 68, 48, 5, _soft);
_bar(canvas, 92, 68, 48, 5, _soft);
case SlideType.image:
_imageBox(canvas, 10, 10, 140, 70);
case SlideType.video:
_imageBox(canvas, 10, 10, 140, 70, pictogram: false);
canvas.drawCircle(const Offset(80, 45), 14, _paint(Colors.white));
final play = Path()
..moveTo(75, 37)
..lineTo(89, 45)
..lineTo(75, 53)
..close();
canvas.drawPath(play, _paint(_ink));
case SlideType.quote:
_quoteMark(canvas, 16, 16);
_bar(canvas, 42, 30, 96, 7, _ink);
_bar(canvas, 42, 43, 78, 7, _ink);
_bar(canvas, 42, 60, 42, 5, _accent);
case SlideType.table:
_bar(canvas, 14, 16, 132, 14, _soft, radius: 2);
final line = _paint(_ink.withValues(alpha: 0.45))..strokeWidth = 1.5;
for (var r = 1; r <= 4; r++) {
canvas.drawLine(
Offset(14, 16 + r * 14),
Offset(146, 16 + r * 14),
line,
);
}
for (var c = 0; c <= 3; c++) {
canvas.drawLine(
Offset(14 + c * 44, 16),
Offset(14 + c * 44, 72),
line,
);
}
case SlideType.chart:
final axis = _paint(_soft)..strokeWidth = 2;
canvas.drawLine(const Offset(20, 14), const Offset(20, 74), axis);
canvas.drawLine(const Offset(20, 74), const Offset(148, 74), axis);
_bar(canvas, 34, 50, 18, 24, _soft, radius: 2);
_bar(canvas, 64, 36, 18, 38, _accent, radius: 2);
_bar(canvas, 94, 44, 18, 30, _soft, radius: 2);
_bar(canvas, 124, 24, 18, 50, _accent, radius: 2);
case SlideType.code:
_bar(canvas, 10, 10, 140, 70, const Color(0xFF1E293B), radius: 4);
_bar(canvas, 20, 22, 44, 6, const Color(0xFF7DD3A7), radius: 3);
_bar(canvas, 30, 34, 64, 6, const Color(0xFF93B8F8), radius: 3);
_bar(canvas, 30, 46, 50, 6, const Color(0xFFE2C08D), radius: 3);
_bar(canvas, 20, 58, 32, 6, const Color(0xFF7DD3A7), radius: 3);
case SlideType.freeMarkdown:
_bar(canvas, 14, 12, 10, 9, _accent, radius: 2);
_bar(canvas, 28, 12, 62, 9, _ink);
_bar(canvas, 14, 32, 120, 6, _soft);
_bar(canvas, 14, 44, 132, 6, _soft);
_bar(canvas, 14, 56, 92, 6, _soft);
_bar(canvas, 14, 68, 110, 6, _soft);
}
}
void _bar(
Canvas canvas,
double x,
double y,
double w,
double h,
Color color, {
double? radius,
}) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, w, h),
Radius.circular(radius ?? h / 2),
),
_paint(color),
);
}
/// A column of bullet points: accent dot plus a soft text line.
void _bullets(Canvas canvas, double x, double y, double w, int count) {
for (var i = 0; i < count; i++) {
final dy = y + i * 13.0;
canvas.drawCircle(Offset(x + 3, dy + 3), 3, _paint(_accent));
_bar(canvas, x + 11, dy, w * (i.isEven ? 1.0 : 0.74), 6, _soft);
}
}
/// Image placeholder: filled box with a sun and mountains pictogram.
void _imageBox(
Canvas canvas,
double x,
double y,
double w,
double h, {
bool pictogram = true,
}) {
_bar(canvas, x, y, w, h, _fill, radius: 4);
if (!pictogram) return;
final dark = _paint(_soft);
canvas.drawCircle(Offset(x + w * 0.28, y + h * 0.30), h * 0.11, dark);
final hills = Path()
..moveTo(x + w * 0.08, y + h * 0.88)
..lineTo(x + w * 0.38, y + h * 0.45)
..lineTo(x + w * 0.58, y + h * 0.72)
..lineTo(x + w * 0.74, y + h * 0.52)
..lineTo(x + w * 0.94, y + h * 0.88)
..close();
canvas.drawPath(hills, dark);
}
/// A stylised double quotation mark.
void _quoteMark(Canvas canvas, double x, double y) {
final paint = _paint(_accent);
for (final dx in [0.0, 11.0]) {
canvas.drawCircle(Offset(x + 4 + dx, y + 8), 4, paint);
final tail = Path()
..moveTo(x + dx, y + 8)
..quadraticBezierTo(x + dx, y + 17, x + 7 + dx, y + 18)
..lineTo(x + 7 + dx, y + 14)
..quadraticBezierTo(x + 4 + dx, y + 13, x + 4 + dx, y + 8)
..close();
canvas.drawPath(tail, paint);
}
}
@override
bool shouldRepaint(SlideTypePreviewPainter old) => old.type != type;
}
Paint _paint(Color color) => Paint()..color = color;

View file

@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../l10n/app_localizations.dart';
import '../../state/consent_provider.dart';
class ConsentDialog extends ConsumerStatefulWidget {
const ConsentDialog({super.key});
@override
ConsumerState<ConsentDialog> createState() => _ConsentDialogState();
}
class _ConsentDialogState extends ConsumerState<ConsentDialog> {
bool _licenseExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.d('Welkom bij OciDeck')),
content: SizedBox(
width: 600,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Privacy & Use Explanation
Text(
l10n.d('Privacy en gebruik'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(color: theme.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.',
),
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 10),
Text(
l10n.d(
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.',
),
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 10),
Text(
l10n.d(
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.',
),
style: const TextStyle(fontSize: 12),
),
],
),
),
const SizedBox(height: 18),
// License Section
ExpansionTile(
title: Text(
l10n.d('Licentie (EUPL 1.2)'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
initiallyExpanded: _licenseExpanded,
onExpansionChanged: (expanded) {
setState(() => _licenseExpanded = expanded);
},
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(8),
),
constraints: const BoxConstraints(maxHeight: 250),
child: SingleChildScrollView(
child: Text(
l10n.d(_getLicenseText()),
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: Color(0xFF475569),
),
),
),
),
],
),
const SizedBox(height: 18),
// Confirmation Section
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.2,
),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
l10n.d(
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.',
),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => _launchLicense(context),
child: Text(l10n.d('Volledige licentie online')),
),
const Spacer(),
ElevatedButton(
onPressed: () => _acceptConsent(ref),
child: Text(l10n.d('Akkoord gaan')),
),
],
);
}
String _getLicenseText() {
return '''SPDX-License-Identifier: EUPL-1.2
Copyright © Brenno de Winter
OciDeck is licensed under the European Union Public Licence (EUPL) v. 1.2.
The Work is provided under the terms of this Licence when the Licensor has
placed the following notice immediately following the copyright notice for
the Work:
Licensed under the EUPL
TERMS:
- The Licence: the European Union Public Licence v1.2
- The Original Work: OciDeck, distributed under this Licence
- Derivative Works: works created based upon OciDeck
- The Work: the Original Work or its Derivative Works
PERMISSIONS:
You are allowed to use, reproduce, modify, and distribute the Work, as long
as you comply with the terms of the EUPL v1.2.
CONDITIONS:
- You must include a copy of the Licence with any distribution
- You must state significant changes made to the Work
- You must maintain all copyright, patent and trademark notices
- You must provide clear notice of any modifications
The full EUPL v1.2 text is available at:
https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12''';
}
void _launchLicense(BuildContext context) async {
const url = 'https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
void _acceptConsent(WidgetRef ref) {
ref.read(consentProvider.notifier).acceptConsent();
}
}

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/classification_policy.dart';
import '../../services/export_service.dart';
import '../../services/slide_rasterizer.dart';
import '../../l10n/app_localizations.dart';
@ -18,6 +19,9 @@ class ExportDialog extends StatefulWidget {
final ExportService exportService;
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.
final String? exportDirectory;
@ -32,6 +36,7 @@ class ExportDialog extends StatefulWidget {
required this.projectPath,
required this.exportService,
this.tlp = TlpLevel.none,
this.policy = const ClassificationPolicy(),
this.exportDirectory,
this.markdown = '',
});
@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget {
required String? projectPath,
required ExportService exportService,
TlpLevel tlp = TlpLevel.none,
ClassificationPolicy policy = const ClassificationPolicy(),
String? exportDirectory,
String markdown = '',
}) {
@ -57,6 +63,7 @@ class ExportDialog extends StatefulWidget {
projectPath: projectPath,
exportService: exportService,
tlp: tlp,
policy: policy,
exportDirectory: exportDirectory,
markdown: markdown,
),
@ -131,6 +138,8 @@ class _ExportDialogState extends State<ExportDialog> {
notes: [for (final s in widget.slides) s.notes],
markdown: widget.markdown,
themeProfile: widget.themeProfile,
tlp: widget.tlp,
policy: widget.policy,
);
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(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,

View file

@ -5,8 +5,11 @@ import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import '../../services/caption_service.dart';
import '../../services/description_service.dart';
import '../../services/image_dedup_service.dart';
import '../../services/image_reference_service.dart';
import '../../services/image_service.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/log.dart';
/// Resultaat van de afbeeldingencarousel.
class ImagePickResult {
@ -19,6 +22,12 @@ class ImagePickResult {
/// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden.
typedef ImageUsageLookup = List<String> Function(String absolutePath);
/// Vervangt in alle open decks elke slideverwijzing naar [fromAbsolute] door
/// [toAbsolute]. Gebruikt bij het opruimen van duplicaten, zodat slides niet
/// leeg raken wanneer hun kopie wordt verwijderd.
typedef ImageUsageReplace =
Future<void> Function(String fromAbsolute, String toAbsolute);
/// Manier waarop de afbeeldingen worden getoond. Tussen beide kan in de
/// header gewisseld worden.
enum _ViewMode {
@ -38,6 +47,12 @@ class ImageCarouselPicker extends StatefulWidget {
final CaptionService captionService;
final DescriptionService descriptionService;
final ImageUsageLookup? usageOf;
final ImageUsageReplace? onReplaceUsages;
/// Bestandspaden van de presentaties die nu in tabs geopend zijn. Die zijn
/// al gedekt door [usageOf]; bij het scannen van decks op schijf worden ze
/// overgeslagen om dubbeltellingen te voorkomen.
final List<String> openDeckFiles;
const ImageCarouselPicker({
super.key,
@ -46,6 +61,8 @@ class ImageCarouselPicker extends StatefulWidget {
required this.descriptionService,
this.initialPath,
this.usageOf,
this.onReplaceUsages,
this.openDeckFiles = const [],
});
static Future<ImagePickResult?> show(
@ -55,6 +72,8 @@ class ImageCarouselPicker extends StatefulWidget {
CaptionService? captionService,
DescriptionService? descriptionService,
ImageUsageLookup? usageOf,
ImageUsageReplace? onReplaceUsages,
List<String> openDeckFiles = const [],
}) {
return showDialog<ImagePickResult>(
context: context,
@ -65,6 +84,8 @@ class ImageCarouselPicker extends StatefulWidget {
captionService: captionService ?? CaptionService(),
descriptionService: descriptionService ?? DescriptionService(),
usageOf: usageOf,
onReplaceUsages: onReplaceUsages,
openDeckFiles: openDeckFiles,
),
);
}
@ -101,6 +122,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
String? _descEditing; // path the description field currently edits
bool _loading = true;
bool _justCopied = false; // korte feedback na kopiëren naar klembord
bool _untaggedOnly = false; // toon alleen afbeeldingen zonder tags
bool _deduping = false; // duplicaten-opruimactie bezig
int _hoveredIndex = -1;
_ViewMode _viewMode = _ViewMode.grid;
@ -147,7 +170,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
if (_exts.contains(ext)) found.add(e.path);
}
}
} catch (_) {}
} catch (e) {
logWarning('_ImageCarouselPickerState._loadImages: directory scan', e);
}
}
// Stat each file exactly once (instead of repeatedly inside the sort
@ -157,7 +182,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
DateTime modified;
try {
modified = File(path).statSync().modified;
} catch (_) {
} catch (e) {
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
modified = DateTime.fromMillisecondsSinceEpoch(0);
}
withTimes.add((path, modified));
@ -187,9 +213,15 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
/// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van
/// [_images] (nieuwste eerst) behouden.
void _applyFilter() {
final base = _untaggedOnly
? [
for (final path in _images)
if ((_descriptions[path] ?? '').trim().isEmpty) path,
]
: _images;
final q = _query.trim().toLowerCase();
if (q.isEmpty) {
_filtered = _images;
_filtered = base;
return;
}
final terms = q
@ -198,9 +230,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
.toList(growable: false);
final hits = <({String path, int score, int order})>[];
for (var i = 0; i < _images.length; i++) {
final score = _relevance(_images[i], terms);
if (score > 0) hits.add((path: _images[i], score: score, order: i));
for (var i = 0; i < base.length; i++) {
final score = _relevance(base[i], terms);
if (score > 0) hits.add((path: base[i], score: score, order: i));
}
hits.sort((a, b) {
final byScore = b.score.compareTo(a.score);
@ -257,6 +289,280 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
);
}
/// Zet het "alleen zonder tags"-filter aan of uit, zodat snel te zien is
/// welke afbeeldingen nog geen beschrijving/tags hebben.
void _toggleUntaggedOnly() {
setState(() {
_untaggedOnly = !_untaggedOnly;
_applyFilter();
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncCoverToSelection(),
);
}
/// Zoek byte-identieke afbeeldingen (md5), laat de gebruiker bevestigen en
/// ruim ze op: per groep blijft één bestand staan, tags/beschrijvingen en
/// opmerkingen/captions worden samengevoegd en slides die een verwijderde
/// kopie gebruikten gaan naar het behouden bestand wijzen zowel in open
/// presentaties als in .md-bestanden op schijf binnen de zoekmappen.
Future<void> _dedupe() async {
await _persistDescription();
setState(() => _deduping = true);
final dedup = ImageDedupService();
final refs = ImageReferenceService();
final groups = await dedup.findDuplicateGroups(_images);
if (!mounted) return;
if (groups.isEmpty) {
setState(() => _deduping = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.d('Geen dubbele afbeeldingen gevonden.')),
),
);
return;
}
// Ook presentaties op schijf tellen mee: zo blijft bij voorkeur het
// bestand staan waar de meeste slides (open of niet) naar wijzen. Open
// decks worden via usageOf geteld en hier overgeslagen.
final deckFiles = await refs.findDeckFiles(widget.searchPaths);
final diskCounts = await refs.countReferences(
_withoutOpenDecks(deckFiles),
[for (final group in groups) ...group],
);
if (!mounted) return;
final plan = <({String keeper, List<String> remove})>[
for (final group in groups)
() {
final keeper = dedup.chooseKeeper(
group,
usageCountOf: (path) =>
(widget.usageOf?.call(path).length ?? 0) +
(diskCounts[p.normalize(path)] ?? 0),
);
return (
keeper: keeper,
remove: [
for (final path in group)
if (path != keeper) path,
],
);
}(),
];
final confirmed = await _showDedupeDialog(plan);
if (confirmed != true) {
if (mounted) setState(() => _deduping = false);
return;
}
var removed = 0;
final updatedDeckFiles = <String>{};
for (final entry in plan) {
// Keeper eerst, zodat zijn eigen tekst vooraan blijft staan.
final ordered = [entry.keeper, ...entry.remove];
final captions = <String?>[
for (final path in ordered)
await widget.captionService.getCaption(path),
];
final mergedCaption = dedup.mergeMetadata(captions);
final mergedDescription = dedup.mergeMetadata([
for (final path in ordered) _descriptions[path],
], separator: ', ');
if (mergedCaption.isNotEmpty) {
await widget.captionService.saveCaption(entry.keeper, mergedCaption);
}
if (mergedDescription.isNotEmpty) {
_descriptions[entry.keeper] = mergedDescription;
await widget.descriptionService.saveDescription(
entry.keeper,
mergedDescription,
);
}
for (final path in entry.remove) {
await widget.onReplaceUsages?.call(path, entry.keeper);
// Ook niet-geopende presentaties op schijf laten meewijzen.
for (final deckFile in deckFiles) {
final updated = await refs.replaceReferences(
deckFile,
path,
entry.keeper,
);
if (updated) updatedDeckFiles.add(deckFile);
}
try {
final file = File(path);
if (file.existsSync()) await file.delete();
} catch (e) {
logWarning('_ImageCarouselPickerState._dedupe: delete file', e);
}
await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path);
_descriptions.remove(path);
removed++;
}
}
if (!mounted) return;
final removedSet = {for (final entry in plan) ...entry.remove};
setState(() {
_images = [
for (final path in _images)
if (!removedSet.contains(path)) path,
];
_descEditing = null;
if (_selected != null && removedSet.contains(_selected)) {
_selected = plan
.firstWhere((entry) => entry.remove.contains(_selected))
.keeper;
}
_deduping = false;
_applyFilter();
});
await _loadCaptionForSelection();
_loadDescriptionForSelection();
if (!mounted) return;
final l10n = context.l10n;
final removedText = removed == 1
? l10n.d('1 dubbele afbeelding verwijderd.')
: '$removed ${l10n.d('dubbele afbeeldingen verwijderd.')}';
final filesText = updatedDeckFiles.isEmpty
? ''
: updatedDeckFiles.length == 1
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$removedText$filesText')));
}
Future<bool?> _showDedupeDialog(
List<({String keeper, List<String> remove})> plan,
) {
final removeCount = plan.fold(0, (sum, e) => sum + e.remove.length);
return showDialog<bool>(
context: context,
builder: (ctx) {
final l10n = ctx.l10n;
return AlertDialog(
backgroundColor: const Color(0xFF161B22),
title: Row(
children: [
const Icon(
Icons.layers_clear_outlined,
color: Color(0xFF60A5FA),
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'${l10n.d('Dubbele afbeeldingen opruimen?')} ($removeCount)',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
],
),
content: SizedBox(
width: 440,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'Van elke groep blijft één bestand staan. Tags en opmerkingen worden samengevoegd en slides die een kopie gebruiken verwijzen daarna naar het behouden bestand — ook in presentaties die nu niet geopend zijn.',
),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 13,
),
),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in plan) ...[
Row(
children: [
const Icon(
Icons.check_circle_outline,
size: 14,
color: Color(0xFF22C55E),
),
const SizedBox(width: 6),
Expanded(
child: Text(
p.basename(entry.keeper),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
for (final path in entry.remove)
Padding(
padding: const EdgeInsets.only(left: 20, top: 2),
child: Row(
children: [
const Icon(
Icons.delete_outline,
size: 13,
color: Color(0xFFE5746E),
),
const SizedBox(width: 6),
Expanded(
child: Text(
p.basename(path),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 10),
],
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.layers_clear_outlined, size: 16),
label: Text(l10n.d('Opruimen')),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF238636),
foregroundColor: Colors.white,
),
),
],
);
},
);
}
Future<void> _confirm() async {
if (_selected == null) return;
await _persistDescription();
@ -386,18 +692,52 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
}
}
/// Filter de deckbestanden op schijf die niet in een tab geopend zijn
/// (open decks zijn al gedekt door [ImageCarouselPicker.usageOf]).
List<String> _withoutOpenDecks(List<String> deckFiles) {
final open = {for (final f in widget.openDeckFiles) p.normalize(f)};
return [
for (final f in deckFiles)
if (!open.contains(p.normalize(f))) f,
];
}
Future<void> _deleteSelected() async {
final path = _selected;
if (path == null) return;
final usages = widget.usageOf?.call(path) ?? const [];
final confirmed = await _showDeleteDialog(path, usages);
final usages = [...widget.usageOf?.call(path) ?? const <String>[]];
var slideCount = usages.length;
// Ook niet-geopende presentaties op schijf meenemen in de waarschuwing.
final refs = ImageReferenceService();
final onDisk = await refs.referencingFiles(
_withoutOpenDecks(await refs.findDeckFiles(widget.searchPaths)),
path,
);
if (!mounted) return;
final notOpen = context.l10n.d('niet geopend');
for (final entry in onDisk.entries) {
slideCount += entry.value;
usages.add(
entry.value == 1
? '${p.basename(entry.key)} · $notOpen'
: '${p.basename(entry.key)} · ${entry.value}× · $notOpen',
);
}
final confirmed = await _showDeleteDialog(path, usages, slideCount);
if (confirmed != true) return;
var deleted = false;
try {
final file = File(path);
if (file.existsSync()) await file.delete();
} catch (_) {}
// Drop the sidecar metadata too.
deleted = true;
} catch (e) {
debugPrint('Kon afbeelding niet verwijderen: $e');
}
// Only drop the sidecar metadata and the carousel entry once the file is
// actually gone; otherwise the image would disappear from the UI while it
// still exists on disk, having silently lost its caption/description.
if (!deleted) return;
await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path);
@ -418,7 +758,11 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
_loadDescriptionForSelection();
}
Future<bool?> _showDeleteDialog(String path, List<String> usages) {
Future<bool?> _showDeleteDialog(
String path,
List<String> usages,
int slideCount,
) {
return showDialog<bool>(
context: context,
builder: (ctx) {
@ -470,7 +814,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
)
else ...[
Text(
'${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} ${usages.length} ${usages.length == 1 ? l10n.d("slide") : l10n.t("slides")}:',
'${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} $slideCount ${slideCount == 1 ? l10n.d("slide") : l10n.t("slides")}:',
style: const TextStyle(
color: Color(0xFFF0B429),
fontSize: 13,
@ -664,7 +1008,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
_query.trim().isEmpty
_query.trim().isEmpty && !_untaggedOnly
? '${_images.length}'
: '${_filtered.length} / ${_images.length}',
style: const TextStyle(
@ -677,6 +1021,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
const SizedBox(width: 16),
Expanded(child: _buildSearchField()),
const SizedBox(width: 12),
_buildUntaggedToggle(),
const SizedBox(width: 12),
_buildViewToggle(),
const SizedBox(width: 12),
IconButton(
@ -739,6 +1085,40 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
);
}
/// Aan/uit-knop voor het filter "alleen afbeeldingen zonder tags". Handig om
/// te zien welke afbeeldingen nog een beschrijving/tags nodig hebben.
Widget _buildUntaggedToggle() {
final l10n = context.l10n;
return Tooltip(
message: l10n.d('Alleen afbeeldingen zonder tags tonen'),
child: GestureDetector(
onTap: _toggleUntaggedOnly,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: _untaggedOnly
? const Color(0xFF1D2433)
: const Color(0xFF0D1117),
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: _untaggedOnly
? const Color(0xFF3B82F6)
: const Color(0xFF30363D),
),
),
child: Icon(
Icons.label_off_outlined,
size: 17,
color: _untaggedOnly
? const Color(0xFF60A5FA)
: const Color(0xFF6E7681),
),
),
),
);
}
/// Segmented control om tussen raster- en coverflow-weergave te wisselen.
Widget _buildViewToggle() {
final l10n = context.l10n;
@ -790,6 +1170,37 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
/// Lege staat gedeeld door raster- en coverflow-weergave.
Widget _buildEmptyState() {
final l10n = context.l10n;
if (_untaggedOnly && _query.trim().isEmpty) {
return Expanded(
flex: 13,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.verified_outlined,
size: 56,
color: Color(0xFF22C55E),
),
const SizedBox(height: 20),
Text(
l10n.d('Alle afbeeldingen hebben tags.'),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
l10n.d('Zet het filter uit om alles weer te zien.'),
style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
),
],
),
),
);
}
final filtering = _query.trim().isNotEmpty;
return Expanded(
flex: 13,
@ -1547,6 +1958,35 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
),
),
const SizedBox(width: 8),
// Duplicaten opruimen (md5)
Tooltip(
message: l10n.d(
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën',
),
child: OutlinedButton.icon(
onPressed: _deduping || _images.length < 2 ? null : _dedupe,
icon: _deduping
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF8B949E),
),
)
: const Icon(Icons.layers_clear_outlined, size: 16),
label: Text(l10n.d('Duplicaten opruimen')),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
side: const BorderSide(color: Color(0xFF30363D)),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
// Hint
Text(
l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'),
@ -1629,7 +2069,9 @@ class _FileSizeState extends State<_FileSize> {
? '${mb.toStringAsFixed(1)} MB'
: '${kb.toStringAsFixed(0)} KB';
if (mounted) setState(() => _size = label);
} catch (_) {}
} catch (e) {
logWarning('_FileSizeState._load: compute size label', e);
}
}
@override

View file

@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/settings.dart';
import '../../state/settings_provider.dart';
import '../../state/tabs_provider.dart';
import '../../state/consent_provider.dart';
import '../../theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
@ -191,7 +192,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
: profiles.first.name;
return DefaultTabController(
length: 5,
length: 6,
child: AlertDialog(
title: Text(l10n.t('settings')),
content: SizedBox(
@ -223,6 +224,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
icon: const Icon(Icons.image_outlined),
text: l10n.t('settingsLogo'),
),
Tab(
icon: const Icon(Icons.privacy_tip_outlined),
text: l10n.d('Privacy'),
),
],
),
const SizedBox(height: 12),
@ -234,6 +239,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_tabBody(_styleTab(profiles, dropdownValue)),
_tabBody(_colorsTab()),
_tabBody(_logoTab()),
_tabBody(_privacyTab()),
],
),
),
@ -432,6 +438,30 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
),
),
const SizedBox(height: 16),
_sectionTitle(l10n.d('Toegankelijkheid')),
_uiTextScaleField(),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
l10n.d(
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
const SizedBox(height: 16),
_sectionTitle(l10n.d('Presentatie')),
_presentationTargetField(),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
l10n.d(
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
const SizedBox(height: 16),
_sectionTitle(l10n.t('presentationFolder')),
Row(
children: [
@ -490,6 +520,85 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
/// Dropdown with interface text-scale steps (WCAG 1.4.4 asks for up to
/// 200%). The stored value snaps to the nearest offered step.
Widget _uiTextScaleField() {
final l10n = context.l10n;
const steps = [1.0, 1.15, 1.3, 1.5, 1.75, 2.0];
final current = ref.watch(settingsProvider.select((s) => s.uiTextScale));
final value = steps.reduce(
(a, b) => (a - current).abs() <= (b - current).abs() ? a : b,
);
return InputDecorator(
decoration: InputDecoration(
labelText: l10n.d('Tekstgrootte van de interface'),
isDense: true,
prefixIcon: const Icon(Icons.text_increase, size: 18),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<double>(
value: value,
isExpanded: true,
isDense: true,
items: [
for (final step in steps)
DropdownMenuItem(
value: step,
child: Text('${(step * 100).round()}%'),
),
],
onChanged: (scale) {
if (scale == null) return;
ref.read(settingsProvider.notifier).setUiTextScale(scale);
},
),
),
);
}
/// 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() {
final l10n = context.l10n;
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
@ -993,6 +1102,14 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v),
),
const SizedBox(height: 12),
_colorSetting(
l10n.d('Tabel kopachtergrond'),
_themeProfile.tableHeaderBackgroundColor,
(v) => _themeProfile = _themeProfile.copyWith(
tableHeaderBackgroundColor: v,
),
),
const SizedBox(height: 12),
_colorSetting(
l10n.d('Titelachtergrond'),
_themeProfile.titleBackgroundColor,
@ -1575,4 +1692,86 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
return Color(value ?? 0xFFFFFFFF);
}
Widget _privacyTab() {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.d('Toestemming')),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF0F9FF),
border: Border.all(color: const Color(0xFFBFDBFE)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
l10n.d(
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF475569)),
),
],
),
),
const SizedBox(height: 16),
Center(
child: ElevatedButton.icon(
onPressed: _revokeConsent,
icon: const Icon(Icons.undo, size: 16),
label: Text(l10n.d('Toestemming intrekken')),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
foregroundColor: Colors.white,
),
),
),
],
);
}
void _revokeConsent() {
final l10n = context.l10n;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.d('Toestemming intrekken?')),
content: Text(
l10n.d(
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.t('cancel')),
),
ElevatedButton(
onPressed: () {
ref.read(consentProvider.notifier).revokeConsent();
Navigator.pop(ctx);
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red[600]),
child: Text(
l10n.d('Intrekken'),
style: const TextStyle(color: Colors.white),
),
),
],
),
);
}
}

View file

@ -117,7 +117,7 @@ class ImageZoomControl extends StatelessWidget {
child: const Icon(
Icons.zoom_out,
size: 16,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
),
Expanded(
@ -141,7 +141,7 @@ class ImageZoomControl extends StatelessWidget {
child: const Icon(
Icons.zoom_in,
size: 16,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
),
const SizedBox(width: 8),
@ -153,7 +153,7 @@ class ImageZoomControl extends StatelessWidget {
fontSize: 12,
color: zoomed
? const Color(0xFF2563EB)
: const Color(0xFF94A3B8),
: const Color(0xFF64748B),
fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal,
),
textAlign: TextAlign.right,
@ -167,7 +167,7 @@ class ImageZoomControl extends StatelessWidget {
onPressed: zoomed ? () => onChanged(100) : null,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
color: const Color(0xFF94A3B8),
color: const Color(0xFF64748B),
),
),
],
@ -176,7 +176,7 @@ class ImageZoomControl extends StatelessWidget {
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text(
_label(context),
style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 10, color: Color(0xFF64748B)),
),
),
],
@ -226,10 +226,57 @@ class ImagePickerBar extends ConsumerWidget {
captionService: captions,
descriptionService: ref.read(descriptionServiceProvider),
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to),
openDeckFiles: [
for (final tab in ref.read(tabsProvider).tabs)
?tab.deckNotifier.currentState.filePath,
],
);
if (result != null) onPicked(result.path, result.caption);
}
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
Future<void> _replaceImageUsages(
WidgetRef ref,
String fromAbsolute,
String toAbsolute,
) async {
final target = p.normalize(fromAbsolute);
for (final tab in ref.read(tabsProvider).tabs) {
final notifier = tab.deckNotifier;
final deck = notifier.currentState.deck;
if (deck == null) continue;
final projectPath = deck.projectPath ?? '';
String resolve(String candidate) => p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
);
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
// binnen het project ligt; anders absoluut.
String replacement(String candidate) {
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
return p.isWithin(projectPath, toAbsolute)
? p.relative(toAbsolute, from: projectPath)
: toAbsolute;
}
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
var updated = slide;
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
}
if (slide.imagePath2.isNotEmpty &&
resolve(slide.imagePath2) == target) {
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
}
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
}
}
}
/// Find every open-deck slide that references [absolutePath], so we can warn
/// before deleting an image that is still in use.
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
@ -282,7 +329,7 @@ class ImagePickerBar extends ConsumerWidget {
style: TextStyle(
fontSize: 12,
color: imagePath.isEmpty
? const Color(0xFF94A3B8)
? const Color(0xFF64748B)
: const Color(0xFF334155),
),
overflow: TextOverflow.ellipsis,
@ -345,7 +392,7 @@ class ImagePickerBar extends ConsumerWidget {
child: IconButton(
onPressed: onClear,
icon: const Icon(Icons.clear, size: 18),
color: const Color(0xFF94A3B8),
color: const Color(0xFF64748B),
),
),
],
@ -447,7 +494,7 @@ class _CaptionFieldState extends State<_CaptionField> {
prefixIcon: const Icon(
Icons.copyright_outlined,
size: 16,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),

View file

@ -48,7 +48,7 @@ class AudioAttachmentEditor extends StatelessWidget {
style: TextStyle(
fontSize: 12,
color: slide.audioPath.isEmpty
? const Color(0xFF94A3B8)
? const Color(0xFF64748B)
: const Color(0xFF334155),
),
overflow: TextOverflow.ellipsis,

View file

@ -289,7 +289,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
),
const SizedBox(width: 8),
Expanded(
@ -342,7 +342,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
onPressed: () => _removeBulletAndFocus(i),
tooltip: l10n.d('Verwijder'),

View file

@ -323,7 +323,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
),
const SizedBox(width: 8),
Expanded(
@ -372,7 +372,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
onPressed: () => _removeBulletAndFocus(i),
padding: const EdgeInsets.symmetric(horizontal: 4),

View file

@ -791,7 +791,7 @@ class _ChartEditorState extends State<ChartEditor> {
decoration: BoxDecoration(
color: Color(
_type == ChartType.pie && c >= 2
? 0xFF94A3B8
? 0xFF64748B
: int.parse(
chartSeriesColor(
ChartSeries(
@ -951,7 +951,7 @@ class _ChartEditorState extends State<ChartEditor> {
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
),
);
@ -1038,7 +1038,7 @@ class _ChartEditorState extends State<ChartEditor> {
key: key,
onPressed: onTap,
icon: Icon(icon, size: 14),
color: const Color(0xFF94A3B8),
color: const Color(0xFF64748B),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),

View file

@ -99,7 +99,7 @@ class _QuoteEditorState extends ConsumerState<QuoteEditor> {
l10n.d(
'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
),
const SizedBox(height: 8),
ImagePickerBar(

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/slide.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/table_clipboard.dart';
import '_editor_field.dart';
/// Editor for a table slide. Stores cells as a rectangular grid of
@ -108,6 +110,69 @@ class _TableEditorState extends State<TableEditor> {
_emit();
}
/// Intercepts the paste shortcut on a cell: Cmd+V (macOS), Ctrl+V
/// (Windows/Linux) and Shift+Insert (Windows/Linux). The clipboard can only
/// be read asynchronously, so the event is always claimed and [_pasteIntoCell]
/// decides between a table fill and a plain in-cell paste.
KeyEventResult _onCellKey(int r, int c, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
final pasteCombo =
(event.logicalKey == LogicalKeyboardKey.keyV &&
(keys.isControlPressed || keys.isMetaPressed)) ||
(event.logicalKey == LogicalKeyboardKey.insert && keys.isShiftPressed);
if (!pasteCombo) return KeyEventResult.ignored;
Clipboard.getData(Clipboard.kTextPlain).then((data) {
final text = data?.text;
if (text == null || text.isEmpty || !mounted) return;
_pasteIntoCell(r, c, text);
});
return KeyEventResult.handled;
}
/// Tabular clipboard content (a spreadsheet selection, CSV, a markdown
/// table) fills the grid starting at cell (r, c), growing it as needed;
/// anything else is pasted into the cell at the cursor as usual.
void _pasteIntoCell(int r, int c, String text) {
final table = parseClipboardTable(text);
if (table == null) {
final ctrl = _cells[r][c];
final value = ctrl.text;
final sel = ctrl.selection;
final start = sel.isValid ? sel.start : value.length;
final end = sel.isValid ? sel.end : value.length;
ctrl.value = TextEditingValue(
text: value.replaceRange(start, end, text),
selection: TextSelection.collapsed(offset: start + text.length),
);
return;
}
setState(() {
final neededCols = c + table.first.length;
final neededRows = r + table.length;
while (_colCount < neededCols) {
for (final row in _cells) {
row.add(_makeCtrl(''));
}
}
while (_cells.length < neededRows) {
_cells.add(
List<TextEditingController>.generate(_colCount, (_) => _makeCtrl('')),
);
}
for (var i = 0; i < table.length; i++) {
for (var j = 0; j < table[i].length; j++) {
final ctrl = _cells[r + i][c + j];
// Rewrite without notifying per cell; one _emit follows below.
ctrl.removeListener(_emit);
ctrl.text = table[i][j];
ctrl.addListener(_emit);
}
}
});
_emit();
}
@override
void dispose() {
_title.dispose();
@ -131,8 +196,9 @@ class _TableEditorState extends State<TableEditor> {
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.'),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
'${l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.')}\n'
'${l10n.d('Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.')}',
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
),
),
_buildColumnControls(),
@ -170,7 +236,7 @@ class _TableEditorState extends State<TableEditor> {
icon: const Icon(
Icons.delete_outline,
size: 16,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
onPressed: _colCount > 1 ? () => _removeColumn(c) : null,
tooltip:
@ -203,26 +269,31 @@ class _TableEditorState extends State<TableEditor> {
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: TextField(
controller: _cells[r][c],
// Meerdere regels toestaan: het veld groeit mee en Enter
// voegt een nieuwe regel toe binnen de cel.
minLines: 1,
maxLines: null,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
style: TextStyle(
fontSize: 13,
fontWeight: isHeader ? FontWeight.w600 : FontWeight.normal,
),
decoration: InputDecoration(
isDense: true,
filled: isHeader,
fillColor: isHeader ? const Color(0xFFF1F5F9) : null,
hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
child: Focus(
onKeyEvent: (node, event) => _onCellKey(r, c, event),
child: TextField(
controller: _cells[r][c],
// Meerdere regels toestaan: het veld groeit mee en Enter
// voegt een nieuwe regel toe binnen de cel.
minLines: 1,
maxLines: null,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
style: TextStyle(
fontSize: 13,
fontWeight: isHeader
? FontWeight.w600
: FontWeight.normal,
),
decoration: InputDecoration(
isDense: true,
filled: isHeader,
fillColor: isHeader ? const Color(0xFFF1F5F9) : null,
hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
),
),
),
@ -236,7 +307,7 @@ class _TableEditorState extends State<TableEditor> {
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
onPressed: _cells.length > 1 ? () => _removeRow(r) : null,
tooltip: isHeader

View file

@ -99,7 +99,7 @@ class _TitleEditorState extends ConsumerState<TitleEditor> {
l10n.d(
'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
),
const SizedBox(height: 8),
ImagePickerBar(

View file

@ -348,7 +348,7 @@ class _BulletColumnState extends State<_BulletColumn> {
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
),
const SizedBox(width: 8),
Expanded(
@ -399,7 +399,7 @@ class _BulletColumnState extends State<_BulletColumn> {
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF94A3B8),
color: Color(0xFF64748B),
),
onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
tooltip: l10n.d('Verwijder'),

View file

@ -135,7 +135,7 @@ class _TwoImagesEditorState extends ConsumerState<TwoImagesEditor> {
child: Text(
'Links ${widget.slide.imageSize > 0 ? widget.slide.imageSize : 50}% — '
'Rechts ${100 - (widget.slide.imageSize > 0 ? widget.slide.imageSize : 50)}%',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
),
),
],

View file

@ -116,7 +116,7 @@ class _PathBox extends StatelessWidget {
style: TextStyle(
fontSize: 12,
color: path.isEmpty
? const Color(0xFF94A3B8)
? const Color(0xFF64748B)
: const Color(0xFF334155),
),
overflow: TextOverflow.ellipsis,

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@ -13,13 +15,21 @@ import '../../services/slide_rasterizer.dart';
import '../../state/slide_clipboard_provider.dart';
import '../../theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/log.dart';
import '../dialogs/add_slide_dialog.dart';
import '../dialogs/import_slides_dialog.dart';
import '../dialogs/slide_finder_dialog.dart';
import '../slides/slide_thumbnail.dart';
class SlideListPanel extends ConsumerStatefulWidget {
const SlideListPanel({super.key});
/// Current width of the slide rail. When it changes (dragging the divider),
/// the slide being edited is scrolled back into view once the resize
/// settles. Passed in by the shell rather than measured with a
/// LayoutBuilder: rebuilding a ReorderableListView during layout trips its
/// overlay bookkeeping ("_RenderLayoutBuilder was mutated…").
final double? railWidth;
const SlideListPanel({super.key, this.railWidth});
@override
ConsumerState<SlideListPanel> createState() => _SlideListPanelState();
@ -31,15 +41,35 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
final _scrollController = ScrollController();
final _focusNode = FocusNode(debugLabel: 'SlideListPanel');
final Map<String, GlobalKey> _slideKeys = {};
Timer? _resizeSettleTimer;
@override
void dispose() {
_resizeSettleTimer?.cancel();
_searchController.dispose();
_scrollController.dispose();
_focusNode.dispose();
super.dispose();
}
/// Thumbnails are 16:9, so when the rail is resized their heights change and
/// the scroll offset no longer points at the slide being edited. Once the
/// resize settles, bring the selected slide back to the top of the list.
@override
void didUpdateWidget(covariant SlideListPanel oldWidget) {
super.didUpdateWidget(oldWidget);
final width = widget.railWidth;
final previous = oldWidget.railWidth;
if (width == null || previous == null || (width - previous).abs() < 0.5) {
return;
}
_resizeSettleTimer?.cancel();
_resizeSettleTimer = Timer(const Duration(milliseconds: 200), () {
if (!mounted) return;
_scrollSlideToTop(ref.read(editorProvider).selectedIndex);
});
}
/// Lower-cased, concatenated text of a slide for searching. Kept broad on
/// purpose: everything you typed into the slide should make it findable.
String _slideText(Slide slide) {
@ -88,7 +118,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
_slideKeys.removeWhere((id, _) => !ids.contains(id));
}
void _scrollSlideToTop(int index) {
void _scrollSlideToTop(int index, {int attempts = 2}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final deck = ref.read(deckProvider).deck;
if (deck == null ||
@ -100,7 +130,21 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
final keyContext = _slideKeys[deck.slides[index].id]?.currentContext;
final target = keyContext?.findRenderObject();
if (target == null) return;
if (target == null) {
// The thumbnail hasn't been built (it sits outside the viewport and
// cache). Jump close to it based on the average item height, then try
// again now that the surrounding items exist.
if (attempts <= 0) return;
final position = _scrollController.position;
final avgItem =
(position.maxScrollExtent + position.viewportDimension) /
deck.slides.length;
_scrollController.jumpTo(
(avgItem * index).clamp(0.0, position.maxScrollExtent),
);
_scrollSlideToTop(index, attempts: attempts - 1);
return;
}
final viewport = RenderAbstractViewport.maybeOf(target);
if (viewport == null) return;
@ -173,7 +217,9 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
tlp: deck.tlp,
);
if (images.isNotEmpty) bytes = images.first;
} catch (_) {}
} catch (e) {
logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e);
}
if (!mounted) return;
final ok =
bytes != null && await ImageService().copyImageBytesToClipboard(bytes);
@ -506,6 +552,67 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
);
}
Widget _buildSlideList(
Deck deck,
bool searching,
String query,
EditorState editor,
DeckNotifier notifier,
EditorNotifier editorNotifier,
) {
if (searching) {
return _buildFilteredList(deck, query, editor, notifier, editorNotifier);
}
return ReorderableListView.builder(
scrollController: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 4),
buildDefaultDragHandles: false,
itemCount: deck.slides.length,
onReorderItem: (old, nw) {
notifier.reorderSlides(old, nw);
// Adjust selection when active slide moved
final selIdx = editor.selectedIndex;
int newSel = selIdx;
if (old == selIdx) {
newSel = nw;
} else if (old < selIdx && nw >= selIdx) {
newSel = selIdx - 1;
} else if (old > selIdx && nw <= selIdx) {
newSel = selIdx + 1;
}
editorNotifier.select(newSel.clamp(0, deck.slides.length - 1));
},
proxyDecorator: (child, index, animation) =>
Material(color: Colors.transparent, child: child),
itemBuilder: (_, i) {
final slide = deck.slides[i];
return SlideThumbnail(
key: _keyForSlide(slide),
slide: slide,
index: i,
isSelected: editor.selection.contains(i),
isPrimary: editor.selectedIndex == i,
projectPath: deck.projectPath,
themeProfile: deck.themeProfile,
slideCount: deck.slides.length,
tlp: deck.tlp,
onTap: () => _onSlideTap(i),
onToggleSkip: () => notifier.toggleSkip(i),
onCopyImage: () => _copySlideAsImage(slide),
onDuplicate: () {
notifier.duplicateSlide(i);
editorNotifier.select(i + 1);
},
onDelete: () {
if (deck.slides.length <= 1) return;
notifier.removeSlide(i);
editorNotifier.clampIndex(deck.slides.length - 2);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@ -605,64 +712,14 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
// Slide list
Expanded(
child: searching
? _buildFilteredList(
deck,
query,
editor,
notifier,
editorNotifier,
)
: ReorderableListView.builder(
scrollController: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 4),
buildDefaultDragHandles: false,
itemCount: deck.slides.length,
onReorderItem: (old, nw) {
notifier.reorderSlides(old, nw);
// Adjust selection when active slide moved
final selIdx = editor.selectedIndex;
int newSel = selIdx;
if (old == selIdx) {
newSel = nw;
} else if (old < selIdx && nw >= selIdx) {
newSel = selIdx - 1;
} else if (old > selIdx && nw <= selIdx) {
newSel = selIdx + 1;
}
editorNotifier.select(
newSel.clamp(0, deck.slides.length - 1),
);
},
proxyDecorator: (child, index, animation) =>
Material(color: Colors.transparent, child: child),
itemBuilder: (_, i) {
final slide = deck.slides[i];
return SlideThumbnail(
key: _keyForSlide(slide),
slide: slide,
index: i,
isSelected: editor.selection.contains(i),
isPrimary: editor.selectedIndex == i,
projectPath: deck.projectPath,
themeProfile: deck.themeProfile,
slideCount: deck.slides.length,
tlp: deck.tlp,
onTap: () => _onSlideTap(i),
onToggleSkip: () => notifier.toggleSkip(i),
onCopyImage: () => _copySlideAsImage(slide),
onDuplicate: () {
notifier.duplicateSlide(i);
editorNotifier.select(i + 1);
},
onDelete: () {
if (deck.slides.length <= 1) return;
notifier.removeSlide(i);
editorNotifier.clampIndex(deck.slides.length - 2);
},
);
},
),
child: _buildSlideList(
deck,
searching,
query,
editor,
notifier,
editorNotifier,
),
),
// Add / Paste slide buttons

View file

@ -31,6 +31,11 @@ class AnnotationLayer extends StatefulWidget {
/// Called as the laser moves (normalized), or null when it leaves.
final ValueChanged<Offset?>? onLaserMove;
/// Called as the in-progress stroke grows, so a presenter can mirror the
/// live drawing to the beamer instead of only the committed result. Carries
/// the current partial stroke, or null when it commits or is cancelled.
final ValueChanged<InkStroke?>? onActiveStrokeChanged;
const AnnotationLayer({
super.key,
required this.strokes,
@ -41,6 +46,7 @@ class AnnotationLayer extends StatefulWidget {
this.laserPoint,
this.onStrokesChanged,
this.onLaserMove,
this.onActiveStrokeChanged,
});
@override
@ -55,6 +61,18 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
bool get _drawing =>
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
/// The in-progress stroke as a committable [InkStroke], or null when there is
/// nothing being drawn. Used to mirror live drawing to the beamer.
InkStroke? _activeStroke() {
if (!_drawing || _active.isEmpty) return null;
return InkStroke(
tool: widget.tool!,
color: widget.color,
width: widget.width,
points: List<Offset>.from(_active),
);
}
Offset _norm(Offset local) => _size.shortestSide == 0
? Offset.zero
: Offset(
@ -65,6 +83,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
void _commitActive() {
if (_active.length < 2) {
setState(() => _active = const []);
widget.onActiveStrokeChanged?.call(null);
return;
}
final stroke = InkStroke(
@ -76,6 +95,8 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
final next = [...widget.strokes, stroke];
setState(() => _active = const []);
widget.onStrokesChanged?.call(next);
// Clear the beamer's live preview; the committed stroke arrives via strokes.
widget.onActiveStrokeChanged?.call(null);
}
void _eraseAt(Offset norm) {
@ -95,6 +116,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
case InkTool.pen:
case InkTool.highlighter:
setState(() => _active = [n]);
widget.onActiveStrokeChanged?.call(_activeStroke());
case InkTool.eraser:
_eraseAt(n);
case InkTool.laser:
@ -110,7 +132,10 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
switch (widget.tool) {
case InkTool.pen:
case InkTool.highlighter:
if (_active.isNotEmpty) setState(() => _active = [..._active, n]);
if (_active.isNotEmpty) {
setState(() => _active = [..._active, n]);
widget.onActiveStrokeChanged?.call(_activeStroke());
}
case InkTool.eraser:
_eraseAt(n);
case InkTool.laser:

View file

@ -6,6 +6,7 @@ import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/markdown_service.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart';
import '../slides/slide_preview.dart';
import 'annotation_overlay.dart';
@ -47,6 +48,10 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
final Map<int, List<InkStroke>> _ink = {};
int? _laserIndex;
Offset? _laserPoint;
// The stroke the presenter is drawing right now, mirrored live until it
// commits (then it arrives as a normal stroke over the 'ink' channel).
int? _activeIndex;
InkStroke? _activeStroke;
@override
void initState() {
@ -84,6 +89,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
_index = (m['index'] as num?)?.toInt() ?? _index;
_blank = (m['blank'] as num?)?.toInt() ?? 0;
_laserPoint = null; // laser never carries over to another slide
_activeStroke = null; // nor does an in-progress stroke
});
case 'ink':
final m = Map<String, dynamic>.from(call.arguments as Map);
@ -92,6 +98,17 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
setState(
() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []),
);
case 'inkLive':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
final s = m['stroke'];
if (!mounted) return null;
setState(() {
_activeIndex = i;
_activeStroke = s == null
? null
: InkStroke.fromJson(Map<String, dynamic>.from(s as Map));
});
case 'laser':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
@ -117,7 +134,12 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
try {
final self = await WindowController.fromCurrentEngine();
await self.close();
} catch (_) {}
} catch (e) {
logWarning(
'_AudienceWindowAppState._onPresenterCall: close window',
e,
);
}
}
return null;
}
@ -198,7 +220,13 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
}),
),
AnnotationLayer(
strokes: _ink[_index] ?? const [],
strokes: [
...?_ink[_index],
// The live in-progress stroke renders just like a committed
// one, so the audience sees the line grow as it's drawn.
if (_activeStroke != null && _activeIndex == _index)
_activeStroke!,
],
interactive: false,
laserPoint: _laserIndex == _index ? _laserPoint : null,
),

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@ -12,11 +13,15 @@ import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/markdown_service.dart';
import '../../services/rehearsal_controller.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart';
import '../../l10n/app_localizations.dart';
import '../slides/inline_markdown.dart';
import '../slides/slide_preview.dart';
import 'annotation_overlay.dart';
import 'audience_window.dart';
import 'rehearsal_summary.dart';
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
enum _Blank { none, black, white }
@ -28,6 +33,10 @@ class FullscreenPresenter extends StatefulWidget {
final int initialIndex;
final TlpLevel tlp;
/// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling.
/// Sessie-only; live aanpasbaar in de presenter (toets K).
final Duration? targetDuration;
/// When set, this presenter drives a separate audience (beamer) window: the
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
/// for the classic single-screen mode.
@ -46,6 +55,7 @@ class FullscreenPresenter extends StatefulWidget {
required this.themeProfile,
required this.initialIndex,
this.tlp = TlpLevel.none,
this.targetDuration,
this.audienceWindow,
this.initialAnnotations = const {},
this.onAnnotationsChanged,
@ -62,6 +72,7 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile,
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
Duration? targetDuration,
Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged,
@ -71,7 +82,8 @@ class FullscreenPresenter extends StatefulWidget {
try {
final displays = await screenRetriever.getAllDisplays();
displayCount = displays.length;
} catch (_) {
} catch (e) {
logWarning('FullscreenPresenter.present: display detection failed', e);
displayCount = 0;
}
}
@ -90,6 +102,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
targetDuration: targetDuration,
annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
@ -102,6 +115,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
targetDuration: targetDuration,
annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
@ -116,6 +130,7 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile,
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
Duration? targetDuration,
Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged,
@ -135,6 +150,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
targetDuration: targetDuration,
initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
@ -161,12 +177,15 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile,
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
Duration? targetDuration,
Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged,
}) async {
// A self-contained markdown deck is the payload for the audience window; it
// carries the slides, the style profile and the TLP level in one string.
// This payload never touches disk, so it inlines the style profile the
// beamer has no other way to learn the deck's styling.
final markdown = MarkdownService().generateDeck(
Deck(
title: 'Presentatie',
@ -175,6 +194,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile,
tlp: tlp,
),
inlineStyleProfile: true,
);
// Pre-existing annotations re-keyed by index so the beamer shows them
// immediately (the audience window has no stable slide ids of its own).
@ -198,7 +218,11 @@ class FullscreenPresenter extends StatefulWidget {
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
);
await audience.coverScreen(external: true);
} catch (_) {
} catch (e) {
logError(
'FullscreenPresenter.showDualScreen: audience window setup failed',
e,
);
audience = null;
}
@ -278,7 +302,8 @@ bool autoAdvanceWaitsForMedia(Slide slide) {
Future<bool> _wakeLockEnabled() async {
try {
return await WakelockPlus.enabled;
} catch (_) {
} catch (e) {
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
return false;
}
}
@ -286,7 +311,8 @@ Future<bool> _wakeLockEnabled() async {
Future<void> _enableWakeLock() async {
try {
await WakelockPlus.enable();
} catch (_) {
} catch (e) {
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
// Best-effort: unsupported platforms should not interrupt presenting.
}
}
@ -298,7 +324,8 @@ Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
} else {
await WakelockPlus.disable();
}
} catch (_) {
} catch (e) {
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
// Best-effort cleanup.
}
}
@ -327,13 +354,19 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
double _gridRowExtent = 220;
final ScrollController _gridScroll = ScrollController();
/// Starttijd voor de verstreken-tijd-teller (resetbaar met R).
late DateTime _startTime;
/// Oefenklok: verstreken tijd, aftelling en per-slide-tijd. Sessie-only,
/// puur meten (geen pacing). Resetbaar met R.
late RehearsalController _rehearsal;
/// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief).
String _typed = '';
Timer? _typedTimer;
/// Doeltijd-invoermodus (toets K): cijfers worden als MMSS gelezen i.p.v. als
/// slidenummer. [_targetTyped] houdt de invoer tot Enter/Esc.
bool _targetInput = false;
String _targetTyped = '';
/// Sneltoets-overzicht (cheatsheet) zichtbaar.
bool _helpOpen = false;
@ -372,6 +405,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
static const _penWidth = 0.004;
static const _highlighterWidth = 0.022;
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0);
double get _toolWidth =>
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
@ -385,7 +419,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
void initState() {
super.initState();
_index = widget.initialIndex;
_startTime = DateTime.now();
_rehearsal = RehearsalController(target: widget.targetDuration);
_focusNode = FocusNode();
_ink = {
for (final e in widget.initialAnnotations.entries)
@ -539,6 +573,25 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
.catchError((_) => null);
}
/// Mirror the stroke that is being drawn right now to the beamer, so the
/// audience sees a pen/highlighter line appear live instead of only after the
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
/// just keeps the in-progress preview in sync for the same slide.
void _onActiveStroke(InkStroke? stroke) {
if (widget.audienceWindow == null) return;
final now = DateTime.now();
// Throttle growth events; always send the "done" (null) event so the
// beamer drops its live preview the moment the stroke commits.
if (stroke != null &&
now.difference(_lastInkLiveSent) < const Duration(milliseconds: 33)) {
return;
}
_lastInkLiveSent = now;
audienceChannel
.invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()})
.catchError((_) => null);
}
/// Select a tool, or toggle it off when it is already active.
void _setTool(InkTool tool) {
setState(() => _tool = _tool == tool ? null : tool);
@ -679,7 +732,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_displays = displays;
_displayIndex = current < 0 ? 0 : current;
});
} catch (_) {
} catch (e) {
logWarning(
'_FullscreenPresenterState._loadDisplays: screen detection failed',
e,
);
// Screen detection is best-effort; presenting should still work.
}
}
@ -696,7 +753,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
);
await windowManager.setFullScreen(true);
if (mounted) setState(() => _displayIndex = index);
} catch (_) {
} catch (e) {
logError(
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
e,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -715,6 +776,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
Future<void> _exit() async {
_advanceTimer?.cancel();
await _maybeShowRehearsalSummary();
final aw = widget.audienceWindow;
if (aw != null) {
// Dual mode: the main window was never put in full screen; just tear down
@ -727,6 +789,30 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
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):
/// visueel verandert de hele slide, maar zonder aankondiging merkt een
/// schermlezer-gebruiker de wissel niet op.
void _announceSlide() {
final total = widget.slides.length;
if (total == 0 || !mounted) return;
final slide = widget.slides[_index.clamp(0, total - 1)];
final title = stripInlineMarkdown(slide.title).trim();
SemanticsService.sendAnnouncement(
View.of(context),
'${context.l10n.d('Slide')} ${_index + 1}/$total'
'${title.isEmpty ? '' : ': $title'}',
TextDirection.ltr,
);
}
void _next() {
// Eerste toets/klik op een blanco scherm haalt het scherm terug.
if (_blank != _Blank.none) {
@ -736,6 +822,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (_index < widget.slides.length - 1) {
setState(() => _index++);
_scheduleAdvance();
_announceSlide();
}
}
@ -747,6 +834,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (_index > 0) {
setState(() => _index--);
_scheduleAdvance();
_announceSlide();
}
}
@ -755,7 +843,40 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
}
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() {
@ -839,6 +960,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_gridOpen = false;
});
_scheduleAdvance();
_announceSlide();
}
/// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End).
@ -851,6 +973,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (target == _index) return;
setState(() => _index = target);
_scheduleAdvance();
_announceSlide();
}
/// Verplaats de rastercursor en houd 'm in beeld.
@ -895,6 +1018,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
return KeyEventResult.handled;
}
// Doeltijd-invoer vangt cijfers/Enter/Esc tot de invoer klaar is.
if (_targetInput) return _handleTargetKey(key);
// Terwijl het raster open is, sturen de pijltjes een aparte cursor aan.
if (_gridOpen) return _handleGridKey(key);
@ -946,6 +1072,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
case LogicalKeyboardKey.keyR:
_resetTimer();
return KeyEventResult.handled;
case LogicalKeyboardKey.keyK:
_beginTargetInput();
return KeyEventResult.handled;
case LogicalKeyboardKey.keyB:
_toggleBlank(_Blank.black);
return KeyEventResult.handled;
@ -1000,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.
KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
final last = widget.slides.length - 1;
@ -1043,6 +1207,12 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss';
}
/// Resterende tijd, met minteken zodra je over de doeltijd gaat.
String _fmtRemaining(Duration d) {
final body = _fmtElapsed(d.abs());
return d.isNegative ? '-$body' : body;
}
@override
Widget build(BuildContext context) {
final total = widget.slides.length;
@ -1054,6 +1224,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
// Keep the beamer window in step with whatever index/blank we now show.
_syncAudience();
// Per-slide-timing: registreer de huidige slide. Idempotent en goedkoop,
// dus veilig om elke build aan te roepen vangt álle navigatiepaden.
final clampedIndex = _index.clamp(0, total - 1);
_rehearsal.observe(widget.slides[clampedIndex].id, clampedIndex);
return Focus(
focusNode: _focusNode,
autofocus: true,
@ -1080,6 +1255,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
bottom: 60,
child: Center(child: _buildTypedBadge(total)),
),
if (_targetInput)
Positioned(
left: 0,
right: 0,
bottom: 60,
child: Center(child: _buildTargetBadge()),
),
if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()),
],
),
@ -1208,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).
Widget _buildHelpOverlay() {
final l10n = context.l10n;
@ -1222,7 +1440,8 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
('B · W', l10n.d('Zwart · wit scherm')),
('D · T · E', l10n.d('Pen · markeerstift · gum')),
('X · C', l10n.d('Laser · annotaties wissen')),
('R', l10n.d('Verstreken tijd resetten')),
('K', l10n.d('Doeltijd / aftellen instellen (MMSS)')),
('R', l10n.d('Tijd & oefenrun resetten')),
('A', l10n.d('Automatische modus aan/uit')),
('L', l10n.d('Herhalen (loop) aan/uit')),
('M', l10n.d('Na media automatisch doorgaan')),
@ -1377,6 +1596,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
AnnotationLayer(
// Keyed by slide so a slide change (e.g. auto-advance) while a
// stroke is in progress resets the layer instead of committing
// the half-drawn stroke onto the next slide.
key: ValueKey(slide.id),
strokes: _currentStrokes,
tool: _tool,
color: _inkColor,
@ -1384,6 +1607,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
interactive: true,
onStrokesChanged: _onStrokesChanged,
onLaserMove: _onLaserMove,
onActiveStrokeChanged: _onActiveStroke,
),
],
),
@ -1517,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() {
final l10n = context.l10n;
final elapsed = DateTime.now().difference(_startTime);
final elapsed = _rehearsal.elapsed;
final remaining = _rehearsal.remaining;
final slideElapsed = _rehearsal.currentSlideElapsed;
final overtime = remaining != null && remaining.isNegative;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@ -1527,59 +1783,64 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF262626)),
),
child: Row(
child: Column(
children: [
// Verstreken tijd
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d('Verstreken'),
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
const SizedBox(height: 2),
Text(
_fmtElapsed(elapsed),
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
// Reset-knop
Tooltip(
message: l10n.d('Tijd resetten (R)'),
child: IconButton(
onPressed: _resetTimer,
icon: const Icon(Icons.restart_alt, size: 18),
color: Colors.white38,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
// Wandklok
Column(
crossAxisAlignment: CrossAxisAlignment.end,
// Bovenrij: verstreken tijd, knoppen, wandklok.
Row(
children: [
Text(
l10n.d('Klok'),
style: const TextStyle(color: Colors.white38, fontSize: 10),
Expanded(
child: _metric(l10n.d('Verstreken'), _fmtElapsed(elapsed)),
),
const SizedBox(height: 2),
Text(
_fmtClock(DateTime.now()),
style: const TextStyle(
color: Colors.white70,
fontSize: 24,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()],
Tooltip(
message: l10n.d('Doeltijd / aftellen (K)'),
child: IconButton(
onPressed: _beginTargetInput,
icon: const Icon(Icons.timer_outlined, size: 18),
color: Colors.white38,
visualDensity: VisualDensity.compact,
),
),
Tooltip(
message: l10n.d('Tijd resetten (R)'),
child: IconButton(
onPressed: _resetTimer,
icon: const Icon(Icons.restart_alt, size: 18),
color: Colors.white38,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
_metric(
l10n.d('Klok'),
_fmtClock(DateTime.now()),
color: Colors.white70,
align: CrossAxisAlignment.end,
),
],
),
const Divider(height: 18, color: Color(0xFF262626)),
// Onderrij: aftelling (resterend/over) en tijd op huidige slide.
Row(
children: [
Expanded(
child: _metric(
overtime ? l10n.d('Over de tijd') : l10n.d('Resterend'),
remaining == null ? ':' : _fmtRemaining(remaining),
color: remaining == null
? Colors.white24
: (overtime
? const Color(0xFFEF4444)
: const Color(0xFF22C55E)),
size: 20,
),
),
_metric(
l10n.d('Deze slide'),
_fmtElapsed(slideElapsed),
color: Colors.white70,
align: CrossAxisAlignment.end,
size: 20,
),
],
),
],

View file

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

View file

@ -0,0 +1,171 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
/// Open the search-based presentation picker and load the chosen file
/// (optionally jumping to a matched slide).
Future<void> _openWithSearch(
BuildContext context,
WidgetRef ref,
String? initialDirectory,
) async {
final settings = ref.read(settingsProvider);
final result = await OpenPresentationDialog.show(
context,
fileService: ref.read(fileServiceProvider),
initialDirectory: initialDirectory ?? settings.homeDirectory,
);
if (result == null) return;
await ref
.read(tabsProvider.notifier)
.openFileByPath(result.path, selectIndex: result.slideIndex);
}
/// Vraag een URL op om een presentatie (pakket of markdown) op te halen.
Future<String?> _showUrlDialog(BuildContext context) {
final l10n = context.l10n;
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.d('Importeren via URL')),
content: SizedBox(
width: 460,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.',
),
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
hintText: 'https://…',
prefixIcon: Icon(Icons.link, size: 18),
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, controller.text),
icon: const Icon(Icons.download, size: 16),
label: Text(l10n.d('Ophalen')),
),
],
),
);
}
List<String> _imageSearchPaths(String? projectPath, String? homeDirectory) {
final projectImagesPath = projectPath == null
? null
: p.join(projectPath, 'images');
return [?projectImagesPath, ?projectPath, ?homeDirectory];
}
String? _resolveImagePath(String path, String? projectPath) {
if (path.isEmpty) return null;
if (p.isAbsolute(path) || projectPath == null) return path;
return p.join(projectPath, path);
}
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
final target = p.normalize(absolutePath);
final usages = <String>[];
for (final tab in ref.read(tabsProvider).tabs) {
final deck = tab.deckNotifier.currentState.deck;
if (deck == null) continue;
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
for (final candidate in [slide.imagePath, slide.imagePath2]) {
if (candidate.isEmpty) continue;
final resolved = p.normalize(
p.isAbsolute(candidate)
? candidate
: p.join(deck.projectPath ?? '', candidate),
);
if (resolved == target) {
usages.add('${tab.label} · slide ${i + 1}');
break;
}
}
}
}
return usages;
}
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
Future<void> _replaceImageUsages(
WidgetRef ref,
String fromAbsolute,
String toAbsolute,
) async {
final target = p.normalize(fromAbsolute);
for (final tab in ref.read(tabsProvider).tabs) {
final notifier = tab.deckNotifier;
final deck = notifier.currentState.deck;
if (deck == null) continue;
final projectPath = deck.projectPath ?? '';
String resolve(String candidate) => p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
);
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
// binnen het project ligt; anders absoluut.
String replacement(String candidate) {
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
return p.isWithin(projectPath, toAbsolute)
? p.relative(toAbsolute, from: projectPath)
: toAbsolute;
}
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
var updated = slide;
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
}
if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) {
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
}
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
}
}
}
List<Slide> _slidesForPresentationOrExport(Deck deck) {
// Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export.
final slides = deck.slides
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
.toList();
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
slides.add(
Slide.create(
SlideType.freeMarkdown,
).copyWith(customMarkdown: closingMarkdown),
);
}
return slides;
}
// App shell

View file

@ -0,0 +1,139 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
/// Visuele hint terwijl bestanden boven het venster zweven.
class _DropOverlay extends StatelessWidget {
const _DropOverlay();
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: Container(
color: const Color(0xFF1C2B47).withValues(alpha: 0.55),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.file_download_outlined,
size: 40,
color: Color(0xFF2563EB),
),
const SizedBox(height: 10),
Text(
context.l10n.d('Laat los om toe te voegen'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 4),
Text(
context.l10n.d(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
),
),
),
);
}
}
// Tab bar
class _ResizableDivider extends StatefulWidget {
final ValueChanged<double> onDrag;
const _ResizableDivider({required this.onDrag});
@override
State<_ResizableDivider> createState() => _ResizableDividerState();
}
class _ResizableDividerState extends State<_ResizableDivider> {
static const double _keyboardStep = 24;
bool _hovered = false;
bool _dragging = false;
bool _focused = false;
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyUpEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
widget.onDrag(-_keyboardStep);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
widget.onDrag(_keyboardStep);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final active = _hovered || _dragging || _focused;
// Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys
// move it, and focus is shown with the same highlight as hovering
// (WCAG 2.4.7). Screen readers see it as an adjustable element.
return Focus(
onKeyEvent: _onKeyEvent,
onFocusChange: (focused) => setState(() => _focused = focused),
child: Semantics(
slider: true,
label: l10n.d('Breedte van het slidepaneel'),
hint: l10n.d('Pijltjestoetsen passen de breedte aan'),
onIncrease: () => widget.onDrag(_keyboardStep),
onDecrease: () => widget.onDrag(-_keyboardStep),
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => setState(() => _dragging = true),
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
onHorizontalDragCancel: () => setState(() => _dragging = false),
onHorizontalDragUpdate: (details) =>
widget.onDrag(details.delta.dx),
child: Tooltip(
message: l10n.d(
'Sleep om de slide-preview breder of smaller te maken',
),
child: SizedBox(
width: 9,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 90),
width: active ? 3 : 1,
color: active
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outlineVariant,
),
),
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,316 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
class _DeckStatusBar extends StatelessWidget {
final Deck deck;
final DeckState deckState;
final String? exportDirectory;
final Future<void> Function() onSave;
final VoidCallback? onExport;
final String exportTooltip;
const _DeckStatusBar({
required this.deck,
required this.deckState,
required this.exportDirectory,
required this.onSave,
required this.onExport,
required this.exportTooltip,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final skipped = deck.slides.where((s) => s.skipped).length;
final fileLabel = deckState.filePath == null
? l10n.t('notSavedYet')
: p.basename(deckState.filePath!);
final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved');
final exportLabel = exportDirectory == null
? l10n.t('exportNextToDeck')
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surface,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
_StatusAction(
icon: deckState.isDirty
? Icons.radio_button_checked
: Icons.check_circle_outline,
label: saveLabel,
tooltip: deckState.isDirty
? l10n.t('unsavedChanges')
: l10n.t('noUnsavedChanges'),
color: deckState.isDirty
? const Color(0xFFD97706)
: const Color(0xFF15803D),
onTap: () => onSave(),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.description_outlined,
label: fileLabel,
tooltip: deckState.filePath ?? l10n.t('noFileYet'),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.slideshow_outlined,
label: skipped == 0
? '${deck.slides.length} ${l10n.t('slides')}'
: '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}',
tooltip: skipped == 0
? l10n.t('allSlidesIncluded')
: '$skipped ${l10n.t('skippedSlidesExcluded')}',
color: skipped == 0 ? null : const Color(0xFF8A6D3B),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.palette_outlined,
label: deck.themeProfile.name,
tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}',
),
if (deck.tlp != TlpLevel.none) ...[
const _StatusDivider(),
_StatusItem(
icon: Icons.shield_outlined,
label: deck.tlp.label,
tooltip: '${l10n.t('classification')}: ${deck.tlp.label}',
color: Color(deck.tlp.foreground),
),
],
const Spacer(),
_StatusItem(
icon: Icons.folder_outlined,
label: exportLabel,
tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'),
),
const SizedBox(width: 6),
_StatusAction(
icon: Icons.upload_file_outlined,
label: l10n.t('export'),
tooltip: exportTooltip,
onTap: onExport,
),
],
),
),
);
}
}
class _StatusItem extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final Color? color;
const _StatusItem({
required this.icon,
required this.label,
required this.tooltip,
this.color,
});
@override
Widget build(BuildContext context) {
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: fg),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 210),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: color == null ? FontWeight.normal : FontWeight.w600,
),
),
),
],
),
);
}
}
class _StatusAction extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final Color? color;
final VoidCallback? onTap;
const _StatusAction({
required this.icon,
required this.label,
required this.tooltip,
this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final fg = enabled
? (color ?? Theme.of(context).colorScheme.secondary)
: Theme.of(context).disabledColor;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: fg),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: enabled ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
);
}
}
class _StatusDivider extends StatelessWidget {
const _StatusDivider();
@override
Widget build(BuildContext context) {
return Container(
width: 1,
height: 14,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: Theme.of(context).colorScheme.outlineVariant,
);
}
}
/// Dunne verticale scheiding tussen groepen AppBar-knoppen.
class _ActionsDivider extends StatelessWidget {
const _ActionsDivider();
@override
Widget build(BuildContext context) {
return Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: Colors.white24,
);
}
}
/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de
/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
class _TlpChip extends StatelessWidget {
final TlpLevel tlp;
final ValueChanged<TlpLevel> onSelected;
const _TlpChip({required this.tlp, required this.onSelected});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final isSet = tlp != TlpLevel.none;
final fg = Color(tlp.foreground);
final child = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: isSet ? Colors.black : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isSet)
const Icon(Icons.shield_outlined, size: 14, color: Colors.white70),
if (!isSet) const SizedBox(width: 5),
Text(
isSet ? tlp.label : 'TLP',
style: TextStyle(
color: isSet ? fg : Colors.white70,
fontSize: 11.5,
fontWeight: FontWeight.w700,
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
letterSpacing: 0.3,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: isSet ? fg : Colors.white54,
),
],
),
);
return PopupMenuButton<TlpLevel>(
tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
position: PopupMenuPosition.under,
onSelected: onSelected,
itemBuilder: (_) => [
for (final level in TlpLevel.values)
PopupMenuItem<TlpLevel>(
value: level,
child: Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: level == TlpLevel.none
? Colors.transparent
: Color(level.foreground),
border: Border.all(color: const Color(0xFF94A3B8)),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 10),
Text(level == TlpLevel.none ? l10n.d('Geen') : level.label),
if (level == tlp) ...[
const SizedBox(width: 12),
const Spacer(),
const Icon(Icons.check, size: 16, color: Color(0xFF475569)),
],
],
),
),
],
child: child,
);
}
}

View file

@ -0,0 +1,167 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
class _AppTabBar extends StatelessWidget {
final TabsState tabsState;
final ValueChanged<int> onSelect;
final ValueChanged<int> onClose;
final VoidCallback onAdd;
const _AppTabBar({
required this.tabsState,
required this.onSelect,
required this.onClose,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final palette = Theme.of(context).extension<AppPalette>()!;
return Container(
height: 36,
color: palette.panel,
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < tabsState.tabs.length; i++)
_TabChip(
tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex,
showClose:
tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i),
onClose: () => onClose(i),
),
],
),
),
),
Tooltip(
message: l10n.t('newTab'),
child: InkWell(
onTap: onAdd,
child: SizedBox(
width: 36,
height: 36,
child: Icon(
Icons.add,
size: 16,
color: palette.panelText.withValues(alpha: 0.55),
),
),
),
),
],
),
);
}
}
class _TabChip extends StatelessWidget {
final TabInfo tab;
final bool isActive;
final bool showClose;
final VoidCallback onTap;
final VoidCallback onClose;
final Color panelText;
final Color accent;
const _TabChip({
required this.tab,
required this.isActive,
required this.showClose,
required this.onTap,
required this.onClose,
required this.panelText,
required this.accent,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
height: 36,
decoration: BoxDecoration(
color: isActive
? panelText.withValues(alpha: 0.12)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isActive ? accent : Colors.transparent,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (tab.isDirty)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: const BoxDecoration(
color: Colors.orangeAccent,
shape: BoxShape.circle,
),
),
Flexible(
child: Text(
tab.label,
style: TextStyle(
fontSize: 12,
color: isActive
? panelText
: panelText.withValues(alpha: 0.72),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
if (showClose) ...[
const SizedBox(width: 4),
InkWell(
onTap: onClose,
borderRadius: BorderRadius.circular(3),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 12,
color: panelText.withValues(alpha: 0.55),
),
),
),
],
],
),
),
);
}
}
// Per-tab content
class _TabContent extends ConsumerWidget {
const _TabContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOpen = ref.watch(deckProvider.select((s) => s.isOpen));
if (!isOpen) return const _WelcomeScreen();
return _MainLayout(exportService: ExportService());
}
}
// Welcome screen

View file

@ -0,0 +1,164 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
class _WelcomeScreen extends ConsumerWidget {
const _WelcomeScreen();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final theme = Theme.of(context);
final palette = theme.extension<AppPalette>()!;
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
final recentFiles = ref.watch(
settingsProvider.select((s) => s.recentFiles),
);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Row(
children: [
// Midden: logo + knoppen
Expanded(
child: Align(
alignment: const Alignment(-0.15, 0.12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: 'De Winter Information Solutions',
image: true,
child: Image.asset(
'assets/images/de-winter-wittegeheel.png',
width: 320,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
const SizedBox(height: 36),
SizedBox(
width: 220,
child: ElevatedButton.icon(
onPressed: () => _newDeck(context, ref),
icon: const Icon(Icons.add, size: 18),
label: Text(l10n.t('newPresentation')),
),
),
const SizedBox(height: 12),
SizedBox(
width: 220,
child: OutlinedButton.icon(
onPressed: () => _openWithSearch(context, ref, homeDir),
icon: const Icon(Icons.folder_open_outlined, size: 18),
label: Text(l10n.t('open')),
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => SettingsDialog.show(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: Text(l10n.t('settings')),
),
],
),
),
),
// Rechts: recente bestanden
if (recentFiles.isNotEmpty)
Container(
width: 280,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
child: Text(
l10n.t('recentPresentations'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: palette.mutedText,
letterSpacing: 0.8,
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: recentFiles.length,
itemBuilder: (_, i) {
final path = recentFiles[i];
final name = path.split('/').last.replaceAll('.md', '');
return InkWell(
onTap: () => ref
.read(tabsProvider.notifier)
.openFileByPath(path),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.slideshow_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
Text(
path,
style: TextStyle(
fontSize: 10,
color: palette.mutedText,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
),
),
],
),
),
],
),
);
}
Future<void> _newDeck(BuildContext context, WidgetRef ref) async {
final title = await NewDeckDialog.show(context);
if (title != null) {
ref.read(tabsProvider.notifier).newDeckInCurrentTab(title);
}
}
}
// Main 2-panel layout

View file

@ -0,0 +1,916 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _BulletsPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _BulletsPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.07;
// Slightly tighter top/bottom margin than the side margin so short
// checklists can grow into more of the slide height instead of leaving a
// wide empty band below the text.
final vPad = w * 0.05;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final titleSize = w * 0.042;
final subtitleSize = w * 0.030;
final bulletSize = w * 0.026;
final spacing = pad * 0.5;
final bulletGap = w * 0.006;
final bullets = slide.bullets
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
final subtitle = slide.subtitle;
final hasSubtitle = subtitle.isNotEmpty;
final showProgress =
slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
bullets.isNotEmpty;
final slideHeight = w * 9 / 16;
final availW = (w - pad * 2).clamp(w * 0.12, w);
// The progress chart only needs a modest, fixed slot; give all remaining
// width to the bullets so the text can grow as large (and readable) as
// possible, especially on slides with many checklist items.
final progressGap = w * 0.025;
final progressW = w * 0.34;
final textAvailW = showProgress
? (availW - progressGap - progressW).clamp(w * 0.12, availW)
: availW;
final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
// Grow (or, when needed, shrink) the text so it uses the full vertical
// space instead of leaving a large empty area below a few short bullets.
final scale = _bulletsFitScale(
availW: textAvailW,
availH: availH,
hasTitle: hasTitle,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
listStyle: slide.listStyle,
);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
vPad + safe.top,
pad,
vPad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasTitle)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize * scale,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
if (hasSubtitle) ...[
SizedBox(height: spacing * scale * 0.4),
_md(
context,
subtitle,
_applyFont(
font,
TextStyle(
fontSize: subtitleSize * scale,
fontWeight: FontWeight.w600,
color: _hexColor(profile.accentColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
],
if ((hasTitle || hasSubtitle) && bullets.isNotEmpty)
SizedBox(height: spacing * scale),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _BulletListColumn(
bullets: bullets,
listStyle: slide.listStyle,
font: font,
profile: profile,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
column: 0,
),
),
if (showProgress) ...[
SizedBox(width: progressGap),
SizedBox(
width: progressW,
child: Center(
child: _ChecklistProgress(
bullets: bullets,
w: w,
font: font,
profile: profile,
),
),
),
],
],
),
],
),
),
),
),
),
);
}
}
class _TwoBulletsPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _TwoBulletsPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
/// One bullet column with an optional heading above it. When any column has a
/// heading, an equal-height slot is reserved in both so the bullet lists line
/// up.
Widget _bulletColumn(
BuildContext context, {
required String title,
required List<String> bullets,
required double columnW,
required double headingSize,
required double headingSlotH,
required double headingGap,
required double bulletSize,
required double bulletGap,
required double scale,
required int column,
}) {
return SizedBox(
width: columnW,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (headingSlotH > 0) ...[
SizedBox(
width: double.infinity,
height: headingSlotH,
child: title.isEmpty
? null
: _md(
context,
title,
_applyFont(
font,
TextStyle(
fontSize: headingSize,
fontWeight: FontWeight.bold,
color: _hexColor(profile.accentColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
),
SizedBox(height: headingGap),
],
_BulletListColumn(
bullets: bullets,
listStyle: slide.listStyle,
font: font,
profile: profile,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
column: column,
),
],
),
);
}
@override
Widget build(BuildContext context) {
final pad = w * 0.065;
// Tighter top/bottom margin than the side margin so dense columns (e.g. a
// 19-item list) can use more of the slide height and stay readable.
final vPad = w * 0.045;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final leftBullets = slide.bullets
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final rightBullets = slide.bullets2
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
// On dense slides (a long column drives the shared text size down) spend
// less of the height on the title, headings and inter-item gaps so the
// list items themselves can render larger and stay readable.
final dense = math.max(leftBullets.length, rightBullets.length) > 12;
final titleSize = w * (dense ? 0.034 : 0.04);
final bulletSize = w * 0.024;
final spacing = pad * (dense ? 0.28 : 0.38);
final bulletGap = w * (dense ? 0.0036 : 0.0055);
final columnGap = w * 0.055;
final col1Title = slide.columnTitle1.trim();
final col2Title = slide.columnTitle2.trim();
final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty;
final headingSize = w * (dense ? 0.023 : 0.03);
final headingGap = w * (dense ? 0.007 : 0.012);
final slideHeight = w * 9 / 16;
final contentW = (w - pad * 2).clamp(w * 0.12, w);
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
if (hasTitle) {
availH -= _measureTextHeight(
slide.title,
titleSize,
contentW,
bold: true,
fontFamily: font,
);
availH -= spacing;
}
// Reserve room for the (optional) column headings so the bullets still fit.
double headingHeight(String t) => t.isEmpty
? 0
: _measureTextHeight(
t,
headingSize,
columnW,
bold: true,
fontFamily: font,
);
final maxHeadingH = math.max(
headingHeight(col1Title),
headingHeight(col2Title),
);
if (hasColumnTitles) availH -= maxHeadingH + headingGap;
final leftScale = _bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: leftBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle,
);
final rightScale = _bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: rightBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle,
);
// Treat both columns as one composition: the busiest column determines
// the shared text size, so left and right never look typographically
// unrelated.
final columnScale = math.min(leftScale, rightScale);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: SizedBox.expand(
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
vPad + safe.top,
pad,
vPad + safe.bottom,
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: contentW,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasTitle)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
if (hasTitle) SizedBox(height: spacing),
if (slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
(leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[
Align(
alignment: Alignment.center,
child: SizedBox(
width: contentW * 0.5,
child: _ChecklistProgress(
bullets: [...leftBullets, ...rightBullets],
w: w,
font: font,
profile: profile,
),
),
),
SizedBox(height: spacing),
],
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bulletColumn(
context,
title: col1Title,
bullets: leftBullets,
columnW: columnW,
headingSize: headingSize,
headingSlotH: hasColumnTitles ? maxHeadingH : 0,
headingGap: headingGap,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: columnScale,
column: 0,
),
SizedBox(width: columnGap),
_bulletColumn(
context,
title: col2Title,
bullets: rightBullets,
columnW: columnW,
headingSize: headingSize,
headingSlotH: hasColumnTitles ? maxHeadingH : 0,
headingGap: headingGap,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: columnScale,
column: 1,
),
],
),
],
),
),
),
),
),
);
}
}
class _BulletsImagePreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _BulletsImagePreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final leftPad = w * 0.038;
final verticalPad = w * 0.042;
// Keep the gap between the text column and the image equal to the slide's
// left margin so the layout stays symmetric.
final gap = leftPad;
final safe = slide.showLogo
? _splitTextLogoSafeInsets(w, profile)
: EdgeInsets.zero;
final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40)
.clamp(0.1, 0.70);
final imgWidth = w * imgFraction;
final bulletSize = w * 0.031;
final titleSize = w * 0.042;
final spacing = verticalPad * 0.32;
final bulletGap = w * 0.005;
final bullets = slide.bullets
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
// The slide is always rendered 16:9, so the available area for the text
// column is fully determined by the width. Computing it directly (instead
// of via a LayoutBuilder) keeps the widget tree identical to the image
// side and avoids any layout-timing surprises.
final slideHeight = w * 9 / 16;
final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w);
final availH =
slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom);
// Pick the largest font scale (capped at the design size) whose content
// still fits the available height at the full column width. This keeps the
// text as large as possible and lets it span the full width toward the
// image, instead of uniformly shrinking and leaving a wide gap.
final scale = _bulletsFitScale(
availW: availW,
availH: availH,
hasTitle: hasTitle,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle,
);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Stack(
children: [
Positioned(
top: 0,
right: 0,
bottom: 0,
width: imgWidth,
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
Positioned(
top: 0,
left: 0,
right: imgWidth + gap,
bottom: 0,
child: Padding(
padding: EdgeInsets.fromLTRB(
leftPad,
verticalPad + safe.top,
0,
verticalPad + safe.bottom,
),
// FittedBox stays as a safety net for measurement rounding; with
// an accurate scale it renders at scale 1 (full width).
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: availW,
child: _contentColumn(
context: context,
scale: scale,
bullets: bullets,
hasTitle: hasTitle,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
),
),
),
),
),
],
),
);
}
Widget _contentColumn({
required BuildContext context,
required double scale,
required List<String> bullets,
required bool hasTitle,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasTitle)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize * scale,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale),
if (slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
bullets.isNotEmpty) ...[
_ChecklistProgress(
bullets: bullets,
w: w,
font: font,
profile: profile,
),
SizedBox(height: spacing * scale),
],
...bullets.asMap().entries.map((entry) {
final b = entry.value;
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
final text = slide.listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final checked =
slide.listStyle == ListStyle.checklist && checklistItemChecked(b);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
return _ChecklistBulletRow(
bullets: bullets,
itemIndex: entry.key,
column: 0,
listStyle: slide.listStyle,
checked: checked,
text: text,
level: level,
fontSize: fontSize,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
font: font,
profile: profile,
);
}),
],
);
}
}
class _BulletListColumn extends StatelessWidget {
final List<String> bullets;
final ListStyle listStyle;
final String font;
final ThemeProfile profile;
final double bulletSize;
final double bulletGap;
final double scale;
final int column;
const _BulletListColumn({
required this.bullets,
required this.listStyle,
required this.font,
required this.profile,
required this.bulletSize,
required this.bulletGap,
required this.scale,
this.column = 0,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
...bullets.asMap().entries.map((entry) {
final b = entry.value;
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final checked =
listStyle == ListStyle.checklist && checklistItemChecked(b);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
return _ChecklistBulletRow(
bullets: bullets,
itemIndex: entry.key,
column: column,
listStyle: listStyle,
checked: checked,
text: text,
level: level,
fontSize: fontSize,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
font: font,
profile: profile,
);
}),
],
);
}
}
/// Upper bound for growing bullet text to fill otherwise empty vertical space.
const double _kBulletsMaxScale = 3.2;
/// Split slides have a much narrower column, so short bullet lists can stay
/// visually timid unless they are allowed to grow a little further.
const double _kSplitBulletsMaxScale = 4.35;
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
/// fraction of the slide width: 32pt on a standard 16:9 deck (PowerPoint's
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
/// text at 2432pt beyond that it stops aiding readability and starts
/// competing with the title. The fit scale multiplies title and bullets
/// alike, so capping the bullet size also keeps the hierarchy intact.
const double _kBulletMaxFontFraction = 0.0335;
/// The largest auto-fit scale that keeps bullets at or under
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
/// Line height used for bullet body text, shared by rendering and measuring.
const double _kBulletLineHeight = 1.16;
String _bulletMarkerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
String _listMarker(List<String> items, int index, ListStyle style) {
int levelOf(String item) {
var level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
return level;
}
final level = levelOf(items[index]);
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
if (style == ListStyle.checklist) {
return checklistItemChecked(items[index]) ? '' : '';
}
var number = 0;
for (var i = 0; i <= index; i++) {
final itemLevel = levelOf(items[i]);
if (itemLevel == level) number++;
if (itemLevel < level) number = 0;
}
return '$number.';
}
double _bulletLevelScale(int level) {
if (level <= 0) return 1.0;
if (level == 1) return 0.86;
if (level == 2) return 0.80;
return 0.76;
}
/// Largest scale in [minScale, maxScale] for which the bullet block fits
/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this
/// also grows the text *above* its design size when there is spare vertical
/// room, so short slides use the full height instead of clustering at the top.
double _bulletsFitScale({
required double availW,
required double availH,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = 0.2,
double maxScale = 1.0,
ListStyle listStyle = ListStyle.bullets,
}) {
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
// 2% safety margin so minor measurement differences never overflow.
final budget = availH * 0.98;
double measure(double scale) => _bulletsBlockHeight(
scale: scale,
availW: availW,
listStyle: listStyle,
hasTitle: hasTitle,
title: title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
);
// Everything already fits at the largest allowed size use it.
if (measure(maxScale) <= budget) return maxScale;
// Otherwise binary-search the largest scale that fits. Search upward from the
// design size when it fits, downward when even the design size overflows.
double lo, hi;
if (maxScale > 1.0 && measure(1.0) <= budget) {
lo = 1.0;
hi = maxScale;
} else {
lo = minScale;
hi = maxScale > 1.0 ? 1.0 : maxScale;
}
for (var i = 0; i < 24; i++) {
final mid = (lo + hi) / 2;
if (measure(mid) <= budget) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
double _bulletsBlockHeight({
required double scale,
required double availW,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
ListStyle listStyle = ListStyle.bullets,
}) {
var height = 0.0;
if (hasTitle) {
height += _measureTextHeight(
title,
titleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if (subtitle.isNotEmpty) {
height += spacing * scale * 0.4;
height += _measureTextHeight(
subtitle,
subtitleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
height += spacing * scale;
}
for (var i = 0; i < bullets.length; i++) {
final b = bullets[i];
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
// Measure exactly what gets rendered: checklists strip the `[x] ` prefix
// and use a checkbox marker, numbered lists use `N.`. Measuring the raw
// string with a bullet marker over-counts the height and would shrink the
// text below the space it actually needs.
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${_listMarker(bullets, i, listStyle)} ';
final markerW = _measureTextWidth(
marker,
fontSize,
bold: true,
fontFamily: font,
);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = _measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: _kBulletLineHeight,
fontFamily: font,
);
final markerH = _measureTextHeight(
marker,
fontSize,
double.infinity,
fontFamily: font,
);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
}
double _measureTextHeight(
String text,
double fontSize,
double maxWidth, {
double? lineHeight,
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
return painter.height;
}
double _measureTextWidth(
String text,
double fontSize, {
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,333 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _ChecklistProgress extends StatelessWidget {
final List<String> bullets;
final double w;
final String font;
final ThemeProfile profile;
const _ChecklistProgress({
required this.bullets,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final items = bullets
.where((bullet) => checklistItemText(bullet).trim().isNotEmpty)
.toList();
final checked = items.where(checklistItemChecked).length;
final total = items.length;
final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round();
final openPercent = total == 0 ? 0 : 100 - checkedPercent;
final textColor = _hexColor(profile.textColor);
final checkedColor = _hexColor(profile.checklistCheckedColor);
final openColor = _hexColor(profile.checklistUncheckedColor);
final labelStyle = _applyFont(
font,
TextStyle(
fontSize: w * 0.0125,
height: 1.2,
color: textColor,
fontWeight: FontWeight.w600,
),
);
final interaction = _ChecklistInteractionScope.maybeOf(context);
return LayoutBuilder(
builder: (context, constraints) {
// Grow the pie to fill the width it is handed instead of staying at a
// fixed, tiny size. Every caller gives this widget a bounded column
// width, so the chart now scales with the space that is actually
// available next to (or above) the bullets.
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: w * 0.4;
// Cap the pie so it stays a balanced companion to the bullet column
// rather than dominating it: a smaller chart keeps the visual split
// closer to 50/50 and, crucially, never forces the surrounding text to
// shrink to fit the chart's height when a slide has many bullets.
final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble();
final baseRadius = diameter * 0.44;
final hoverRadius = diameter * 0.48;
final pieTitleStyle = _applyFont(
font,
TextStyle(
fontSize: diameter * 0.085,
height: 1.1,
fontWeight: FontWeight.bold,
color: textColor,
),
);
Widget pie(bool? hovered) => PieChart(
key: const ValueKey('checklist-progress-pie'),
PieChartData(
sectionsSpace: w * 0.002,
centerSpaceRadius: 0,
startDegreeOffset: -90,
sections: [
if (checkedPercent > 0)
PieChartSectionData(
value: checkedPercent.toDouble(),
color: checkedColor,
radius: hovered == true ? hoverRadius : baseRadius,
title: '$checkedPercent%',
titleStyle: pieTitleStyle.copyWith(color: Colors.white),
),
if (openPercent > 0)
PieChartSectionData(
value: openPercent.toDouble(),
color: openColor,
radius: hovered == false ? hoverRadius : baseRadius,
title: '$openPercent%',
titleStyle: pieTitleStyle,
),
],
pieTouchData: PieTouchData(
enabled: interaction?.enabled == true,
touchCallback: (event, response) {
if (interaction?.enabled != true) return;
final index = event.isInterestedForInteractions
? response?.touchedSection?.touchedSectionIndex
: null;
if (index == null) {
interaction!.hovered.value = null;
} else if (checkedPercent == 0) {
interaction!.hovered.value = false;
} else {
interaction!.hovered.value = index == 0;
}
},
),
),
duration: Duration.zero,
);
return Semantics(
label:
'${context.l10n.d('Afgevinkt')} $checkedPercent%, '
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: diameter,
height: diameter,
child: interaction == null
? pie(null)
: ValueListenableBuilder<bool?>(
valueListenable: interaction.hovered,
builder: (_, hovered, _) => pie(hovered),
),
),
SizedBox(height: w * 0.008),
MouseRegion(
key: const ValueKey('checklist-progress-checked'),
onEnter: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = true,
onExit: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = null,
child: Text(
'${context.l10n.d('Afgevinkt')} $checkedPercent%',
style: labelStyle,
),
),
MouseRegion(
key: const ValueKey('checklist-progress-unchecked'),
onEnter: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = false,
onExit: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = null,
child: Text(
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
style: labelStyle.copyWith(
color: textColor.withValues(alpha: 0.7),
),
),
),
],
),
);
},
);
}
}
class _ChecklistBulletRow extends StatelessWidget {
final List<String> bullets;
final int itemIndex;
final int column;
final ListStyle listStyle;
final bool checked;
final String text;
final int level;
final double fontSize;
final double bulletSize;
final double bulletGap;
final double scale;
final String font;
final ThemeProfile profile;
const _ChecklistBulletRow({
required this.bullets,
required this.itemIndex,
required this.column,
required this.listStyle,
required this.checked,
required this.text,
required this.level,
required this.fontSize,
required this.bulletSize,
required this.bulletGap,
required this.scale,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final interaction = _ChecklistInteractionScope.maybeOf(context);
Widget row(bool highlighted) => AnimatedContainer(
key: ValueKey('checklist-preview-item-$column-$itemIndex'),
duration: const Duration(milliseconds: 140),
padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0),
decoration: BoxDecoration(
color: highlighted
? _hexColor(profile.accentColor).withValues(alpha: 0.16)
: Colors.transparent,
borderRadius: BorderRadius.circular(wScale(5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
key: ValueKey('checklist-preview-toggle-$column-$itemIndex'),
behavior: HitTestBehavior.opaque,
onTap:
listStyle == ListStyle.checklist && interaction?.enabled == true
? () => interaction!.onToggle?.call(column, itemIndex)
: null,
child: MouseRegion(
cursor:
listStyle == ListStyle.checklist &&
interaction?.enabled == true
? SystemMouseCursors.click
: MouseCursor.defer,
child: Text(
'${_listMarker(bullets, itemIndex, listStyle)} ',
style: TextStyle(
fontSize: fontSize,
color: _hexColor(profile.accentColor),
fontWeight: FontWeight.bold,
),
),
),
),
Expanded(
child: _md(
context,
text,
_applyFont(
font,
TextStyle(
fontSize: fontSize,
height: _kBulletLineHeight,
color: _hexColor(profile.textColor),
decoration: checked && profile.checklistStrikeThrough
? TextDecoration.lineThrough
: null,
decorationColor: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
),
],
),
);
final padded = Padding(
padding: EdgeInsets.only(
left: level * bulletSize * 1.05 * scale,
top: bulletGap * scale,
bottom: bulletGap * scale,
),
child: interaction == null || listStyle != ListStyle.checklist
? row(false)
: ValueListenableBuilder<bool?>(
valueListenable: interaction.hovered,
builder: (_, hovered, _) => row(hovered == checked),
),
);
return padded;
}
double wScale(double value) => value * scale;
}
class _ChecklistInteractionHost extends StatefulWidget {
final bool enabled;
final void Function(int column, int itemIndex)? onToggle;
final Widget child;
const _ChecklistInteractionHost({
required this.enabled,
required this.onToggle,
required this.child,
});
@override
State<_ChecklistInteractionHost> createState() =>
_ChecklistInteractionHostState();
}
class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> {
final ValueNotifier<bool?> hovered = ValueNotifier(null);
@override
void dispose() {
hovered.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _ChecklistInteractionScope(
enabled: widget.enabled,
hovered: hovered,
onToggle: widget.onToggle,
child: widget.child,
);
}
}
class _ChecklistInteractionScope extends InheritedWidget {
final bool enabled;
final ValueNotifier<bool?> hovered;
final void Function(int column, int itemIndex)? onToggle;
const _ChecklistInteractionScope({
required this.enabled,
required this.hovered,
required this.onToggle,
required super.child,
});
static _ChecklistInteractionScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>();
@override
bool updateShouldNotify(_ChecklistInteractionScope oldWidget) =>
enabled != oldWidget.enabled || onToggle != oldWidget.onToggle;
}

View file

@ -0,0 +1,180 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
/// Een 'broncode-sheet': de code op een donker editor-vlak, met
/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar
/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de
/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen.
class _CodePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _CodePreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
/// Natural (unwrapped) size of [text] in [style]: width is the longest line,
/// height the full block. Used to scale code to the available space.
static Size _measureMono(String text, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: text.isEmpty ? ' ' : text, style: style),
textDirection: TextDirection.ltr,
)..layout();
return painter.size;
}
@override
Widget build(BuildContext context) {
_ensureHighlightLanguages();
final pad = w * 0.05;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final code = slide.customMarkdown;
final lang = slide.codeLanguage.trim();
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
final codeBg = _hexColor(profile.codeBackgroundColor);
final codeFg = _hexColor(profile.codeTextColor);
// The chosen monospace family, always backed by a generic monospace fallback
// so an uninstalled face still renders fixed-width.
final fallback = <String>['Menlo', 'Consolas', 'Courier New', 'monospace']
..removeWhere((f) => f == profile.codeFontFamily);
final baseFont = w * 0.024;
final maxFont = w * 0.040; // grow to fill, but never huge
TextStyle monoAt(double size) => TextStyle(
fontFamily: profile.codeFontFamily,
fontFamilyFallback: fallback,
fontSize: size,
height: 1.4,
color: codeFg,
);
// HighlightView throws on an unknown language, so fall back to plain (but
// monospace) text. When syntax highlighting is off we always render plain
// text so the whole block is one colour needed for a CRT-green screen.
final useHighlight = known && profile.codeHighlightSyntax;
final highlightTheme = {
...atomOneDarkTheme,
// Keep atom-one-dark's per-token colours but drop its own background so
// our themed [codeBg] shows through unchanged.
'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith(
backgroundColor: codeBg,
color: codeFg,
),
};
Widget buildCode(TextStyle style) => useHighlight
? HighlightView(
code,
language: lang,
theme: highlightTheme,
padding: EdgeInsets.zero,
textStyle: style,
)
: Text(code, style: style);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The slide title belongs to the slide, not inside the code window,
// so it sits above the panel like other slide types.
if (slide.title.isNotEmpty) ...[
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: w * 0.025,
vertical: w * 0.01,
),
decoration: BoxDecoration(
color: _hexColor(profile.titleBackgroundColor),
borderRadius: BorderRadius.circular(w * 0.012),
border: Border(
left: BorderSide(
color: _hexColor(profile.accentColor),
width: w * 0.006,
),
),
),
child: _md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: w * 0.032,
height: 1.1,
fontWeight: FontWeight.bold,
color: _hexColor(profile.titleTextColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
),
SizedBox(height: w * 0.018),
],
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: codeBg,
borderRadius: BorderRadius.circular(w * 0.012),
border: Border.all(color: codeFg.withValues(alpha: 0.22)),
),
padding: EdgeInsets.all(w * 0.03),
child: LayoutBuilder(
builder: (context, constraints) {
// Size the code to fill the panel: scale up to use spare
// space (capped at [maxFont]) and down so long fragments
// still fit, rather than leaving a small block in a big box.
final measured = useHighlight
? code.replaceAll('\t', ' ')
: code;
final natural = _measureMono(measured, monoAt(baseFont));
final availW = math.max(1.0, constraints.maxWidth - 1);
final availH = math.max(1.0, constraints.maxHeight - 1);
var scale = math.min(
availW / natural.width,
availH / natural.height,
);
if (!scale.isFinite || scale <= 0) scale = 1;
final size = math.min(baseFont * scale, maxFont);
return Align(
alignment: Alignment.topLeft,
child: buildCode(monoAt(size)),
);
},
),
),
),
],
),
),
);
}
}
/// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing.
bool _highlightReady = false;
void _ensureHighlightLanguages() {
if (_highlightReady) return;
allLanguages.forEach(highlight.registerLanguage);
_highlightReady = true;
}
// Shared helper

View file

@ -0,0 +1,601 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _AudioPlayback extends StatefulWidget {
final String audioPath;
final String? projectPath;
final bool autoplay;
final double w;
final VoidCallback? onComplete;
const _AudioPlayback({
required this.audioPath,
required this.projectPath,
required this.autoplay,
required this.w,
this.onComplete,
});
@override
State<_AudioPlayback> createState() => _AudioPlaybackState();
}
class _AudioPlaybackState extends State<_AudioPlayback> {
VideoPlayerController? _controller;
bool _completed = false;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(_AudioPlayback oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.audioPath != widget.audioPath ||
oldWidget.autoplay != widget.autoplay) {
_init();
}
}
Future<void> _init() async {
_controller?.removeListener(_onTick);
await _controller?.dispose();
_completed = false;
final path = _resolvePath(widget.audioPath, widget.projectPath);
if (path == null) return;
final controller = VideoPlayerController.file(File(path));
_controller = controller;
try {
await controller.initialize();
controller.addListener(_onTick);
if (widget.autoplay) await controller.play();
} catch (e) {
logWarning('_AudioPlaybackState._init: audio controller init failed', e);
}
if (mounted) setState(() {});
}
/// Detecteer het einde van de audio en meld dat één keer (voor auto-advance).
void _onTick() {
final c = _controller;
if (c == null || !c.value.isInitialized || _completed) return;
final pos = c.value.position;
final dur = c.value.duration;
if (dur > Duration.zero &&
pos.inMilliseconds >= dur.inMilliseconds - 200 &&
!c.value.isPlaying) {
_completed = true;
widget.onComplete?.call();
}
}
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
return Positioned(
right: widget.w * 0.035,
bottom: widget.w * 0.035,
child: IconButton(
tooltip: 'Audio',
onPressed: controller == null || !controller.value.isInitialized
? null
: () {
setState(() {
controller.value.isPlaying
? controller.pause()
: controller.play();
});
},
icon: Icon(
controller?.value.isPlaying == true
? Icons.volume_up
: Icons.volume_up_outlined,
),
iconSize: widget.w * 0.032,
),
);
}
}
// Individual slide-type renderers
class _TwoImagesPreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _TwoImagesPreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5)
.clamp(0.1, 0.9);
final leftW = w * splitFraction;
final rightW = w * (1 - splitFraction);
final titleSize = w * 0.032;
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Stack(
fit: StackFit.expand,
children: [
// Twee afbeeldingen naast elkaar
Row(
children: [
SizedBox(
width: leftW,
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
SizedBox(
width: rightW,
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(context, slide.imagePath2, projectPath),
_captionOverlay(context, slide.imageCaption2, w),
],
),
),
],
),
// Optionele ondertitel
if (slide.title.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: w * 0.04,
child: Container(
color: Colors.black54,
padding: EdgeInsets.symmetric(
horizontal: w * 0.04,
vertical: w * 0.015,
),
child: _md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.w500,
),
),
linkColor: const Color(0xFF8BB8FF),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
);
}
}
class _ImagePreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _ImagePreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final hasTitle = slide.title.isNotEmpty;
return Stack(
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
bgColor: _hexColor(profile.slideBackgroundColor),
// When zoomed out, anchor the image to the top so the bottom title
// banner sits in the freed-up space instead of over the picture.
alignment: hasTitle ? Alignment.topCenter : Alignment.center,
),
if (slide.title.isNotEmpty)
Positioned(
left: w * 0.06,
right: w * 0.06,
bottom: w * 0.06,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: w * 0.04,
vertical: w * 0.02,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: _md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: Colors.white,
fontSize: w * 0.038,
fontWeight: FontWeight.bold,
),
),
linkColor: const Color(0xFF8BB8FF),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
_captionOverlay(context, slide.imageCaption, w),
],
);
}
}
class _VideoPreview extends StatefulWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
final bool autoplay;
final VoidCallback? onComplete;
const _VideoPreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
this.autoplay = false,
this.onComplete,
});
@override
State<_VideoPreview> createState() => _VideoPreviewState();
}
class _VideoPreviewState extends State<_VideoPreview> {
VideoPlayerController? _controller;
String? _path;
bool _completed = false;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(_VideoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.slide.videoPath != widget.slide.videoPath ||
oldWidget.autoplay != widget.autoplay) {
_init();
}
}
Future<void> _init() async {
_controller?.removeListener(_onTick);
await _controller?.dispose();
_controller = null;
_completed = false;
_path = _resolvePath(widget.slide.videoPath, widget.projectPath);
if (_path == null) {
if (mounted) setState(() {});
return;
}
final controller = VideoPlayerController.file(File(_path!));
_controller = controller;
try {
await controller.initialize();
controller.addListener(_onTick);
await controller.setLooping(false);
if (widget.autoplay) await controller.play();
} catch (e) {
logWarning('_VideoPreviewState._init: video controller init failed', e);
// Keep the placeholder visible when the platform cannot open the file.
}
if (mounted) setState(() {});
}
void _onTick() {
final controller = _controller;
if (controller == null ||
!controller.value.isInitialized ||
_completed ||
!widget.autoplay) {
return;
}
final duration = controller.value.duration;
final position = controller.value.position;
if (duration > Duration.zero &&
position.inMilliseconds >= duration.inMilliseconds - 200 &&
!controller.value.isPlaying) {
_completed = true;
widget.onComplete?.call();
}
}
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
return Container(
color: _hexColor(widget.profile.slideBackgroundColor),
child: Stack(
fit: StackFit.expand,
children: [
if (controller != null && controller.value.isInitialized)
Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
)
else
_mediaPlaceholder(Icons.movie_outlined, 'Video'),
if (widget.slide.title.isNotEmpty)
Positioned(
left: widget.w * 0.06,
right: widget.w * 0.06,
top: widget.w * 0.04,
child: _md(
context,
widget.slide.title,
_applyFont(
widget.font,
TextStyle(
color: _hexColor(widget.profile.textColor),
fontSize: widget.w * 0.038,
fontWeight: FontWeight.bold,
),
),
linkColor: _hexColor(widget.profile.accentColor),
),
),
Positioned(
left: widget.w * 0.04,
bottom: widget.w * 0.035,
child: IconButton(
onPressed: controller == null || !controller.value.isInitialized
? null
: () {
setState(() {
controller.value.isPlaying
? controller.pause()
: controller.play();
});
},
icon: Icon(
controller?.value.isPlaying == true
? Icons.pause_circle
: Icons.play_circle,
),
iconSize: widget.w * 0.045,
),
),
],
),
);
}
}
/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain.
/// imageSize = 0 cover (Marp-standaard, vult frame, snijdt bij)
/// imageSize = 100 volledige afbeelding zichtbaar (contain, evt. randen)
/// imageSize > 100 inzoomen: groter dan contain, bijgesneden door ClipRect
/// imageSize < 100 nog meer uitzoomen: afbeelding kleiner dan contain
Widget _zoomedImage(
BuildContext context,
String imagePath,
String? projectPath,
int imageSize, {
Color bgColor = Colors.black,
Alignment alignment = Alignment.center,
}) {
if (imageSize == 0) {
return _resolvedImage(
context,
imagePath,
projectPath,
); // BoxFit.cover standaard
}
final scale = imageSize / 100.0;
// Size the image box to `scale` × the available area and let BoxFit.contain
// fit the picture inside it. This produces the same visual result as a
// Transform.scale but without a transform layer, which `RepaintBoundary
// .toImage` (used for exports) captures far more reliably a scaled
// transform layer would frequently render blank in the exported PNG.
return ClipRect(
child: ColoredBox(
color: bgColor,
child: LayoutBuilder(
builder: (context, constraints) {
final boxW = constraints.maxWidth * scale;
final boxH = constraints.maxHeight * scale;
return Align(
alignment: alignment,
child: SizedBox(
width: boxW,
height: boxH,
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
child: _resolvedImage(
context,
imagePath,
projectPath,
fit: BoxFit.contain,
),
),
);
},
),
),
);
}
Widget _resolvedImage(
BuildContext context,
String imagePath,
String? projectPath, {
BoxFit fit = BoxFit.cover,
}) {
if (imagePath.isEmpty) return _imagePlaceholder(context);
final String resolved;
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
resolved = imagePath;
} else if (projectPath != null) {
resolved = '$projectPath/$imagePath';
} else {
resolved = imagePath;
}
return Image.file(
File(resolved),
fit: fit,
width: double.infinity,
height: double.infinity,
// Keep showing the previous frame while the next image decodes. Without
// this the widget paints nothing for a frame on a source change, which
// shows up as a black flash between slides fatal when recording video.
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
);
}
Widget _captionOverlay(
BuildContext context,
String caption,
double w, {
double? right,
double? bottom,
}) {
final text = caption.trim();
if (text.isEmpty) return const SizedBox.shrink();
// Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering
// staat, schuift het bijschrift erboven zodat het niet wordt overschreven.
final lift = _SlideLinkScope.hasBottomTlpOf(context)
? _tlpVerticalReserve(w)
: 0.0;
return Positioned(
right: right ?? w * _kTlpEdge,
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
child: Container(
constraints: BoxConstraints(maxWidth: w * 0.5),
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.58),
borderRadius: BorderRadius.circular(3),
),
child: Text(
text,
textAlign: TextAlign.right,
style: TextStyle(
color: Colors.white,
fontSize: w * 0.011,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
}
Widget _mediaPlaceholder(IconData icon, String label) {
return Container(
color: const Color(0xFFE2E8F0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: const Color(0xFF94A3B8), size: 32),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12),
),
],
),
),
);
}
Widget _imagePlaceholder(BuildContext context) {
return ColoredBox(
color: const Color(0xFFE2E8F0),
child: LayoutBuilder(
builder: (context, constraints) {
final shortestSide = constraints.biggest.shortestSide;
if (shortestSide < 48) {
return Center(
child: Icon(
Icons.image_outlined,
color: const Color(0xFF94A3B8),
size: shortestSide * 0.65,
),
);
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.image_outlined,
color: Color(0xFF94A3B8),
size: 24,
),
const SizedBox(height: 4),
Text(
context.l10n.d('Afbeelding'),
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
],
),
);
},
),
);
}

View file

@ -0,0 +1,246 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _LogoOverlay extends StatelessWidget {
final String logoPath;
final String? projectPath;
final String position;
final double size;
const _LogoOverlay({
required this.logoPath,
required this.projectPath,
required this.position,
required this.size,
});
@override
Widget build(BuildContext context) {
final horizontalInset = size * 0.28;
final topInset = size * 0.42;
final bottomInset = size * 0.12;
return Positioned(
top: position.startsWith('top') ? topInset : null,
bottom: position.startsWith('bottom') ? bottomInset : null,
left: position.endsWith('left') ? horizontalInset : null,
right: position.endsWith('right') ? horizontalInset : null,
child: SizedBox(
width: size,
height: size,
child: _resolvedImage(
context,
logoPath,
projectPath,
fit: BoxFit.contain,
),
),
);
}
}
// TLP-markering: maten gedeeld door de badge en de footer-uitsparing
const double _kTlpFont = 0.018; // × slidebreedte
const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
const double _kTlpHPad = 0.011;
const double _kTlpVPad = 0.005;
double _tlpBottomInset(double w) => w * 0.022;
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
double _tlpVerticalReserve(double w) =>
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
class _TlpOverlay extends StatelessWidget {
final TlpLevel tlp;
final double w;
final ThemeProfile profile;
final bool hasLogo;
const _TlpOverlay({
required this.tlp,
required this.w,
required this.profile,
required this.hasLogo,
});
@override
Widget build(BuildContext context) {
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
return Positioned(
bottom: _tlpBottomInset(w),
left: toLeft ? w * _kTlpEdge : null,
right: toLeft ? null : w * _kTlpEdge,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: w * _kTlpHPad,
vertical: w * _kTlpVPad,
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(w * 0.005),
),
child: Text(
tlp.label,
style: TextStyle(
color: Color(tlp.foreground),
fontSize: w * _kTlpFont,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
height: 1.0,
),
),
),
);
}
}
class _FooterOverlay extends StatelessWidget {
final Slide slide;
final double w;
final ThemeProfile profile;
final int? slideNumber;
final int? slideCount;
final TlpLevel tlp;
const _FooterOverlay({
required this.slide,
required this.w,
required this.profile,
this.slideNumber,
this.slideCount,
this.tlp = TlpLevel.none,
});
String _applyTokens(String s) {
final now = DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
final date = '${two(now.day)}-${two(now.month)}-${now.year}';
return s
.replaceAll('{page}', slideNumber?.toString() ?? '')
.replaceAll('{total}', slideCount?.toString() ?? '')
.replaceAll('{date}', date)
.replaceAll('{title}', slide.title);
}
@override
Widget build(BuildContext context) {
if (!slide.showFooter) return const SizedBox.shrink();
if (slide.type == SlideType.title || slide.type == SlideType.section) {
return const SizedBox.shrink();
}
final footerText = _applyTokens(profile.footerText).trim();
final showPages = profile.footerShowPageNumbers && slideNumber != null;
if (footerText.isEmpty && !showPages) return const SizedBox.shrink();
// Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt.
final fontSize = w * 0.0145;
final style = TextStyle(
color: _hexColor(profile.textColor).withValues(alpha: 0.7),
fontSize: fontSize,
// Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft.
shadows: [
Shadow(
color: Colors.white.withValues(alpha: 0.5),
blurRadius: w * 0.003,
),
],
);
// Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en
// de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen.
double mx(double a, double b) => a > b ? a : b;
final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo;
final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom');
final logoOnLeft = profile.logoPosition.endsWith('left');
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
final tlpSpan = tlp == TlpLevel.none
? 0.0
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
final footerLeftAligned = profile.footerPosition == 'left';
// Links uitgelijnd begint de footer waar het logo of de bullets beginnen,
// voor een consistente linkermarge. Anders de standaardmarge.
var left = footerLeftAligned
? (logoBottom && logoOnLeft
? logoLeftEdge
: _contentLeftInset(slide, w))
: w * 0.04;
var right = w * 0.04;
if (logoBottom) {
if (logoOnLeft) {
// Een links-uitgelijnde footer mag bewust met de logo-linkerkant
// uitlijnen; anders schuift 'ie rechts van het logo om overlap te
// voorkomen.
if (!footerLeftAligned) left = mx(left, logoSpan);
} else {
right = mx(right, logoSpan);
}
}
if (tlp != TlpLevel.none) {
if (tlpOnRight) {
right = mx(right, tlpSpan);
} else {
left = mx(left, tlpSpan);
}
}
final alignment = switch (profile.footerPosition) {
'left' => Alignment.centerLeft,
'center' => Alignment.center,
_ => Alignment.centerRight,
};
final textAlign = switch (profile.footerPosition) {
'left' => TextAlign.left,
'center' => TextAlign.center,
_ => TextAlign.right,
};
return Positioned(
left: left,
right: right,
bottom: w * 0.02,
child: Align(
alignment: alignment,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: w - left - right),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (footerText.isNotEmpty)
Flexible(
child: Text(
footerText,
style: style,
textAlign: textAlign,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02),
if (showPages)
Text(
'$slideNumber / ${slideCount ?? slideNumber}',
style: style,
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,130 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _TablePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _TablePreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.06;
final safe = slide.showLogo
? _splitTextLogoSafeInsets(w, profile)
: EdgeInsets.zero;
final titleSize = w * 0.038;
final rows = slide.tableRows.where((r) => r.isNotEmpty).toList();
final colCount = rows.fold<int>(0, (m, r) => r.length > m ? r.length : m);
// Scale cell text down as the table grows so it keeps fitting nicely.
final density = (rows.length + colCount).clamp(2, 24);
final cellSize = (w * 0.025 * (10 / (density + 6))).clamp(
w * 0.010,
w * 0.021,
);
final accent = _hexColor(profile.accentColor);
final textColor = _hexColor(profile.tableTextColor);
final headerTextColor = _hexColor(profile.tableHeaderTextColor);
final headerBackground = _hexColor(profile.tableHeaderBackgroundColor);
final borderColor = accent.withValues(alpha: 0.35);
Widget cell(String value, {required bool header}) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: cellSize * 0.55,
vertical: cellSize * 0.36,
),
child: _md(
context,
value,
_applyFont(
font,
TextStyle(
fontSize: cellSize,
color: header ? headerTextColor : textColor,
fontWeight: header ? FontWeight.bold : FontWeight.normal,
),
),
linkColor: header ? headerTextColor : accent,
),
);
}
TableRow buildRow(List<String> row, {required bool header}) {
return TableRow(
decoration: BoxDecoration(color: header ? headerBackground : null),
children: List.generate(colCount, (c) {
final value = c < row.length ? row[c] : '';
return TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: cell(value, header: header),
);
}),
);
}
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (slide.title.isNotEmpty) ...[
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
SizedBox(height: pad * 0.35),
],
if (rows.isNotEmpty && colCount > 0)
Table(
border: TableBorder.all(
color: borderColor,
width: w * 0.0012,
),
defaultColumnWidth: const FlexColumnWidth(),
children: [
buildRow(rows.first, header: true),
for (var i = 1; i < rows.length; i++)
buildRow(rows[i], header: false),
],
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,478 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _TitlePreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _TitlePreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
Widget _content(BuildContext context) {
final pad = w * 0.08;
final link = _hexColor(profile.accentColor);
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slide.title.isNotEmpty)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: _hexColor(profile.titleTextColor),
fontSize: w * 0.055,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
linkColor: link,
),
if (slide.subtitle.isNotEmpty) ...[
SizedBox(height: w * 0.02),
_md(
context,
slide.subtitle,
_applyFont(
font,
TextStyle(
color: _hexColor(
profile.titleTextColor,
).withValues(alpha: 0.72),
fontSize: w * 0.03,
height: 1.3,
),
),
linkColor: link,
),
],
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final hasBg = slide.imagePath.isNotEmpty;
if (!hasBg) {
return Container(
color: _hexColor(profile.titleBackgroundColor),
child: SizedBox.expand(child: _content(context)),
);
}
return Stack(
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
bgColor: _hexColor(profile.titleBackgroundColor),
),
Container(
color: _hexColor(
profile.titleBackgroundColor,
).withValues(alpha: 0.62),
),
_content(context),
_captionOverlay(context, slide.imageCaption, w),
],
);
}
}
class _SectionPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _SectionPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.08;
return Container(
color: _hexColor(profile.sectionBackgroundColor),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slide.title.isNotEmpty)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: _hexColor(profile.titleTextColor),
fontSize: w * 0.05,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
linkColor: _hexColor(profile.accentColor),
),
if (slide.subtitle.isNotEmpty) ...[
SizedBox(height: w * 0.015),
_md(
context,
slide.subtitle,
_applyFont(
font,
TextStyle(
color: _hexColor(
profile.titleTextColor,
).withValues(alpha: 0.72),
fontSize: w * 0.025,
),
),
linkColor: _hexColor(profile.accentColor),
),
],
],
),
),
),
),
),
);
}
}
class _QuotePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final String? projectPath;
final ThemeProfile profile;
const _QuotePreview({
required this.slide,
required this.w,
required this.font,
this.projectPath,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.08;
final hasBg = slide.imagePath.isNotEmpty;
final textColor = hasBg ? Colors.white : _hexColor(profile.textColor);
final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!;
final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor);
final content = FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: w * 0.008,
height: w * 0.12,
color: accentColor,
margin: EdgeInsets.only(right: pad * 0.4),
),
Expanded(
child: _md(
context,
slide.quote.isEmpty ? '' : '"${slide.quote}"',
_applyFont(
font,
TextStyle(
fontSize: w * 0.033,
fontStyle: FontStyle.italic,
color: textColor,
height: 1.4,
),
),
linkColor: accentColor,
),
),
],
),
if (slide.quoteAuthor.isNotEmpty) ...[
SizedBox(height: pad * 0.6),
_md(
context,
'${slide.quoteAuthor}',
_applyFont(
font,
TextStyle(
fontSize: w * 0.026,
color: authorColor,
fontWeight: FontWeight.w500,
),
),
linkColor: accentColor,
),
],
],
),
),
),
);
if (!hasBg) {
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: SizedBox.expand(child: content),
);
}
return Stack(
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
bgColor: _hexColor(profile.slideBackgroundColor),
),
Container(color: Colors.black.withValues(alpha: 0.52)),
content,
_captionOverlay(context, slide.imageCaption, w),
],
);
}
}
class _MarkdownPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _MarkdownPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.07;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
return Container(
color: Colors.white,
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: _buildBlocks(context),
),
),
),
),
),
);
}
/// Parse the free Markdown into block widgets: fenced ```code``` (syntax
/// highlighted), `$$$$` display math, and ordinary heading/bullet/text lines.
List<Widget> _buildBlocks(BuildContext context) {
final link = _hexColor(profile.accentColor);
final lines = slide.customMarkdown.split('\n');
final widgets = <Widget>[];
var i = 0;
// Cap rendered blocks so a huge slide can't blow up layout (the preview is a
// thumbnail; FittedBox scales the rest down).
while (i < lines.length && widgets.length < 24) {
final line = lines[i];
// Fenced code block: ``` or ```language ```
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
final language = fence.group(1)!.trim();
final code = <String>[];
i++;
while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) {
code.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing fence
widgets.add(_codeBlock(code.join('\n'), language));
continue;
}
// Display math fenced by lines containing only `$$`.
if (line.trim() == r'$$') {
final tex = <String>[];
i++;
while (i < lines.length && lines[i].trim() != r'$$') {
tex.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing $$
widgets.add(_mathBlock(tex.join('\n')));
continue;
}
// Single-line display math: $$ $$
final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line);
if (oneLine != null) {
widgets.add(_mathBlock(oneLine.group(1)!.trim()));
i++;
continue;
}
widgets.add(_textLine(context, line, link));
i++;
}
return widgets;
}
Widget _textLine(BuildContext context, String line, Color link) {
if (line.startsWith('# ')) {
return _md(
context,
line.substring(2),
_applyFont(
font,
TextStyle(
fontSize: w * 0.04,
fontWeight: FontWeight.bold,
color: AppTheme.navy,
),
),
linkColor: link,
);
} else if (line.startsWith('## ')) {
return _md(
context,
line.substring(3),
_applyFont(
font,
TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600),
),
linkColor: link,
);
} else if (line.startsWith('- ')) {
return _md(
context,
'${line.substring(2)}',
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
} else if (line.isEmpty) {
return SizedBox(height: w * 0.01);
}
return _md(
context,
line,
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
}
Widget _codeBlock(String code, String language) {
_ensureHighlightLanguages();
final mono = TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.02,
height: 1.3,
color: const Color(0xFF24292E),
);
// HighlightView throws on an unregistered language, so only use it for ones
// we actually know; otherwise fall back to plain monospace.
final known = language.isNotEmpty && allLanguages.containsKey(language);
final Widget content = known
? HighlightView(
code,
language: language,
theme: githubTheme,
padding: EdgeInsets.zero,
textStyle: mono,
)
: Text(code, style: mono);
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: w * 0.008),
padding: EdgeInsets.all(w * 0.018),
decoration: BoxDecoration(
color: const Color(0xFFF6F8FA),
borderRadius: BorderRadius.circular(w * 0.008),
border: Border.all(color: const Color(0xFFE1E4E8)),
),
child: content,
);
}
Widget _mathBlock(String tex) {
return Padding(
padding: EdgeInsets.symmetric(vertical: w * 0.012),
child: Math.tex(
tex,
textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)),
onErrorFallback: (err) => Text(
'\$\$$tex\$\$',
style: TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.022,
color: Colors.red,
),
),
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -55,204 +55,222 @@ class SlideThumbnail extends ConsumerWidget {
// Actieve slide krijgt een dikkere rand dan de overige geselecteerde.
final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: borderColor, width: borderWidth),
color: isSelected ? const Color(0xFF2A2F3B) : const Color(0xFF252830),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mini slide preview
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(5),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
// Overgeslagen slides worden gedimd weergegeven.
Opacity(
opacity: skipped ? 0.32 : 1,
child: SlidePreviewWidget(
slide: slide,
projectPath: projectPath,
themeProfile: themeProfile,
slideNumber: index + 1,
slideCount: slideCount,
tlp: tlp,
),
// Eén beknopt label per kaart voor schermlezers: nummer, titel (of type)
// en status. De mini-preview eronder is puur visueel en zou anders de
// volledige slide-inhoud per thumbnail laten voorlezen.
final title = slide.title.trim();
final semanticLabel =
'${l10n.d('Slide')} ${index + 1}/$slideCount: '
'${title.isNotEmpty ? title : l10n.d(slide.type.label)}'
'${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}';
return Semantics(
button: true,
selected: isSelected,
label: semanticLabel,
child: GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: borderColor, width: borderWidth),
color: isSelected
? const Color(0xFF2A2F3B)
: const Color(0xFF252830),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mini slide preview
ExcludeSemantics(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(5),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
// Overgeslagen slides worden gedimd weergegeven.
Opacity(
opacity: skipped ? 0.32 : 1,
child: SlidePreviewWidget(
slide: slide,
projectPath: projectPath,
themeProfile: themeProfile,
slideNumber: index + 1,
slideCount: slideCount,
tlp: tlp,
),
),
if (skipped)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xCC8A6D3B),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.visibility_off_outlined,
size: 10,
color: Colors.white,
),
const SizedBox(width: 3),
Text(
l10n.d('Overgeslagen'),
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
if (skipped)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xCC8A6D3B),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.visibility_off_outlined,
size: 10,
color: Colors.white,
),
const SizedBox(width: 3),
Text(
l10n.d('Overgeslagen'),
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
// Footer: slide number, type label, action buttons
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: isSelected
? AppTheme.accent
: const Color(0xFF4A4F5B),
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.d(slide.type.label),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 9,
),
overflow: TextOverflow.ellipsis,
),
),
// Drag handle
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(
Icons.drag_handle,
size: 14,
color: Color(0xFF64748B),
),
),
),
// Snelle overslaan-toggle
SizedBox(
width: 20,
height: 20,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 14,
splashRadius: 12,
tooltip: skipped
? l10n.d('Weer tonen bij presenteren/exporteren')
: l10n.d('Overslaan bij presenteren/exporteren'),
icon: Icon(
skipped
? Icons.visibility_off
: Icons.visibility_outlined,
color: skipped
? const Color(0xFFD4A24E)
: const Color(0xFF64748B),
),
onPressed: onToggleSkip,
),
),
// Context menu
SizedBox(
width: 20,
height: 20,
child: PopupMenuButton<String>(
icon: const Icon(
Icons.more_vert,
color: Color(0xFF64748B),
size: 14,
),
padding: EdgeInsets.zero,
itemBuilder: (_) => [
PopupMenuItem(
value: 'copy',
child: Text(l10n.d('Kopiëren')),
),
PopupMenuItem(
value: 'copy_image',
child: Text(l10n.d('Kopieer als afbeelding')),
),
PopupMenuItem(
value: 'duplicate',
child: Text(l10n.d('Dupliceren')),
),
PopupMenuItem(
value: 'skip',
child: Text(
skipped
? l10n.d('Niet meer overslaan')
: l10n.d('Overslaan'),
),
),
PopupMenuItem(
value: 'delete',
child: Text(
l10n.d('Verwijderen'),
style: const TextStyle(color: Colors.red),
),
),
],
onSelected: (v) {
if (v == 'copy') {
ref.read(slideClipboardProvider.notifier).state =
slide;
}
if (v == 'copy_image') onCopyImage();
if (v == 'duplicate') onDuplicate();
if (v == 'skip') onToggleSkip();
if (v == 'delete') onDelete();
},
),
),
],
),
),
),
// Footer: slide number, type label, action buttons
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: isSelected
? AppTheme.accent
: const Color(0xFF4A4F5B),
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.d(slide.type.label),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 9,
),
overflow: TextOverflow.ellipsis,
),
),
// Drag handle
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(
Icons.drag_handle,
size: 14,
color: Color(0xFF64748B),
),
),
),
// Snelle overslaan-toggle
SizedBox(
width: 20,
height: 20,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 14,
splashRadius: 12,
tooltip: skipped
? l10n.d('Weer tonen bij presenteren/exporteren')
: l10n.d('Overslaan bij presenteren/exporteren'),
icon: Icon(
skipped
? Icons.visibility_off
: Icons.visibility_outlined,
color: skipped
? const Color(0xFFD4A24E)
: const Color(0xFF64748B),
),
onPressed: onToggleSkip,
),
),
// Context menu
SizedBox(
width: 20,
height: 20,
child: PopupMenuButton<String>(
icon: const Icon(
Icons.more_vert,
color: Color(0xFF64748B),
size: 14,
),
padding: EdgeInsets.zero,
itemBuilder: (_) => [
PopupMenuItem(
value: 'copy',
child: Text(l10n.d('Kopiëren')),
),
PopupMenuItem(
value: 'copy_image',
child: Text(l10n.d('Kopieer als afbeelding')),
),
PopupMenuItem(
value: 'duplicate',
child: Text(l10n.d('Dupliceren')),
),
PopupMenuItem(
value: 'skip',
child: Text(
skipped
? l10n.d('Niet meer overslaan')
: l10n.d('Overslaan'),
),
),
PopupMenuItem(
value: 'delete',
child: Text(
l10n.d('Verwijderen'),
style: const TextStyle(color: Colors.red),
),
),
],
onSelected: (v) {
if (v == 'copy') {
ref.read(slideClipboardProvider.notifier).state =
slide;
}
if (v == 'copy_image') onCopyImage();
if (v == 'duplicate') onDuplicate();
if (v == 'skip') onToggleSkip();
if (v == 'delete') onDelete();
},
),
),
],
),
),
],
],
),
),
),
);

View file

@ -5,6 +5,11 @@ import desktop_multi_window
class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
let flutterViewController = FlutterViewController()
// Keep hover events flowing while this window is not key (e.g. when a
// dialog or the beamer window is in front): otherwise an element that was
// hovered when the window lost key status keeps its hover styling because
// the matching exit event is never delivered.
flutterViewController.mouseTrackingMode = .inActiveApp
let windowFrame = self.frame
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)

View file

@ -130,7 +130,7 @@ packages:
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf

View file

@ -25,6 +25,7 @@ dependencies:
archive: ^4.0.9
video_player: ^2.11.1
characters: ^1.3.0
crypto: ^3.0.0
url_launcher: ^6.3.0
desktop_drop: ^0.7.1
image: ^4.8.0

View file

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/dialogs/add_slide_dialog.dart';
void main() {
Future<SlideType?> Function() openDialog(WidgetTester tester) {
SlideType? picked;
return () async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () async =>
picked = await AddSlideDialog.show(context),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return picked;
};
}
testWidgets('every slide type shows a wireframe preview', (tester) async {
await openDialog(tester)();
final painters = tester
.widgetList<CustomPaint>(find.byType(CustomPaint))
.map((p) => p.painter)
.whereType<SlideTypePreviewPainter>()
.map((p) => p.type)
.toSet();
expect(painters, SlideType.values.toSet());
});
testWidgets('type cards are labelled buttons (WCAG name/role)', (
tester,
) async {
final handle = tester.ensureSemantics();
await openDialog(tester)();
expect(
tester.getSemantics(find.text('Tabel')),
isSemantics(isButton: true, isFocusable: true, label: 'Tabel'),
);
handle.dispose();
});
testWidgets('the dialog is fully keyboard-operable', (tester) async {
SlideType? picked;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () async =>
picked = await AddSlideDialog.show(context),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// The first card (title slide) is focused on open; tab moves to the
// second card and Enter activates it.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(picked, SlideType.section);
});
testWidgets('escape closes the dialog without choosing', (tester) async {
await openDialog(tester)();
expect(find.byType(AddSlideDialog), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(AddSlideDialog), findsNothing);
});
}

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/annotation.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/annotation_codec.dart';
import 'package:ocideck/widgets/presentation/annotation_overlay.dart';
void main() {
group('InkStroke JSON', () {
@ -83,4 +85,86 @@ void main() {
expect(back, isEmpty);
});
});
group('AnnotationLayer live stroke', () {
testWidgets('streams the in-progress stroke and clears it on commit', (
tester,
) async {
final active = <InkStroke?>[];
final committed = <List<InkStroke>>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 400,
height: 225,
child: AnnotationLayer(
strokes: const [],
tool: InkTool.pen,
interactive: true,
onStrokesChanged: committed.add,
onActiveStrokeChanged: active.add,
),
),
),
),
),
);
final center = tester.getCenter(find.byType(AnnotationLayer));
final gesture = await tester.startGesture(center);
await gesture.moveBy(const Offset(20, 0));
await gesture.moveBy(const Offset(20, 10));
await tester.pump();
// While drawing, the partial stroke is reported with growing points.
final partials = active.whereType<InkStroke>().toList();
expect(partials, isNotEmpty);
expect(partials.last.tool, InkTool.pen);
expect(partials.last.points.length, greaterThan(1));
await gesture.up();
await tester.pump();
// Committing clears the live preview (null) and emits the final stroke.
expect(active.last, isNull);
expect(committed.single.single.points.length, greaterThan(1));
});
testWidgets('reports null when a tap is too short to commit', (
tester,
) async {
final active = <InkStroke?>[];
final committed = <List<InkStroke>>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 400,
height: 225,
child: AnnotationLayer(
strokes: const [],
tool: InkTool.pen,
interactive: true,
onStrokesChanged: committed.add,
onActiveStrokeChanged: active.add,
),
),
),
),
),
);
await tester.tap(find.byType(AnnotationLayer));
await tester.pump();
// A single down/up makes no stroke, but the preview must still be cleared.
expect(active.last, isNull);
expect(committed, isEmpty);
});
});
}

View file

@ -47,6 +47,7 @@ void main() {
'Logo px',
'PREVIEW',
'Preview',
'Privacy',
'SLIDES',
'Slide',
'slide',

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -327,13 +329,27 @@ void main() {
await tester.pumpWidget(_host(spec, presentationMode: true));
await tester.pump();
// fl_chart draws the spider at 0.8 × half the (square) widget side; the
// labels hug the polygon, so they may overlap the widget's corners but
// must stay off the polygon itself (its apothem) and off each other.
final radarRect = tester.getRect(find.byType(RadarChart));
final center = radarRect.center;
final radius = radarRect.width / 2 * 0.8;
final apothem = radius * math.cos(math.pi / spec.x.length);
double distanceToRect(Offset c, Rect r) {
final nearest = Offset(
c.dx.clamp(r.left, r.right),
c.dy.clamp(r.top, r.bottom),
);
return (c - nearest).distance;
}
final labelRects = [
for (var i = 0; i < spec.x.length; i++)
tester.getRect(find.byKey(ValueKey('radar-axis-label-$i'))),
];
for (final rect in labelRects) {
expect(rect.overlaps(radarRect), isFalse);
expect(distanceToRect(center, rect), greaterThan(apothem * 0.98));
}
for (var i = 0; i < labelRects.length; i++) {
for (var j = i + 1; j < labelRects.length; j++) {
@ -533,6 +549,67 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('charts expose their data as a text alternative', (tester) async {
final handle = tester.ensureSemantics();
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2026', data: [10, 14]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
// WCAG 1.1.1: the chart carries a label with type, title and values.
expect(
find.bySemanticsLabel('Grafiek (Staaf): Omzet. 2026: Q1 10, Q2 14'),
findsOneWidget,
);
handle.dispose();
});
testWidgets('bar chart x-axis labels never run through each other', (
tester,
) async {
// Six groups: enough that the label slots are narrower than the clamp,
// which used to overlap because the spacing was computed with n-1
// intervals while bar groups each occupy a full nth of the axis.
const labels = [
'Strategische koers',
'Operationele basis',
'Innovatievermogen',
'Mensen en cultuur',
'Financiële ruimte',
'Digitale veiligheid',
];
const spec = ChartSpec(
type: ChartType.bar,
x: labels,
series: [
ChartSeries(name: 'Score', data: [3, 4, 5, 2, 4, 3]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final rects = [
for (final label in labels)
if (find.text(label).evaluate().isNotEmpty)
tester.getRect(find.text(label).first),
];
expect(rects.length, greaterThanOrEqualTo(2));
for (var i = 0; i < rects.length; i++) {
for (var j = i + 1; j < rects.length; j++) {
expect(rects[i].overlaps(rects[j]), isFalse);
}
}
expect(tester.takeException(), isNull);
});
testWidgets(
'pie shows at most two series and keeps labels inside the slide',
(tester) async {

View file

@ -108,7 +108,9 @@ void main() {
const spec = ChartSpec(
type: ChartType.line,
x: ['Q1'],
series: [ChartSeries(name: 'A', data: [10])],
series: [
ChartSeries(name: 'A', data: [10]),
],
minBound: 5,
maxBound: 20,
);
@ -121,7 +123,9 @@ void main() {
const spec = ChartSpec(
type: ChartType.pie,
x: ['Q1'],
series: [ChartSeries(name: 'A', data: [10])],
series: [
ChartSeries(name: 'A', data: [10]),
],
minBound: 5,
maxBound: 20,
);
@ -149,7 +153,9 @@ void main() {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C'],
series: [ChartSeries(name: 'A', data: [1, 2, 3])],
series: [
ChartSeries(name: 'A', data: [1, 2, 3]),
],
minBound: 1,
maxBound: 5,
);

View 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);
});
});
});
}

View file

@ -45,19 +45,20 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('syntax highlighting on uses HighlightView for a known language', (
tester,
) async {
final slide = Slide.create(
SlideType.code,
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
const profile = ThemeProfile(codeHighlightSyntax: true);
testWidgets(
'syntax highlighting on uses HighlightView for a known language',
(tester) async {
final slide = Slide.create(
SlideType.code,
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
const profile = ThemeProfile(codeHighlightSyntax: true);
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
expect(find.byType(HighlightView), findsOneWidget);
});
expect(find.byType(HighlightView), findsOneWidget);
},
);
testWidgets('syntax highlighting off renders monochrome (CRT) text', (
tester,

View file

@ -26,7 +26,7 @@ void main() {
expect(n.state.isDirty, isTrue);
});
test('loadDeck resolves a relative logo for an unsaved recovered deck', () {
test('loadDeck applies the active profile and resolves its relative logo', () {
final temp = Directory.systemTemp.createTempSync(
'ocideck_recovered_logo_test_',
);
@ -36,17 +36,19 @@ void main() {
..writeAsBytesSync([1, 2, 3]);
final md = MarkdownService();
// Styling comes from the active style profile, not from the deck/markdown.
final file = FileService(
md,
ImageService(),
() => const ThemeProfile(),
() => const ThemeProfile(logoPath: 'logos/client.png'),
homeDirectory: () => temp.path,
);
final notifier = DeckNotifier(md, file);
notifier.loadDeck(
Deck(
title: 'Hersteld',
themeProfile: const ThemeProfile(logoPath: 'logos/client.png'),
// The deck's own profile is ignored on load.
themeProfile: const ThemeProfile(logoPath: 'should-be-ignored.png'),
slides: [Slide.create(SlideType.title)],
),
);
@ -164,37 +166,39 @@ void main() {
expect(n.state.revision, greaterThan(revisionBefore));
});
test('clearAllChecklists is a single undoable step that restores the checks', () {
final n = _notifier()..newDeck('D');
final slide = Slide.create(SlideType.bullets).copyWith(
listStyle: ListStyle.checklist,
bullets: ['[x] Klaar', '[ ] Open'],
bullets2: ['[x] Tweede'],
);
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.checkedChecklistCount, 2);
test(
'clearAllChecklists is a single undoable step that restores the checks',
() {
final n = _notifier()..newDeck('D');
final slide = Slide.create(SlideType.bullets).copyWith(
listStyle: ListStyle.checklist,
bullets: ['[x] Klaar', '[ ] Open'],
bullets2: ['[x] Tweede'],
);
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.checkedChecklistCount, 2);
n.clearAllChecklists();
expect(n.checkedChecklistCount, 0);
expect(n.state.canUndo, isTrue);
final revisionAfterClear = n.state.revision;
n.clearAllChecklists();
expect(n.checkedChecklistCount, 0);
expect(n.state.canUndo, isTrue);
final revisionAfterClear = n.state.revision;
n.undo();
n.undo();
// One undo restores every checked item in both columns...
expect(n.checkedChecklistCount, 2);
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
// ...and bumps the revision again so the open editor reflects the restore.
expect(n.state.revision, greaterThan(revisionAfterClear));
});
// One undo restores every checked item in both columns...
expect(n.checkedChecklistCount, 2);
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
// ...and bumps the revision again so the open editor reflects the restore.
expect(n.state.revision, greaterThan(revisionAfterClear));
},
);
test('clearAllChecklists is a no-op when nothing is checked', () {
final n = _notifier()..newDeck('D');
final slide = Slide.create(SlideType.bullets).copyWith(
listStyle: ListStyle.checklist,
bullets: ['[ ] Open'],
);
final slide = Slide.create(
SlideType.bullets,
).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.state.canUndo, isFalse);
@ -470,4 +474,31 @@ void main() {
n.undo(); // één stap terug herstelt de hele vervanging
expect(n.state.deck!.slides.first.title, 'Hallo wereld');
});
test('find and replace covers the second column and both column titles', () {
final n = _notifier();
n.loadDeck(
Deck(
title: 'D',
slides: [
Slide.create(SlideType.twoBullets).copyWith(
title: 'foo title',
columnTitle1: 'foo left',
columnTitle2: 'foo right',
bullets: ['foo a'],
bullets2: ['foo b', 'foo c'],
),
],
),
);
// title + columnTitle1 + columnTitle2 + bullets(1) + bullets2(2) = 6
expect(n.countMatches('foo'), 6);
expect(n.replaceAll('foo', 'bar'), 6);
final s = n.state.deck!.slides.first;
expect(s.columnTitle1, 'bar left');
expect(s.columnTitle2, 'bar right');
expect(s.bullets2, ['bar b', 'bar c']);
});
}

View file

@ -5,6 +5,8 @@ import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
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/marp_html_service.dart';
import 'package:path/path.dart' as p;
@ -56,6 +58,41 @@ void main() {
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 {
final images = [_png(), _png()];
final r = await service.export(deckPath(), ExportFormat.pdf, images);

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart';
@ -62,4 +64,67 @@ void main() {
expect(service.currentThemeProfile.logoPath, logo.path);
},
);
test(
'importPackageBytes ignores path-traversal entries (zip slip)',
() async {
final temp = await Directory.systemTemp.createTemp('ocideck_zipslip_');
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final archive = Archive();
final md = utf8.encode('---\nmarp: true\n---\n# Hi');
archive.addFile(ArchiveFile('deck.md', md.length, md));
final evil = utf8.encode('pwned');
archive.addFile(ArchiveFile('../evil.txt', evil.length, evil));
final zipBytes = ZipEncoder().encode(archive);
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(),
);
final mdPath = await service.importPackageBytes(zipBytes, temp.path);
// The traversal entry must not have escaped the extraction folder.
expect(await File(p.join(temp.path, 'evil.txt')).exists(), isFalse);
// The legitimate markdown landed inside it.
expect(mdPath, isNotNull);
expect(p.isWithin(temp.path, mdPath!), isTrue);
expect(await File(mdPath).exists(), isTrue);
},
);
test(
'importFromUrl refuses non-web schemes and private/loopback hosts',
() async {
final temp = await Directory.systemTemp.createTemp('ocideck_ssrf_');
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(),
);
// These are all rejected before any network access happens.
for (final url in [
'ftp://example.com/x', // non-web scheme
'file:///etc/passwd', // non-web scheme
'http://localhost:8080/x.ocideck', // loopback name
'http://127.0.0.1/x', // loopback IP
'http://192.168.1.5/x', // private IP
'http://10.0.0.9/x', // private IP
'http://169.254.1.1/x', // link-local IP
]) {
expect(
await service.importFromUrl(url, temp.path),
isNull,
reason: 'should refuse $url',
);
}
},
);
}

View file

@ -0,0 +1,109 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:ocideck/services/image_dedup_service.dart';
void main() {
late Directory tmp;
final service = ImageDedupService();
setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_dedup'));
tearDown(() => tmp.deleteSync(recursive: true));
String write(String name, List<int> bytes) {
final file = File(p.join(tmp.path, name))..writeAsBytesSync(bytes);
return file.path;
}
group('findDuplicateGroups', () {
test('groups byte-identical files and leaves unique files out', () async {
final a1 = write('a1.png', [1, 2, 3, 4]);
final a2 = write('a2.png', [1, 2, 3, 4]);
final b = write('b.png', [9, 9, 9]);
final groups = await service.findDuplicateGroups([a1, a2, b]);
expect(groups, hasLength(1));
expect(groups.single, unorderedEquals([a1, a2]));
});
test('same size but different content is not a duplicate', () async {
final a = write('a.png', [1, 2, 3, 4]);
final b = write('b.png', [4, 3, 2, 1]);
expect(await service.findDuplicateGroups([a, b]), isEmpty);
});
test('finds multiple independent groups', () async {
final a1 = write('a1.png', [1, 2, 3]);
final a2 = write('a2.png', [1, 2, 3]);
final b1 = write('b1.png', [7, 7, 7, 7]);
final b2 = write('b2.png', [7, 7, 7, 7]);
final b3 = write('b3.png', [7, 7, 7, 7]);
final groups = await service.findDuplicateGroups([a1, a2, b1, b2, b3]);
expect(groups, hasLength(2));
final bySize = {for (final g in groups) g.length: g};
expect(bySize[2], unorderedEquals([a1, a2]));
expect(bySize[3], unorderedEquals([b1, b2, b3]));
});
test('silently skips missing files', () async {
final a1 = write('a1.png', [1, 2, 3]);
final a2 = write('a2.png', [1, 2, 3]);
final gone = p.join(tmp.path, 'bestaat-niet.png');
final groups = await service.findDuplicateGroups([a1, gone, a2]);
expect(groups, hasLength(1));
expect(groups.single, unorderedEquals([a1, a2]));
});
});
group('chooseKeeper', () {
test('prefers the path with the most slide usages', () {
final a = write('a.png', [1]);
final b = write('b.png', [1]);
final keeper = service.chooseKeeper([
a,
b,
], usageCountOf: (path) => path == b ? 2 : 0);
expect(keeper, b);
});
test('falls back to the oldest file when usages are equal', () {
final newer = write('newer.png', [1]);
final older = write('older.png', [1]);
File(
older,
).setLastModifiedSync(DateTime.now().subtract(const Duration(days: 7)));
expect(service.chooseKeeper([newer, older]), older);
});
});
group('mergeMetadata', () {
test('joins unique non-empty values', () {
expect(
service.mergeMetadata(['boot', null, '', 'haven'], separator: ', '),
'boot, haven',
);
});
test('drops values already contained in an earlier one', () {
expect(
service.mergeMetadata(['Boot in de haven', 'boot']),
'Boot in de haven',
);
});
test('returns empty string when nothing is set', () {
expect(service.mergeMetadata([null, '', ' ']), '');
});
});
}

View file

@ -0,0 +1,140 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:ocideck/services/image_reference_service.dart';
void main() {
late Directory tmp;
final service = ImageReferenceService();
setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_refs'));
tearDown(() => tmp.deleteSync(recursive: true));
String write(String relativePath, String content) {
final file = File(p.join(tmp.path, relativePath));
file.parent.createSync(recursive: true);
file.writeAsStringSync(content);
return file.path;
}
group('findDeckFiles', () {
test('finds .md files recursively but skips asset directories', () async {
final deck = write('presentaties/deck.md', '# Deck');
write('presentaties/images/notitie.md', 'hoort niet mee');
write('presentaties/.verborgen/geheim.md', 'hoort niet mee');
final found = await service.findDeckFiles([tmp.path]);
expect(found, [p.normalize(deck)]);
});
test('deduplicates hits from overlapping search paths', () async {
final deck = write('project/deck.md', '# Deck');
final found = await service.findDeckFiles([
tmp.path,
p.join(tmp.path, 'project'),
]);
expect(found, [p.normalize(deck)]);
});
});
group('countReferences', () {
test('resolves relative paths against the deck file directory', () async {
final img = p.join(tmp.path, 'project', 'images', 'foto.png');
final deck = write(
'project/deck.md',
'![bg left:50%](images/foto.png)\n\n---\n\n![](images/foto.png)\n',
);
final counts = await service.countReferences([deck], [img]);
expect(counts[p.normalize(img)], 2);
});
test('ignores other images and web URLs', () async {
final img = p.join(tmp.path, 'project', 'images', 'foto.png');
final deck = write(
'project/deck.md',
'![](images/anders.png)\n![](https://example.com/foto.png)\n',
);
expect(await service.countReferences([deck], [img]), isEmpty);
});
});
group('referencingFiles', () {
test('reports per deck file how often the image is referenced', () async {
final img = p.join(tmp.path, 'project', 'images', 'foto.png');
final twice = write(
'project/deck.md',
'![](images/foto.png)\n---\n![bg](images/foto.png)\n',
);
final never = write('project/anders.md', '![](images/anders.png)\n');
final result = await service.referencingFiles([twice, never], img);
expect(result, {twice: 2});
});
});
group('replaceReferences', () {
test('rewrites relative references and keeps them relative', () async {
final from = p.join(tmp.path, 'project', 'images', 'kopie.png');
final to = p.join(tmp.path, 'project', 'images', 'origineel.png');
final deck = write(
'project/deck.md',
'# Titel\n\n![bg right:40%](images/kopie.png)\n\nTekst blijft staan.\n',
);
final changed = await service.replaceReferences(deck, from, to);
expect(changed, isTrue);
expect(
File(deck).readAsStringSync(),
'# Titel\n\n![bg right:40%](images/origineel.png)\n\nTekst blijft staan.\n',
);
});
test('rewrites absolute references to the absolute kept path', () async {
final from = p.join(tmp.path, 'elders', 'kopie.png');
final to = p.join(tmp.path, 'elders', 'origineel.png');
final deck = write('project/deck.md', '![]($from)\n');
final changed = await service.replaceReferences(deck, from, to);
expect(changed, isTrue);
expect(File(deck).readAsStringSync(), '![]($to)\n');
});
test('leaves the file untouched when nothing matches', () async {
final from = p.join(tmp.path, 'project', 'images', 'kopie.png');
final to = p.join(tmp.path, 'project', 'images', 'origineel.png');
final deck = write('project/deck.md', '![](images/anders.png)\n');
final before = File(deck).lastModifiedSync();
final changed = await service.replaceReferences(deck, from, to);
expect(changed, isFalse);
expect(File(deck).readAsStringSync(), '![](images/anders.png)\n');
expect(File(deck).lastModifiedSync(), before);
});
test(
'uses an absolute path when the kept file lies outside the project',
() async {
final from = p.join(tmp.path, 'project', 'images', 'kopie.png');
final to = p.join(tmp.path, 'elders', 'origineel.png');
final deck = write('project/deck.md', '![](images/kopie.png)\n');
final changed = await service.replaceReferences(deck, from, to);
expect(changed, isTrue);
expect(File(deck).readAsStringSync(), '![]($to)\n');
},
);
});
}

View file

@ -565,8 +565,7 @@ void main() {
);
final deck = service.parseDeck(markdown);
expect(deck, isNotNull);
expect(deck!.themeProfile.footerPosition, 'center');
expect(deck.slides[0].showFooter, isTrue);
expect(deck!.slides[0].showFooter, isTrue);
expect(deck.slides[1].showFooter, isFalse);
});

View file

@ -87,12 +87,15 @@ void main() {
closingSlideMarkdown: '# Einde\n\nDank voor jullie aandacht.',
);
// The style profile only travels inside the markdown when explicitly
// inlined (transient beamer payloads); a plain save keeps the file clean.
final markdown = service.generateDeck(
Deck(
title: 'Demo',
themeProfile: profile,
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
),
inlineStyleProfile: true,
);
final deck = service.parseDeck(markdown);
@ -115,6 +118,27 @@ void main() {
);
});
test('a saved deck does not embed the style profile', () {
final service = MarkdownService();
final markdown = service.generateDeck(
Deck(
title: 'Demo',
themeProfile: const ThemeProfile(
name: 'Klant A',
slideBackgroundColor: '#111827',
),
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
),
);
// The file is the base content only; styling stays out of it. Parsing it
// back yields the default profile, not the one that was saved.
expect(markdown.contains('ocideck_style_profile'), isFalse);
final deck = service.parseDeck(markdown);
expect(deck!.themeProfile.name, 'Standaard');
expect(deck.themeProfile.slideBackgroundColor, isNot('#111827'));
});
test('adds logo-safe class when deck profile has logo', () {
final service = MarkdownService();
final markdown = service.generateDeck(

View file

@ -77,6 +77,25 @@ void main() {}
expect(html, contains(r'<\/script'));
});
test('build() neutralises a mixed-case closing-script breakout', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X\n\nfoo </ScRiPt> bar');
// Case tricks must not slip past the guard.
expect(html, isNot(contains('</ScRiPt>')));
expect(html, contains(r'<\/ScRiPt'));
});
test(
'build() bundles DOMPurify and sanitises the rendered markdown',
() async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X');
// The sanitiser is inlined and actually used before content hits the DOM.
expect(html, contains('DOMPurify'));
expect(html, contains('DOMPurify.sanitize('));
},
);
test('a theme colours the slides with the profile palette', () async {
final service = MarpHtmlService(
loadAsset: _diskLoader,
@ -108,7 +127,10 @@ void main() {}
codeTextColor: '#33FF33',
codeFontFamily: 'Courier New',
);
final html = await service.build('```dart\nvoid main() {}\n```', theme: theme);
final html = await service.build(
'```dart\nvoid main() {}\n```',
theme: theme,
);
expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
expect(html, contains('.slide pre code{color:#33FF33'));

View file

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

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/state/deck_provider.dart';
import 'package:ocideck/state/editor_provider.dart';
import 'package:ocideck/theme/app_theme.dart';
import 'package:ocideck/widgets/panels/slide_list_panel.dart';
import 'package:ocideck/widgets/slides/slide_thumbnail.dart';
void main() {
testWidgets('resizing the rail brings the edited slide back into view', (
tester,
) async {
final container = ProviderContainer();
addTearDown(container.dispose);
final deckNotifier = container.read(deckProvider.notifier);
deckNotifier.newDeck('Test');
for (var i = 0; i < 19; i++) {
deckNotifier.addSlide(SlideType.bullets);
}
container.read(editorProvider.notifier).select(12);
final width = ValueNotifier<double>(320);
addTearDown(width.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: ValueListenableBuilder<double>(
valueListenable: width,
builder: (_, w, _) => SizedBox(
width: w,
height: 600,
child: SlideListPanel(railWidth: w),
),
),
),
),
),
),
);
await tester.pump();
// The selected slide (12) sits far below the fold and nothing scrolls it
// into view on its own.
bool slide12Visible() => find
.byWidgetPredicate((w) => w is SlideThumbnail && w.index == 12)
.evaluate()
.isNotEmpty;
expect(slide12Visible(), isFalse);
// Drag the rail wider: thumbnails change height, and once the resize
// settles the list scrolls the slide being edited back to the top.
width.value = 240;
await tester.pump();
await tester.pump(const Duration(milliseconds: 250)); // debounce fires
await tester.pump(); // coarse jump near the unbuilt slide
await tester.pump(); // precise reveal starts
await tester.pump(const Duration(milliseconds: 200)); // animateTo settles
expect(slide12Visible(), isTrue);
final rect = tester.getRect(
find.byWidgetPredicate((w) => w is SlideThumbnail && w.index == 12),
);
// At the top of the list area (below the panel header).
expect(rect.top, lessThan(120));
});
testWidgets('thumbnails expose one concise semantic label per slide', (
tester,
) async {
final handle = tester.ensureSemantics();
final container = ProviderContainer();
addTearDown(container.dispose);
final deckNotifier = container.read(deckProvider.notifier);
deckNotifier.newDeck('Test');
deckNotifier.addSlide(SlideType.bullets);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: AppTheme.light,
home: const Scaffold(
body: SizedBox(width: 320, child: SlideListPanel(railWidth: 320)),
),
),
),
);
await tester.pump();
// Screen readers get "Slide n/m: title-or-type" per card, instead of the
// full content of every mini preview.
expect(find.bySemanticsLabel(RegExp(r'^Slide 1/2: ')), findsOneWidget);
expect(find.bySemanticsLabel(RegExp(r'^Slide 2/2: ')), findsOneWidget);
handle.dispose();
});
}

View file

@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/utils/table_clipboard.dart';
void main() {
group('parseClipboardTable', () {
test('parses a spreadsheet (TSV) selection', () {
expect(parseClipboardTable('Naam\tScore\nJan\t8\nPiet\t9'), [
['Naam', 'Score'],
['Jan', '8'],
['Piet', '9'],
]);
});
test('handles Windows line endings and the trailing newline', () {
expect(parseClipboardTable('a\tb\r\nc\td\r\n'), [
['a', 'b'],
['c', 'd'],
]);
});
test('a single spreadsheet row is still a table', () {
expect(parseClipboardTable('a\tb\tc'), [
['a', 'b', 'c'],
]);
});
test('pads ragged rows to a rectangle', () {
expect(parseClipboardTable('a\tb\tc\nd\te'), [
['a', 'b', 'c'],
['d', 'e', ''],
]);
});
test('quoted cells may contain delimiters and newlines', () {
expect(parseClipboardTable('"regel 1\nregel 2"\t"met\ttab"\nx\ty'), [
['regel 1\nregel 2', 'met\ttab'],
['x', 'y'],
]);
expect(parseClipboardTable('"a ""quote"""\tb'), [
['a "quote"', 'b'],
]);
});
test('parses comma and semicolon CSV with consistent columns', () {
expect(parseClipboardTable('Naam,Score\nJan,8'), [
['Naam', 'Score'],
['Jan', '8'],
]);
expect(parseClipboardTable('Naam;Score\nJan;8'), [
['Naam', 'Score'],
['Jan', '8'],
]);
});
test('prefers the semicolon when commas are decimal separators', () {
expect(parseClipboardTable('Prijs;Marge\n1,50;0,25\n2,00;0,30'), [
['Prijs', 'Marge'],
['1,50', '0,25'],
['2,00', '0,30'],
]);
});
test('parses a markdown table and drops the separator row', () {
expect(parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'), [
['Naam', 'Score'],
['Jan', '8'],
]);
});
test('plain text is not a table', () {
expect(parseClipboardTable('gewoon wat tekst'), isNull);
expect(parseClipboardTable('regel een\nregel twee'), isNull);
// A sentence with a comma stays a sentence.
expect(parseClipboardTable('hallo, wereld'), isNull);
// Inconsistent comma counts: prose, not CSV.
expect(parseClipboardTable('een, twee en drie\nvier, vijf, zes'), isNull);
expect(parseClipboardTable(' '), isNull);
});
});
}

View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/editors/table_editor.dart';
void main() {
Future<Slide> pasteIntoFirstCell(WidgetTester tester, String clip) async {
var updated = Slide.create(SlideType.table);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.getData') {
return <String, dynamic>{'text': clip};
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TableEditor(slide: updated, onUpdate: (s) => updated = s),
),
),
);
// Field 0 is the title; the first table cell is the next TextField.
await tester.tap(find.byType(TextField).at(1));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
return updated;
}
testWidgets('pasting a spreadsheet selection fills and grows the grid', (
tester,
) async {
final updated = await pasteIntoFirstCell(
tester,
'Naam\tScore\nJan\t8\nPiet\t9\n',
);
expect(updated.tableRows, [
['Naam', 'Score'],
['Jan', '8'],
['Piet', '9'],
]);
});
testWidgets('pasting plain text stays inside the one cell', (tester) async {
final updated = await pasteIntoFirstCell(tester, 'hallo wereld');
expect(updated.tableRows, [
['hallo wereld', ''],
['', ''],
]);
});
}

View file

@ -81,6 +81,32 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('short bullet lists grow but respect the body-text maximum', (
tester,
) async {
final slide = Slide.create(
SlideType.bullets,
).copyWith(title: 'Kort', bullets: const ['Eén punt', 'Twee punten']);
await tester.pumpWidget(_host(slide));
await tester.pump();
final size = tester
.widget<InlineMarkdownText>(
find.byWidgetPredicate(
(x) => x is InlineMarkdownText && x.text == 'Eén punt',
),
)
.style
.fontSize!;
// Auto-fit still grows the text beyond its design size (w * 0.026)
expect(size, greaterThan(800 * 0.026));
// but never past 32pt-on-a-16:9-deck (w * 0.0335), so the body text
// keeps a clear distance from the title.
expect(size, lessThanOrEqualTo(800 * 0.0335 + 0.1));
expect(tester.takeException(), isNull);
});
testWidgets('bullets slide renders an optional subheading below the title', (
tester,
) async {

Some files were not shown because too many files have changed in this diff Show more