Compare commits
20 commits
fix/two-bu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4859d66d95 | |||
|
|
483264b652 | ||
|
|
b719c43991 | ||
|
|
f93417dc3c | ||
| 0b3d0be30f | |||
|
|
b270e71755 | ||
|
|
97b825f1b9 | ||
|
|
6b2ba4df89 | ||
|
|
ee9e2bfc58 | ||
|
|
f08055c7ae | ||
| 6bf85773b0 | |||
|
|
2c4a6f7358 | ||
|
|
47b2555dc5 | ||
|
|
56932a2dda | ||
|
|
4f595d1340 | ||
|
|
c6190dc31b | ||
|
|
e86d30e75a | ||
|
|
68725341a7 | ||
|
|
280934d331 | ||
| 815f5f2cee |
105 changed files with 12414 additions and 6852 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -34,3 +34,8 @@ jobs:
|
||||||
# Fail the build if any dependency is not open source.
|
# Fail the build if any dependency is not open source.
|
||||||
- name: Licence compliance (make licenses)
|
- name: Licence compliance (make licenses)
|
||||||
run: 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
|
||||||
|
|
|
||||||
76
CHANGELOG.md
76
CHANGELOG.md
|
|
@ -8,6 +8,27 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Presentation timer / rehearsal mode** — the presenter view now doubles as a
|
||||||
|
rehearsal clock that measures without coaching. A **countdown** runs against a
|
||||||
|
target time (default under *Settings → General → Presentation*, or set live with
|
||||||
|
`K` as `MMSS`; `0` turns it off) and turns red when you go over. The clock bar
|
||||||
|
also shows the time spent on the **current slide**, accumulated per slide across
|
||||||
|
the run. `R` resets the run (elapsed and per-slide timings, keeping the target).
|
||||||
|
Leaving the presenter after a run shows a **summary** (total vs. target, time per
|
||||||
|
slide) with copy-to-clipboard. Session-only: nothing is persisted to disk or the
|
||||||
|
`.md` file.
|
||||||
|
- **Duplicate clean-up in the image library** — a footer button finds
|
||||||
|
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,
|
- **Source-code slides** — a "code sheet" with per-language syntax highlighting,
|
||||||
stored as a fenced code block. Background, text colour and monospace font are
|
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
|
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
|
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
|
||||||
Protocol level; slides classified stricter than the level the deck is shown at
|
Protocol level; slides classified stricter than the level the deck is shown at
|
||||||
are withheld when presenting and exporting.
|
are withheld when presenting and exporting.
|
||||||
|
- **Export release ceiling** — an optional maximum TLP level that may be
|
||||||
|
exported. When set, a deck classified *above* it cannot be exported in any
|
||||||
|
format; the gate is enforced at the single export chokepoint and fails closed
|
||||||
|
(no file is written when blocked, and the export dialog explains why).
|
||||||
|
Classifying a deck stays optional — the ceiling only stops decks that exceed
|
||||||
|
it, and it is off by default.
|
||||||
- **Dual-screen presenter** — on a second display the beamer shows the slide
|
- **Dual-screen presenter** — on a second display the beamer shows the slide
|
||||||
while the laptop shows the presenter view (current/next slide, notes, timer),
|
while the laptop shows the presenter view (current/next slide, notes, timer),
|
||||||
kept in sync over method channels.
|
kept in sync over method channels.
|
||||||
|
|
@ -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.
|
live to the beamer, and persisted in a `<name>.ink.json` sidecar.
|
||||||
- **App theming** — customizable app appearance profiles, including a dark
|
- **App theming** — customizable app appearance profiles, including a dark
|
||||||
interface.
|
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 (100–200%, 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
|
- Project documentation: contributing guide, security policy, architecture and
|
||||||
build notes, user guide, keyboard-shortcut reference, third-party notices, and
|
build notes, user guide, keyboard-shortcut reference, third-party notices, and
|
||||||
the EUPL-1.2 licence text.
|
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.
|
separate from the slide title.
|
||||||
- Slide text auto-sizing now measures with the deck's own font, so text grows to
|
- 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.
|
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
|
- The two bullet columns are measured **independently** and then rendered at a
|
||||||
is no longer shrunk down to the size of a crowded one beside it.
|
**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
|
- Slide transitions in the presenter no longer flash a black frame (neighbour
|
||||||
images are precached and `gaplessPlayback` is enabled) — important for
|
images are precached and `gaplessPlayback` is enabled) — important for
|
||||||
recording.
|
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 24–32 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]
|
## [1.0.0]
|
||||||
|
|
||||||
|
|
|
||||||
19
Makefile
19
Makefile
|
|
@ -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:
|
help:
|
||||||
@echo "OciDeck quality targets:"
|
@echo "OciDeck quality targets:"
|
||||||
|
|
@ -11,6 +11,7 @@ help:
|
||||||
@echo " make test-services Caption/description/image service tests."
|
@echo " make test-services Caption/description/image service tests."
|
||||||
@echo " make test-presenter Fullscreen presenter interaction tests."
|
@echo " make test-presenter Fullscreen presenter interaction tests."
|
||||||
@echo " make deps-outdated Advisory dependency freshness report."
|
@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."
|
@echo " make licenses Verify all dependencies use open-source licences."
|
||||||
|
|
||||||
# Install Flutter/Dart dependencies.
|
# Install Flutter/Dart dependencies.
|
||||||
|
|
@ -106,6 +107,18 @@ deps-outdated:
|
||||||
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
|
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
|
||||||
flutter pub outdated
|
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.
|
# Open-source licence compliance check for all resolved dependencies.
|
||||||
licenses:
|
licenses:
|
||||||
@echo "== OciDeck check: licences =="
|
@echo "== OciDeck check: licences =="
|
||||||
|
|
@ -120,6 +133,6 @@ check: format-check analyze test
|
||||||
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
|
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
|
||||||
|
|
||||||
# Extended local check with advisory dependency freshness after the required gate.
|
# 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 "== 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."
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -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.
|
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
|
||||||
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
|
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
|
||||||
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
|
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
|
||||||
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting.
|
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. An optional **export release ceiling** can block exporting any deck classified above a chosen level — enforced for every format, off by default.
|
||||||
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
|
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
|
||||||
|
- **Presentation timer / rehearsal mode** — the presenter view doubles as a rehearsal clock: a countdown against a target time (set in Settings or live with `K`), the time spent on the current slide, and an end-of-run summary (total vs. target and per-slide times, copyable). It measures only — no pacing coaching — and is session-only, never written to disk.
|
||||||
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
|
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
|
||||||
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
||||||
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
|
||||||
|
|
@ -69,7 +71,9 @@ lib/
|
||||||
services/ # Markdown, export, file, image, caption, recovery, rasterizer
|
services/ # Markdown, export, file, image, caption, recovery, rasterizer
|
||||||
state/ # Riverpod providers (deck, editor, settings, tabs, clipboard)
|
state/ # Riverpod providers (deck, editor, settings, tabs, clipboard)
|
||||||
widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter
|
widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter
|
||||||
|
l10n/ # AppLocalizations (8 languages)
|
||||||
theme/ # App theming
|
theme/ # App theming
|
||||||
|
utils/ # Small shared helpers (clipboard table parsing, URL launching)
|
||||||
```
|
```
|
||||||
|
|
||||||
State is managed with [Riverpod](https://riverpod.dev/).
|
State is managed with [Riverpod](https://riverpod.dev/).
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ OciDeck is an offline desktop application. Areas of particular interest:
|
||||||
- Importing presentations from a URL.
|
- Importing presentations from a URL.
|
||||||
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
|
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
|
||||||
mermaid, MathJax) to render offline.
|
mermaid, MathJax) to render offline.
|
||||||
|
- The export classification gate (`ClassificationPolicy`) — any way to export a
|
||||||
|
deck classified above the configured release ceiling.
|
||||||
|
|
||||||
## Supported versions
|
## Supported versions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Vendored (forked) plugins
|
||||||
|
|
||||||
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /
|
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "com.example.ocideck"
|
namespace = "com.example.ocideck"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|
@ -25,11 +39,27 @@ android {
|
||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Use the real release keystore when configured; otherwise fall back
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// to the debug key so `flutter run --release` still works locally.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// Do NOT distribute a build signed with the debug key.
|
||||||
|
signingConfig = if (hasReleaseKeystore) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
assets/web_export/MANIFEST.json
Normal file
46
assets/web_export/MANIFEST.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
771
assets/web_export/highlight.min.js
vendored
771
assets/web_export/highlight.min.js
vendored
File diff suppressed because one or more lines are too long
79
assets/web_export/marked.min.js
vendored
79
assets/web_export/marked.min.js
vendored
File diff suppressed because one or more lines are too long
3
assets/web_export/purify.min.js
vendored
Normal file
3
assets/web_export/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -15,12 +15,15 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
||||||
services/ # markdown, file, export, image, caption, description,
|
services/ # markdown, file, export, classification_policy, image, caption,
|
||||||
# recovery, rasterizer, marp_html, annotation_codec
|
# 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
|
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
|
||||||
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
|
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
|
||||||
l10n/ # AppLocalizations (8 languages)
|
l10n/ # AppLocalizations (8 languages)
|
||||||
theme/ # app theming
|
theme/ # app theming
|
||||||
|
utils/ # small shared helpers (clipboard table parsing, URL launching)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data model
|
## 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
|
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
|
||||||
renderer by design.
|
renderer by design.
|
||||||
|
|
||||||
|
Both worlds converge at one chokepoint: `services/export_service.dart`
|
||||||
|
(`ExportService.export()`) is the only place that writes an export, so the
|
||||||
|
**classification gate** lives there rather than in the export dialog. A
|
||||||
|
`ClassificationPolicy` enforces an optional *release ceiling* and refuses,
|
||||||
|
**fail-closed**, to export a deck classified above it — no format can bypass it.
|
||||||
|
The ceiling is stored in app settings (`maxReleaseExportTlpKey`, off by default);
|
||||||
|
the dialog also runs the same check up front so a blocked export is explained
|
||||||
|
before any work starts.
|
||||||
|
|
||||||
## Presenter
|
## Presenter
|
||||||
|
|
||||||
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
|
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
|
||||||
|
|
@ -72,6 +84,13 @@ the key thing to understand before touching rendering:
|
||||||
and the **annotation tools** (pen/highlighter/eraser/laser).
|
and the **annotation tools** (pen/highlighter/eraser/laser).
|
||||||
- Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide
|
- Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide
|
||||||
changes never flash black (important for screen recording).
|
changes never flash black (important for screen recording).
|
||||||
|
- **Rehearsal timing** lives in `services/rehearsal_controller.dart` — a plain,
|
||||||
|
unit-testable controller (injectable clock) that the presenter feeds via a
|
||||||
|
cheap, idempotent `observe(id, index)` on every build, so it captures every
|
||||||
|
navigation path. It measures only: elapsed, remaining against a target, and
|
||||||
|
per-slide time — no pacing logic. State is **session-only** (no prefs, no `.md`);
|
||||||
|
`_exit` shows a summary (`rehearsal_summary.dart`) and discards it. The default
|
||||||
|
target lives in `AppSettings.presentationTargetSeconds`.
|
||||||
|
|
||||||
### Dual-screen mode
|
### Dual-screen mode
|
||||||
|
|
||||||
|
|
@ -90,10 +109,12 @@ hence the vendored multi-window fork below.
|
||||||
|
|
||||||
## Sidecars (separate layers)
|
## 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):
|
`FILE_FORMAT.md` §6):
|
||||||
|
|
||||||
- **Captions** — `.ocideck_captions.json` (per image, in `images/`).
|
- **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`).
|
- **Annotations** — `<name>.ink.json` (`services/annotation_codec.dart`).
|
||||||
- **Linked chart data** — `data/*.csv` (the living source for a chart).
|
- **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
|
- **`desktop_multi_window`** (MixinNetwork) — published 0.3.0 dropped the native
|
||||||
window-geometry API. The fork adds macOS `window_setFrame`,
|
window-geometry API. The fork adds macOS `window_setFrame`,
|
||||||
`window_coverScreen` (borderless fill of a chosen screen), and `window_close`,
|
`window_coverScreen` (borderless fill of a chosen screen), and `window_close`,
|
||||||
exposed on `WindowController`. This is what makes the dual-screen audience
|
exposed on `WindowController`. It also tracks the mouse for **non-key
|
||||||
window possible.
|
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
|
- **`screen_retriever_macos`** (leanflutter) — a packaging fix for recent
|
||||||
Xcode/CocoaPods.
|
Xcode/CocoaPods.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,13 @@ Bijschriften worden op **twee** plaatsen bewaard:
|
||||||
```
|
```
|
||||||
Een lege caption verwijdert de sleutel; een leeg bestand wordt verwijderd.
|
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`)
|
### 6.2 Annotatielaag (`<naam>.ink.json`)
|
||||||
|
|
||||||
Vrije-hand-annotaties (pen, markeerstift) die tijdens het presenteren worden
|
Vrije-hand-annotaties (pen, markeerstift) die tijdens het presenteren worden
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@
|
||||||
| `Ctrl/Cmd + Shift + Z` | Redo |
|
| `Ctrl/Cmd + Shift + Z` | Redo |
|
||||||
| `Ctrl + Y` | Redo (alternative) |
|
| `Ctrl + Y` | Redo (alternative) |
|
||||||
| `Ctrl/Cmd + H` | Find & replace |
|
| `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
|
## Fullscreen presenter
|
||||||
|
|
||||||
|
|
@ -30,10 +35,11 @@ View & timing:
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `P` | Toggle presenter view (notes, clock, next slide) |
|
| `P` | Toggle presenter view (notes, clock, countdown, per-slide timer, next slide) |
|
||||||
| `S` | Move the presentation to another screen |
|
| `S` | Move the presentation to another screen |
|
||||||
| `B` · `W` | Black · white screen |
|
| `B` · `W` | Black · white screen |
|
||||||
| `R` | Reset the elapsed-time counter |
|
| `K` | Set the target time / countdown (type `MMSS`, `Enter` to confirm, `0` = off) |
|
||||||
|
| `R` | Reset the time & rehearsal run (elapsed and per-slide timings; the target stays) |
|
||||||
| `A` | Auto-advance on/off |
|
| `A` | Auto-advance on/off |
|
||||||
| `L` | Loop (restart after the last slide) on/off |
|
| `L` | Loop (restart after the last slide) on/off |
|
||||||
| `M` | Advance automatically after a slide's audio finishes |
|
| `M` | Advance automatically after a slide's audio finishes |
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ Marp tools.
|
||||||
Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
|
Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
|
||||||
bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
|
bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
|
||||||
**audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or
|
**audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or
|
||||||
spider/radar), and **free Markdown**. Each type has a dedicated editor on the left
|
spider/radar), and **free Markdown**. Each card in the chooser shows a miniature
|
||||||
and a live preview on the right.
|
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` ``,
|
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
|
||||||
`[links](…)`). Free-Markdown slides also render fenced code with syntax
|
`[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
|
panel — larger when there's room, smaller for long fragments. Stored as a fenced
|
||||||
code block in the Markdown.
|
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
|
### Charts
|
||||||
|
|
||||||
Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then
|
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
|
- **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
|
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 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
|
- Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
|
||||||
HTML export.
|
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
|
## Per-slide options
|
||||||
|
|
||||||
Below each editor you can set:
|
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
|
deck can be shown safely to audiences with different clearances. Order, least to
|
||||||
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
|
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
|
||||||
|
|
||||||
|
Classifying a deck is **optional**. As an extra guardrail, an organisation can
|
||||||
|
set a **release ceiling** — a maximum level that may leave the machine; see
|
||||||
|
*Exporting* below.
|
||||||
|
|
||||||
## Presenting
|
## Presenting
|
||||||
|
|
||||||
Start the fullscreen presenter from the toolbar. See
|
Start the fullscreen presenter from the toolbar. See
|
||||||
[`SHORTCUTS.md`](SHORTCUTS.md) for the full key list; highlights: arrows to move,
|
[`SHORTCUTS.md`](SHORTCUTS.md) for the full key list; highlights: arrows to move,
|
||||||
`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `H` for the
|
`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `K` for the
|
||||||
in-app cheatsheet.
|
countdown, `R` to reset the timing, `H` for the in-app cheatsheet.
|
||||||
|
|
||||||
|
### Rehearsing and timing
|
||||||
|
|
||||||
|
The presenter view (`P`) is also a rehearsal clock — it measures, it does not
|
||||||
|
nag. The clock bar shows four things:
|
||||||
|
|
||||||
|
- **Elapsed** — time since the run started (or since the last `R`).
|
||||||
|
- **Remaining** — a countdown against a **target time**. It turns red and shows a
|
||||||
|
minus sign once you go over; there is no "speed up" coaching, just the number.
|
||||||
|
- **This slide** — how long you have spent on the current slide. Time accumulates
|
||||||
|
per slide across the whole run, even if you jump back and forth.
|
||||||
|
- **Clock** — the wall-clock time.
|
||||||
|
|
||||||
|
Set the target time up front under *Settings → General → Presentation*, or change
|
||||||
|
it live while presenting with **`K`** (type the minutes and seconds as `MMSS`,
|
||||||
|
`Enter` to confirm, `0` to switch the countdown off). **`R`** resets the run —
|
||||||
|
elapsed time and per-slide timings — while keeping the target.
|
||||||
|
|
||||||
|
When you leave the presenter after a run of at least ten seconds, a **summary**
|
||||||
|
shows the total time against the target and the time spent per slide, with a
|
||||||
|
button to copy the times to the clipboard. This is **session-only**: nothing is
|
||||||
|
written to disk or into the `.md` file.
|
||||||
|
|
||||||
### Two screens (beamer + laptop)
|
### Two screens (beamer + laptop)
|
||||||
|
|
||||||
|
|
@ -112,6 +174,26 @@ Export to:
|
||||||
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
||||||
assets, to hand the whole deck to someone else.
|
assets, to hand the whole deck to someone else.
|
||||||
|
|
||||||
|
**Release ceiling (optional).** When a maximum TLP level is configured, exporting
|
||||||
|
a deck classified *above* it is blocked for every format, and the export dialog
|
||||||
|
explains why. The ceiling is off by default and classifying a deck stays
|
||||||
|
optional — it only stops decks that exceed the configured level.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
OciDeck aims for WCAG 2.1 in the editor:
|
||||||
|
|
||||||
|
- **Interface text size** — Settings → General → Accessibility offers 100–200%
|
||||||
|
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
|
## Theming and language
|
||||||
|
|
||||||
- **Style profiles** control deck colours (including the source-code background,
|
- **Style profiles** control deck colours (including the source-code background,
|
||||||
|
|
|
||||||
44
lib/app.dart
44
lib/app.dart
|
|
@ -3,8 +3,10 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'state/settings_provider.dart';
|
import 'state/settings_provider.dart';
|
||||||
|
import 'state/consent_provider.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'widgets/app_shell.dart';
|
import 'widgets/app_shell.dart';
|
||||||
|
import 'widgets/dialogs/consent_dialog.dart';
|
||||||
|
|
||||||
class OciDeckApp extends ConsumerWidget {
|
class OciDeckApp extends ConsumerWidget {
|
||||||
const OciDeckApp({super.key});
|
const OciDeckApp({super.key});
|
||||||
|
|
@ -17,11 +19,28 @@ class OciDeckApp extends ConsumerWidget {
|
||||||
final appearance = ref.watch(
|
final appearance = ref.watch(
|
||||||
settingsProvider.select((s) => s.appAppearanceProfile),
|
settingsProvider.select((s) => s.appAppearanceProfile),
|
||||||
);
|
);
|
||||||
|
final uiTextScale = ref.watch(
|
||||||
|
settingsProvider.select((s) => s.uiTextScale),
|
||||||
|
);
|
||||||
AppLocalizations.setActiveLanguageCode(languageCode);
|
AppLocalizations.setActiveLanguageCode(languageCode);
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'OciDeck',
|
title: 'OciDeck',
|
||||||
theme: AppTheme.fromProfile(appearance),
|
theme: AppTheme.fromProfile(appearance),
|
||||||
debugShowCheckedModeBanner: false,
|
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),
|
locale: AppLocalizations.materialLocaleFor(languageCode),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
|
|
@ -30,7 +49,30 @@ class OciDeckApp extends ConsumerWidget {
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.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
|
|
@ -1,5 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
|
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
|
||||||
/// data files stay tidily in one place — separate from images/media.
|
/// data files stay tidily in one place — separate from images/media.
|
||||||
const String chartDataDirName = 'data';
|
const String chartDataDirName = 'data';
|
||||||
|
|
@ -155,7 +157,8 @@ class ChartSpec {
|
||||||
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
|
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();
|
return const ChartSpec();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
lib/models/rehearsal.dart
Normal file
46
lib/models/rehearsal.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Tijd die tijdens een oefenrun aan één slide is besteed.
|
||||||
|
///
|
||||||
|
/// Sessie-only: niets hiervan wordt op schijf bewaard (geen prefs, geen `.md`).
|
||||||
|
/// Het bestand blijft inhoud; oefentijden leven alleen in het draaiende
|
||||||
|
/// presenter-venster.
|
||||||
|
@immutable
|
||||||
|
class SlideTiming {
|
||||||
|
/// Stabiele slide-id binnen de sessie ([Slide.id]).
|
||||||
|
final String slideId;
|
||||||
|
|
||||||
|
/// 0-gebaseerde positie waarop de slide voor het eerst werd getoond. Dient
|
||||||
|
/// alleen voor een stabiele weergavevolgorde in de samenvatting.
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
/// Opgetelde wandkloktijd op deze slide over de hele run (een slide kan
|
||||||
|
/// meerdere keren bezocht zijn).
|
||||||
|
final Duration spent;
|
||||||
|
|
||||||
|
const SlideTiming({
|
||||||
|
required this.slideId,
|
||||||
|
required this.index,
|
||||||
|
required this.spent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samenvatting van één oefenrun in de huidige sessie: totale tijd, de
|
||||||
|
/// (optionele) doeltijd en de tijd per slide. Puur beschrijvend — er zit
|
||||||
|
/// geen pacing-advies in, alleen gemeten tijd.
|
||||||
|
@immutable
|
||||||
|
class RehearsalRun {
|
||||||
|
final Duration total;
|
||||||
|
final Duration? target;
|
||||||
|
final List<SlideTiming> perSlide;
|
||||||
|
|
||||||
|
const RehearsalRun({
|
||||||
|
required this.total,
|
||||||
|
required this.target,
|
||||||
|
required this.perSlide,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Verschil t.o.v. de doeltijd: positief = over de tijd, negatief = ruim
|
||||||
|
/// binnen. Null wanneer er geen doeltijd was.
|
||||||
|
Duration? get delta => target == null ? null : total - target!;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ class ThemeProfile {
|
||||||
final bool checklistStrikeThrough;
|
final bool checklistStrikeThrough;
|
||||||
final String tableTextColor;
|
final String tableTextColor;
|
||||||
final String tableHeaderTextColor;
|
final String tableHeaderTextColor;
|
||||||
|
final String tableHeaderBackgroundColor;
|
||||||
final String titleBackgroundColor;
|
final String titleBackgroundColor;
|
||||||
final String titleTextColor;
|
final String titleTextColor;
|
||||||
final String sectionBackgroundColor;
|
final String sectionBackgroundColor;
|
||||||
|
|
@ -58,6 +59,7 @@ class ThemeProfile {
|
||||||
this.checklistStrikeThrough = true,
|
this.checklistStrikeThrough = true,
|
||||||
String? tableTextColor,
|
String? tableTextColor,
|
||||||
this.tableHeaderTextColor = '#FFFFFF',
|
this.tableHeaderTextColor = '#FFFFFF',
|
||||||
|
String? tableHeaderBackgroundColor,
|
||||||
this.titleBackgroundColor = '#1C2B47',
|
this.titleBackgroundColor = '#1C2B47',
|
||||||
this.titleTextColor = '#FFFFFF',
|
this.titleTextColor = '#FFFFFF',
|
||||||
this.sectionBackgroundColor = '#2E7D64',
|
this.sectionBackgroundColor = '#2E7D64',
|
||||||
|
|
@ -74,7 +76,9 @@ class ThemeProfile {
|
||||||
this.footerPosition = 'right',
|
this.footerPosition = 'right',
|
||||||
this.closingSlideEnabled = false,
|
this.closingSlideEnabled = false,
|
||||||
this.closingSlideMarkdown = '# Bedankt\n\nVragen?',
|
this.closingSlideMarkdown = '# Bedankt\n\nVragen?',
|
||||||
}) : tableTextColor = tableTextColor ?? textColor;
|
}) : tableTextColor = tableTextColor ?? textColor,
|
||||||
|
tableHeaderBackgroundColor =
|
||||||
|
tableHeaderBackgroundColor ?? accentColor;
|
||||||
|
|
||||||
static const logoPositions = [
|
static const logoPositions = [
|
||||||
'top-left',
|
'top-left',
|
||||||
|
|
@ -95,6 +99,7 @@ class ThemeProfile {
|
||||||
bool? checklistStrikeThrough,
|
bool? checklistStrikeThrough,
|
||||||
String? tableTextColor,
|
String? tableTextColor,
|
||||||
String? tableHeaderTextColor,
|
String? tableHeaderTextColor,
|
||||||
|
String? tableHeaderBackgroundColor,
|
||||||
String? titleBackgroundColor,
|
String? titleBackgroundColor,
|
||||||
String? titleTextColor,
|
String? titleTextColor,
|
||||||
String? sectionBackgroundColor,
|
String? sectionBackgroundColor,
|
||||||
|
|
@ -126,6 +131,8 @@ class ThemeProfile {
|
||||||
checklistStrikeThrough ?? this.checklistStrikeThrough,
|
checklistStrikeThrough ?? this.checklistStrikeThrough,
|
||||||
tableTextColor: tableTextColor ?? this.tableTextColor,
|
tableTextColor: tableTextColor ?? this.tableTextColor,
|
||||||
tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor,
|
tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor,
|
||||||
|
tableHeaderBackgroundColor:
|
||||||
|
tableHeaderBackgroundColor ?? this.tableHeaderBackgroundColor,
|
||||||
titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor,
|
titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor,
|
||||||
titleTextColor: titleTextColor ?? this.titleTextColor,
|
titleTextColor: titleTextColor ?? this.titleTextColor,
|
||||||
sectionBackgroundColor:
|
sectionBackgroundColor:
|
||||||
|
|
@ -158,6 +165,7 @@ class ThemeProfile {
|
||||||
'checklistStrikeThrough': checklistStrikeThrough,
|
'checklistStrikeThrough': checklistStrikeThrough,
|
||||||
'tableTextColor': tableTextColor,
|
'tableTextColor': tableTextColor,
|
||||||
'tableHeaderTextColor': tableHeaderTextColor,
|
'tableHeaderTextColor': tableHeaderTextColor,
|
||||||
|
'tableHeaderBackgroundColor': tableHeaderBackgroundColor,
|
||||||
'titleBackgroundColor': titleBackgroundColor,
|
'titleBackgroundColor': titleBackgroundColor,
|
||||||
'titleTextColor': titleTextColor,
|
'titleTextColor': titleTextColor,
|
||||||
'sectionBackgroundColor': sectionBackgroundColor,
|
'sectionBackgroundColor': sectionBackgroundColor,
|
||||||
|
|
@ -197,6 +205,10 @@ class ThemeProfile {
|
||||||
'#222222',
|
'#222222',
|
||||||
tableHeaderTextColor:
|
tableHeaderTextColor:
|
||||||
json['tableHeaderTextColor'] as String? ?? '#FFFFFF',
|
json['tableHeaderTextColor'] as String? ?? '#FFFFFF',
|
||||||
|
tableHeaderBackgroundColor:
|
||||||
|
json['tableHeaderBackgroundColor'] as String? ??
|
||||||
|
json['accentColor'] as String? ??
|
||||||
|
'#2E7D64',
|
||||||
titleBackgroundColor:
|
titleBackgroundColor:
|
||||||
json['titleBackgroundColor'] as String? ?? '#1C2B47',
|
json['titleBackgroundColor'] as String? ?? '#1C2B47',
|
||||||
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
|
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
|
||||||
|
|
@ -364,6 +376,21 @@ class AppSettings {
|
||||||
final String selectedAppAppearanceProfileName;
|
final String selectedAppAppearanceProfileName;
|
||||||
final List<String> recentFiles;
|
final List<String> recentFiles;
|
||||||
|
|
||||||
|
/// Optioneel vrijgaveplafond voor de classificatie-gate, opgeslagen als
|
||||||
|
/// TLP-sleutel (zie `TlpLevelX.key`). `null` = geen plafond, alles mag worden
|
||||||
|
/// geëxporteerd (standaard). Classificeren blijft optioneel; dit plafond
|
||||||
|
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
|
||||||
|
final String? maxReleaseExportTlpKey;
|
||||||
|
|
||||||
|
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
||||||
|
/// 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({
|
const AppSettings({
|
||||||
this.languageCode = 'nl',
|
this.languageCode = 'nl',
|
||||||
this.homeDirectory,
|
this.homeDirectory,
|
||||||
|
|
@ -373,6 +400,9 @@ class AppSettings {
|
||||||
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||||
this.selectedAppAppearanceProfileName = 'Basic',
|
this.selectedAppAppearanceProfileName = 'Basic',
|
||||||
this.recentFiles = const [],
|
this.recentFiles = const [],
|
||||||
|
this.maxReleaseExportTlpKey,
|
||||||
|
this.uiTextScale = 1.0,
|
||||||
|
this.presentationTargetSeconds = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
ThemeProfile get themeProfile {
|
ThemeProfile get themeProfile {
|
||||||
|
|
@ -424,8 +454,12 @@ class AppSettings {
|
||||||
List<AppAppearanceProfile>? appAppearanceProfiles,
|
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||||
String? selectedAppAppearanceProfileName,
|
String? selectedAppAppearanceProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
|
String? maxReleaseExportTlpKey,
|
||||||
|
double? uiTextScale,
|
||||||
|
int? presentationTargetSeconds,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
bool clearExportDirectory = false,
|
bool clearExportDirectory = false,
|
||||||
|
bool clearMaxReleaseExportTlp = false,
|
||||||
}) {
|
}) {
|
||||||
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
|
|
@ -457,6 +491,12 @@ class AppSettings {
|
||||||
selectedAppAppearanceProfileName ??
|
selectedAppAppearanceProfileName ??
|
||||||
this.selectedAppAppearanceProfileName,
|
this.selectedAppAppearanceProfileName,
|
||||||
recentFiles: recentFiles ?? this.recentFiles,
|
recentFiles: recentFiles ?? this.recentFiles,
|
||||||
|
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
|
||||||
|
? null
|
||||||
|
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
|
||||||
|
uiTextScale: uiTextScale ?? this.uiTextScale,
|
||||||
|
presentationTargetSeconds:
|
||||||
|
presentationTargetSeconds ?? this.presentationTargetSeconds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
|
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
/// Serializes the annotation layer into a sidecar payload that is fully
|
/// Serializes the annotation layer into a sidecar payload that is fully
|
||||||
/// decoupled from the Marp markdown.
|
/// decoupled from the Marp markdown.
|
||||||
|
|
@ -96,7 +97,8 @@ class AnnotationCodec {
|
||||||
used.add(target);
|
used.add(target);
|
||||||
result[slides[target].id] = strokes;
|
result[slides[target].id] = strokes;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e, s) {
|
||||||
|
logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
|
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
|
||||||
/// Bestandsnaam: .ocideck_captions.json
|
/// Bestandsnaam: .ocideck_captions.json
|
||||||
class CaptionService {
|
class CaptionService {
|
||||||
|
|
@ -17,7 +19,8 @@ class CaptionService {
|
||||||
final data = jsonDecode(await file.readAsString()) as Map;
|
final data = jsonDecode(await file.readAsString()) as Map;
|
||||||
final caption = data[p.basename(resolvedPath)];
|
final caption = data[p.basename(resolvedPath)];
|
||||||
return caption is String ? caption : null;
|
return caption is String ? caption : null;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('CaptionService.getCaption: read caption sidecar', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +39,9 @@ class CaptionService {
|
||||||
data = Map<String, dynamic>.from(
|
data = Map<String, dynamic>.from(
|
||||||
jsonDecode(await file.readAsString()) as Map,
|
jsonDecode(await file.readAsString()) as Map,
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (e, s) {
|
||||||
|
logError('CaptionService.saveCaption: parse existing sidecar', e, s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final key = p.basename(resolvedPath);
|
final key = p.basename(resolvedPath);
|
||||||
if (caption.trim().isEmpty) {
|
if (caption.trim().isEmpty) {
|
||||||
|
|
|
||||||
58
lib/services/classification_policy.dart
Normal file
58
lib/services/classification_policy.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import '../models/deck.dart';
|
||||||
|
|
||||||
|
/// Uitkomst van de export-gate: mag deze export door, en zo niet, waarom niet.
|
||||||
|
class ExportDecision {
|
||||||
|
/// Of de export is toegestaan.
|
||||||
|
final bool allowed;
|
||||||
|
|
||||||
|
/// Reden waarom de export geweigerd is (`null` wanneer toegestaan). Bedoeld om
|
||||||
|
/// 1-op-1 aan de gebruiker te tonen.
|
||||||
|
final String? reason;
|
||||||
|
|
||||||
|
const ExportDecision._(this.allowed, this.reason);
|
||||||
|
|
||||||
|
const ExportDecision.allow() : this._(true, null);
|
||||||
|
const ExportDecision.block(String reason) : this._(false, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centrale, pure beslisser voor classificatie bij export.
|
||||||
|
///
|
||||||
|
/// Classificeren is **optioneel**: een deck zonder TLP-niveau ([TlpLevel.none])
|
||||||
|
/// exporteert altijd. Maar zodra een organisatie een vrijgaveplafond instelt, is
|
||||||
|
/// dit de enige plek die bepaalt of een geclassificeerd deck naar buiten mag.
|
||||||
|
///
|
||||||
|
/// De gate hangt aan het export-chokepoint ([ExportService.export]), zodat geen
|
||||||
|
/// enkel formaat (PDF/PPTX/HTML) eromheen kan. Fail-closed: bij twijfel weigert
|
||||||
|
/// de gate in plaats van stilletjes te exporteren.
|
||||||
|
class ClassificationPolicy {
|
||||||
|
/// Hoogste TLP-niveau dat geëxporteerd mag worden — het vrijgaveplafond.
|
||||||
|
///
|
||||||
|
/// `null` = geen plafond, alles mag (standaard). Een deck dat híérboven is
|
||||||
|
/// geclassificeerd wordt geweigerd in plaats van naar buiten gebracht. Let op:
|
||||||
|
/// een plafond van [TlpLevel.none] staat alléén ongeclassificeerde decks toe.
|
||||||
|
final TlpLevel? maxReleaseLevel;
|
||||||
|
|
||||||
|
const ClassificationPolicy({this.maxReleaseLevel});
|
||||||
|
|
||||||
|
/// Bouw het beleid uit de opgeslagen instelling: een TLP-sleutel (zie
|
||||||
|
/// [TlpLevelX.key]) of `null` wanneer er geen plafond is ingesteld.
|
||||||
|
factory ClassificationPolicy.fromKey(String? key) => ClassificationPolicy(
|
||||||
|
maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Of er überhaupt een gate actief is.
|
||||||
|
bool get hasGate => maxReleaseLevel != null;
|
||||||
|
|
||||||
|
/// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden.
|
||||||
|
ExportDecision evaluate(TlpLevel deckLevel) {
|
||||||
|
final ceiling = maxReleaseLevel;
|
||||||
|
if (ceiling != null && deckLevel.index > ceiling.index) {
|
||||||
|
return ExportDecision.block(
|
||||||
|
'Export geblokkeerd door classificatiebeleid: dit deck is '
|
||||||
|
'${deckLevel.label}, hoger dan het toegestane vrijgaveniveau '
|
||||||
|
'${ceiling.label}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const ExportDecision.allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
/// Stores short, searchable image descriptions as a JSON sidecar in the image's
|
/// Stores short, searchable image descriptions as a JSON sidecar in the image's
|
||||||
/// own directory. File name: .ocideck_descriptions.json, keyed by base name.
|
/// 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 data = jsonDecode(await file.readAsString()) as Map;
|
||||||
final value = data[p.basename(imagePath)];
|
final value = data[p.basename(imagePath)];
|
||||||
return value is String ? value : null;
|
return value is String ? value : null;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning(
|
||||||
|
'DescriptionService.getDescription: read description sidecar',
|
||||||
|
e,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +39,13 @@ class DescriptionService {
|
||||||
data = Map<String, dynamic>.from(
|
data = Map<String, dynamic>.from(
|
||||||
jsonDecode(await file.readAsString()) as Map,
|
jsonDecode(await file.readAsString()) as Map,
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (e, s) {
|
||||||
|
logError(
|
||||||
|
'DescriptionService.saveDescription: parse existing sidecar',
|
||||||
|
e,
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final key = p.basename(imagePath);
|
final key = p.basename(imagePath);
|
||||||
if (description.trim().isEmpty) {
|
if (description.trim().isEmpty) {
|
||||||
|
|
@ -71,7 +83,9 @@ class DescriptionService {
|
||||||
result[p.join(dir, entry.key as String)] = entry.value as String;
|
result[p.join(dir, entry.key as String)] = entry.value as String;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning('DescriptionService.loadFor: read description sidecar', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import 'package:path/path.dart' as p;
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import 'classification_policy.dart';
|
||||||
import 'marp_html_service.dart';
|
import 'marp_html_service.dart';
|
||||||
|
|
||||||
enum ExportFormat { pdf, pptx, html }
|
enum ExportFormat { pdf, pptx, html }
|
||||||
|
|
@ -103,7 +105,17 @@ class ExportService {
|
||||||
List<String>? notes,
|
List<String>? notes,
|
||||||
String? markdown,
|
String? markdown,
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
ClassificationPolicy policy = const ClassificationPolicy(),
|
||||||
}) async {
|
}) async {
|
||||||
|
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat
|
||||||
|
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
|
||||||
|
// UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een
|
||||||
|
// weigering wordt er niets gebouwd of weggeschreven.
|
||||||
|
final decision = policy.evaluate(tlp);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return ExportResult.fail(decision.reason!);
|
||||||
|
}
|
||||||
if (format == ExportFormat.html) {
|
if (format == ExportFormat.html) {
|
||||||
if (markdown == null || markdown.trim().isEmpty) {
|
if (markdown == null || markdown.trim().isEmpty) {
|
||||||
return ExportResult.fail('Geen inhoud om te exporteren.');
|
return ExportResult.fail('Geen inhoud om te exporteren.');
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../l10n/app_localizations.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/chart.dart';
|
import '../models/chart.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import '../utils/log.dart';
|
||||||
import 'annotation_codec.dart';
|
import 'annotation_codec.dart';
|
||||||
import 'caption_service.dart';
|
import 'caption_service.dart';
|
||||||
import 'image_service.dart';
|
import 'image_service.dart';
|
||||||
|
|
@ -58,6 +59,25 @@ class FileService {
|
||||||
|
|
||||||
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
|
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 resolveThemeProfile(
|
||||||
ThemeProfile profile, {
|
ThemeProfile profile, {
|
||||||
String? projectPath,
|
String? projectPath,
|
||||||
|
|
@ -106,7 +126,11 @@ class FileService {
|
||||||
List<FileSystemEntity> entries;
|
List<FileSystemEntity> entries;
|
||||||
try {
|
try {
|
||||||
entries = await dir.list(followLinks: false).toList();
|
entries = await dir.list(followLinks: false).toList();
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning(
|
||||||
|
'FileService.scanPresentations: directory listing failed',
|
||||||
|
e,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (final entity in entries) {
|
for (final entity in entries) {
|
||||||
|
|
@ -118,7 +142,8 @@ class FileService {
|
||||||
String content;
|
String content;
|
||||||
try {
|
try {
|
||||||
content = await entity.readAsString();
|
content = await entity.readAsString();
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('FileService.scanPresentations: file not readable', e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final deck = await openDeck(entity.path, content: content);
|
final deck = await openDeck(entity.path, content: content);
|
||||||
|
|
@ -167,8 +192,12 @@ class FileService {
|
||||||
if (!await file.exists()) return null;
|
if (!await file.exists()) return null;
|
||||||
raw = await file.readAsString();
|
raw = await file.readAsString();
|
||||||
}
|
}
|
||||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
final parsed = _md.parseDeck(raw, filePath: filePath);
|
||||||
if (deck == null) return null;
|
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));
|
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
|
||||||
// Re-attach the separate annotation layer from its sidecar, if present.
|
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
|
|
@ -180,8 +209,9 @@ class FileService {
|
||||||
hydrated.slides,
|
hydrated.slides,
|
||||||
);
|
);
|
||||||
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// A broken sidecar must never block opening the deck.
|
// 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);
|
slides.add(s);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final abs = p.isAbsolute(spec.source!)
|
// A chart's CSV link must stay inside the project (no absolute paths or
|
||||||
? spec.source!
|
// `../` escapes) — otherwise an untrusted deck could read arbitrary files.
|
||||||
: p.join(deck.projectPath!, spec.source!);
|
final abs = _projectFile(deck.projectPath, spec.source!);
|
||||||
final file = File(abs);
|
final file = abs == null ? null : File(abs);
|
||||||
if (!await file.exists()) {
|
if (file == null || !await file.exists()) {
|
||||||
slides.add(s);
|
slides.add(s);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +261,8 @@ class FileService {
|
||||||
final csv = await file.readAsString();
|
final csv = await file.readAsString();
|
||||||
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
||||||
changed = true;
|
changed = true;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
|
||||||
slides.add(s);
|
slides.add(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,9 +346,18 @@ class FileService {
|
||||||
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
|
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
|
||||||
String? addAsset(String path, String subdir) {
|
String? addAsset(String path, String subdir) {
|
||||||
if (path.trim().isEmpty) return null;
|
if (path.trim().isEmpty) return null;
|
||||||
final abs = p.isAbsolute(path)
|
final String abs;
|
||||||
? path
|
if (p.isAbsolute(path)) {
|
||||||
: (deck.projectPath != null ? p.join(deck.projectPath!, path) : 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);
|
final file = File(abs);
|
||||||
if (!file.existsSync()) return null;
|
if (!file.existsSync()) return null;
|
||||||
final rel = p.posix.join(subdir, p.basename(abs));
|
final rel = p.posix.join(subdir, p.basename(abs));
|
||||||
|
|
@ -405,7 +445,8 @@ class FileService {
|
||||||
profile,
|
profile,
|
||||||
logoRel == null ? null : '../$logoRel',
|
logoRel == null ? null : '../$logoRel',
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +460,8 @@ class FileService {
|
||||||
final Archive archive;
|
final Archive archive;
|
||||||
try {
|
try {
|
||||||
archive = ZipDecoder().decodeBytes(zipBytes);
|
archive = ZipDecoder().decodeBytes(zipBytes);
|
||||||
} catch (_) {
|
} catch (e, s) {
|
||||||
|
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -438,14 +480,33 @@ class FileService {
|
||||||
final destDir = _uniqueDir(destParentDir, folderName);
|
final destDir = _uniqueDir(destParentDir, folderName);
|
||||||
await destDir.create(recursive: true);
|
await destDir.create(recursive: true);
|
||||||
|
|
||||||
for (final f in archive.files) {
|
// Resolve an archive entry name to a path strictly inside [destDir], or
|
||||||
if (!f.isFile) continue;
|
// null when it would escape (zip-slip: `../`, absolute paths, …).
|
||||||
final out = File(p.join(destDir.path, f.name));
|
String? safeOutPath(String entryName) {
|
||||||
await out.parent.create(recursive: true);
|
final resolved = p.normalize(p.join(destDir.path, entryName));
|
||||||
await out.writeAsBytes(f.content as List<int>, flush: true);
|
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) {
|
Directory _uniqueDir(String parent, String name) {
|
||||||
|
|
@ -461,26 +522,65 @@ class FileService {
|
||||||
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
|
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
|
||||||
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
|
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
|
||||||
/// markdown-bestand terug.
|
/// 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 {
|
Future<String?> importFromUrl(String url, String destParentDir) async {
|
||||||
final uri = Uri.tryParse(url.trim());
|
final uri = Uri.tryParse(url.trim());
|
||||||
if (uri == null || !uri.hasScheme) return null;
|
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;
|
final List<int> bytes;
|
||||||
try {
|
try {
|
||||||
final client = HttpClient();
|
final client = HttpClient()
|
||||||
|
..connectionTimeout = const Duration(seconds: 15);
|
||||||
try {
|
try {
|
||||||
final request = await client.getUrl(uri);
|
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.statusCode != 200) return null;
|
||||||
|
if (response.contentLength > _maxDownloadBytes) return null;
|
||||||
final builder = BytesBuilder(copy: false);
|
final builder = BytesBuilder(copy: false);
|
||||||
await for (final chunk in response) {
|
await for (final chunk in response) {
|
||||||
builder.add(chunk);
|
builder.add(chunk);
|
||||||
|
if (builder.length > _maxDownloadBytes) return null; // runaway body
|
||||||
}
|
}
|
||||||
bytes = builder.takeBytes();
|
bytes = builder.takeBytes();
|
||||||
} finally {
|
} finally {
|
||||||
client.close(force: true);
|
client.close(force: true);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logError('FileService.importFromUrl: download failed', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,7 +599,8 @@ class FileService {
|
||||||
final String markdown;
|
final String markdown;
|
||||||
try {
|
try {
|
||||||
markdown = utf8.decode(bytes);
|
markdown = utf8.decode(bytes);
|
||||||
} catch (_) {
|
} catch (e, s) {
|
||||||
|
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
|
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
|
||||||
|
|
@ -620,8 +721,9 @@ class FileService {
|
||||||
'assets/themes/ocideck.css',
|
'assets/themes/ocideck.css',
|
||||||
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
|
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
|
||||||
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
|
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Asset not bundled in this build context; skip
|
// 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 {
|
thead th, tr:first-child th {
|
||||||
background: ${profile.accentColor};
|
background: ${profile.tableHeaderBackgroundColor};
|
||||||
color: ${profile.tableHeaderTextColor};
|
color: ${profile.tableHeaderTextColor};
|
||||||
}
|
}
|
||||||
$logoCss
|
$logoCss
|
||||||
|
|
|
||||||
103
lib/services/image_dedup_service.dart
Normal file
103
lib/services/image_dedup_service.dart
Normal 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(),
|
||||||
|
);
|
||||||
176
lib/services/image_reference_service.dart
Normal file
176
lib/services/image_reference_service.dart
Normal 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 (``) 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: ``.
|
||||||
|
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 '';
|
||||||
|
});
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
class ImageService {
|
class ImageService {
|
||||||
final String Function() _languageCode;
|
final String Function() _languageCode;
|
||||||
|
|
@ -46,7 +47,8 @@ class ImageService {
|
||||||
if (bytes.isEmpty) return false;
|
if (bytes.isEmpty) return false;
|
||||||
await Pasteboard.writeImage(bytes);
|
await Pasteboard.writeImage(bytes);
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logError('ImageService.copyImageBytesToClipboard: write image', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +62,8 @@ class ImageService {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (!await file.exists()) return false;
|
if (!await file.exists()) return false;
|
||||||
return copyImageBytesToClipboard(await file.readAsBytes());
|
return copyImageBytesToClipboard(await file.readAsBytes());
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('ImageService.copyImageToClipboard: read image file', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,26 @@ import '../models/chart.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
const _uuid = Uuid();
|
const _uuid = Uuid();
|
||||||
|
|
||||||
class MarkdownService {
|
class MarkdownService {
|
||||||
// ── Generation ──────────────────────────────────────────────────────────────
|
// ── 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();
|
final buf = StringBuffer();
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
buf.writeln('marp: true');
|
buf.writeln('marp: true');
|
||||||
|
|
@ -42,9 +55,11 @@ class MarkdownService {
|
||||||
if (deck.tlp != TlpLevel.none) {
|
if (deck.tlp != TlpLevel.none) {
|
||||||
buf.writeln('tlp: ${deck.tlp.key}');
|
buf.writeln('tlp: ${deck.tlp.key}');
|
||||||
}
|
}
|
||||||
buf.writeln(
|
if (inlineStyleProfile) {
|
||||||
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
|
buf.writeln(
|
||||||
);
|
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
|
||||||
|
);
|
||||||
|
}
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
|
|
||||||
|
|
@ -514,7 +529,8 @@ class MarkdownService {
|
||||||
static String _decodeText(String encoded) {
|
static String _decodeText(String encoded) {
|
||||||
try {
|
try {
|
||||||
return utf8.decode(base64Url.decode(encoded.trim()));
|
return utf8.decode(base64Url.decode(encoded.trim()));
|
||||||
} catch (_) {
|
} catch (e, s) {
|
||||||
|
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -524,7 +540,9 @@ class MarkdownService {
|
||||||
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
|
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
|
||||||
final raw = jsonDecode(decoded);
|
final raw = jsonDecode(decoded);
|
||||||
if (raw is List) return raw.map((v) => v.toString()).toList();
|
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 [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,7 +650,8 @@ class MarkdownService {
|
||||||
Deck? parseDeck(String markdown, {String? filePath}) {
|
Deck? parseDeck(String markdown, {String? filePath}) {
|
||||||
try {
|
try {
|
||||||
return _doParse(markdown, filePath: filePath);
|
return _doParse(markdown, filePath: filePath);
|
||||||
} catch (_) {
|
} catch (e, s) {
|
||||||
|
logError('MarkdownService.parseDeck: parse markdown', e, s);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -678,11 +697,22 @@ class MarkdownService {
|
||||||
} else if (line.startsWith('tlp:')) {
|
} else if (line.startsWith('tlp:')) {
|
||||||
tlp = TlpLevelX.fromKey(line.substring(4));
|
tlp = TlpLevelX.fromKey(line.substring(4));
|
||||||
} else if (line.startsWith('ocideck_style_profile:')) {
|
} else if (line.startsWith('ocideck_style_profile:')) {
|
||||||
final encoded = line.substring(22).trim();
|
// Best-effort: a corrupt profile token must not fail the whole
|
||||||
final decoded = utf8.decode(base64Url.decode(encoded));
|
// parse (which would blank the audience window). Keep the default.
|
||||||
themeProfile = ThemeProfile.fromJson(
|
try {
|
||||||
Map<String, Object?>.from(jsonDecode(decoded) as Map),
|
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();
|
content = content.substring(end + 5).trim();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
import '../models/chart.dart';
|
import '../models/chart.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
/// 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.
|
/// colours and font so the export matches the in-app / PDF look.
|
||||||
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
|
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
|
||||||
final marked = await loadAsset('$_assetDir/marked.min.js');
|
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 hljs = await loadAsset('$_assetDir/highlight.min.js');
|
||||||
final hljsCss = await loadAsset('$_assetDir/highlight.css');
|
final hljsCss = await loadAsset('$_assetDir/highlight.css');
|
||||||
final mathjax = await loadAsset('$_assetDir/tex-svg.js');
|
final mathjax = await loadAsset('$_assetDir/tex-svg.js');
|
||||||
|
|
@ -61,6 +63,7 @@ class MarpHtmlService {
|
||||||
'<style>$css\n$hljsCss</style>'
|
'<style>$css\n$hljsCss</style>'
|
||||||
'<script>$_mathjaxConfig</script>'
|
'<script>$_mathjaxConfig</script>'
|
||||||
'${inline(marked)}'
|
'${inline(marked)}'
|
||||||
|
'${inline(purify)}'
|
||||||
'${inline(hljs)}'
|
'${inline(hljs)}'
|
||||||
'${inline(mathjax)}'
|
'${inline(mathjax)}'
|
||||||
'${inline(mermaid)}'
|
'${inline(mermaid)}'
|
||||||
|
|
@ -97,11 +100,16 @@ class MarpHtmlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Neutralise any `</script` inside inlined content so it can't break out of
|
/// 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 surrounding <script> element. Case-insensitive — `</ScRiPt>` must not
|
||||||
/// the embedded Markdown payloads.
|
/// slip through. Safe for both JS (string contexts) and the embedded Markdown
|
||||||
static String _guard(String s) => s
|
/// payloads.
|
||||||
.replaceAll('</script', r'<\/script')
|
static final RegExp _scriptClose = RegExp(
|
||||||
.replaceAll('</SCRIPT', r'<\/SCRIPT');
|
r'</(script)',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
static String _guard(String s) =>
|
||||||
|
s.replaceAllMapped(_scriptClose, (m) => '<\\/${m.group(1)}');
|
||||||
|
|
||||||
// ── Charts → inline SVG ────────────────────────────────────────────────────
|
// ── Charts → inline SVG ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -563,7 +571,9 @@ class MarpHtmlService {
|
||||||
final range = (rawHi - rawLo).abs();
|
final range = (rawHi - rawLo).abs();
|
||||||
final r = range <= 0 ? 1.0 : range;
|
final r = range <= 0 ? 1.0 : range;
|
||||||
final rawStep = r / 4;
|
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 norm = rawStep / mag;
|
||||||
final niceNorm = norm < 1.5
|
final niceNorm = norm < 1.5
|
||||||
? 1.0
|
? 1.0
|
||||||
|
|
@ -616,7 +626,7 @@ class MarpHtmlService {
|
||||||
'.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;'
|
'.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;'
|
||||||
'padding-left:16px;opacity:.85}'
|
'padding-left:16px;opacity:.85}'
|
||||||
'.slide table{border-collapse:collapse}'
|
'.slide table{border-collapse:collapse}'
|
||||||
'.slide th{background:${t.sectionBackgroundColor};color:${t.tableHeaderTextColor};'
|
'.slide th{background:${t.tableHeaderBackgroundColor};color:${t.tableHeaderTextColor};'
|
||||||
'border:1px solid #ccc;padding:6px 12px;font-size:20px}'
|
'border:1px solid #ccc;padding:6px 12px;font-size:20px}'
|
||||||
'.slide td{color:${t.tableTextColor};border:1px solid #ccc;padding:6px 12px;font-size:20px}'
|
'.slide td{color:${t.tableTextColor};border:1px solid #ccc;padding:6px 12px;font-size:20px}'
|
||||||
'@media print{body{background:#fff}.slide{margin:0;box-shadow:none;'
|
'@media print{body{background:#fff}.slide{margin:0;box-shadow:none;'
|
||||||
|
|
@ -640,7 +650,8 @@ class MarpHtmlService {
|
||||||
return "@font-face{font-family:'EB Garamond';font-weight:400 800;"
|
return "@font-face{font-family:'EB Garamond';font-weight:400 800;"
|
||||||
"font-style:normal;src:url(data:font/ttf;base64,$b64) "
|
"font-style:normal;src:url(data:font/ttf;base64,$b64) "
|
||||||
"format('truetype');}";
|
"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.
|
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 holder=sec.querySelector('script[type="text/markdown"]');
|
||||||
var src=holder?holder.textContent:'';
|
var src=holder?holder.textContent:'';
|
||||||
var div=document.createElement('div');div.className='content';
|
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);
|
sec.innerHTML='';sec.appendChild(div);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('code.language-mermaid').forEach(function(code){
|
document.querySelectorAll('code.language-mermaid').forEach(function(code){
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../utils/log.dart';
|
||||||
|
|
||||||
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
|
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
|
||||||
class RecoverySnapshot {
|
class RecoverySnapshot {
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -72,7 +74,8 @@ class RecoveryService {
|
||||||
dir,
|
dir,
|
||||||
snapshot.id,
|
snapshot.id,
|
||||||
).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
|
).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('RecoveryService.save: write recovery snapshot', e);
|
||||||
// Autosave mag nooit de app verstoren.
|
// Autosave mag nooit de app verstoren.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +84,9 @@ class RecoveryService {
|
||||||
try {
|
try {
|
||||||
final file = _file(await _dir(), id);
|
final file = _file(await _dir(), id);
|
||||||
if (file.existsSync()) await file.delete();
|
if (file.existsSync()) await file.delete();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning('RecoveryService.discard: delete recovery file', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RecoverySnapshot>> loadAll() async {
|
Future<List<RecoverySnapshot>> loadAll() async {
|
||||||
|
|
@ -93,12 +98,15 @@ class RecoveryService {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(await entry.readAsString());
|
final data = jsonDecode(await entry.readAsString());
|
||||||
out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data)));
|
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));
|
out.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||||
return out;
|
return out;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('RecoveryService.loadAll: list recovery dir', e);
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,10 +118,14 @@ class RecoveryService {
|
||||||
if (entry is File && entry.path.endsWith('.json')) {
|
if (entry is File && entry.path.endsWith('.json')) {
|
||||||
try {
|
try {
|
||||||
await entry.delete();
|
await entry.delete();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning('RecoveryService.clearAll: delete recovery file', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning('RecoveryService.clearAll: list recovery dir', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
115
lib/services/rehearsal_controller.dart
Normal file
115
lib/services/rehearsal_controller.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import '../models/rehearsal.dart';
|
||||||
|
|
||||||
|
/// Meet verstreken tijd, resterende tijd t.o.v. een doeltijd, en de tijd per
|
||||||
|
/// slide tijdens het presenteren/oefenen.
|
||||||
|
///
|
||||||
|
/// Bewust *alleen een klok*: het registreert wandkloktijd en rekent geen
|
||||||
|
/// pacing-advies uit ("ga sneller/langzamer"). Alles is sessie-only; er wordt
|
||||||
|
/// niets gepersisteerd.
|
||||||
|
///
|
||||||
|
/// De klokbron is injecteerbaar ([now]) zodat de timing in tests deterministisch
|
||||||
|
/// is — productie gebruikt [DateTime.now].
|
||||||
|
class RehearsalController {
|
||||||
|
RehearsalController({DateTime Function()? now, Duration? target})
|
||||||
|
: _now = now ?? DateTime.now,
|
||||||
|
_target = (target != null && target > Duration.zero) ? target : null {
|
||||||
|
final t = _now();
|
||||||
|
_runStart = t;
|
||||||
|
_slideEntered = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
Duration? _target;
|
||||||
|
late DateTime _runStart;
|
||||||
|
late DateTime _slideEntered;
|
||||||
|
String? _currentId;
|
||||||
|
|
||||||
|
/// Opgetelde tijd per slide-id over de hele run.
|
||||||
|
final Map<String, Duration> _spent = {};
|
||||||
|
|
||||||
|
/// Eerste positie waarop een slide werd gezien (voor stabiele volgorde).
|
||||||
|
final Map<String, int> _firstIndex = {};
|
||||||
|
|
||||||
|
/// Volgorde van eerste verschijning, voor een leesbare samenvatting.
|
||||||
|
final List<String> _order = [];
|
||||||
|
|
||||||
|
/// Huidige doeltijd, of null als er geen aftelling loopt.
|
||||||
|
Duration? get target => _target;
|
||||||
|
|
||||||
|
/// Zet de doeltijd. Een waarde van nul of minder zet de aftelling uit.
|
||||||
|
set target(Duration? value) =>
|
||||||
|
_target = (value != null && value > Duration.zero) ? value : null;
|
||||||
|
|
||||||
|
/// Verstreken tijd sinds de (her)start van de run.
|
||||||
|
Duration get elapsed => _now().difference(_runStart);
|
||||||
|
|
||||||
|
/// Resterende tijd t.o.v. de doeltijd; null zonder doeltijd. Kan negatief
|
||||||
|
/// worden wanneer je over de tijd gaat.
|
||||||
|
Duration? get remaining {
|
||||||
|
final t = _target;
|
||||||
|
return t == null ? null : t - elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tijd op de huidige slide sinds binnenkomst.
|
||||||
|
Duration get currentSlideElapsed => _now().difference(_slideEntered);
|
||||||
|
|
||||||
|
/// Heeft de run genoeg gegevens om een zinvolle samenvatting te tonen?
|
||||||
|
/// Voorkomt een dialoog bij het meteen weer sluiten van de presentatie.
|
||||||
|
bool get hasMeaningfulData => _order.isNotEmpty && elapsed.inSeconds >= 10;
|
||||||
|
|
||||||
|
/// Registreer dat slide [id] op positie [index] nu zichtbaar is.
|
||||||
|
///
|
||||||
|
/// Idempotent: alleen een échte wissel sluit de vorige slide af. Wordt elke
|
||||||
|
/// build aangeroepen, dus moet goedkoop blijven.
|
||||||
|
void observe(String id, int index) {
|
||||||
|
if (_currentId == id) return;
|
||||||
|
final t = _now();
|
||||||
|
final prev = _currentId;
|
||||||
|
if (prev != null) {
|
||||||
|
_spent[prev] = (_spent[prev] ?? Duration.zero) + t.difference(_slideEntered);
|
||||||
|
}
|
||||||
|
_currentId = id;
|
||||||
|
_slideEntered = t;
|
||||||
|
if (!_firstIndex.containsKey(id)) {
|
||||||
|
_firstIndex[id] = index;
|
||||||
|
_order.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset de hele run (verstreken tijd én per-slide-tijden). De doeltijd blijft
|
||||||
|
/// staan. De eerstvolgende [observe] registreert de huidige slide opnieuw.
|
||||||
|
void reset() {
|
||||||
|
final t = _now();
|
||||||
|
_runStart = t;
|
||||||
|
_slideEntered = t;
|
||||||
|
_spent.clear();
|
||||||
|
_firstIndex.clear();
|
||||||
|
_order.clear();
|
||||||
|
_currentId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sluit de lopende slide af en geef de samenvatting van deze run terug.
|
||||||
|
/// Niet-destructief: je kunt erna gewoon doorpresenteren.
|
||||||
|
RehearsalRun finish() {
|
||||||
|
final t = _now();
|
||||||
|
final spent = Map<String, Duration>.from(_spent);
|
||||||
|
final cur = _currentId;
|
||||||
|
if (cur != null) {
|
||||||
|
spent[cur] = (spent[cur] ?? Duration.zero) + t.difference(_slideEntered);
|
||||||
|
}
|
||||||
|
final perSlide = [
|
||||||
|
for (final id in _order)
|
||||||
|
SlideTiming(
|
||||||
|
slideId: id,
|
||||||
|
index: _firstIndex[id] ?? 0,
|
||||||
|
spent: spent[id] ?? Duration.zero,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return RehearsalRun(
|
||||||
|
total: t.difference(_runStart),
|
||||||
|
target: _target,
|
||||||
|
perSlide: perSlide,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/state/consent_provider.dart
Normal file
67
lib/state/consent_provider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,13 +127,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
state = DeckState(deck: deck, isDirty: true);
|
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}) {
|
void loadDeck(Deck deck, {String? filePath}) {
|
||||||
final resolvedDeck = deck.copyWith(
|
final resolvedDeck = deck.copyWith(
|
||||||
themeProfile: _file.resolveThemeProfile(
|
themeProfile: _file.activeProfileFor(projectPath: deck.projectPath),
|
||||||
deck.themeProfile,
|
|
||||||
projectPath: deck.projectPath,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_clearHistory();
|
_clearHistory();
|
||||||
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
|
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
|
||||||
|
|
@ -202,7 +201,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
|
|
||||||
void removeSlide(int index) {
|
void removeSlide(int index) {
|
||||||
final deck = state.deck;
|
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);
|
final slides = List<Slide>.from(deck.slides)..removeAt(index);
|
||||||
_mutate(deck.copyWith(slides: slides));
|
_mutate(deck.copyWith(slides: slides));
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +259,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
|
|
||||||
void duplicateSlide(int index) {
|
void duplicateSlide(int index) {
|
||||||
final deck = state.deck;
|
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);
|
final slides = List<Slide>.from(deck.slides);
|
||||||
slides.insert(index + 1, Slide.duplicate(slides[index]));
|
slides.insert(index + 1, Slide.duplicate(slides[index]));
|
||||||
_mutate(deck.copyWith(slides: slides));
|
_mutate(deck.copyWith(slides: slides));
|
||||||
|
|
@ -273,7 +277,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
|
|
||||||
void updateSlide(int index, Slide updated) {
|
void updateSlide(int index, Slide updated) {
|
||||||
final deck = state.deck;
|
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);
|
final slides = List<Slide>.from(deck.slides);
|
||||||
slides[index] = updated;
|
slides[index] = updated;
|
||||||
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
|
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
|
||||||
|
|
@ -394,6 +398,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
title: sub(s.title),
|
title: sub(s.title),
|
||||||
subtitle: sub(s.subtitle),
|
subtitle: sub(s.subtitle),
|
||||||
bullets: [for (final b in s.bullets) sub(b)],
|
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),
|
quote: sub(s.quote),
|
||||||
quoteAuthor: sub(s.quoteAuthor),
|
quoteAuthor: sub(s.quoteAuthor),
|
||||||
customMarkdown: sub(s.customMarkdown),
|
customMarkdown: sub(s.customMarkdown),
|
||||||
|
|
@ -415,6 +422,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
s.title,
|
s.title,
|
||||||
s.subtitle,
|
s.subtitle,
|
||||||
...s.bullets,
|
...s.bullets,
|
||||||
|
...s.bullets2,
|
||||||
|
s.columnTitle1,
|
||||||
|
s.columnTitle2,
|
||||||
s.quote,
|
s.quote,
|
||||||
s.quoteAuthor,
|
s.quoteAuthor,
|
||||||
s.customMarkdown,
|
s.customMarkdown,
|
||||||
|
|
@ -499,8 +509,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
|
|
||||||
/// Returns false if parsing fails (content is preserved).
|
/// Returns false if parsing fails (content is preserved).
|
||||||
bool applyMarkdown(String markdown) {
|
bool applyMarkdown(String markdown) {
|
||||||
final deck = _md.parseDeck(markdown, filePath: state.filePath);
|
final parsed = _md.parseDeck(markdown, filePath: state.filePath);
|
||||||
if (deck == null) return false;
|
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
|
_mutate(deck); // discrete stap → ook ongedaan te maken
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,43 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
? selectedAppearance
|
? selectedAppearance
|
||||||
: 'Basic',
|
: 'Basic',
|
||||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||||
|
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
|
||||||
|
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
|
||||||
|
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 {
|
Future<void> addRecentFile(String path) async {
|
||||||
final updated = [
|
final updated = [
|
||||||
path,
|
path,
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,26 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
||||||
for (final sub in _subs.values) {
|
for (final sub in _subs.values) {
|
||||||
sub.cancel();
|
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();
|
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() {
|
TabInfo _createTab() {
|
||||||
final id = _nextId++;
|
final id = _nextId++;
|
||||||
final recoveryId = _uuid.v4();
|
final recoveryId = _uuid.v4();
|
||||||
|
|
@ -163,7 +180,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
||||||
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
|
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
|
||||||
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
|
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
|
||||||
if (replaceEmpty) {
|
if (replaceEmpty) {
|
||||||
_subs.remove(state.tabs.first.id)?.cancel();
|
_disposeTab(state.tabs.first);
|
||||||
state = state.copyWith(tabs: restored, selectedIndex: 0);
|
state = state.copyWith(tabs: restored, selectedIndex: 0);
|
||||||
} else {
|
} else {
|
||||||
final tabs = [...state.tabs, ...restored];
|
final tabs = [...state.tabs, ...restored];
|
||||||
|
|
@ -282,7 +299,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
||||||
}
|
}
|
||||||
final tab = state.tabs[index];
|
final tab = state.tabs[index];
|
||||||
_recovery.discard(tab.recoveryId);
|
_recovery.discard(tab.recoveryId);
|
||||||
_subs.remove(tab.id)?.cancel();
|
_disposeTab(tab);
|
||||||
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
|
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
|
||||||
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
|
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
|
||||||
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);
|
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);
|
||||||
|
|
|
||||||
41
lib/utils/log.dart
Normal file
41
lib/utils/log.dart
Normal 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);
|
||||||
|
}
|
||||||
123
lib/utils/table_clipboard.dart
Normal file
123
lib/utils/table_clipboard.dart
Normal 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] : ''],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
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
|
/// Open een link uit slide-tekst in de externe browser. Kale domeinen
|
||||||
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige
|
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige,
|
||||||
/// of niet-openbare URLs.
|
/// niet-openbare of niet-toegestane URLs.
|
||||||
Future<void> openExternalUrl(String url) async {
|
Future<void> openExternalUrl(String url) async {
|
||||||
var u = url.trim();
|
var u = url.trim();
|
||||||
if (u.isEmpty) return;
|
if (u.isEmpty) return;
|
||||||
|
|
@ -11,11 +17,13 @@ Future<void> openExternalUrl(String url) async {
|
||||||
}
|
}
|
||||||
final uri = Uri.tryParse(u);
|
final uri = Uri.tryParse(u);
|
||||||
if (uri == null) return;
|
if (uri == null) return;
|
||||||
|
if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return;
|
||||||
try {
|
try {
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
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.
|
// Nooit de presentatie laten crashen op een kapotte link.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,23 +15,19 @@ class AddSlideDialog extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _types = [
|
static const _types = [
|
||||||
(SlideType.title, Icons.title, 'Titelpagina'),
|
(SlideType.title, 'Titelpagina'),
|
||||||
(SlideType.section, Icons.bookmark_outline, 'Tussentitel'),
|
(SlideType.section, 'Tussentitel'),
|
||||||
(SlideType.bullets, Icons.format_list_bulleted, 'Alleen Bullets'),
|
(SlideType.bullets, 'Alleen Bullets'),
|
||||||
(SlideType.twoBullets, Icons.view_column_outlined, 'Twee Bulletkolommen'),
|
(SlideType.twoBullets, 'Twee Bulletkolommen'),
|
||||||
(
|
(SlideType.bulletsImage, 'Bullets + Afbeelding'),
|
||||||
SlideType.bulletsImage,
|
(SlideType.twoImages, 'Twee Afbeeldingen'),
|
||||||
Icons.view_agenda_outlined,
|
(SlideType.image, 'Grote Afbeelding'),
|
||||||
'Bullets + Afbeelding',
|
(SlideType.video, 'Video'),
|
||||||
),
|
(SlideType.quote, 'Quote'),
|
||||||
(SlideType.twoImages, Icons.auto_stories_outlined, 'Twee Afbeeldingen'),
|
(SlideType.table, 'Tabel'),
|
||||||
(SlideType.image, Icons.image_outlined, 'Grote Afbeelding'),
|
(SlideType.chart, 'Grafiek'),
|
||||||
(SlideType.video, Icons.movie_outlined, 'Video'),
|
(SlideType.code, 'Broncode'),
|
||||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
(SlideType.freeMarkdown, 'Vrije Markdown'),
|
||||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
|
||||||
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
|
||||||
(SlideType.code, Icons.terminal, 'Broncode'),
|
|
||||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -42,73 +38,272 @@ class AddSlideDialog extends StatelessWidget {
|
||||||
const SingleActivator(LogicalKeyboardKey.escape): () =>
|
const SingleActivator(LogicalKeyboardKey.escape): () =>
|
||||||
Navigator.pop(context),
|
Navigator.pop(context),
|
||||||
},
|
},
|
||||||
child: Focus(
|
child: AlertDialog(
|
||||||
autofocus: true,
|
title: Text(l10n.d('Slide type kiezen')),
|
||||||
child: AlertDialog(
|
content: SizedBox(
|
||||||
title: Text(l10n.d('Slide type kiezen')),
|
width: 440,
|
||||||
content: SizedBox(
|
// Reading-order tabbing through the cards; the first one takes
|
||||||
width: 400,
|
// focus so the dialog is fully keyboard-operable right away.
|
||||||
|
child: FocusTraversalGroup(
|
||||||
|
policy: ReadingOrderTraversalPolicy(),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 10,
|
spacing: 10,
|
||||||
runSpacing: 10,
|
runSpacing: 10,
|
||||||
children: _types.map((entry) {
|
children: [
|
||||||
final (type, icon, label) = entry;
|
for (var i = 0; i < _types.length; i++)
|
||||||
return _TypeCard(
|
_TypeCard(
|
||||||
icon: icon,
|
type: _types[i].$1,
|
||||||
label: l10n.d(label),
|
label: l10n.d(_types[i].$2),
|
||||||
onTap: () => Navigator.pop(context, type),
|
autofocus: i == 0,
|
||||||
);
|
onTap: () => Navigator.pop(context, _types[i].$1),
|
||||||
}).toList(),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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 {
|
class _TypeCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final SlideType type;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool autofocus;
|
||||||
|
|
||||||
const _TypeCard({
|
const _TypeCard({
|
||||||
required this.icon,
|
required this.type,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.autofocus = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Semantics(
|
||||||
onTap: onTap,
|
button: true,
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: InkWell(
|
||||||
child: Container(
|
onTap: onTap,
|
||||||
width: 110,
|
autofocus: autofocus,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
decoration: BoxDecoration(
|
focusColor: AppTheme.accent.withValues(alpha: 0.14),
|
||||||
border: Border.all(color: const Color(0xFFCBD5E1)),
|
hoverColor: AppTheme.accent.withValues(alpha: 0.06),
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: Container(
|
||||||
),
|
width: 100,
|
||||||
child: Column(
|
padding: const EdgeInsets.all(8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
decoration: BoxDecoration(
|
||||||
children: [
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
||||||
Icon(icon, size: 28, color: AppTheme.navy),
|
borderRadius: BorderRadius.circular(8),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Text(
|
child: Column(
|
||||||
label,
|
mainAxisSize: MainAxisSize.min,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: const TextStyle(fontSize: 11),
|
// 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;
|
||||||
|
|
|
||||||
194
lib/widgets/dialogs/consent_dialog.dart
Normal file
194
lib/widgets/dialogs/consent_dialog.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
import '../../services/classification_policy.dart';
|
||||||
import '../../services/export_service.dart';
|
import '../../services/export_service.dart';
|
||||||
import '../../services/slide_rasterizer.dart';
|
import '../../services/slide_rasterizer.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
@ -18,6 +19,9 @@ class ExportDialog extends StatefulWidget {
|
||||||
final ExportService exportService;
|
final ExportService exportService;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Classificatie-gate. Standaard geen plafond (alles mag).
|
||||||
|
final ClassificationPolicy policy;
|
||||||
|
|
||||||
/// Folder all exports are written to. Null = next to the source deck.
|
/// Folder all exports are written to. Null = next to the source deck.
|
||||||
final String? exportDirectory;
|
final String? exportDirectory;
|
||||||
|
|
||||||
|
|
@ -32,6 +36,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required this.projectPath,
|
required this.projectPath,
|
||||||
required this.exportService,
|
required this.exportService,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.policy = const ClassificationPolicy(),
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
this.markdown = '',
|
this.markdown = '',
|
||||||
});
|
});
|
||||||
|
|
@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required String? projectPath,
|
required String? projectPath,
|
||||||
required ExportService exportService,
|
required ExportService exportService,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
ClassificationPolicy policy = const ClassificationPolicy(),
|
||||||
String? exportDirectory,
|
String? exportDirectory,
|
||||||
String markdown = '',
|
String markdown = '',
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -57,6 +63,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
policy: policy,
|
||||||
exportDirectory: exportDirectory,
|
exportDirectory: exportDirectory,
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
),
|
),
|
||||||
|
|
@ -131,6 +138,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
notes: [for (final s in widget.slides) s.notes],
|
notes: [for (final s in widget.slides) s.notes],
|
||||||
markdown: widget.markdown,
|
markdown: widget.markdown,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
tlp: widget.tlp,
|
||||||
|
policy: widget.policy,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -231,6 +240,25 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-flight classificatie-gate: blokkeert de export al vóór een poging,
|
||||||
|
// zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde
|
||||||
|
// regel nog eens als backstop, dus dit is puur UX — niet de beveiliging.
|
||||||
|
final decision = widget.policy.evaluate(widget.tlp);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.block, color: Colors.red, size: 36),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
decision.reason!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.red[800]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import '../../services/caption_service.dart';
|
import '../../services/caption_service.dart';
|
||||||
import '../../services/description_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 '../../services/image_service.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/log.dart';
|
||||||
|
|
||||||
/// Resultaat van de afbeeldingencarousel.
|
/// Resultaat van de afbeeldingencarousel.
|
||||||
class ImagePickResult {
|
class ImagePickResult {
|
||||||
|
|
@ -19,6 +22,12 @@ class ImagePickResult {
|
||||||
/// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden.
|
/// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden.
|
||||||
typedef ImageUsageLookup = List<String> Function(String absolutePath);
|
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
|
/// Manier waarop de afbeeldingen worden getoond. Tussen beide kan in de
|
||||||
/// header gewisseld worden.
|
/// header gewisseld worden.
|
||||||
enum _ViewMode {
|
enum _ViewMode {
|
||||||
|
|
@ -38,6 +47,12 @@ class ImageCarouselPicker extends StatefulWidget {
|
||||||
final CaptionService captionService;
|
final CaptionService captionService;
|
||||||
final DescriptionService descriptionService;
|
final DescriptionService descriptionService;
|
||||||
final ImageUsageLookup? usageOf;
|
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({
|
const ImageCarouselPicker({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -46,6 +61,8 @@ class ImageCarouselPicker extends StatefulWidget {
|
||||||
required this.descriptionService,
|
required this.descriptionService,
|
||||||
this.initialPath,
|
this.initialPath,
|
||||||
this.usageOf,
|
this.usageOf,
|
||||||
|
this.onReplaceUsages,
|
||||||
|
this.openDeckFiles = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<ImagePickResult?> show(
|
static Future<ImagePickResult?> show(
|
||||||
|
|
@ -55,6 +72,8 @@ class ImageCarouselPicker extends StatefulWidget {
|
||||||
CaptionService? captionService,
|
CaptionService? captionService,
|
||||||
DescriptionService? descriptionService,
|
DescriptionService? descriptionService,
|
||||||
ImageUsageLookup? usageOf,
|
ImageUsageLookup? usageOf,
|
||||||
|
ImageUsageReplace? onReplaceUsages,
|
||||||
|
List<String> openDeckFiles = const [],
|
||||||
}) {
|
}) {
|
||||||
return showDialog<ImagePickResult>(
|
return showDialog<ImagePickResult>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -65,6 +84,8 @@ class ImageCarouselPicker extends StatefulWidget {
|
||||||
captionService: captionService ?? CaptionService(),
|
captionService: captionService ?? CaptionService(),
|
||||||
descriptionService: descriptionService ?? DescriptionService(),
|
descriptionService: descriptionService ?? DescriptionService(),
|
||||||
usageOf: usageOf,
|
usageOf: usageOf,
|
||||||
|
onReplaceUsages: onReplaceUsages,
|
||||||
|
openDeckFiles: openDeckFiles,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +122,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
String? _descEditing; // path the description field currently edits
|
String? _descEditing; // path the description field currently edits
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _justCopied = false; // korte feedback na kopiëren naar klembord
|
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;
|
int _hoveredIndex = -1;
|
||||||
_ViewMode _viewMode = _ViewMode.grid;
|
_ViewMode _viewMode = _ViewMode.grid;
|
||||||
|
|
||||||
|
|
@ -147,7 +170,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
if (_exts.contains(ext)) found.add(e.path);
|
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
|
// Stat each file exactly once (instead of repeatedly inside the sort
|
||||||
|
|
@ -157,7 +182,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
DateTime modified;
|
DateTime modified;
|
||||||
try {
|
try {
|
||||||
modified = File(path).statSync().modified;
|
modified = File(path).statSync().modified;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
|
||||||
modified = DateTime.fromMillisecondsSinceEpoch(0);
|
modified = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
}
|
}
|
||||||
withTimes.add((path, modified));
|
withTimes.add((path, modified));
|
||||||
|
|
@ -187,9 +213,15 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
/// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van
|
/// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van
|
||||||
/// [_images] (nieuwste eerst) behouden.
|
/// [_images] (nieuwste eerst) behouden.
|
||||||
void _applyFilter() {
|
void _applyFilter() {
|
||||||
|
final base = _untaggedOnly
|
||||||
|
? [
|
||||||
|
for (final path in _images)
|
||||||
|
if ((_descriptions[path] ?? '').trim().isEmpty) path,
|
||||||
|
]
|
||||||
|
: _images;
|
||||||
final q = _query.trim().toLowerCase();
|
final q = _query.trim().toLowerCase();
|
||||||
if (q.isEmpty) {
|
if (q.isEmpty) {
|
||||||
_filtered = _images;
|
_filtered = base;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final terms = q
|
final terms = q
|
||||||
|
|
@ -198,9 +230,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
final hits = <({String path, int score, int order})>[];
|
final hits = <({String path, int score, int order})>[];
|
||||||
for (var i = 0; i < _images.length; i++) {
|
for (var i = 0; i < base.length; i++) {
|
||||||
final score = _relevance(_images[i], terms);
|
final score = _relevance(base[i], terms);
|
||||||
if (score > 0) hits.add((path: _images[i], score: score, order: i));
|
if (score > 0) hits.add((path: base[i], score: score, order: i));
|
||||||
}
|
}
|
||||||
hits.sort((a, b) {
|
hits.sort((a, b) {
|
||||||
final byScore = b.score.compareTo(a.score);
|
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 {
|
Future<void> _confirm() async {
|
||||||
if (_selected == null) return;
|
if (_selected == null) return;
|
||||||
await _persistDescription();
|
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 {
|
Future<void> _deleteSelected() async {
|
||||||
final path = _selected;
|
final path = _selected;
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
final usages = widget.usageOf?.call(path) ?? const [];
|
final usages = [...widget.usageOf?.call(path) ?? const <String>[]];
|
||||||
final confirmed = await _showDeleteDialog(path, usages);
|
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;
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
var deleted = false;
|
||||||
try {
|
try {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (file.existsSync()) await file.delete();
|
if (file.existsSync()) await file.delete();
|
||||||
} catch (_) {}
|
deleted = true;
|
||||||
// Drop the sidecar metadata too.
|
} 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.captionService.saveCaption(path, '');
|
||||||
await widget.descriptionService.removeDescription(path);
|
await widget.descriptionService.removeDescription(path);
|
||||||
|
|
||||||
|
|
@ -418,7 +758,11 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
_loadDescriptionForSelection();
|
_loadDescriptionForSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool?> _showDeleteDialog(String path, List<String> usages) {
|
Future<bool?> _showDeleteDialog(
|
||||||
|
String path,
|
||||||
|
List<String> usages,
|
||||||
|
int slideCount,
|
||||||
|
) {
|
||||||
return showDialog<bool>(
|
return showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
|
|
@ -470,7 +814,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
)
|
)
|
||||||
else ...[
|
else ...[
|
||||||
Text(
|
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(
|
style: const TextStyle(
|
||||||
color: Color(0xFFF0B429),
|
color: Color(0xFFF0B429),
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -664,7 +1008,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_query.trim().isEmpty
|
_query.trim().isEmpty && !_untaggedOnly
|
||||||
? '${_images.length}'
|
? '${_images.length}'
|
||||||
: '${_filtered.length} / ${_images.length}',
|
: '${_filtered.length} / ${_images.length}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
@ -677,6 +1021,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(child: _buildSearchField()),
|
Expanded(child: _buildSearchField()),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
_buildUntaggedToggle(),
|
||||||
|
const SizedBox(width: 12),
|
||||||
_buildViewToggle(),
|
_buildViewToggle(),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
IconButton(
|
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.
|
/// Segmented control om tussen raster- en coverflow-weergave te wisselen.
|
||||||
Widget _buildViewToggle() {
|
Widget _buildViewToggle() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
@ -790,6 +1170,37 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
/// Lege staat — gedeeld door raster- en coverflow-weergave.
|
/// Lege staat — gedeeld door raster- en coverflow-weergave.
|
||||||
Widget _buildEmptyState() {
|
Widget _buildEmptyState() {
|
||||||
final l10n = context.l10n;
|
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;
|
final filtering = _query.trim().isNotEmpty;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
flex: 13,
|
flex: 13,
|
||||||
|
|
@ -1547,6 +1958,35 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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
|
// Hint
|
||||||
Text(
|
Text(
|
||||||
l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'),
|
l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'),
|
||||||
|
|
@ -1629,7 +2069,9 @@ class _FileSizeState extends State<_FileSize> {
|
||||||
? '${mb.toStringAsFixed(1)} MB'
|
? '${mb.toStringAsFixed(1)} MB'
|
||||||
: '${kb.toStringAsFixed(0)} KB';
|
: '${kb.toStringAsFixed(0)} KB';
|
||||||
if (mounted) setState(() => _size = label);
|
if (mounted) setState(() => _size = label);
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning('_FileSizeState._load: compute size label', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../state/settings_provider.dart';
|
import '../../state/settings_provider.dart';
|
||||||
import '../../state/tabs_provider.dart';
|
import '../../state/tabs_provider.dart';
|
||||||
|
import '../../state/consent_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
@ -191,7 +192,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
: profiles.first.name;
|
: profiles.first.name;
|
||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 5,
|
length: 6,
|
||||||
child: AlertDialog(
|
child: AlertDialog(
|
||||||
title: Text(l10n.t('settings')),
|
title: Text(l10n.t('settings')),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
|
|
@ -223,6 +224,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
icon: const Icon(Icons.image_outlined),
|
icon: const Icon(Icons.image_outlined),
|
||||||
text: l10n.t('settingsLogo'),
|
text: l10n.t('settingsLogo'),
|
||||||
),
|
),
|
||||||
|
Tab(
|
||||||
|
icon: const Icon(Icons.privacy_tip_outlined),
|
||||||
|
text: l10n.d('Privacy'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -234,6 +239,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
_tabBody(_styleTab(profiles, dropdownValue)),
|
_tabBody(_styleTab(profiles, dropdownValue)),
|
||||||
_tabBody(_colorsTab()),
|
_tabBody(_colorsTab()),
|
||||||
_tabBody(_logoTab()),
|
_tabBody(_logoTab()),
|
||||||
|
_tabBody(_privacyTab()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -432,6 +438,30 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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')),
|
_sectionTitle(l10n.t('presentationFolder')),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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() {
|
Widget _appearanceTab() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
|
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
|
||||||
|
|
@ -993,6 +1102,14 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
_themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v),
|
_themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_colorSetting(
|
||||||
|
l10n.d('Tabel kopachtergrond'),
|
||||||
|
_themeProfile.tableHeaderBackgroundColor,
|
||||||
|
(v) => _themeProfile = _themeProfile.copyWith(
|
||||||
|
tableHeaderBackgroundColor: v,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_colorSetting(
|
_colorSetting(
|
||||||
l10n.d('Titelachtergrond'),
|
l10n.d('Titelachtergrond'),
|
||||||
_themeProfile.titleBackgroundColor,
|
_themeProfile.titleBackgroundColor,
|
||||||
|
|
@ -1575,4 +1692,86 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
);
|
);
|
||||||
return Color(value ?? 0xFFFFFFFF);
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ class ImageZoomControl extends StatelessWidget {
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.zoom_out,
|
Icons.zoom_out,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -141,7 +141,7 @@ class ImageZoomControl extends StatelessWidget {
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.zoom_in,
|
Icons.zoom_in,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -153,7 +153,7 @@ class ImageZoomControl extends StatelessWidget {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: zoomed
|
color: zoomed
|
||||||
? const Color(0xFF2563EB)
|
? const Color(0xFF2563EB)
|
||||||
: const Color(0xFF94A3B8),
|
: const Color(0xFF64748B),
|
||||||
fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal,
|
fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.right,
|
textAlign: TextAlign.right,
|
||||||
|
|
@ -167,7 +167,7 @@ class ImageZoomControl extends StatelessWidget {
|
||||||
onPressed: zoomed ? () => onChanged(100) : null,
|
onPressed: zoomed ? () => onChanged(100) : null,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
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),
|
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
_label(context),
|
_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,
|
captionService: captions,
|
||||||
descriptionService: ref.read(descriptionServiceProvider),
|
descriptionService: ref.read(descriptionServiceProvider),
|
||||||
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
|
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);
|
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
|
/// Find every open-deck slide that references [absolutePath], so we can warn
|
||||||
/// before deleting an image that is still in use.
|
/// before deleting an image that is still in use.
|
||||||
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
||||||
|
|
@ -282,7 +329,7 @@ class ImagePickerBar extends ConsumerWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: imagePath.isEmpty
|
color: imagePath.isEmpty
|
||||||
? const Color(0xFF94A3B8)
|
? const Color(0xFF64748B)
|
||||||
: const Color(0xFF334155),
|
: const Color(0xFF334155),
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
@ -345,7 +392,7 @@ class ImagePickerBar extends ConsumerWidget {
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: onClear,
|
onPressed: onClear,
|
||||||
icon: const Icon(Icons.clear, size: 18),
|
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(
|
prefixIcon: const Icon(
|
||||||
Icons.copyright_outlined,
|
Icons.copyright_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class AudioAttachmentEditor extends StatelessWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: slide.audioPath.isEmpty
|
color: slide.audioPath.isEmpty
|
||||||
? const Color(0xFF94A3B8)
|
? const Color(0xFF64748B)
|
||||||
: const Color(0xFF334155),
|
: const Color(0xFF334155),
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
_markerForItem(i),
|
_markerForItem(i),
|
||||||
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -342,7 +342,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.remove_circle_outline,
|
Icons.remove_circle_outline,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
onPressed: () => _removeBulletAndFocus(i),
|
onPressed: () => _removeBulletAndFocus(i),
|
||||||
tooltip: l10n.d('Verwijder'),
|
tooltip: l10n.d('Verwijder'),
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
_markerForItem(i),
|
_markerForItem(i),
|
||||||
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -372,7 +372,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.remove_circle_outline,
|
Icons.remove_circle_outline,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
onPressed: () => _removeBulletAndFocus(i),
|
onPressed: () => _removeBulletAndFocus(i),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
|
|
||||||
|
|
@ -791,7 +791,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(
|
color: Color(
|
||||||
_type == ChartType.pie && c >= 2
|
_type == ChartType.pie && c >= 2
|
||||||
? 0xFF94A3B8
|
? 0xFF64748B
|
||||||
: int.parse(
|
: int.parse(
|
||||||
chartSeriesColor(
|
chartSeriesColor(
|
||||||
ChartSeries(
|
ChartSeries(
|
||||||
|
|
@ -951,7 +951,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1038,7 +1038,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
key: key,
|
key: key,
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
icon: Icon(icon, size: 14),
|
icon: Icon(icon, size: 14),
|
||||||
color: const Color(0xFF94A3B8),
|
color: const Color(0xFF64748B),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ class _QuoteEditorState extends ConsumerState<QuoteEditor> {
|
||||||
l10n.d(
|
l10n.d(
|
||||||
'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.',
|
'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),
|
const SizedBox(height: 8),
|
||||||
ImagePickerBar(
|
ImagePickerBar(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/table_clipboard.dart';
|
||||||
import '_editor_field.dart';
|
import '_editor_field.dart';
|
||||||
|
|
||||||
/// Editor for a table slide. Stores cells as a rectangular grid of
|
/// Editor for a table slide. Stores cells as a rectangular grid of
|
||||||
|
|
@ -108,6 +110,69 @@ class _TableEditorState extends State<TableEditor> {
|
||||||
_emit();
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_title.dispose();
|
_title.dispose();
|
||||||
|
|
@ -131,8 +196,9 @@ class _TableEditorState extends State<TableEditor> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.'),
|
'${l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.')}\n'
|
||||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
'${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(),
|
_buildColumnControls(),
|
||||||
|
|
@ -170,7 +236,7 @@ class _TableEditorState extends State<TableEditor> {
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.delete_outline,
|
Icons.delete_outline,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
onPressed: _colCount > 1 ? () => _removeColumn(c) : null,
|
onPressed: _colCount > 1 ? () => _removeColumn(c) : null,
|
||||||
tooltip:
|
tooltip:
|
||||||
|
|
@ -203,26 +269,31 @@ class _TableEditorState extends State<TableEditor> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
child: TextField(
|
child: Focus(
|
||||||
controller: _cells[r][c],
|
onKeyEvent: (node, event) => _onCellKey(r, c, event),
|
||||||
// Meerdere regels toestaan: het veld groeit mee en Enter
|
child: TextField(
|
||||||
// voegt een nieuwe regel toe binnen de cel.
|
controller: _cells[r][c],
|
||||||
minLines: 1,
|
// Meerdere regels toestaan: het veld groeit mee en Enter
|
||||||
maxLines: null,
|
// voegt een nieuwe regel toe binnen de cel.
|
||||||
keyboardType: TextInputType.multiline,
|
minLines: 1,
|
||||||
textInputAction: TextInputAction.newline,
|
maxLines: null,
|
||||||
style: TextStyle(
|
keyboardType: TextInputType.multiline,
|
||||||
fontSize: 13,
|
textInputAction: TextInputAction.newline,
|
||||||
fontWeight: isHeader ? FontWeight.w600 : FontWeight.normal,
|
style: TextStyle(
|
||||||
),
|
fontSize: 13,
|
||||||
decoration: InputDecoration(
|
fontWeight: isHeader
|
||||||
isDense: true,
|
? FontWeight.w600
|
||||||
filled: isHeader,
|
: FontWeight.normal,
|
||||||
fillColor: isHeader ? const Color(0xFFF1F5F9) : null,
|
),
|
||||||
hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null,
|
decoration: InputDecoration(
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
isDense: true,
|
||||||
horizontal: 8,
|
filled: isHeader,
|
||||||
vertical: 8,
|
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(
|
icon: const Icon(
|
||||||
Icons.remove_circle_outline,
|
Icons.remove_circle_outline,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
onPressed: _cells.length > 1 ? () => _removeRow(r) : null,
|
onPressed: _cells.length > 1 ? () => _removeRow(r) : null,
|
||||||
tooltip: isHeader
|
tooltip: isHeader
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ class _TitleEditorState extends ConsumerState<TitleEditor> {
|
||||||
l10n.d(
|
l10n.d(
|
||||||
'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.',
|
'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),
|
const SizedBox(height: 8),
|
||||||
ImagePickerBar(
|
ImagePickerBar(
|
||||||
|
|
|
||||||
|
|
@ -348,7 +348,7 @@ class _BulletColumnState extends State<_BulletColumn> {
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
_markerForItem(i),
|
_markerForItem(i),
|
||||||
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -399,7 +399,7 @@ class _BulletColumnState extends State<_BulletColumn> {
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.remove_circle_outline,
|
Icons.remove_circle_outline,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF94A3B8),
|
color: Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
|
onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
|
||||||
tooltip: l10n.d('Verwijder'),
|
tooltip: l10n.d('Verwijder'),
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ class _TwoImagesEditorState extends ConsumerState<TwoImagesEditor> {
|
||||||
child: Text(
|
child: Text(
|
||||||
'Links ${widget.slide.imageSize > 0 ? widget.slide.imageSize : 50}% — '
|
'Links ${widget.slide.imageSize > 0 ? widget.slide.imageSize : 50}% — '
|
||||||
'Rechts ${100 - (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)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ class _PathBox extends StatelessWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: path.isEmpty
|
color: path.isEmpty
|
||||||
? const Color(0xFF94A3B8)
|
? const Color(0xFF64748B)
|
||||||
: const Color(0xFF334155),
|
: const Color(0xFF334155),
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
@ -13,13 +15,21 @@ import '../../services/slide_rasterizer.dart';
|
||||||
import '../../state/slide_clipboard_provider.dart';
|
import '../../state/slide_clipboard_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/log.dart';
|
||||||
import '../dialogs/add_slide_dialog.dart';
|
import '../dialogs/add_slide_dialog.dart';
|
||||||
import '../dialogs/import_slides_dialog.dart';
|
import '../dialogs/import_slides_dialog.dart';
|
||||||
import '../dialogs/slide_finder_dialog.dart';
|
import '../dialogs/slide_finder_dialog.dart';
|
||||||
import '../slides/slide_thumbnail.dart';
|
import '../slides/slide_thumbnail.dart';
|
||||||
|
|
||||||
class SlideListPanel extends ConsumerStatefulWidget {
|
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
|
@override
|
||||||
ConsumerState<SlideListPanel> createState() => _SlideListPanelState();
|
ConsumerState<SlideListPanel> createState() => _SlideListPanelState();
|
||||||
|
|
@ -31,15 +41,35 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
final _focusNode = FocusNode(debugLabel: 'SlideListPanel');
|
final _focusNode = FocusNode(debugLabel: 'SlideListPanel');
|
||||||
final Map<String, GlobalKey> _slideKeys = {};
|
final Map<String, GlobalKey> _slideKeys = {};
|
||||||
|
Timer? _resizeSettleTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_resizeSettleTimer?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
super.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
|
/// Lower-cased, concatenated text of a slide for searching. Kept broad on
|
||||||
/// purpose: everything you typed into the slide should make it findable.
|
/// purpose: everything you typed into the slide should make it findable.
|
||||||
String _slideText(Slide slide) {
|
String _slideText(Slide slide) {
|
||||||
|
|
@ -88,7 +118,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
_slideKeys.removeWhere((id, _) => !ids.contains(id));
|
_slideKeys.removeWhere((id, _) => !ids.contains(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollSlideToTop(int index) {
|
void _scrollSlideToTop(int index, {int attempts = 2}) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final deck = ref.read(deckProvider).deck;
|
final deck = ref.read(deckProvider).deck;
|
||||||
if (deck == null ||
|
if (deck == null ||
|
||||||
|
|
@ -100,7 +130,21 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
|
|
||||||
final keyContext = _slideKeys[deck.slides[index].id]?.currentContext;
|
final keyContext = _slideKeys[deck.slides[index].id]?.currentContext;
|
||||||
final target = keyContext?.findRenderObject();
|
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);
|
final viewport = RenderAbstractViewport.maybeOf(target);
|
||||||
if (viewport == null) return;
|
if (viewport == null) return;
|
||||||
|
|
@ -173,7 +217,9 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
);
|
);
|
||||||
if (images.isNotEmpty) bytes = images.first;
|
if (images.isNotEmpty) bytes = images.first;
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ok =
|
final ok =
|
||||||
bytes != null && await ImageService().copyImageBytesToClipboard(bytes);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
@ -605,64 +712,14 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
|
|
||||||
// ── Slide list ───────────────────────────────────────────────────
|
// ── Slide list ───────────────────────────────────────────────────
|
||||||
Expanded(
|
Expanded(
|
||||||
child: searching
|
child: _buildSlideList(
|
||||||
? _buildFilteredList(
|
deck,
|
||||||
deck,
|
searching,
|
||||||
query,
|
query,
|
||||||
editor,
|
editor,
|
||||||
notifier,
|
notifier,
|
||||||
editorNotifier,
|
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Add / Paste slide buttons ─────────────────────────────────
|
// ── Add / Paste slide buttons ─────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ class AnnotationLayer extends StatefulWidget {
|
||||||
/// Called as the laser moves (normalized), or null when it leaves.
|
/// Called as the laser moves (normalized), or null when it leaves.
|
||||||
final ValueChanged<Offset?>? onLaserMove;
|
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({
|
const AnnotationLayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.strokes,
|
required this.strokes,
|
||||||
|
|
@ -41,6 +46,7 @@ class AnnotationLayer extends StatefulWidget {
|
||||||
this.laserPoint,
|
this.laserPoint,
|
||||||
this.onStrokesChanged,
|
this.onStrokesChanged,
|
||||||
this.onLaserMove,
|
this.onLaserMove,
|
||||||
|
this.onActiveStrokeChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -55,6 +61,18 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||||
bool get _drawing =>
|
bool get _drawing =>
|
||||||
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
|
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 _norm(Offset local) => _size.shortestSide == 0
|
||||||
? Offset.zero
|
? Offset.zero
|
||||||
: Offset(
|
: Offset(
|
||||||
|
|
@ -65,6 +83,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||||
void _commitActive() {
|
void _commitActive() {
|
||||||
if (_active.length < 2) {
|
if (_active.length < 2) {
|
||||||
setState(() => _active = const []);
|
setState(() => _active = const []);
|
||||||
|
widget.onActiveStrokeChanged?.call(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final stroke = InkStroke(
|
final stroke = InkStroke(
|
||||||
|
|
@ -76,6 +95,8 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||||
final next = [...widget.strokes, stroke];
|
final next = [...widget.strokes, stroke];
|
||||||
setState(() => _active = const []);
|
setState(() => _active = const []);
|
||||||
widget.onStrokesChanged?.call(next);
|
widget.onStrokesChanged?.call(next);
|
||||||
|
// Clear the beamer's live preview; the committed stroke arrives via strokes.
|
||||||
|
widget.onActiveStrokeChanged?.call(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _eraseAt(Offset norm) {
|
void _eraseAt(Offset norm) {
|
||||||
|
|
@ -95,6 +116,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||||
case InkTool.pen:
|
case InkTool.pen:
|
||||||
case InkTool.highlighter:
|
case InkTool.highlighter:
|
||||||
setState(() => _active = [n]);
|
setState(() => _active = [n]);
|
||||||
|
widget.onActiveStrokeChanged?.call(_activeStroke());
|
||||||
case InkTool.eraser:
|
case InkTool.eraser:
|
||||||
_eraseAt(n);
|
_eraseAt(n);
|
||||||
case InkTool.laser:
|
case InkTool.laser:
|
||||||
|
|
@ -110,7 +132,10 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||||
switch (widget.tool) {
|
switch (widget.tool) {
|
||||||
case InkTool.pen:
|
case InkTool.pen:
|
||||||
case InkTool.highlighter:
|
case InkTool.highlighter:
|
||||||
if (_active.isNotEmpty) setState(() => _active = [..._active, n]);
|
if (_active.isNotEmpty) {
|
||||||
|
setState(() => _active = [..._active, n]);
|
||||||
|
widget.onActiveStrokeChanged?.call(_activeStroke());
|
||||||
|
}
|
||||||
case InkTool.eraser:
|
case InkTool.eraser:
|
||||||
_eraseAt(n);
|
_eraseAt(n);
|
||||||
case InkTool.laser:
|
case InkTool.laser:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../services/markdown_service.dart';
|
import '../../services/markdown_service.dart';
|
||||||
|
import '../../utils/log.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
import 'annotation_overlay.dart';
|
import 'annotation_overlay.dart';
|
||||||
|
|
@ -47,6 +48,10 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
final Map<int, List<InkStroke>> _ink = {};
|
final Map<int, List<InkStroke>> _ink = {};
|
||||||
int? _laserIndex;
|
int? _laserIndex;
|
||||||
Offset? _laserPoint;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -84,6 +89,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
_index = (m['index'] as num?)?.toInt() ?? _index;
|
_index = (m['index'] as num?)?.toInt() ?? _index;
|
||||||
_blank = (m['blank'] as num?)?.toInt() ?? 0;
|
_blank = (m['blank'] as num?)?.toInt() ?? 0;
|
||||||
_laserPoint = null; // laser never carries over to another slide
|
_laserPoint = null; // laser never carries over to another slide
|
||||||
|
_activeStroke = null; // nor does an in-progress stroke
|
||||||
});
|
});
|
||||||
case 'ink':
|
case 'ink':
|
||||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||||
|
|
@ -92,6 +98,17 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
setState(
|
setState(
|
||||||
() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []),
|
() => _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':
|
case 'laser':
|
||||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||||
final i = (m['index'] as num?)?.toInt();
|
final i = (m['index'] as num?)?.toInt();
|
||||||
|
|
@ -117,7 +134,12 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
try {
|
try {
|
||||||
final self = await WindowController.fromCurrentEngine();
|
final self = await WindowController.fromCurrentEngine();
|
||||||
await self.close();
|
await self.close();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
logWarning(
|
||||||
|
'_AudienceWindowAppState._onPresenterCall: close window',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +220,13 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
AnnotationLayer(
|
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,
|
interactive: false,
|
||||||
laserPoint: _laserIndex == _index ? _laserPoint : null,
|
laserPoint: _laserIndex == _index ? _laserPoint : null,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:screen_retriever/screen_retriever.dart';
|
import 'package:screen_retriever/screen_retriever.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
@ -12,11 +13,15 @@ import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../services/markdown_service.dart';
|
import '../../services/markdown_service.dart';
|
||||||
|
import '../../services/rehearsal_controller.dart';
|
||||||
|
import '../../utils/log.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../slides/inline_markdown.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
import 'annotation_overlay.dart';
|
import 'annotation_overlay.dart';
|
||||||
import 'audience_window.dart';
|
import 'audience_window.dart';
|
||||||
|
import 'rehearsal_summary.dart';
|
||||||
|
|
||||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||||
enum _Blank { none, black, white }
|
enum _Blank { none, black, white }
|
||||||
|
|
@ -28,6 +33,10 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling.
|
||||||
|
/// Sessie-only; live aanpasbaar in de presenter (toets K).
|
||||||
|
final Duration? targetDuration;
|
||||||
|
|
||||||
/// When set, this presenter drives a separate audience (beamer) window: the
|
/// When set, this presenter drives a separate audience (beamer) window: the
|
||||||
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
|
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
|
||||||
/// for the classic single-screen mode.
|
/// for the classic single-screen mode.
|
||||||
|
|
@ -46,6 +55,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required this.themeProfile,
|
required this.themeProfile,
|
||||||
required this.initialIndex,
|
required this.initialIndex,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.targetDuration,
|
||||||
this.audienceWindow,
|
this.audienceWindow,
|
||||||
this.initialAnnotations = const {},
|
this.initialAnnotations = const {},
|
||||||
this.onAnnotationsChanged,
|
this.onAnnotationsChanged,
|
||||||
|
|
@ -62,6 +72,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Duration? targetDuration,
|
||||||
Map<String, List<InkStroke>> annotations = const {},
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
ValueChanged<Slide>? onSlideChanged,
|
ValueChanged<Slide>? onSlideChanged,
|
||||||
|
|
@ -71,7 +82,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
try {
|
try {
|
||||||
final displays = await screenRetriever.getAllDisplays();
|
final displays = await screenRetriever.getAllDisplays();
|
||||||
displayCount = displays.length;
|
displayCount = displays.length;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('FullscreenPresenter.present: display detection failed', e);
|
||||||
displayCount = 0;
|
displayCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +102,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
targetDuration: targetDuration,
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
onSlideChanged: onSlideChanged,
|
onSlideChanged: onSlideChanged,
|
||||||
|
|
@ -102,6 +115,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
targetDuration: targetDuration,
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
onSlideChanged: onSlideChanged,
|
onSlideChanged: onSlideChanged,
|
||||||
|
|
@ -116,6 +130,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Duration? targetDuration,
|
||||||
Map<String, List<InkStroke>> annotations = const {},
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
ValueChanged<Slide>? onSlideChanged,
|
ValueChanged<Slide>? onSlideChanged,
|
||||||
|
|
@ -135,6 +150,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
targetDuration: targetDuration,
|
||||||
initialAnnotations: annotations,
|
initialAnnotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
onSlideChanged: onSlideChanged,
|
onSlideChanged: onSlideChanged,
|
||||||
|
|
@ -161,12 +177,15 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Duration? targetDuration,
|
||||||
Map<String, List<InkStroke>> annotations = const {},
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
ValueChanged<Slide>? onSlideChanged,
|
ValueChanged<Slide>? onSlideChanged,
|
||||||
}) async {
|
}) async {
|
||||||
// A self-contained markdown deck is the payload for the audience window; it
|
// 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.
|
// 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(
|
final markdown = MarkdownService().generateDeck(
|
||||||
Deck(
|
Deck(
|
||||||
title: 'Presentatie',
|
title: 'Presentatie',
|
||||||
|
|
@ -175,6 +194,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
),
|
),
|
||||||
|
inlineStyleProfile: true,
|
||||||
);
|
);
|
||||||
// Pre-existing annotations re-keyed by index so the beamer shows them
|
// Pre-existing annotations re-keyed by index so the beamer shows them
|
||||||
// immediately (the audience window has no stable slide ids of its own).
|
// 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),
|
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||||||
);
|
);
|
||||||
await audience.coverScreen(external: true);
|
await audience.coverScreen(external: true);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logError(
|
||||||
|
'FullscreenPresenter.showDualScreen: audience window setup failed',
|
||||||
|
e,
|
||||||
|
);
|
||||||
audience = null;
|
audience = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,7 +302,8 @@ bool autoAdvanceWaitsForMedia(Slide slide) {
|
||||||
Future<bool> _wakeLockEnabled() async {
|
Future<bool> _wakeLockEnabled() async {
|
||||||
try {
|
try {
|
||||||
return await WakelockPlus.enabled;
|
return await WakelockPlus.enabled;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +311,8 @@ Future<bool> _wakeLockEnabled() async {
|
||||||
Future<void> _enableWakeLock() async {
|
Future<void> _enableWakeLock() async {
|
||||||
try {
|
try {
|
||||||
await WakelockPlus.enable();
|
await WakelockPlus.enable();
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
|
||||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
// Best-effort: unsupported platforms should not interrupt presenting.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +324,8 @@ Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
|
||||||
} else {
|
} else {
|
||||||
await WakelockPlus.disable();
|
await WakelockPlus.disable();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
|
||||||
// Best-effort cleanup.
|
// Best-effort cleanup.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -327,13 +354,19 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
double _gridRowExtent = 220;
|
double _gridRowExtent = 220;
|
||||||
final ScrollController _gridScroll = ScrollController();
|
final ScrollController _gridScroll = ScrollController();
|
||||||
|
|
||||||
/// Starttijd voor de verstreken-tijd-teller (resetbaar met R).
|
/// Oefenklok: verstreken tijd, aftelling en per-slide-tijd. Sessie-only,
|
||||||
late DateTime _startTime;
|
/// puur meten (geen pacing). Resetbaar met R.
|
||||||
|
late RehearsalController _rehearsal;
|
||||||
|
|
||||||
/// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief).
|
/// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief).
|
||||||
String _typed = '';
|
String _typed = '';
|
||||||
Timer? _typedTimer;
|
Timer? _typedTimer;
|
||||||
|
|
||||||
|
/// Doeltijd-invoermodus (toets K): cijfers worden als MMSS gelezen i.p.v. als
|
||||||
|
/// slidenummer. [_targetTyped] houdt de invoer tot Enter/Esc.
|
||||||
|
bool _targetInput = false;
|
||||||
|
String _targetTyped = '';
|
||||||
|
|
||||||
/// Sneltoets-overzicht (cheatsheet) zichtbaar.
|
/// Sneltoets-overzicht (cheatsheet) zichtbaar.
|
||||||
bool _helpOpen = false;
|
bool _helpOpen = false;
|
||||||
|
|
||||||
|
|
@ -372,6 +405,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
static const _penWidth = 0.004;
|
static const _penWidth = 0.004;
|
||||||
static const _highlighterWidth = 0.022;
|
static const _highlighterWidth = 0.022;
|
||||||
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
double get _toolWidth =>
|
double get _toolWidth =>
|
||||||
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
||||||
|
|
@ -385,7 +419,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_index = widget.initialIndex;
|
_index = widget.initialIndex;
|
||||||
_startTime = DateTime.now();
|
_rehearsal = RehearsalController(target: widget.targetDuration);
|
||||||
_focusNode = FocusNode();
|
_focusNode = FocusNode();
|
||||||
_ink = {
|
_ink = {
|
||||||
for (final e in widget.initialAnnotations.entries)
|
for (final e in widget.initialAnnotations.entries)
|
||||||
|
|
@ -539,6 +573,25 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
.catchError((_) => null);
|
.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.
|
/// Select a tool, or toggle it off when it is already active.
|
||||||
void _setTool(InkTool tool) {
|
void _setTool(InkTool tool) {
|
||||||
setState(() => _tool = _tool == tool ? null : tool);
|
setState(() => _tool = _tool == tool ? null : tool);
|
||||||
|
|
@ -679,7 +732,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
_displays = displays;
|
_displays = displays;
|
||||||
_displayIndex = current < 0 ? 0 : current;
|
_displayIndex = current < 0 ? 0 : current;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logWarning(
|
||||||
|
'_FullscreenPresenterState._loadDisplays: screen detection failed',
|
||||||
|
e,
|
||||||
|
);
|
||||||
// Screen detection is best-effort; presenting should still work.
|
// Screen detection is best-effort; presenting should still work.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -696,7 +753,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
);
|
);
|
||||||
await windowManager.setFullScreen(true);
|
await windowManager.setFullScreen(true);
|
||||||
if (mounted) setState(() => _displayIndex = index);
|
if (mounted) setState(() => _displayIndex = index);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
logError(
|
||||||
|
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
|
||||||
|
e,
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|
@ -715,6 +776,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
|
|
||||||
Future<void> _exit() async {
|
Future<void> _exit() async {
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
|
await _maybeShowRehearsalSummary();
|
||||||
final aw = widget.audienceWindow;
|
final aw = widget.audienceWindow;
|
||||||
if (aw != null) {
|
if (aw != null) {
|
||||||
// Dual mode: the main window was never put in full screen; just tear down
|
// Dual mode: the main window was never put in full screen; just tear down
|
||||||
|
|
@ -727,6 +789,30 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toon na afloop de oefenrun-samenvatting, mits er genoeg gemeten is.
|
||||||
|
/// Sessie-only: niets wordt opgeslagen.
|
||||||
|
Future<void> _maybeShowRehearsalSummary() async {
|
||||||
|
if (!mounted || !_rehearsal.hasMeaningfulData) return;
|
||||||
|
final run = _rehearsal.finish();
|
||||||
|
await showRehearsalSummary(context, run: run, slides: widget.slides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meld de slidewissel aan schermlezers (WCAG 4.1.3, statusberichten):
|
||||||
|
/// 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() {
|
void _next() {
|
||||||
// Eerste toets/klik op een blanco scherm haalt het scherm terug.
|
// Eerste toets/klik op een blanco scherm haalt het scherm terug.
|
||||||
if (_blank != _Blank.none) {
|
if (_blank != _Blank.none) {
|
||||||
|
|
@ -736,6 +822,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
if (_index < widget.slides.length - 1) {
|
if (_index < widget.slides.length - 1) {
|
||||||
setState(() => _index++);
|
setState(() => _index++);
|
||||||
_scheduleAdvance();
|
_scheduleAdvance();
|
||||||
|
_announceSlide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -747,6 +834,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
if (_index > 0) {
|
if (_index > 0) {
|
||||||
setState(() => _index--);
|
setState(() => _index--);
|
||||||
_scheduleAdvance();
|
_scheduleAdvance();
|
||||||
|
_announceSlide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -755,7 +843,40 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetTimer() {
|
void _resetTimer() {
|
||||||
setState(() => _startTime = DateTime.now());
|
setState(() => _rehearsal.reset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open de doeltijd-invoer (toets K): cijfers worden voortaan als MMSS
|
||||||
|
/// gelezen. Een lege invoer laat de huidige doeltijd ongemoeid.
|
||||||
|
void _beginTargetInput() {
|
||||||
|
_clearTyped();
|
||||||
|
setState(() {
|
||||||
|
_targetInput = true;
|
||||||
|
_targetTyped = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelTargetInput() {
|
||||||
|
setState(() {
|
||||||
|
_targetInput = false;
|
||||||
|
_targetTyped = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lees [_targetTyped] als MMSS en zet de doeltijd. Leeg = ongewijzigd,
|
||||||
|
/// nul = aftelling uit.
|
||||||
|
void _commitTarget() {
|
||||||
|
final raw = _targetTyped;
|
||||||
|
setState(() {
|
||||||
|
_targetInput = false;
|
||||||
|
_targetTyped = '';
|
||||||
|
});
|
||||||
|
if (raw.isEmpty) return;
|
||||||
|
final n = int.tryParse(raw) ?? 0;
|
||||||
|
final secs = (n ~/ 100) * 60 + (n % 100);
|
||||||
|
setState(
|
||||||
|
() => _rehearsal.target = secs <= 0 ? null : Duration(seconds: secs),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleHelp() {
|
void _toggleHelp() {
|
||||||
|
|
@ -839,6 +960,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
_gridOpen = false;
|
_gridOpen = false;
|
||||||
});
|
});
|
||||||
_scheduleAdvance();
|
_scheduleAdvance();
|
||||||
|
_announceSlide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End).
|
/// 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;
|
if (target == _index) return;
|
||||||
setState(() => _index = target);
|
setState(() => _index = target);
|
||||||
_scheduleAdvance();
|
_scheduleAdvance();
|
||||||
|
_announceSlide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verplaats de rastercursor en houd 'm in beeld.
|
/// Verplaats de rastercursor en houd 'm in beeld.
|
||||||
|
|
@ -895,6 +1018,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Doeltijd-invoer vangt cijfers/Enter/Esc tot de invoer klaar is.
|
||||||
|
if (_targetInput) return _handleTargetKey(key);
|
||||||
|
|
||||||
// Terwijl het raster open is, sturen de pijltjes een aparte cursor aan.
|
// Terwijl het raster open is, sturen de pijltjes een aparte cursor aan.
|
||||||
if (_gridOpen) return _handleGridKey(key);
|
if (_gridOpen) return _handleGridKey(key);
|
||||||
|
|
||||||
|
|
@ -946,6 +1072,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
case LogicalKeyboardKey.keyR:
|
case LogicalKeyboardKey.keyR:
|
||||||
_resetTimer();
|
_resetTimer();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.keyK:
|
||||||
|
_beginTargetInput();
|
||||||
|
return KeyEventResult.handled;
|
||||||
case LogicalKeyboardKey.keyB:
|
case LogicalKeyboardKey.keyB:
|
||||||
_toggleBlank(_Blank.black);
|
_toggleBlank(_Blank.black);
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
|
|
@ -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.
|
/// Toetsen terwijl het rasteroverzicht open is.
|
||||||
KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
|
KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
|
||||||
final last = widget.slides.length - 1;
|
final last = widget.slides.length - 1;
|
||||||
|
|
@ -1043,6 +1207,12 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss';
|
return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resterende tijd, met minteken zodra je over de doeltijd gaat.
|
||||||
|
String _fmtRemaining(Duration d) {
|
||||||
|
final body = _fmtElapsed(d.abs());
|
||||||
|
return d.isNegative ? '-$body' : body;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final total = widget.slides.length;
|
final total = widget.slides.length;
|
||||||
|
|
@ -1054,6 +1224,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
// Keep the beamer window in step with whatever index/blank we now show.
|
// Keep the beamer window in step with whatever index/blank we now show.
|
||||||
_syncAudience();
|
_syncAudience();
|
||||||
|
|
||||||
|
// Per-slide-timing: registreer de huidige slide. Idempotent en goedkoop,
|
||||||
|
// dus veilig om elke build aan te roepen — vangt álle navigatiepaden.
|
||||||
|
final clampedIndex = _index.clamp(0, total - 1);
|
||||||
|
_rehearsal.observe(widget.slides[clampedIndex].id, clampedIndex);
|
||||||
|
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -1080,6 +1255,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
bottom: 60,
|
bottom: 60,
|
||||||
child: Center(child: _buildTypedBadge(total)),
|
child: Center(child: _buildTypedBadge(total)),
|
||||||
),
|
),
|
||||||
|
if (_targetInput)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 60,
|
||||||
|
child: Center(child: _buildTargetBadge()),
|
||||||
|
),
|
||||||
if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()),
|
if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -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).
|
/// Sneltoets-overzicht (cheatsheet).
|
||||||
Widget _buildHelpOverlay() {
|
Widget _buildHelpOverlay() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
@ -1222,7 +1440,8 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
('B · W', l10n.d('Zwart · wit scherm')),
|
('B · W', l10n.d('Zwart · wit scherm')),
|
||||||
('D · T · E', l10n.d('Pen · markeerstift · gum')),
|
('D · T · E', l10n.d('Pen · markeerstift · gum')),
|
||||||
('X · C', l10n.d('Laser · annotaties wissen')),
|
('X · C', l10n.d('Laser · annotaties wissen')),
|
||||||
('R', l10n.d('Verstreken tijd resetten')),
|
('K', l10n.d('Doeltijd / aftellen instellen (MMSS)')),
|
||||||
|
('R', l10n.d('Tijd & oefenrun resetten')),
|
||||||
('A', l10n.d('Automatische modus aan/uit')),
|
('A', l10n.d('Automatische modus aan/uit')),
|
||||||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||||||
('M', l10n.d('Na media automatisch doorgaan')),
|
('M', l10n.d('Na media automatisch doorgaan')),
|
||||||
|
|
@ -1377,6 +1596,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||||||
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||||||
AnnotationLayer(
|
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,
|
strokes: _currentStrokes,
|
||||||
tool: _tool,
|
tool: _tool,
|
||||||
color: _inkColor,
|
color: _inkColor,
|
||||||
|
|
@ -1384,6 +1607,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
interactive: true,
|
interactive: true,
|
||||||
onStrokesChanged: _onStrokesChanged,
|
onStrokesChanged: _onStrokesChanged,
|
||||||
onLaserMove: _onLaserMove,
|
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() {
|
Widget _buildClockBar() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final elapsed = DateTime.now().difference(_startTime);
|
final elapsed = _rehearsal.elapsed;
|
||||||
|
final remaining = _rehearsal.remaining;
|
||||||
|
final slideElapsed = _rehearsal.currentSlideElapsed;
|
||||||
|
final overtime = remaining != null && remaining.isNegative;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -1527,59 +1783,64 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: const Color(0xFF262626)),
|
border: Border.all(color: const Color(0xFF262626)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Verstreken tijd
|
// Bovenrij: verstreken tijd, knoppen, wandklok.
|
||||||
Expanded(
|
Row(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.d('Verstreken'),
|
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
_fmtElapsed(elapsed),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Reset-knop
|
|
||||||
Tooltip(
|
|
||||||
message: l10n.d('Tijd resetten (R)'),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: _resetTimer,
|
|
||||||
icon: const Icon(Icons.restart_alt, size: 18),
|
|
||||||
color: Colors.white38,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
// Wandklok
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
l10n.d('Klok'),
|
child: _metric(l10n.d('Verstreken'), _fmtElapsed(elapsed)),
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
Tooltip(
|
||||||
Text(
|
message: l10n.d('Doeltijd / aftellen (K)'),
|
||||||
_fmtClock(DateTime.now()),
|
child: IconButton(
|
||||||
style: const TextStyle(
|
onPressed: _beginTargetInput,
|
||||||
color: Colors.white70,
|
icon: const Icon(Icons.timer_outlined, size: 18),
|
||||||
fontSize: 24,
|
color: Colors.white38,
|
||||||
fontWeight: FontWeight.w600,
|
visualDensity: VisualDensity.compact,
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: l10n.d('Tijd resetten (R)'),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: _resetTimer,
|
||||||
|
icon: const Icon(Icons.restart_alt, size: 18),
|
||||||
|
color: Colors.white38,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_metric(
|
||||||
|
l10n.d('Klok'),
|
||||||
|
_fmtClock(DateTime.now()),
|
||||||
|
color: Colors.white70,
|
||||||
|
align: CrossAxisAlignment.end,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 18, color: Color(0xFF262626)),
|
||||||
|
// Onderrij: aftelling (resterend/over) en tijd op huidige slide.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _metric(
|
||||||
|
overtime ? l10n.d('Over de tijd') : l10n.d('Resterend'),
|
||||||
|
remaining == null ? '–:––' : _fmtRemaining(remaining),
|
||||||
|
color: remaining == null
|
||||||
|
? Colors.white24
|
||||||
|
: (overtime
|
||||||
|
? const Color(0xFFEF4444)
|
||||||
|
: const Color(0xFF22C55E)),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_metric(
|
||||||
|
l10n.d('Deze slide'),
|
||||||
|
_fmtElapsed(slideElapsed),
|
||||||
|
color: Colors.white70,
|
||||||
|
align: CrossAxisAlignment.end,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
178
lib/widgets/presentation/rehearsal_summary.dart
Normal file
178
lib/widgets/presentation/rehearsal_summary.dart
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/rehearsal.dart';
|
||||||
|
import '../../models/slide.dart';
|
||||||
|
|
||||||
|
/// Toon de samenvatting van een oefenrun (sessie-only). Beschrijvend: totale
|
||||||
|
/// tijd, doeltijd en de tijd per slide — geen pacing-oordeel.
|
||||||
|
Future<void> showRehearsalSummary(
|
||||||
|
BuildContext context, {
|
||||||
|
required RehearsalRun run,
|
||||||
|
required List<Slide> slides,
|
||||||
|
}) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _RehearsalSummaryDialog(run: run, slides: slides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(Duration d) {
|
||||||
|
final neg = d.isNegative;
|
||||||
|
final a = d.abs();
|
||||||
|
final mm = (a.inMinutes % 60).toString().padLeft(2, '0');
|
||||||
|
final ss = (a.inSeconds % 60).toString().padLeft(2, '0');
|
||||||
|
final body = a.inHours > 0 ? '${a.inHours}:$mm:$ss' : '$mm:$ss';
|
||||||
|
return neg ? '-$body' : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RehearsalSummaryDialog extends StatelessWidget {
|
||||||
|
final RehearsalRun run;
|
||||||
|
final List<Slide> slides;
|
||||||
|
|
||||||
|
const _RehearsalSummaryDialog({required this.run, required this.slides});
|
||||||
|
|
||||||
|
String _label(SlideTiming t) {
|
||||||
|
final slide = slides.firstWhere(
|
||||||
|
(s) => s.id == t.slideId,
|
||||||
|
orElse: () => slides.isNotEmpty ? slides.first : (throw StateError('')),
|
||||||
|
);
|
||||||
|
final title = slide.title.trim();
|
||||||
|
return title.isEmpty ? '${t.index + 1}.' : '${t.index + 1}. $title';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copy(BuildContext context) async {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final buf = StringBuffer()
|
||||||
|
..writeln('${l10n.d('Totaal')}: ${_fmt(run.total)}');
|
||||||
|
if (run.target != null) {
|
||||||
|
buf.writeln('${l10n.d('Doeltijd')}: ${_fmt(run.target!)}');
|
||||||
|
}
|
||||||
|
buf.writeln('');
|
||||||
|
for (final t in run.perSlide) {
|
||||||
|
buf.writeln('${_label(t)}\t${_fmt(t.spent)}');
|
||||||
|
}
|
||||||
|
await Clipboard.setData(ClipboardData(text: buf.toString()));
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.d('Tijden gekopieerd naar klembord.'))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final delta = run.delta;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.d('Oefenrun')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Totaal vs. doeltijd.
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.d('Totale tijd'),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_fmt(run.total),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (run.target != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(l10n.d('Doeltijd')),
|
||||||
|
Text(
|
||||||
|
_fmt(run.target!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (delta != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
delta.isNegative
|
||||||
|
? l10n.d('Binnen de tijd')
|
||||||
|
: l10n.d('Over de tijd'),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_fmt(delta),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: delta.isNegative
|
||||||
|
? Colors.green.shade700
|
||||||
|
: Colors.red.shade700,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
const Divider(height: 24),
|
||||||
|
Flexible(
|
||||||
|
child: run.perSlide.isEmpty
|
||||||
|
? Text(l10n.d('Geen slides gemeten.'))
|
||||||
|
: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: run.perSlide.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final t = run.perSlide[i];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_label(t),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_fmt(t.spent),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _copy(context),
|
||||||
|
child: Text(l10n.d('Kopieer')),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.d('Sluiten')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
lib/widgets/shell/shell_actions.dart
Normal file
171
lib/widgets/shell/shell_actions.dart
Normal 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 ─────────────────────────────────────────────────────────────────
|
||||||
139
lib/widgets/shell/shell_overlays.dart
Normal file
139
lib/widgets/shell/shell_overlays.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
316
lib/widgets/shell/status_bar.dart
Normal file
316
lib/widgets/shell/status_bar.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
lib/widgets/shell/tab_bar.dart
Normal file
167
lib/widgets/shell/tab_bar.dart
Normal 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 ────────────────────────────────────────────────────────────
|
||||||
164
lib/widgets/shell/welcome_screen.dart
Normal file
164
lib/widgets/shell/welcome_screen.dart
Normal 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 ───────────────────────────────────────────────────────
|
||||||
916
lib/widgets/slides/previews/bullets_previews.dart
Normal file
916
lib/widgets/slides/previews/bullets_previews.dart
Normal 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 24–32pt — 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;
|
||||||
|
}
|
||||||
1478
lib/widgets/slides/previews/chart_preview.dart
Normal file
1478
lib/widgets/slides/previews/chart_preview.dart
Normal file
File diff suppressed because it is too large
Load diff
333
lib/widgets/slides/previews/checklist_previews.dart
Normal file
333
lib/widgets/slides/previews/checklist_previews.dart
Normal 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;
|
||||||
|
}
|
||||||
180
lib/widgets/slides/previews/code_preview.dart
Normal file
180
lib/widgets/slides/previews/code_preview.dart
Normal 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 ─────────────────────────────────────────────────────────────
|
||||||
601
lib/widgets/slides/previews/media_previews.dart
Normal file
601
lib/widgets/slides/previews/media_previews.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
246
lib/widgets/slides/previews/overlays.dart
Normal file
246
lib/widgets/slides/previews/overlays.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
lib/widgets/slides/previews/table_preview.dart
Normal file
130
lib/widgets/slides/previews/table_preview.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
478
lib/widgets/slides/previews/text_previews.dart
Normal file
478
lib/widgets/slides/previews/text_previews.dart
Normal 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
|
|
@ -55,204 +55,222 @@ class SlideThumbnail extends ConsumerWidget {
|
||||||
// Actieve slide krijgt een dikkere rand dan de overige geselecteerde.
|
// Actieve slide krijgt een dikkere rand dan de overige geselecteerde.
|
||||||
final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0;
|
final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0;
|
||||||
|
|
||||||
return GestureDetector(
|
// Eén beknopt label per kaart voor schermlezers: nummer, titel (of type)
|
||||||
onTap: onTap,
|
// en status. De mini-preview eronder is puur visueel en zou anders de
|
||||||
child: Container(
|
// volledige slide-inhoud per thumbnail laten voorlezen.
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
final title = slide.title.trim();
|
||||||
decoration: BoxDecoration(
|
final semanticLabel =
|
||||||
borderRadius: BorderRadius.circular(6),
|
'${l10n.d('Slide')} ${index + 1}/$slideCount: '
|
||||||
border: Border.all(color: borderColor, width: borderWidth),
|
'${title.isNotEmpty ? title : l10n.d(slide.type.label)}'
|
||||||
color: isSelected ? const Color(0xFF2A2F3B) : const Color(0xFF252830),
|
'${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}';
|
||||||
),
|
|
||||||
child: Column(
|
return Semantics(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
button: true,
|
||||||
children: [
|
selected: isSelected,
|
||||||
// Mini slide preview
|
label: semanticLabel,
|
||||||
ClipRRect(
|
child: GestureDetector(
|
||||||
borderRadius: const BorderRadius.vertical(
|
onTap: onTap,
|
||||||
top: Radius.circular(5),
|
child: Container(
|
||||||
),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: AspectRatio(
|
decoration: BoxDecoration(
|
||||||
aspectRatio: 16 / 9,
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: Stack(
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
fit: StackFit.expand,
|
color: isSelected
|
||||||
children: [
|
? const Color(0xFF2A2F3B)
|
||||||
// Overgeslagen slides worden gedimd weergegeven.
|
: const Color(0xFF252830),
|
||||||
Opacity(
|
),
|
||||||
opacity: skipped ? 0.32 : 1,
|
child: Column(
|
||||||
child: SlidePreviewWidget(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
slide: slide,
|
children: [
|
||||||
projectPath: projectPath,
|
// Mini slide preview
|
||||||
themeProfile: themeProfile,
|
ExcludeSemantics(
|
||||||
slideNumber: index + 1,
|
child: ClipRRect(
|
||||||
slideCount: slideCount,
|
borderRadius: const BorderRadius.vertical(
|
||||||
tlp: tlp,
|
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,
|
// Footer: slide number, type label, action buttons
|
||||||
child: Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
horizontal: 6,
|
child: Row(
|
||||||
vertical: 2,
|
children: [
|
||||||
),
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 18,
|
||||||
color: const Color(0xCC8A6D3B),
|
height: 18,
|
||||||
borderRadius: BorderRadius.circular(4),
|
decoration: BoxDecoration(
|
||||||
),
|
color: isSelected
|
||||||
child: Row(
|
? AppTheme.accent
|
||||||
mainAxisSize: MainAxisSize.min,
|
: const Color(0xFF4A4F5B),
|
||||||
children: [
|
borderRadius: BorderRadius.circular(9),
|
||||||
const Icon(
|
),
|
||||||
Icons.visibility_off_outlined,
|
child: Center(
|
||||||
size: 10,
|
child: Text(
|
||||||
color: Colors.white,
|
'${index + 1}',
|
||||||
),
|
style: const TextStyle(
|
||||||
const SizedBox(width: 3),
|
color: Colors.white,
|
||||||
Text(
|
fontSize: 9,
|
||||||
l10n.d('Overgeslagen'),
|
fontWeight: FontWeight.bold,
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ import desktop_multi_window
|
||||||
class MainFlutterWindow: NSWindow {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
let flutterViewController = FlutterViewController()
|
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
|
let windowFrame = self.frame
|
||||||
self.contentViewController = flutterViewController
|
self.contentViewController = flutterViewController
|
||||||
self.setFrame(windowFrame, display: true)
|
self.setFrame(windowFrame, display: true)
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+2"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ dependencies:
|
||||||
archive: ^4.0.9
|
archive: ^4.0.9
|
||||||
video_player: ^2.11.1
|
video_player: ^2.11.1
|
||||||
characters: ^1.3.0
|
characters: ^1.3.0
|
||||||
|
crypto: ^3.0.0
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
desktop_drop: ^0.7.1
|
desktop_drop: ^0.7.1
|
||||||
image: ^4.8.0
|
image: ^4.8.0
|
||||||
|
|
|
||||||
87
test/add_slide_dialog_test.dart
Normal file
87
test/add_slide_dialog_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/models/annotation.dart';
|
import 'package:ocideck/models/annotation.dart';
|
||||||
import 'package:ocideck/models/slide.dart';
|
import 'package:ocideck/models/slide.dart';
|
||||||
import 'package:ocideck/services/annotation_codec.dart';
|
import 'package:ocideck/services/annotation_codec.dart';
|
||||||
|
import 'package:ocideck/widgets/presentation/annotation_overlay.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('InkStroke JSON', () {
|
group('InkStroke JSON', () {
|
||||||
|
|
@ -83,4 +85,86 @@ void main() {
|
||||||
expect(back, isEmpty);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ void main() {
|
||||||
'Logo px',
|
'Logo px',
|
||||||
'PREVIEW',
|
'PREVIEW',
|
||||||
'Preview',
|
'Preview',
|
||||||
|
'Privacy',
|
||||||
'SLIDES',
|
'SLIDES',
|
||||||
'Slide',
|
'Slide',
|
||||||
'slide',
|
'slide',
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -327,13 +329,27 @@ void main() {
|
||||||
await tester.pumpWidget(_host(spec, presentationMode: true));
|
await tester.pumpWidget(_host(spec, presentationMode: true));
|
||||||
await tester.pump();
|
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 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 = [
|
final labelRects = [
|
||||||
for (var i = 0; i < spec.x.length; i++)
|
for (var i = 0; i < spec.x.length; i++)
|
||||||
tester.getRect(find.byKey(ValueKey('radar-axis-label-$i'))),
|
tester.getRect(find.byKey(ValueKey('radar-axis-label-$i'))),
|
||||||
];
|
];
|
||||||
for (final rect in labelRects) {
|
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 i = 0; i < labelRects.length; i++) {
|
||||||
for (var j = i + 1; j < labelRects.length; j++) {
|
for (var j = i + 1; j < labelRects.length; j++) {
|
||||||
|
|
@ -533,6 +549,67 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
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(
|
testWidgets(
|
||||||
'pie shows at most two series and keeps labels inside the slide',
|
'pie shows at most two series and keeps labels inside the slide',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,9 @@ void main() {
|
||||||
const spec = ChartSpec(
|
const spec = ChartSpec(
|
||||||
type: ChartType.line,
|
type: ChartType.line,
|
||||||
x: ['Q1'],
|
x: ['Q1'],
|
||||||
series: [ChartSeries(name: 'A', data: [10])],
|
series: [
|
||||||
|
ChartSeries(name: 'A', data: [10]),
|
||||||
|
],
|
||||||
minBound: 5,
|
minBound: 5,
|
||||||
maxBound: 20,
|
maxBound: 20,
|
||||||
);
|
);
|
||||||
|
|
@ -121,7 +123,9 @@ void main() {
|
||||||
const spec = ChartSpec(
|
const spec = ChartSpec(
|
||||||
type: ChartType.pie,
|
type: ChartType.pie,
|
||||||
x: ['Q1'],
|
x: ['Q1'],
|
||||||
series: [ChartSeries(name: 'A', data: [10])],
|
series: [
|
||||||
|
ChartSeries(name: 'A', data: [10]),
|
||||||
|
],
|
||||||
minBound: 5,
|
minBound: 5,
|
||||||
maxBound: 20,
|
maxBound: 20,
|
||||||
);
|
);
|
||||||
|
|
@ -149,7 +153,9 @@ void main() {
|
||||||
const spec = ChartSpec(
|
const spec = ChartSpec(
|
||||||
type: ChartType.radar,
|
type: ChartType.radar,
|
||||||
x: ['A', 'B', 'C'],
|
x: ['A', 'B', 'C'],
|
||||||
series: [ChartSeries(name: 'A', data: [1, 2, 3])],
|
series: [
|
||||||
|
ChartSeries(name: 'A', data: [1, 2, 3]),
|
||||||
|
],
|
||||||
minBound: 1,
|
minBound: 1,
|
||||||
maxBound: 5,
|
maxBound: 5,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
62
test/classification_policy_test.dart
Normal file
62
test/classification_policy_test.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ClassificationPolicy', () {
|
||||||
|
test(
|
||||||
|
'without a ceiling every level is allowed (classificeren optioneel)',
|
||||||
|
() {
|
||||||
|
const policy = ClassificationPolicy();
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
for (final level in TlpLevel.values) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a ceiling allows levels at or below it', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
expect(policy.hasGate, isTrue);
|
||||||
|
for (final level in [
|
||||||
|
TlpLevel.none,
|
||||||
|
TlpLevel.clear,
|
||||||
|
TlpLevel.green,
|
||||||
|
TlpLevel.amber,
|
||||||
|
]) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling blocks levels above it, with a clear reason', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
for (final level in [TlpLevel.amberStrict, TlpLevel.red]) {
|
||||||
|
final decision = policy.evaluate(level);
|
||||||
|
expect(decision.allowed, isFalse, reason: level.name);
|
||||||
|
expect(decision.reason, contains(level.label));
|
||||||
|
expect(decision.reason, contains(TlpLevel.amber.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling of none only allows unclassified decks', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.none);
|
||||||
|
expect(policy.evaluate(TlpLevel.none).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.clear).allowed, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromKey', () {
|
||||||
|
test('null key means no gate', () {
|
||||||
|
final policy = ClassificationPolicy.fromKey(null);
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.red).allowed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a TLP key sets the ceiling', () {
|
||||||
|
final policy = ClassificationPolicy.fromKey(TlpLevel.green.key);
|
||||||
|
expect(policy.maxReleaseLevel, TlpLevel.green);
|
||||||
|
expect(policy.evaluate(TlpLevel.green).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.amber).allowed, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -45,19 +45,20 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('syntax highlighting on uses HighlightView for a known language', (
|
testWidgets(
|
||||||
tester,
|
'syntax highlighting on uses HighlightView for a known language',
|
||||||
) async {
|
(tester) async {
|
||||||
final slide = Slide.create(
|
final slide = Slide.create(
|
||||||
SlideType.code,
|
SlideType.code,
|
||||||
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
|
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
|
||||||
const profile = ThemeProfile(codeHighlightSyntax: true);
|
const profile = ThemeProfile(codeHighlightSyntax: true);
|
||||||
|
|
||||||
await tester.pumpWidget(_host(slide, profile));
|
await tester.pumpWidget(_host(slide, profile));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(HighlightView), findsOneWidget);
|
expect(find.byType(HighlightView), findsOneWidget);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('syntax highlighting off renders monochrome (CRT) text', (
|
testWidgets('syntax highlighting off renders monochrome (CRT) text', (
|
||||||
tester,
|
tester,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ void main() {
|
||||||
expect(n.state.isDirty, isTrue);
|
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(
|
final temp = Directory.systemTemp.createTempSync(
|
||||||
'ocideck_recovered_logo_test_',
|
'ocideck_recovered_logo_test_',
|
||||||
);
|
);
|
||||||
|
|
@ -36,17 +36,19 @@ void main() {
|
||||||
..writeAsBytesSync([1, 2, 3]);
|
..writeAsBytesSync([1, 2, 3]);
|
||||||
|
|
||||||
final md = MarkdownService();
|
final md = MarkdownService();
|
||||||
|
// Styling comes from the active style profile, not from the deck/markdown.
|
||||||
final file = FileService(
|
final file = FileService(
|
||||||
md,
|
md,
|
||||||
ImageService(),
|
ImageService(),
|
||||||
() => const ThemeProfile(),
|
() => const ThemeProfile(logoPath: 'logos/client.png'),
|
||||||
homeDirectory: () => temp.path,
|
homeDirectory: () => temp.path,
|
||||||
);
|
);
|
||||||
final notifier = DeckNotifier(md, file);
|
final notifier = DeckNotifier(md, file);
|
||||||
notifier.loadDeck(
|
notifier.loadDeck(
|
||||||
Deck(
|
Deck(
|
||||||
title: 'Hersteld',
|
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)],
|
slides: [Slide.create(SlideType.title)],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -164,37 +166,39 @@ void main() {
|
||||||
expect(n.state.revision, greaterThan(revisionBefore));
|
expect(n.state.revision, greaterThan(revisionBefore));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearAllChecklists is a single undoable step that restores the checks', () {
|
test(
|
||||||
final n = _notifier()..newDeck('D');
|
'clearAllChecklists is a single undoable step that restores the checks',
|
||||||
final slide = Slide.create(SlideType.bullets).copyWith(
|
() {
|
||||||
listStyle: ListStyle.checklist,
|
final n = _notifier()..newDeck('D');
|
||||||
bullets: ['[x] Klaar', '[ ] Open'],
|
final slide = Slide.create(SlideType.bullets).copyWith(
|
||||||
bullets2: ['[x] Tweede'],
|
listStyle: ListStyle.checklist,
|
||||||
);
|
bullets: ['[x] Klaar', '[ ] Open'],
|
||||||
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
bullets2: ['[x] Tweede'],
|
||||||
expect(n.checkedChecklistCount, 2);
|
);
|
||||||
|
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
||||||
|
expect(n.checkedChecklistCount, 2);
|
||||||
|
|
||||||
n.clearAllChecklists();
|
n.clearAllChecklists();
|
||||||
expect(n.checkedChecklistCount, 0);
|
expect(n.checkedChecklistCount, 0);
|
||||||
expect(n.state.canUndo, isTrue);
|
expect(n.state.canUndo, isTrue);
|
||||||
final revisionAfterClear = n.state.revision;
|
final revisionAfterClear = n.state.revision;
|
||||||
|
|
||||||
n.undo();
|
n.undo();
|
||||||
|
|
||||||
// One undo restores every checked item in both columns...
|
// One undo restores every checked item in both columns...
|
||||||
expect(n.checkedChecklistCount, 2);
|
expect(n.checkedChecklistCount, 2);
|
||||||
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
|
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
|
||||||
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
|
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
|
||||||
// ...and bumps the revision again so the open editor reflects the restore.
|
// ...and bumps the revision again so the open editor reflects the restore.
|
||||||
expect(n.state.revision, greaterThan(revisionAfterClear));
|
expect(n.state.revision, greaterThan(revisionAfterClear));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('clearAllChecklists is a no-op when nothing is checked', () {
|
test('clearAllChecklists is a no-op when nothing is checked', () {
|
||||||
final n = _notifier()..newDeck('D');
|
final n = _notifier()..newDeck('D');
|
||||||
final slide = Slide.create(SlideType.bullets).copyWith(
|
final slide = Slide.create(
|
||||||
listStyle: ListStyle.checklist,
|
SlideType.bullets,
|
||||||
bullets: ['[ ] Open'],
|
).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
|
||||||
);
|
|
||||||
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
||||||
expect(n.state.canUndo, isFalse);
|
expect(n.state.canUndo, isFalse);
|
||||||
|
|
||||||
|
|
@ -470,4 +474,31 @@ void main() {
|
||||||
n.undo(); // één stap terug herstelt de hele vervanging
|
n.undo(); // één stap terug herstelt de hele vervanging
|
||||||
expect(n.state.deck!.slides.first.title, 'Hallo wereld');
|
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']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
import 'package:ocideck/services/export_service.dart';
|
import 'package:ocideck/services/export_service.dart';
|
||||||
import 'package:ocideck/services/marp_html_service.dart';
|
import 'package:ocideck/services/marp_html_service.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -56,6 +58,41 @@ void main() {
|
||||||
|
|
||||||
String deckPath() => p.join(tmp.path, 'deck.md');
|
String deckPath() => p.join(tmp.path, 'deck.md');
|
||||||
|
|
||||||
|
test(
|
||||||
|
'classificatie-gate blocks an over-classified export, writes nothing',
|
||||||
|
() async {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.red,
|
||||||
|
policy: policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.success, isFalse);
|
||||||
|
expect(r.outputPath, isNull);
|
||||||
|
expect(r.error, contains('classificatiebeleid'));
|
||||||
|
// Fail-closed: no file may be produced when the gate refuses.
|
||||||
|
final produced = tmp.listSync().whereType<File>().where(
|
||||||
|
(f) => p.extension(f.path) == '.pdf',
|
||||||
|
);
|
||||||
|
expect(produced, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('classificatie-gate allows an export at or below the ceiling', () async {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
policy: policy,
|
||||||
|
);
|
||||||
|
expect(r.success, isTrue, reason: r.error);
|
||||||
|
});
|
||||||
|
|
||||||
test('exports a PDF that starts with the PDF magic header', () async {
|
test('exports a PDF that starts with the PDF magic header', () async {
|
||||||
final images = [_png(), _png()];
|
final images = [_png(), _png()];
|
||||||
final r = await service.export(deckPath(), ExportFormat.pdf, images);
|
final r = await service.export(deckPath(), ExportFormat.pdf, images);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/models/deck.dart';
|
import 'package:ocideck/models/deck.dart';
|
||||||
import 'package:ocideck/models/settings.dart';
|
import 'package:ocideck/models/settings.dart';
|
||||||
|
|
@ -62,4 +64,67 @@ void main() {
|
||||||
expect(service.currentThemeProfile.logoPath, logo.path);
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
test/image_dedup_service_test.dart
Normal file
109
test/image_dedup_service_test.dart
Normal 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, '', ' ']), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
140
test/image_reference_service_test.dart
Normal file
140
test/image_reference_service_test.dart
Normal 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',
|
||||||
|
'\n\n---\n\n\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',
|
||||||
|
'\n\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',
|
||||||
|
'\n---\n\n',
|
||||||
|
);
|
||||||
|
final never = write('project/anders.md', '\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\n\nTekst blijft staan.\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
final changed = await service.replaceReferences(deck, from, to);
|
||||||
|
|
||||||
|
expect(changed, isTrue);
|
||||||
|
expect(
|
||||||
|
File(deck).readAsStringSync(),
|
||||||
|
'# Titel\n\n\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', '\n');
|
||||||
|
|
||||||
|
final changed = await service.replaceReferences(deck, from, to);
|
||||||
|
|
||||||
|
expect(changed, isTrue);
|
||||||
|
expect(File(deck).readAsStringSync(), '\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', '\n');
|
||||||
|
final before = File(deck).lastModifiedSync();
|
||||||
|
|
||||||
|
final changed = await service.replaceReferences(deck, from, to);
|
||||||
|
|
||||||
|
expect(changed, isFalse);
|
||||||
|
expect(File(deck).readAsStringSync(), '\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', '\n');
|
||||||
|
|
||||||
|
final changed = await service.replaceReferences(deck, from, to);
|
||||||
|
|
||||||
|
expect(changed, isTrue);
|
||||||
|
expect(File(deck).readAsStringSync(), '\n');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -565,8 +565,7 @@ void main() {
|
||||||
);
|
);
|
||||||
final deck = service.parseDeck(markdown);
|
final deck = service.parseDeck(markdown);
|
||||||
expect(deck, isNotNull);
|
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);
|
expect(deck.slides[1].showFooter, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,15 @@ void main() {
|
||||||
closingSlideMarkdown: '# Einde\n\nDank voor jullie aandacht.',
|
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(
|
final markdown = service.generateDeck(
|
||||||
Deck(
|
Deck(
|
||||||
title: 'Demo',
|
title: 'Demo',
|
||||||
themeProfile: profile,
|
themeProfile: profile,
|
||||||
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
|
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
|
||||||
),
|
),
|
||||||
|
inlineStyleProfile: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final deck = service.parseDeck(markdown);
|
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', () {
|
test('adds logo-safe class when deck profile has logo', () {
|
||||||
final service = MarkdownService();
|
final service = MarkdownService();
|
||||||
final markdown = service.generateDeck(
|
final markdown = service.generateDeck(
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,25 @@ void main() {}
|
||||||
expect(html, contains(r'<\/script'));
|
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 {
|
test('a theme colours the slides with the profile palette', () async {
|
||||||
final service = MarpHtmlService(
|
final service = MarpHtmlService(
|
||||||
loadAsset: _diskLoader,
|
loadAsset: _diskLoader,
|
||||||
|
|
@ -108,7 +127,10 @@ void main() {}
|
||||||
codeTextColor: '#33FF33',
|
codeTextColor: '#33FF33',
|
||||||
codeFontFamily: 'Courier New',
|
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{background:#000000;color:#33FF33'));
|
||||||
expect(html, contains('.slide pre code{color:#33FF33'));
|
expect(html, contains('.slide pre code{color:#33FF33'));
|
||||||
|
|
|
||||||
90
test/rehearsal_controller_test.dart
Normal file
90
test/rehearsal_controller_test.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/services/rehearsal_controller.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Bestuurbare klok zodat de timing deterministisch is.
|
||||||
|
late DateTime now;
|
||||||
|
DateTime clock() => now;
|
||||||
|
|
||||||
|
setUp(() => now = DateTime(2026, 1, 1, 10, 0, 0));
|
||||||
|
void advance(Duration d) => now = now.add(d);
|
||||||
|
|
||||||
|
test('elapsed loopt met de klok mee', () {
|
||||||
|
final c = RehearsalController(now: clock);
|
||||||
|
expect(c.elapsed, Duration.zero);
|
||||||
|
advance(const Duration(seconds: 90));
|
||||||
|
expect(c.elapsed, const Duration(seconds: 90));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-slide-tijd telt op per slide en houdt volgorde aan', () {
|
||||||
|
final c = RehearsalController(now: clock);
|
||||||
|
c.observe('a', 0);
|
||||||
|
advance(const Duration(seconds: 30));
|
||||||
|
c.observe('b', 1);
|
||||||
|
advance(const Duration(seconds: 20));
|
||||||
|
c.observe('a', 0); // terug naar a
|
||||||
|
advance(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
final run = c.finish();
|
||||||
|
expect(run.total, const Duration(seconds: 60));
|
||||||
|
expect(run.perSlide.map((t) => t.slideId).toList(), ['a', 'b']);
|
||||||
|
expect(run.perSlide[0].spent, const Duration(seconds: 40)); // 30 + 10
|
||||||
|
expect(run.perSlide[1].spent, const Duration(seconds: 20));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observe is idempotent: dezelfde slide sluit niet af', () {
|
||||||
|
final c = RehearsalController(now: clock);
|
||||||
|
c.observe('a', 0);
|
||||||
|
advance(const Duration(seconds: 5));
|
||||||
|
c.observe('a', 0); // geen wissel
|
||||||
|
advance(const Duration(seconds: 5));
|
||||||
|
expect(c.currentSlideElapsed, const Duration(seconds: 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aftelling: resterend wordt negatief na de doeltijd', () {
|
||||||
|
final c = RehearsalController(now: clock, target: const Duration(minutes: 1));
|
||||||
|
advance(const Duration(seconds: 40));
|
||||||
|
expect(c.remaining, const Duration(seconds: 20));
|
||||||
|
advance(const Duration(seconds: 30));
|
||||||
|
expect(c.remaining, const Duration(seconds: -10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geen doeltijd → geen resterende tijd; nul-target zet aftelling uit', () {
|
||||||
|
final c = RehearsalController(now: clock);
|
||||||
|
expect(c.remaining, isNull);
|
||||||
|
c.target = Duration.zero;
|
||||||
|
expect(c.target, isNull);
|
||||||
|
c.target = const Duration(minutes: 5);
|
||||||
|
expect(c.target, const Duration(minutes: 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset wist run en per-slide-tijden, behoudt doeltijd', () {
|
||||||
|
final c = RehearsalController(now: clock, target: const Duration(minutes: 1));
|
||||||
|
c.observe('a', 0);
|
||||||
|
advance(const Duration(seconds: 30));
|
||||||
|
c.reset();
|
||||||
|
expect(c.elapsed, Duration.zero);
|
||||||
|
expect(c.target, const Duration(minutes: 1));
|
||||||
|
// Na reset is er nog geen geregistreerde slide.
|
||||||
|
advance(const Duration(seconds: 5));
|
||||||
|
expect(c.finish().perSlide, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMeaningfulData vereist een slide én ≥10s', () {
|
||||||
|
final c = RehearsalController(now: clock);
|
||||||
|
expect(c.hasMeaningfulData, isFalse);
|
||||||
|
c.observe('a', 0);
|
||||||
|
advance(const Duration(seconds: 9));
|
||||||
|
expect(c.hasMeaningfulData, isFalse);
|
||||||
|
advance(const Duration(seconds: 1));
|
||||||
|
expect(c.hasMeaningfulData, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delta beschrijft over/onder de doeltijd', () {
|
||||||
|
final c = RehearsalController(now: clock, target: const Duration(minutes: 1));
|
||||||
|
c.observe('a', 0);
|
||||||
|
advance(const Duration(seconds: 70));
|
||||||
|
final run = c.finish();
|
||||||
|
expect(run.delta, const Duration(seconds: 10)); // over de tijd
|
||||||
|
});
|
||||||
|
}
|
||||||
103
test/slide_list_panel_test.dart
Normal file
103
test/slide_list_panel_test.dart
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
80
test/table_clipboard_test.dart
Normal file
80
test/table_clipboard_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
66
test/table_editor_test.dart
Normal file
66
test/table_editor_test.dart
Normal 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', ''],
|
||||||
|
['', ''],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,32 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
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', (
|
testWidgets('bullets slide renders an optional subheading below the title', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue