Merge branch 'fix/two-bullets-independent-scaling': Afbeeldingenbibliotheek: md5-duplicaten opruimen en filter zonder tags

Duplicaten opruimen (md5) met samenvoegen van tags en opmerkingen en het
omzetten van slideverwijzingen — ook in presentaties op schijf die niet
geopend zijn. Filter om alleen afbeeldingen zonder tags te tonen. De
verwijder-waarschuwing dekt nu ook niet-geopende presentaties. Plus de
eerder uitstaande toegankelijkheids- en tabelplak-verbeteringen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-11 13:45:55 +02:00
commit e86d30e75a
45 changed files with 3222 additions and 550 deletions

View file

@ -8,6 +8,18 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **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
@ -37,6 +49,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 (100200%, Settings → General →
Accessibility) that scales all editor text; slides themselves keep their
fixed design size.
- The panel divider is focusable and **keyboard-resizable** (arrow keys), with
a visible focus state, and presents itself to screen readers as a slider.
- **Screen-reader support**: slide thumbnails announce one concise label
("Slide 3/12: title") instead of their full content; charts expose their
type, title, and underlying values as a text alternative; the presenter
announces each slide change.
- Improved contrast for hint/label text in the editors.
- Project documentation: contributing guide, security policy, architecture and - 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 +79,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 2432 pt range presentation-design guidance recommends for body text —
so slides with few bullets no longer render body text that competes with the
title.
- After resizing the slide panel (dragging the divider or resizing the window),
the list scrolls the slide being edited back into view.
### Fixed
- Hover on charts (tooltips, legend highlight) now works on a second screen:
macOS only delivered mouse-moved events to the key window, so the borderless
beamer window never saw them; the stuck hover state after the pointer left a
window is gone for the same reason.
- Bar-chart x-axis labels could run through each other: the spacing maths now
matches how bar groups are actually laid out, and the final label shrinks to
the real gap when it sits closer than a full step.
- A crash in the slide list ("A _RenderLayoutBuilder was mutated…") when its
keyed items were rebuilt during layout — both the resize-detection inside the
panel and the shell's width computation now avoid LayoutBuilders above the
reorderable list.
## [1.0.0] ## [1.0.0]

View file

@ -16,9 +16,10 @@ Built with Flutter for macOS, Windows, and Linux.
- **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.
- **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 +70,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/).

View file

@ -16,11 +16,13 @@ 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, image, caption, description,
# image_dedup (md5 duplicates), image_reference (.md rewrites),
# recovery, rasterizer, marp_html, annotation_codec # recovery, rasterizer, marp_html, annotation_codec
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
@ -90,10 +92,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 +109,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.

View file

@ -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

View file

@ -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

View file

@ -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:
@ -112,6 +148,21 @@ 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.
## Accessibility
OciDeck aims for WCAG 2.1 in the editor:
- **Interface text size** — Settings → General → Accessibility offers 100200%
text scaling for the whole editing environment, on top of what the operating
system asks for. Slides keep their fixed 16:9 design size, so what you see is
still exactly what you present and export.
- **Keyboard** — the panel divider between the slide list and the editor can be
focused with `Tab` and resized with `←`/`→`; the add-slide dialog is fully
keyboard-operable.
- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12:
title", including the skipped state), charts read out their data as a text
alternative, and the fullscreen presenter announces every slide change.
## Theming and language ## Theming and language
- **Style profiles** control deck colours (including the source-code background, - **Style profiles** control deck colours (including the source-code background,

View file

@ -17,11 +17,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 [

View file

@ -2326,6 +2326,14 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = { const _dutchSourceStringAdditions = {
'en': { 'en': {
'Toegankelijkheid': 'Accessibility',
'Tekstgrootte van de interface': 'Interface text size',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Enlarges all editor text up to 200%. The slides themselves are not affected.',
'Breedte van het slidepaneel': 'Width of the slide panel',
'Pijltjestoetsen passen de breedte aan': 'Arrow keys adjust the width',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Tip: paste a table from your spreadsheet into a cell with Cmd/Ctrl+V to fill the whole table.',
'Annuleren': 'Cancel', 'Annuleren': 'Cancel',
'Checklist': 'Task checklist', 'Checklist': 'Task checklist',
'Voortgangsgrafiek tonen': 'Show progress chart', 'Voortgangsgrafiek tonen': 'Show progress chart',
@ -2432,8 +2440,34 @@ const _dutchSourceStringAdditions = {
'gerenderd.': 'rendered.', 'gerenderd.': 'rendered.',
'renderen…': 'rendering…', 'renderen…': 'rendering…',
'voorbereiden…': 'preparing…', 'voorbereiden…': 'preparing…',
'Duplicaten opruimen': 'Clean up duplicates',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Find byte-identical images (md5), merge tags and notes, and remove the copies',
'Geen dubbele afbeeldingen gevonden.': 'No duplicate images found.',
'Dubbele afbeeldingen opruimen?': 'Clean up duplicate images?',
'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.':
'One file per group is kept. Tags and notes are merged, and slides that use a copy will point to the kept file afterwards — including presentations that are not currently open.',
'Opruimen': 'Clean up',
'1 presentatiebestand bijgewerkt.': '1 presentation file updated.',
'presentatiebestanden bijgewerkt.': 'presentation files updated.',
'niet geopend': 'not open',
'1 dubbele afbeelding verwijderd.': '1 duplicate image removed.',
'dubbele afbeeldingen verwijderd.': 'duplicate images removed.',
'Alleen afbeeldingen zonder tags tonen': 'Show only images without tags',
'Alle afbeeldingen hebben tags.': 'All images have tags.',
'Zet het filter uit om alles weer te zien.':
'Turn off the filter to see everything again.',
}, },
'it': { 'it': {
'Toegankelijkheid': 'Accessibilità',
'Tekstgrootte van de interface': 'Dimensione del testo dell\'interfaccia',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Ingrandisce tutto il testo dell\'editor fino al 200%. Le slide non cambiano.',
'Breedte van het slidepaneel': 'Larghezza del pannello delle slide',
'Pijltjestoetsen passen de breedte aan':
'I tasti freccia regolano la larghezza',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Suggerimento: incolla con Cmd/Ctrl+V una tabella dal tuo foglio di calcolo in una cella per riempire l\'intera tabella.',
'Annuleren': 'Annulla', 'Annuleren': 'Annulla',
'Checklist': 'Lista di controllo', 'Checklist': 'Lista di controllo',
'Voortgangsgrafiek tonen': 'Mostra grafico di avanzamento', 'Voortgangsgrafiek tonen': 'Mostra grafico di avanzamento',
@ -2694,8 +2728,35 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparazione…', 'voorbereiden…': 'preparazione…',
'↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert':
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
'Duplicaten opruimen': 'Rimuovi duplicati',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Trova immagini identiche byte per byte (md5), unisci tag e note ed elimina le copie',
'Geen dubbele afbeeldingen gevonden.':
'Nessuna immagine duplicata trovata.',
'Dubbele afbeeldingen opruimen?': 'Rimuovere le immagini duplicate?',
'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.':
'Di ogni gruppo resta un solo file. Tag e note vengono uniti e le slide che usano una copia punteranno poi al file conservato, anche nelle presentazioni non aperte al momento.',
'Opruimen': 'Rimuovi',
'1 presentatiebestand bijgewerkt.': '1 file di presentazione aggiornato.',
'presentatiebestanden bijgewerkt.': 'file di presentazione aggiornati.',
'niet geopend': 'non aperto',
'1 dubbele afbeelding verwijderd.': '1 immagine duplicata eliminata.',
'dubbele afbeeldingen verwijderd.': 'immagini duplicate eliminate.',
'Alleen afbeeldingen zonder tags tonen':
'Mostra solo immagini senza tag',
'Alle afbeeldingen hebben tags.': 'Tutte le immagini hanno tag.',
'Zet het filter uit om alles weer te zien.':
'Disattiva il filtro per rivedere tutto.',
}, },
'de': { 'de': {
'Toegankelijkheid': 'Barrierefreiheit',
'Tekstgrootte van de interface': 'Textgröße der Oberfläche',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Vergrößert sämtlichen Text der Bearbeitungsumgebung auf bis zu 200 %. Die Folien selbst ändern sich nicht.',
'Breedte van het slidepaneel': 'Breite des Folienbereichs',
'Pijltjestoetsen passen de breedte aan': 'Pfeiltasten passen die Breite an',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Tipp: Füge mit Cmd/Strg+V eine Tabelle aus deiner Tabellenkalkulation in eine Zelle ein, um die ganze Tabelle zu füllen.',
'Annuleren': 'Abbrechen', 'Annuleren': 'Abbrechen',
'Checklist': 'Checkliste', 'Checklist': 'Checkliste',
'Voortgangsgrafiek tonen': 'Fortschrittsdiagramm anzeigen', 'Voortgangsgrafiek tonen': 'Fortschrittsdiagramm anzeigen',
@ -2956,8 +3017,34 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'vorbereiten…', 'voorbereiden…': 'vorbereiten…',
'↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert':
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
'Duplicaten opruimen': 'Duplikate aufräumen',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Byte-identische Bilder (md5) finden, Tags und Anmerkungen zusammenführen und die Kopien löschen',
'Geen dubbele afbeeldingen gevonden.': 'Keine doppelten Bilder gefunden.',
'Dubbele afbeeldingen opruimen?': 'Doppelte Bilder aufräumen?',
'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.':
'Aus jeder Gruppe bleibt eine Datei erhalten. Tags und Anmerkungen werden zusammengeführt, und Folien, die eine Kopie verwenden, verweisen danach auf die erhaltene Datei — auch in Präsentationen, die gerade nicht geöffnet sind.',
'Opruimen': 'Aufräumen',
'1 presentatiebestand bijgewerkt.': '1 Präsentationsdatei aktualisiert.',
'presentatiebestanden bijgewerkt.': 'Präsentationsdateien aktualisiert.',
'niet geopend': 'nicht geöffnet',
'1 dubbele afbeelding verwijderd.': '1 doppeltes Bild entfernt.',
'dubbele afbeeldingen verwijderd.': 'doppelte Bilder entfernt.',
'Alleen afbeeldingen zonder tags tonen': 'Nur Bilder ohne Tags anzeigen',
'Alle afbeeldingen hebben tags.': 'Alle Bilder haben Tags.',
'Zet het filter uit om alles weer te zien.':
'Filter ausschalten, um wieder alles zu sehen.',
}, },
'fr': { 'fr': {
'Toegankelijkheid': 'Accessibilité',
'Tekstgrootte van de interface': 'Taille du texte de l\'interface',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Agrandit tout le texte de l\'éditeur jusqu\'à 200 %. Les diapositives ne changent pas.',
'Breedte van het slidepaneel': 'Largeur du panneau des diapositives',
'Pijltjestoetsen passen de breedte aan':
'Les touches fléchées ajustent la largeur',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Astuce : collez avec Cmd/Ctrl+V un tableau de votre tableur dans une cellule pour remplir tout le tableau.',
'Annuleren': 'Annuler', 'Annuleren': 'Annuler',
'Checklist': 'Liste de contrôle', 'Checklist': 'Liste de contrôle',
'Voortgangsgrafiek tonen': 'Afficher le graphique de progression', 'Voortgangsgrafiek tonen': 'Afficher le graphique de progression',
@ -3218,8 +3305,38 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'préparation…', 'voorbereiden…': 'préparation…',
'↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert':
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
'Duplicaten opruimen': 'Nettoyer les doublons',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Trouver les images identiques octet par octet (md5), fusionner tags et remarques et supprimer les copies',
'Geen dubbele afbeeldingen gevonden.':
'Aucune image en double trouvée.',
'Dubbele afbeeldingen opruimen?': 'Nettoyer les images en double ?',
'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.':
'Un seul fichier par groupe est conservé. Les tags et remarques sont fusionnés et les diapositives utilisant une copie pointeront ensuite vers le fichier conservé — y compris dans les présentations qui ne sont pas ouvertes actuellement.',
'Opruimen': 'Nettoyer',
'1 presentatiebestand bijgewerkt.':
'1 fichier de présentation mis à jour.',
'presentatiebestanden bijgewerkt.':
'fichiers de présentation mis à jour.',
'niet geopend': 'non ouvert',
'1 dubbele afbeelding verwijderd.': '1 image en double supprimée.',
'dubbele afbeeldingen verwijderd.': 'images en double supprimées.',
'Alleen afbeeldingen zonder tags tonen':
'Afficher uniquement les images sans tags',
'Alle afbeeldingen hebben tags.': 'Toutes les images ont des tags.',
'Zet het filter uit om alles weer te zien.':
'Désactivez le filtre pour tout revoir.',
}, },
'es': { 'es': {
'Toegankelijkheid': 'Accesibilidad',
'Tekstgrootte van de interface': 'Tamaño del texto de la interfaz',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Amplía todo el texto del editor hasta un 200 %. Las diapositivas no cambian.',
'Breedte van het slidepaneel': 'Ancho del panel de diapositivas',
'Pijltjestoetsen passen de breedte aan':
'Las teclas de flecha ajustan el ancho',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Consejo: pega con Cmd/Ctrl+V una tabla de tu hoja de cálculo en una celda para rellenar toda la tabla.',
'Annuleren': 'Cancelar', 'Annuleren': 'Cancelar',
'Checklist': 'Lista de verificación', 'Checklist': 'Lista de verificación',
'Voortgangsgrafiek tonen': 'Mostrar gráfico de progreso', 'Voortgangsgrafiek tonen': 'Mostrar gráfico de progreso',
@ -3481,8 +3598,37 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparando…', 'voorbereiden…': 'preparando…',
'↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert':
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
'Duplicaten opruimen': 'Limpiar duplicados',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Buscar imágenes idénticas byte a byte (md5), combinar etiquetas y notas y eliminar las copias',
'Geen dubbele afbeeldingen gevonden.':
'No se encontraron imágenes duplicadas.',
'Dubbele afbeeldingen opruimen?': '¿Limpiar imágenes duplicadas?',
'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.':
'De cada grupo se conserva un archivo. Las etiquetas y notas se combinan y las diapositivas que usan una copia pasarán a apuntar al archivo conservado, también en las presentaciones que no estén abiertas.',
'Opruimen': 'Limpiar',
'1 presentatiebestand bijgewerkt.':
'1 archivo de presentación actualizado.',
'presentatiebestanden bijgewerkt.':
'archivos de presentación actualizados.',
'niet geopend': 'no abierto',
'1 dubbele afbeelding verwijderd.': '1 imagen duplicada eliminada.',
'dubbele afbeeldingen verwijderd.': 'imágenes duplicadas eliminadas.',
'Alleen afbeeldingen zonder tags tonen':
'Mostrar solo imágenes sin etiquetas',
'Alle afbeeldingen hebben tags.': 'Todas las imágenes tienen etiquetas.',
'Zet het filter uit om alles weer te zien.':
'Desactiva el filtro para volver a ver todo.',
}, },
'fy': { 'fy': {
'Toegankelijkheid': 'Tagonklikens',
'Tekstgrootte van de interface': 'Tekstgrutte fan de ynterface',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Fergruttet alle tekst fan de bewurkomjouwing oant maksimaal 200%. De slides sels feroarje net.',
'Breedte van het slidepaneel': 'Breedte fan it slidepaniel',
'Pijltjestoetsen passen de breedte aan': 'Pylktoetsen passe de breedte oan',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Tip: plak mei Cmd/Ctrl+V in tabel út dyn rekkenblêd yn in sel om de hiele tabel te foljen.',
'Annuleren': 'Annulearje', 'Annuleren': 'Annulearje',
'Checklist': 'Kontrôlelist', 'Checklist': 'Kontrôlelist',
'Voortgangsgrafiek tonen': 'Fuortgongsgrafyk toane', 'Voortgangsgrafiek tonen': 'Fuortgongsgrafyk toane',
@ -3740,8 +3886,35 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'tariede…', 'voorbereiden…': 'tariede…',
'↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert':
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
'Duplicaten opruimen': 'Duplikaten opromje',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Sykje byte-identike ôfbyldings (md5), foegje tags en opmerkings gear en smyt de kopyen fuort',
'Geen dubbele afbeeldingen gevonden.': 'Gjin dûbele ôfbyldings fûn.',
'Dubbele afbeeldingen opruimen?': 'Dûbele ôfbyldings opromje?',
'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.':
'Fan elke groep bliuwt ien bestân stean. Tags en opmerkings wurde gearfoege en slides dy\'t in kopy brûke, ferwize dêrnei nei it behâlden bestân — ek yn presintaasjes dy\'t no net iepene binne.',
'Opruimen': 'Opromje',
'1 presentatiebestand bijgewerkt.': '1 presintaasjebestân bywurke.',
'presentatiebestanden bijgewerkt.': 'presintaasjebestannen bywurke.',
'niet geopend': 'net iepene',
'1 dubbele afbeelding verwijderd.': '1 dûbele ôfbylding fuorthelle.',
'dubbele afbeeldingen verwijderd.': 'dûbele ôfbyldings fuorthelle.',
'Alleen afbeeldingen zonder tags tonen':
'Allinnich ôfbyldings sûnder tags toane',
'Alle afbeeldingen hebben tags.': 'Alle ôfbyldings hawwe tags.',
'Zet het filter uit om alles weer te zien.':
'Set it filter út om alles wer te sjen.',
}, },
'pap': { 'pap': {
'Toegankelijkheid': 'Aksesibilidat',
'Tekstgrootte van de interface': 'Tamaño di teksto di e interfaz',
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.':
'Ta hasi tur teksto di e editor mas grandi te 200%. E slidenan mes no ta kambia.',
'Breedte van het slidepaneel': 'Hanchura di e panel di slide',
'Pijltjestoetsen passen de breedte aan':
'Tekla di flecha ta atapta e hanchura',
'Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.':
'Tip: pega ku Cmd/Ctrl+V un tabel for di bo spreadsheet den un sèl pa yena henter e tabel.',
'Annuleren': 'Kanselá', 'Annuleren': 'Kanselá',
'Checklist': 'Lista di kontrol', 'Checklist': 'Lista di kontrol',
'Voortgangsgrafiek tonen': 'Mustra gráfiko di progreso', 'Voortgangsgrafiek tonen': 'Mustra gráfiko di progreso',
@ -3999,5 +4172,24 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparando…', 'voorbereiden…': 'preparando…',
'↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert':
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
'Duplicaten opruimen': 'Limpia duplikadonan',
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën':
'Buska imágennan idéntiko byte pa byte (md5), kombiná tag i remarkanan i eliminá e kopianan',
'Geen dubbele afbeeldingen gevonden.': 'No a haña imágen duplikado.',
'Dubbele afbeeldingen opruimen?': 'Limpia imágennan duplikado?',
'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.':
'Di kada grupo un archivo so ta keda. Tag i remarkanan ta wòrdu kombiná i e slidenan ku ta usa un kopia lo mustra despues riba e archivo ku a keda — tambe den presentashonnan ku no ta habrí awor.',
'Opruimen': 'Limpia',
'1 presentatiebestand bijgewerkt.': '1 archivo di presentashon aktualisá.',
'presentatiebestanden bijgewerkt.':
'archivonan di presentashon aktualisá.',
'niet geopend': 'no habrí',
'1 dubbele afbeelding verwijderd.': '1 imágen duplikado eliminá.',
'dubbele afbeeldingen verwijderd.': 'imágennan duplikado eliminá.',
'Alleen afbeeldingen zonder tags tonen':
'Mustra solamente imágennan sin tag',
'Alle afbeeldingen hebben tags.': 'Tur imágen tin tag.',
'Zet het filter uit om alles weer te zien.':
'Paga e filter pa mira tur kos atrobe.',
}, },
}; };

View file

@ -364,6 +364,11 @@ class AppSettings {
final String selectedAppAppearanceProfileName; final String selectedAppAppearanceProfileName;
final List<String> recentFiles; final List<String> recentFiles;
/// Scale factor for all interface text (1.02.0), on top of the system
/// text scaling. The slide canvas itself is never scaled: slides are a
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
final double uiTextScale;
const AppSettings({ const AppSettings({
this.languageCode = 'nl', this.languageCode = 'nl',
this.homeDirectory, this.homeDirectory,
@ -373,6 +378,7 @@ class AppSettings {
this.appAppearanceProfiles = AppAppearanceProfile.builtIns, this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic', this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [], this.recentFiles = const [],
this.uiTextScale = 1.0,
}); });
ThemeProfile get themeProfile { ThemeProfile get themeProfile {
@ -424,6 +430,7 @@ class AppSettings {
List<AppAppearanceProfile>? appAppearanceProfiles, List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName, String? selectedAppAppearanceProfileName,
List<String>? recentFiles, List<String>? recentFiles,
double? uiTextScale,
bool clearHomeDirectory = false, bool clearHomeDirectory = false,
bool clearExportDirectory = false, bool clearExportDirectory = false,
}) { }) {
@ -457,6 +464,7 @@ class AppSettings {
selectedAppAppearanceProfileName ?? selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName, this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles, recentFiles: recentFiles ?? this.recentFiles,
uiTextScale: uiTextScale ?? this.uiTextScale,
); );
} }
} }

View file

@ -0,0 +1,97 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 (_) {}
}
// 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 (_) {}
}
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 (_) {
return DateTime.fromMillisecondsSinceEpoch(0);
}
}
var keeper = group.first;
var keeperUsages = usageCountOf?.call(keeper) ?? 0;
var keeperModified = modifiedOf(keeper);
for (final candidate in group.skip(1)) {
final usages = usageCountOf?.call(candidate) ?? 0;
final modified = modifiedOf(candidate);
final wins =
usages > keeperUsages ||
(usages == keeperUsages && modified.isBefore(keeperModified));
if (wins) {
keeper = candidate;
keeperUsages = usages;
keeperModified = modified;
}
}
return keeper;
}
/// Voeg metadata-teksten (tags/beschrijvingen of opmerkingen/captions) van
/// duplicaten samen tot één waarde: unieke, niet-lege teksten gescheiden
/// door [separator]. Een tekst die al letterlijk in een eerdere voorkomt
/// (zoals dezelfde tag op beide duplicaten) wordt niet herhaald.
String mergeMetadata(Iterable<String?> values, {String separator = ' · '}) {
final merged = <String>[];
for (final value in values) {
final text = value?.trim() ?? '';
if (text.isEmpty) continue;
final isDuplicate = merged.any(
(existing) => existing.toLowerCase().contains(text.toLowerCase()),
);
if (!isDuplicate) merged.add(text);
}
return merged.join(separator);
}
}
final imageDedupServiceProvider = Provider<ImageDedupService>(
(_) => ImageDedupService(),
);

View file

@ -0,0 +1,168 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
/// Vindt en herschrijft afbeeldingsverwijzingen (`![](pad)`) in
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
/// ook presentaties mee die nu niet geopend zijn.
class ImageReferenceService {
/// Zelfde mappen als FileService.scanPresentations overslaat.
static const _ignoredDirs = {
'images',
'logos',
'themes',
'node_modules',
'build',
'.git',
'.dart_tool',
};
static const _maxDepth = 4;
/// Markdown-afbeelding: `![alt of bg-directive](pad)`.
static final _imageRef = RegExp(r'!\[([^\]]*)\]\(([^)\n]+)\)');
/// Zoek recursief alle `.md`-bestanden onder [searchDirs] (begrensd op
/// diepte, asset- en verborgen mappen worden overgeslagen). Dubbele treffers
/// via overlappende zoekpaden worden één keer teruggegeven.
Future<List<String>> findDeckFiles(Iterable<String> searchDirs) async {
final found = <String>{};
Future<void> walk(Directory dir, int depth) async {
List<FileSystemEntity> entries;
try {
entries = await dir.list(followLinks: false).toList();
} catch (_) {
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 (_) {
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 (_) {
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 (_) {
return false;
}
final mdDir = p.dirname(deckFile);
var changed = false;
final updated = content.replaceAllMapped(_imageRef, (m) {
final ref = m.group(2)!;
final resolved = _resolve(ref, mdDir);
if (resolved == null || !p.equals(resolved, fromAbsolute)) {
return m.group(0)!;
}
changed = true;
// Blijf relatief schrijven als de verwijzing dat al was en het nieuwe
// pad binnen de projectmap ligt; anders absoluut.
final replacement =
!p.isAbsolute(ref.trim()) && p.isWithin(mdDir, toAbsolute)
? p.relative(toAbsolute, from: mdDir)
: toAbsolute;
return '![${m.group(1)}]($replacement)';
});
if (!changed) return false;
try {
await file.writeAsString(updated);
} catch (_) {
return false;
}
return true;
}
String? _resolve(String ref, String mdDir) {
final cleaned = ref.trim();
if (cleaned.isEmpty || cleaned.contains('://')) return null;
return p.normalize(p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned));
}
}
final imageReferenceServiceProvider = Provider<ImageReferenceService>(
(_) => ImageReferenceService(),
);

View file

@ -54,9 +54,17 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
? selectedAppearance ? selectedAppearance
: 'Basic', : 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [], recentFiles: prefs.getStringList('recentFiles') ?? [],
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
); );
} }
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);
}
Future<void> addRecentFile(String path) async { Future<void> addRecentFile(String path) async {
final updated = [ final updated = [
path, path,

View file

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

View file

@ -138,6 +138,47 @@ List<String> _imageUsages(WidgetRef ref, String absolutePath) {
return usages; 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) { List<Slide> _slidesForPresentationOrExport(Deck deck) {
// Drop skipped slides and slides whose TLP classification is stricter than // Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export. // the level chosen for this presentation/export.
@ -926,9 +967,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('$count ${l10n.d('checklist-items uitgevinkt.')}'),
'$count ${l10n.d('checklist-items uitgevinkt.')}',
),
), ),
); );
} }
@ -947,6 +986,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
captionService: ref.read(captionServiceProvider), captionService: ref.read(captionServiceProvider),
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) return; if (result == null) return;
@ -1444,10 +1488,16 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
}); });
} }
return LayoutBuilder( // The available width comes from MediaQuery, NOT a
builder: (context, constraints) { // LayoutBuilder: a LayoutBuilder rebuilds this subtree during
final maxRailWidth = (constraints.maxWidth - _minEditorWidth) // the layout phase, and when the slide list's keyed
.clamp(_minSlideRailWidth, constraints.maxWidth) // ReorderableListView items get reparented in that pass their
// overlay children are activated outside an active layout
// "A _RenderLayoutBuilder was mutated in performLayout". The
// body row spans the window, so the window width is equivalent.
final bodyWidth = MediaQuery.sizeOf(ctx).width;
final maxRailWidth = (bodyWidth - _minEditorWidth)
.clamp(_minSlideRailWidth, bodyWidth)
.toDouble(); .toDouble();
final railWidth = _slideRailWidth final railWidth = _slideRailWidth
.clamp(_minSlideRailWidth, maxRailWidth) .clamp(_minSlideRailWidth, maxRailWidth)
@ -1460,7 +1510,10 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
return Row( return Row(
children: [ children: [
SizedBox(width: railWidth, child: const SlideListPanel()), SizedBox(
width: railWidth,
child: SlideListPanel(railWidth: railWidth),
),
_ResizableDivider( _ResizableDivider(
onDrag: (delta) { onDrag: (delta) {
setState(() { setState(() {
@ -1474,8 +1527,6 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
], ],
); );
}, },
);
},
), ),
), ),
), ),
@ -1721,14 +1772,42 @@ class _ResizableDivider extends StatefulWidget {
} }
class _ResizableDividerState extends State<_ResizableDivider> { class _ResizableDividerState extends State<_ResizableDivider> {
static const double _keyboardStep = 24;
bool _hovered = false; bool _hovered = false;
bool _dragging = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final active = _hovered || _dragging; final active = _hovered || _dragging || _focused;
return MouseRegion( // 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, cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
@ -1737,7 +1816,8 @@ class _ResizableDividerState extends State<_ResizableDivider> {
onHorizontalDragStart: (_) => setState(() => _dragging = true), onHorizontalDragStart: (_) => setState(() => _dragging = true),
onHorizontalDragEnd: (_) => setState(() => _dragging = false), onHorizontalDragEnd: (_) => setState(() => _dragging = false),
onHorizontalDragCancel: () => setState(() => _dragging = false), onHorizontalDragCancel: () => setState(() => _dragging = false),
onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx), onHorizontalDragUpdate: (details) =>
widget.onDrag(details.delta.dx),
child: Tooltip( child: Tooltip(
message: l10n.d( message: l10n.d(
'Sleep om de slide-preview breder of smaller te maken', 'Sleep om de slide-preview breder of smaller te maken',
@ -1756,6 +1836,8 @@ class _ResizableDividerState extends State<_ResizableDivider> {
), ),
), ),
), ),
),
),
); );
} }
} }

View file

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

View file

@ -5,6 +5,8 @@ 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';
@ -19,6 +21,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 +46,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 +60,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 +71,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 +83,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 +121,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;
@ -187,9 +209,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 +226,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 +285,277 @@ 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 (_) {}
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,11 +685,38 @@ 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;
try { try {
@ -418,7 +744,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 +800,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 +994,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 +1007,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 +1071,38 @@ 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 +1154,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 +1942,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'),

View file

@ -432,6 +432,18 @@ 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.t('presentationFolder')), _sectionTitle(l10n.t('presentationFolder')),
Row( Row(
children: [ children: [
@ -490,6 +502,42 @@ 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);
},
),
),
);
}
Widget _appearanceTab() { Widget _appearanceTab() {
final l10n = context.l10n; final l10n = context.l10n;
final profiles = ref.watch(settingsProvider).appAppearanceProfiles; final profiles = ref.watch(settingsProvider).appAppearanceProfiles;

View file

@ -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,59 @@ 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 +331,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 +394,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 +496,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),

View file

@ -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,

View file

@ -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'),

View file

@ -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),

View file

@ -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),

View file

@ -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(

View file

@ -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,6 +269,8 @@ class _TableEditorState extends State<TableEditor> {
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3), padding: const EdgeInsets.symmetric(horizontal: 3),
child: Focus(
onKeyEvent: (node, event) => _onCellKey(r, c, event),
child: TextField( child: TextField(
controller: _cells[r][c], controller: _cells[r][c],
// Meerdere regels toestaan: het veld groeit mee en Enter // Meerdere regels toestaan: het veld groeit mee en Enter
@ -213,7 +281,9 @@ class _TableEditorState extends State<TableEditor> {
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: isHeader ? FontWeight.w600 : FontWeight.normal, fontWeight: isHeader
? FontWeight.w600
: FontWeight.normal,
), ),
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
@ -228,6 +298,7 @@ class _TableEditorState extends State<TableEditor> {
), ),
), ),
), ),
),
// Verwijderknop op de hoogte van de eerste regel houden. // Verwijderknop op de hoogte van de eerste regel houden.
SizedBox( SizedBox(
width: _rowActionWidth, width: _rowActionWidth,
@ -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

View file

@ -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(

View file

@ -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'),

View file

@ -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)),
), ),
), ),
], ],

View file

@ -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,

View file

@ -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';
@ -19,7 +21,14 @@ 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 +40,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 +117,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 +129,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;
@ -506,6 +549,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,63 +709,13 @@ 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);
},
);
},
), ),
), ),

View file

@ -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';
@ -14,6 +15,7 @@ import '../../models/slide.dart';
import '../../services/markdown_service.dart'; import '../../services/markdown_service.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';
@ -727,6 +729,22 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} }
/// 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 +754,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 +766,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (_index > 0) { if (_index > 0) {
setState(() => _index--); setState(() => _index--);
_scheduleAdvance(); _scheduleAdvance();
_announceSlide();
} }
} }
@ -839,6 +859,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 +872,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.

View file

@ -186,7 +186,11 @@ class SlidePreviewWidget extends StatelessWidget {
// falls back to Flutter's broken default — red letters with a yellow // falls back to Flutter's broken default — red letters with a yellow
// underline which is exactly what showed up in exports. Wrapping here // underline which is exactly what showed up in exports. Wrapping here
// guarantees identical results in the preview and the export. // guarantees identical results in the preview and the export.
return _ChecklistInteractionHost( // The slide is a fixed 16:9 design surface whose sizes all derive from
// its width; interface text scaling must not reflow it (the auto-fit
// measuring assumes unscaled text), so the canvas opts out.
return MediaQuery.withNoTextScaling(
child: _ChecklistInteractionHost(
enabled: presentationMode && onChecklistItemToggle != null, enabled: presentationMode && onChecklistItemToggle != null,
onToggle: onChecklistItemToggle, onToggle: onChecklistItemToggle,
child: Directionality( child: Directionality(
@ -205,6 +209,7 @@ class SlidePreviewWidget extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
@ -707,7 +712,7 @@ class _BulletsPreview extends StatelessWidget {
font: font, font: font,
subtitle: subtitle, subtitle: subtitle,
subtitleSize: subtitleSize, subtitleSize: subtitleSize,
maxScale: _kSplitBulletsMaxScale, maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
@ -1072,7 +1077,7 @@ class _TwoBulletsPreview extends StatelessWidget {
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font, font: font,
maxScale: _kBulletsMaxScale, maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
final rightScale = _bulletsFitScale( final rightScale = _bulletsFitScale(
@ -1086,7 +1091,7 @@ class _TwoBulletsPreview extends StatelessWidget {
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font, font: font,
maxScale: _kBulletsMaxScale, maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
// Treat both columns as one composition: the busiest column determines // Treat both columns as one composition: the busiest column determines
@ -1247,7 +1252,7 @@ class _BulletsImagePreview extends StatelessWidget {
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font, font: font,
maxScale: _kBulletsMaxScale, maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
@ -1776,6 +1781,19 @@ const double _kBulletsMaxScale = 3.2;
/// visually timid unless they are allowed to grow a little further. /// visually timid unless they are allowed to grow a little further.
const double _kSplitBulletsMaxScale = 4.35; const double _kSplitBulletsMaxScale = 4.35;
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
/// fraction of the slide width: 32pt on a standard 16:9 deck (PowerPoint's
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
/// text at 2432pt beyond that it stops aiding readability and starts
/// competing with the title. The fit scale multiplies title and bullets
/// alike, so capping the bullet size also keeps the hierarchy intact.
const double _kBulletMaxFontFraction = 0.0335;
/// The largest auto-fit scale that keeps bullets at or under
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
/// Line height used for bullet body text, shared by rendering and measuring. /// Line height used for bullet body text, shared by rendering and measuring.
const double _kBulletLineHeight = 1.16; const double _kBulletLineHeight = 1.16;
@ -2872,6 +2890,36 @@ class _ChartPreviewState extends State<_ChartPreview> {
return _hexColor(chartSeriesColor(series, i)); return _hexColor(chartSeriesColor(series, i));
} }
/// Text alternative for the chart (WCAG 1.1.1): chart type, title and the
/// underlying values per series, so a screen reader conveys the same
/// information the visual encodes.
String _semanticsLabel(BuildContext context, ChartSpec spec) {
final l10n = context.l10n;
final typeName = switch (spec.type) {
ChartType.bar => l10n.d('Staaf'),
ChartType.line => l10n.d('Lijn'),
ChartType.pie => l10n.d('Cirkel'),
ChartType.radar => l10n.d('Spider'),
};
final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)');
if (spec.title.isNotEmpty) {
buffer.write(': ${stripInlineMarkdown(spec.title)}');
}
if (!spec.hasInlineData) return buffer.toString();
for (var si = 0; si < spec.series.length; si++) {
final series = spec.series[si];
final name = series.name.isEmpty
? '${l10n.d('Reeks')} ${si + 1}'
: series.name;
final values = [
for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++)
'${spec.x[xi]} ${_fmtNum(series.data[xi])}',
];
buffer.write('. $name: ${values.join(', ')}');
}
return buffer.toString();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spec = ChartSpec.parse(slide.customMarkdown); final spec = ChartSpec.parse(slide.customMarkdown);
@ -2880,6 +2928,32 @@ class _ChartPreviewState extends State<_ChartPreview> {
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final textColor = _hexColor(profile.textColor); final textColor = _hexColor(profile.textColor);
return Semantics(
image: true,
label: _semanticsLabel(context, spec),
// The visual chart (axis labels, legend chips, tooltips) would read as
// disconnected fragments; the label above carries the full story.
child: ExcludeSemantics(
child: _chartBody(
context,
spec,
horizontalPad,
verticalPad,
safe,
textColor,
),
),
);
}
Widget _chartBody(
BuildContext context,
ChartSpec spec,
double horizontalPad,
double verticalPad,
EdgeInsets safe,
Color textColor,
) {
return Container( return Container(
color: _hexColor(profile.slideBackgroundColor), color: _hexColor(profile.slideBackgroundColor),
child: Padding( child: Padding(
@ -3201,7 +3275,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
); );
} }
FlTitlesData _titles(ChartSpec spec, Color textColor) { FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) {
final style = _applyFont( final style = _applyFont(
font, font,
TextStyle( TextStyle(
@ -3234,16 +3308,30 @@ class _ChartPreviewState extends State<_ChartPreview> {
if (i < 0 || i >= n) return const SizedBox.shrink(); if (i < 0 || i >= n) return const SizedBox.shrink();
// Show as many labels as fit without colliding: keep at least // Show as many labels as fit without colliding: keep at least
// [minSlot] of horizontal room per label, then thin them out // [minSlot] of horizontal room per label, then thin them out
// evenly based on the actual pixel spacing between points. // evenly based on the actual pixel spacing between points. Line
final spacing = n > 1 // charts spread n points over n-1 intervals; bar groups are laid
? meta.parentAxisSize / (n - 1) // out spaceEvenly, which puts their centres (axis + groupWidth) /
: meta.parentAxisSize; // (n + 1) apart.
final spacing = bars
? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1)
: (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize);
final minSlot = w * 0.085 * _labelScale; final minSlot = w * 0.085 * _labelScale;
final step = math.max(1, (minSlot / spacing).ceil()); final step = math.max(1, (minSlot / spacing).ceil());
final lastMultiple = ((n - 1) ~/ step) * step; final lastMultiple = ((n - 1) ~/ step) * step;
final showLast = i == n - 1 && (n - 1 - lastMultiple) > step / 2; final lastGap = n - 1 - lastMultiple;
final showLast = i == n - 1 && lastGap > step / 2;
if (i % step != 0 && !showLast) return const SizedBox.shrink(); if (i % step != 0 && !showLast) return const SizedBox.shrink();
final slot = (step * spacing - w * 0.012).clamp(w * 0.04, w * 0.16); // The extra end label can sit closer than a full step to its
// neighbour; shrink both of their slots to the real gap so they
// never run through each other.
var slotSteps = step.toDouble();
if (showLast || (i == lastMultiple && lastGap > step / 2)) {
slotSteps = math.min(slotSteps, lastGap.toDouble());
}
final slot = (slotSteps * spacing - w * 0.012).clamp(
w * 0.04,
w * 0.16,
);
return Padding( return Padding(
padding: EdgeInsets.only(top: w * 0.008), padding: EdgeInsets.only(top: w * 0.008),
child: SizedBox( child: SizedBox(
@ -3275,6 +3363,17 @@ class _ChartPreviewState extends State<_ChartPreview> {
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1), FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
); );
/// Width of one bar rod, shared by the chart and the axis-label spacing.
double _barRodWidth(ChartSpec spec) =>
(w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022);
/// Total width of one bar group: its rods plus fl_chart's default 2px
/// spacing between rods within a group.
double _barGroupWidth(ChartSpec spec) {
final rods = math.max(1, spec.series.length);
return rods * _barRodWidth(spec) + (rods - 1) * 2;
}
Widget _barChart(ChartSpec spec, Color textColor) { Widget _barChart(ChartSpec spec, Color textColor) {
final groups = <BarChartGroupData>[]; final groups = <BarChartGroupData>[];
for (var xi = 0; xi < spec.x.length; xi++) { for (var xi = 0; xi < spec.x.length; xi++) {
@ -3287,10 +3386,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
BarChartRodData( BarChartRodData(
toY: spec.series[si].data[xi], toY: spec.series[si].data[xi],
color: _seriesDisplayColor(spec.series[si], si), color: _seriesDisplayColor(spec.series[si], si),
width: (w * 0.032 / spec.series.length).clamp( width: _barRodWidth(spec),
w * 0.008,
w * 0.022,
),
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(w * 0.006), top: Radius.circular(w * 0.006),
), ),
@ -3308,8 +3404,11 @@ class _ChartPreviewState extends State<_ChartPreview> {
BarChartData( BarChartData(
minY: _minY(spec), minY: _minY(spec),
maxY: _maxY(spec), maxY: _maxY(spec),
// The axis-label spacing in _titles assumes this layout; keep it
// explicit rather than relying on fl_chart's default.
alignment: BarChartAlignment.spaceEvenly,
barGroups: groups, barGroups: groups,
titlesData: _titles(spec, textColor), titlesData: _titles(spec, textColor, bars: true),
gridData: _grid(textColor), gridData: _grid(textColor),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
extraLinesData: _boundLines(spec), extraLinesData: _boundLines(spec),
@ -3537,19 +3636,25 @@ class _ChartPreviewState extends State<_ChartPreview> {
final scale = radarScale(spec); final scale = radarScale(spec);
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.03, vertical: w * 0.012), padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Reserve a slim column on the right for the scale legend, then keep // Reserve a slim column on the right for the scale legend; the rest
// the chart square so fl_chart's centre/radius stay predictable. // of the area is shared between the spider and its axis labels.
final legendWidth = w * 0.075; final legendWidth = w * 0.075;
final available = constraints.maxWidth - legendWidth - w * 0.02; final boxW = math.max(
final side = math.max(
0.0, 0.0,
math.min(available, constraints.maxHeight), constraints.maxWidth - legendWidth - w * 0.02,
); );
final labelBand = side * 0.23; final boxH = constraints.maxHeight;
final chartSide = math.max(0.0, side - labelBand * 2); if (boxW <= 0 || !boxH.isFinite || boxH <= 0) {
return const SizedBox.shrink();
}
// Measure every axis label and grow the spider until the labels just
// fit between the polygon and the edges of the available area, so
// the diagram uses the space the old fixed label bands wasted.
final layout = _radarLabelLayout(spec, boxW, boxH, textColor);
final chartSide = layout.chartSide;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@ -3557,8 +3662,8 @@ class _ChartPreviewState extends State<_ChartPreview> {
Expanded( Expanded(
child: Center( child: Center(
child: SizedBox( child: SizedBox(
width: side, width: boxW,
height: side, height: boxH,
child: Stack( child: Stack(
children: [ children: [
for (var i = 0; i < spec.x.length; i++) for (var i = 0; i < spec.x.length; i++)
@ -3566,12 +3671,12 @@ class _ChartPreviewState extends State<_ChartPreview> {
label: spec.x[i], label: spec.x[i],
index: i, index: i,
count: spec.x.length, count: spec.x.length,
side: side, layout: layout,
textColor: textColor, textColor: textColor,
), ),
Positioned( Positioned(
left: labelBand, left: (boxW - chartSide) / 2,
top: labelBand, top: (boxH - chartSide) / 2,
width: chartSide, width: chartSide,
height: chartSide, height: chartSide,
child: Stack( child: Stack(
@ -3725,48 +3830,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
); );
} }
Widget _radarAxisLabel({ TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont(
required String label,
required int index,
required int count,
required double side,
required Color textColor,
}) {
final angle = (2 * math.pi * index / count) - math.pi / 2;
final boxWidth = side * (count <= 4 ? 0.22 : (count <= 6 ? 0.2 : 0.17));
final boxHeight = side * (count <= 6 ? 0.13 : 0.105);
final center = side / 2;
final horizontal = math.cos(angle);
final vertical = math.sin(angle);
final left = horizontal < -0.35
? 0.0
: (horizontal > 0.35 ? side - boxWidth : center - boxWidth / 2);
final top = vertical < -0.7
? 0.0
: (vertical > 0.7
? side - boxHeight
: (center + vertical * side * 0.32 - boxHeight / 2).clamp(
0.0,
side - boxHeight,
));
final alignment = horizontal < -0.25
? TextAlign.left
: (horizontal > 0.25 ? TextAlign.right : TextAlign.center);
return Positioned(
key: ValueKey('radar-axis-label-$index'),
left: left,
top: top,
width: boxWidth,
height: boxHeight,
child: Align(
alignment: Alignment.center,
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: alignment,
style: _applyFont(
font, font,
TextStyle( TextStyle(
fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale,
@ -3774,8 +3838,141 @@ class _ChartPreviewState extends State<_ChartPreview> {
color: textColor.withValues(alpha: 0.88), color: textColor.withValues(alpha: 0.88),
fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500,
), ),
);
/// True when the vertex in [direction] gets its label placed beside the
/// polygon (left/right) rather than above/below it.
static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35;
/// Sizes the spider and places every axis label around it.
///
/// Each label is measured at its real text size, then the polygon radius is
/// grown until the tightest label exactly fits between the polygon and the
/// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the
/// side of its (square) widget, which is what ties [chartSide] to the
/// resulting radius.
({double chartSide, List<Rect> rects, List<TextAlign> aligns, int maxLines})
_radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) {
const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8
final n = spec.x.length;
final style = _radarLabelStyle(n, textColor);
final gap = w * 0.008;
final maxLines = n <= 6 ? 3 : 2;
final sideCap = math.min(boxW * 0.28, w * 0.2);
final topCap = math.min(boxW * 0.5, w * 0.3);
Size measure(String text, double maxWidth) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
maxLines: maxLines,
ellipsis: '',
)..layout(maxWidth: math.max(0.0, maxWidth));
final size = Size(painter.width, painter.height);
painter.dispose();
return size;
}
final directions = <Offset>[];
final sizes = <Size>[];
for (var i = 0; i < n; i++) {
final angle = (2 * math.pi * i / n) - math.pi / 2;
final dir = Offset(math.cos(angle), math.sin(angle));
directions.add(dir);
sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap));
}
// The largest polygon radius every label still fits next to.
var radius = radiusFactor * math.min(boxW, boxH);
for (var i = 0; i < n; i++) {
final dx = directions[i].dx.abs();
final dy = directions[i].dy.abs();
if (_radarLabelBeside(directions[i])) {
radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx);
if (dy > 0.01) {
radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy);
}
} else {
radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy);
if (dx > 0.01) {
radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx);
}
}
}
// Never let extreme labels crush the spider entirely; below this floor the
// labels get clamped (and ellipsized) instead.
final floor = 0.18 * math.min(boxW, boxH);
radius = radius.clamp(
math.min(floor, radiusFactor * math.min(boxW, boxH)),
radiusFactor * math.min(boxW, boxH),
);
final chartSide = radius / radiusFactor;
final center = Offset(boxW / 2, boxH / 2);
final rects = <Rect>[];
final aligns = <TextAlign>[];
for (var i = 0; i < n; i++) {
final dir = directions[i];
final anchor = center + dir * (radius + gap);
var size = sizes[i];
double left;
double top;
if (_radarLabelBeside(dir)) {
// Re-measure against the room actually left beside the polygon, so a
// clamped radius still produces a label that wraps inside the box.
final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx;
if (size.width > room) size = measure(spec.x[i], room);
left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width;
top = anchor.dy - size.height / 2;
aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right);
} else {
left = anchor.dx - size.width / 2;
top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy;
aligns.add(TextAlign.center);
}
rects.add(
Rect.fromLTWH(
left.clamp(0.0, math.max(0.0, boxW - size.width)),
top.clamp(0.0, math.max(0.0, boxH - size.height)),
size.width,
size.height,
), ),
), );
}
return (
chartSide: chartSide,
rects: rects,
aligns: aligns,
maxLines: maxLines,
);
}
Widget _radarAxisLabel({
required String label,
required int index,
required int count,
required ({
double chartSide,
List<Rect> rects,
List<TextAlign> aligns,
int maxLines,
})
layout,
required Color textColor,
}) {
final rect = layout.rects[index];
return Positioned(
key: ValueKey('radar-axis-label-$index'),
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
child: Text(
label,
maxLines: layout.maxLines,
overflow: TextOverflow.ellipsis,
textAlign: layout.aligns[index],
style: _radarLabelStyle(count, textColor),
), ),
); );
} }

View file

@ -55,20 +55,36 @@ 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)
// en status. De mini-preview eronder is puur visueel en zou anders de
// volledige slide-inhoud per thumbnail laten voorlezen.
final title = slide.title.trim();
final semanticLabel =
'${l10n.d('Slide')} ${index + 1}/$slideCount: '
'${title.isNotEmpty ? title : l10n.d(slide.type.label)}'
'${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}';
return Semantics(
button: true,
selected: isSelected,
label: semanticLabel,
child: GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
border: Border.all(color: borderColor, width: borderWidth), border: Border.all(color: borderColor, width: borderWidth),
color: isSelected ? const Color(0xFF2A2F3B) : const Color(0xFF252830), color: isSelected
? const Color(0xFF2A2F3B)
: const Color(0xFF252830),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Mini slide preview // Mini slide preview
ClipRRect( ExcludeSemantics(
child: ClipRRect(
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(5), top: Radius.circular(5),
), ),
@ -127,6 +143,7 @@ class SlideThumbnail extends ConsumerWidget {
), ),
), ),
), ),
),
// Footer: slide number, type label, action buttons // Footer: slide number, type label, action buttons
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
@ -255,6 +272,7 @@ class SlideThumbnail extends ConsumerWidget {
], ],
), ),
), ),
),
); );
} }
} }

View file

@ -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)

View file

@ -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

View file

@ -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

View file

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

View file

@ -1,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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,6 +81,32 @@ void main() {
expect(tester.takeException(), isNull); 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 {

View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/app.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/state/settings_provider.dart';
import 'package:ocideck/widgets/app_shell.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets('the interface text-scale setting scales the editor UI', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(AppShell)),
);
TextScaler scalerAtShell() =>
MediaQuery.textScalerOf(tester.element(find.byType(AppShell)));
expect(scalerAtShell().scale(10), 10);
await container.read(settingsProvider.notifier).setUiTextScale(1.5);
await tester.pump();
expect(scalerAtShell().scale(10), 15);
// The setting is clamped to WCAG's 200%.
await container.read(settingsProvider.notifier).setUiTextScale(5.0);
await tester.pump();
expect(scalerAtShell().scale(10), 20);
});
testWidgets('the slide canvas opts out of text scaling', (tester) async {
final slide = Slide.create(
SlideType.bullets,
).copyWith(title: 'Titel', bullets: const ['Punt een']);
await tester.pumpWidget(
MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: child!,
),
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(slide: slide),
),
),
),
);
await tester.pump();
// The slide is a fixed design surface: its text must render unscaled so
// the auto-fit measuring stays truthful.
final inSlide = MediaQuery.textScalerOf(
tester.element(find.text('Punt een')),
);
expect(inSlide.scale(10), 10);
});
}

View file

@ -107,6 +107,12 @@ class MultiWindowManager: NSObject {
let project = FlutterDartProject() let project = FlutterDartProject()
project.dartEntrypointArguments = ["multi_window", windowId, config.arguments] project.dartEntrypointArguments = ["multi_window", windowId, config.arguments]
let flutterViewController = FlutterViewController(project: project) let flutterViewController = FlutterViewController(project: project)
// By default Flutter only delivers hover (mouse-moved) events to the
// key window. The audience/beamer window is borderless and never
// becomes key (the keyboard must stay with the presenter), so without
// this it would never see hover at all and state set by a click
// (e.g. a chart highlight) would never be cleared again.
flutterViewController.mouseTrackingMode = .inActiveApp
window.contentViewController = flutterViewController window.contentViewController = flutterViewController
window.setFrame(NSRect(x: 0, y: 0, width: 800, height: 600), display: true) window.setFrame(NSRect(x: 0, y: 0, width: 800, height: 600), display: true)