diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a8989..596568d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### 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, 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 @@ -37,6 +49,26 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). live to the beamer, and persisted in a `.ink.json` sidecar. - **App theming** — customizable app appearance profiles, including a dark interface. +- **Paste a table into a table cell** — pasting a spreadsheet selection (Excel, + Numbers, LibreOffice Calc, Google Sheets), CSV (comma or semicolon), or a + markdown table into any cell of the table editor fills the whole grid from + that cell, growing rows and columns as needed. Works with `Ctrl/Cmd+V` and + `Shift+Insert` on macOS, Windows, and Linux; plain text still pastes into the + single cell. +- **Slide-type chooser previews** — the add-slide dialog shows a miniature + wireframe of each layout (in the spirit of other presentation tools) instead + of an abstract icon, and is fully keyboard-operable (`Tab`/`Enter`/`Esc`). +- **Accessibility (WCAG 2.1)**: + - An **interface text size** setting (100–200%, Settings → General → + Accessibility) that scales all editor text; slides themselves keep their + fixed design size. + - The panel divider is focusable and **keyboard-resizable** (arrow keys), with + a visible focus state, and presents itself to screen readers as a slider. + - **Screen-reader support**: slide thumbnails announce one concise label + ("Slide 3/12: title") instead of their full content; charts expose their + type, title, and underlying values as a text alternative; the presenter + announces each slide change. + - Improved contrast for hint/label text in the editors. - Project documentation: contributing guide, security policy, architecture and build notes, user guide, keyboard-shortcut reference, third-party notices, and 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. - Slide text auto-sizing now measures with the deck's own font, so text grows to use the available space more accurately instead of staying smaller than needed. -- The two bullet columns now scale **independently**, so a column with few items - is no longer shrunk down to the size of a crowded one beside it. +- The two bullet columns are measured **independently** and then rendered at a + **shared size** set by the busiest column, so the two columns always look + typographically related. Dense two-column slides spend less height on the + title, headings, and gaps so the list items themselves render larger. - Slide transitions in the presenter no longer flash a black frame (neighbour images are precached and `gaplessPlayback` is enabled) — important for recording. +- **Spider/radar charts** now use the available space: axis labels are measured + and placed snugly around the polygon (up to three lines, full remaining + width), so the diagram renders considerably larger and long labels stay + readable instead of being truncated. +- Bullet auto-fit now stops growing at ≈32 pt (on a 16:9 deck) — the upper end + of the 24–32 pt range presentation-design guidance recommends for body text — + so slides with few bullets no longer render body text that competes with the + title. +- After resizing the slide panel (dragging the divider or resizing the window), + the list scrolls the slide being edited back into view. + +### Fixed +- Hover on charts (tooltips, legend highlight) now works on a second screen: + macOS only delivered mouse-moved events to the key window, so the borderless + beamer window never saw them; the stuck hover state after the pointer left a + window is gone for the same reason. +- Bar-chart x-axis labels could run through each other: the spacing maths now + matches how bar groups are actually laid out, and the final label shrinks to + the real gap when it sits closer than a full step. +- A crash in the slide list ("A _RenderLayoutBuilder was mutated…") when its + keyed items were rebuilt during layout — both the resize-detection inside the + panel and the shell's width computation now avoid LayoutBuilders above the + reorderable list. ## [1.0.0] diff --git a/README.md b/README.md index 30107e5..c911274 100644 --- a/README.md +++ b/README.md @@ -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. - **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync. - **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar. -- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. +- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file). - **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets. -- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves. +- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves. +- **Accessibility** — WCAG 2.1-oriented: interface text scaling up to 200%, keyboard-operable panel divider and dialogs, screen-reader labels for slides and charts (charts read out their data), and slide-change announcements while presenting. - **Crash recovery** — automatic snapshots so work survives an unexpected exit. - **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch). - **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento. @@ -69,7 +70,9 @@ lib/ services/ # Markdown, export, file, image, caption, recovery, rasterizer state/ # Riverpod providers (deck, editor, settings, tabs, clipboard) widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter + l10n/ # AppLocalizations (8 languages) theme/ # App theming + utils/ # Small shared helpers (clipboard table parsing, URL launching) ``` State is managed with [Riverpod](https://riverpod.dev/). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9ef7b05..1ae8ccd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,11 +16,13 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md). lib/ models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation services/ # markdown, file, export, image, caption, description, + # image_dedup (md5 duplicates), image_reference (.md rewrites), # recovery, rasterizer, marp_html, annotation_codec state/ # Riverpod providers: deck, editor, settings, tabs, clipboard widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter l10n/ # AppLocalizations (8 languages) theme/ # app theming + utils/ # small shared helpers (clipboard table parsing, URL launching) ``` ## Data model @@ -90,10 +92,12 @@ hence the vendored multi-window fork below. ## Sidecars (separate layers) -To keep the `.md` pure Marp, three kinds of data live beside it (see +To keep the `.md` pure Marp, four kinds of data live beside it (see `FILE_FORMAT.md` §6): - **Captions** — `.ocideck_captions.json` (per image, in `images/`). +- **Descriptions/tags** — `.ocideck_descriptions.json` (searchable image + metadata, used by the library's search and the untagged filter). - **Annotations** — `.ink.json` (`services/annotation_codec.dart`). - **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 window-geometry API. The fork adds macOS `window_setFrame`, `window_coverScreen` (borderless fill of a chosen screen), and `window_close`, - exposed on `WindowController`. This is what makes the dual-screen audience - window possible. + exposed on `WindowController`. It also tracks the mouse for **non-key + windows** (matched by `macos/Runner/MainFlutterWindow.swift` for the main + window): macOS only delivers mouse-moved events to the key window by default, + and the borderless audience window deliberately never becomes key, so chart + tooltips and hover states would otherwise never appear on the beamer. This is + what makes the dual-screen audience window possible. - **`screen_retriever_macos`** (leanflutter) — a packaging fix for recent Xcode/CocoaPods. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 1f8eb01..826200a 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -382,6 +382,13 @@ Bijschriften worden op **twee** plaatsen bewaard: ``` Een lege caption verwijdert de sleutel; een leeg bestand wordt verwijderd. +Naast de captions bestaat er een tweede, gelijkvormige sidecar +`.ocideck_descriptions.json` voor **beschrijvingen/tags**: doorzoekbare +vrije tekst per afbeelding, gebruikt door het zoekveld en het +"zonder tags"-filter van de afbeeldingenbibliotheek (en samengevoegd bij het +opruimen van md5-duplicaten). Zelfde formaat en dezelfde leeg-opruimregels als +de captions-sidecar. + ### 6.2 Annotatielaag (`.ink.json`) Vrije-hand-annotaties (pen, markeerstift) die tijdens het presenteren worden diff --git a/docs/SHORTCUTS.md b/docs/SHORTCUTS.md index aa1402d..b7c0c0c 100644 --- a/docs/SHORTCUTS.md +++ b/docs/SHORTCUTS.md @@ -12,6 +12,11 @@ | `Ctrl/Cmd + Shift + Z` | Redo | | `Ctrl + Y` | Redo (alternative) | | `Ctrl/Cmd + H` | Find & replace | +| `Ctrl/Cmd + V` (in a table cell) | Paste a spreadsheet/CSV/markdown selection as a table (also `Shift + Insert`) | +| `Tab` to the panel divider, then `←` / `→` | Resize the slide panel | + +In the **add-slide dialog**, `Tab` moves between the type cards, `Enter` picks +the focused one, and `Esc` cancels. ## Fullscreen presenter diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 43939f3..6a5a8bd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -21,8 +21,10 @@ Marp tools. Add a slide and pick a type: **title**, **section** divider, **bullets**, **two bullet columns**, **bullets + image**, **two images**, **large image**, **video**, **audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or -spider/radar), and **free Markdown**. Each type has a dedicated editor on the left -and a live preview on the right. +spider/radar), and **free Markdown**. Each card in the chooser shows a miniature +wireframe of the layout, and the dialog works entirely with the keyboard +(`Tab`/`Enter` to choose, `Esc` to cancel). Each type has a dedicated editor on +the left and a live preview on the right. Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``, `[links](…)`). Free-Markdown slides also render fenced code with syntax @@ -38,6 +40,16 @@ green on black for a classic CRT-terminal look. The text is sized to fill the panel — larger when there's room, smaller for long fragments. Stored as a fenced code block in the Markdown. +### Tables + +The first row is the header. Press `Enter` inside a cell for a new line within +that cell. To bring in existing data, **paste a table into any cell** with +`Ctrl/Cmd+V` (or `Shift+Insert`): a selection copied from a spreadsheet (Excel, +Numbers, LibreOffice Calc, Google Sheets), CSV text (comma- or +semicolon-separated), or a markdown table fills the grid from that cell onward, +adding rows and columns as needed. Ordinary text — even a sentence with a comma +in it — still pastes into just the one cell. + ### Charts Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then @@ -56,10 +68,34 @@ row/column. Each series and (for pie/radar) each label can be given its own colo - **Reading values** — hovering a legend entry highlights its series (or pie slice). On a line chart the tooltip belongs to the dot under the cursor and shows every overlapping dot at once; on a spider/radar chart hovering a point - shows its value in a tooltip too. + shows its value in a tooltip too. For screen readers every chart also carries + a text alternative with its type, title, and the values per series. - Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the HTML export. +## Image library + +Image fields open a library that shows every image found in the deck's +directories, with a grid and a coverflow view, search, and a preview pane. Per +image you can store a **caption** (source/credit line, shown on the slide) and a +searchable **description** — in practice your tags. The search box matches file +names and descriptions. + +- **Filter untagged images** — the label toggle next to the search box shows + only images that have no description/tags yet, so you can see at a glance + which ones still need attention. +- **Clean up duplicates** — the button in the footer finds byte-identical + images by md5 checksum. Per group one file is kept (preferring the one used + in slides, then the oldest), tags and captions of the copies are merged onto + it, slides that referenced a copy are repointed to the kept file, and the + copies are deleted — after a confirmation that lists exactly what will + happen. References are updated in the open decks *and* in `.md` + presentations found on disk in the search directories, so presentations + that are not currently open keep working too. +- **Deleting an image** warns when it is still in use — in open decks (per + slide) and in presentations on disk that are not currently open (per file, + marked "not open"). + ## Per-slide options Below each editor you can set: @@ -112,6 +148,21 @@ Export to: - **Portable package** (`.ocideck`) — a single zip with the Markdown and all 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 100–200% + text scaling for the whole editing environment, on top of what the operating + system asks for. Slides keep their fixed 16:9 design size, so what you see is + still exactly what you present and export. +- **Keyboard** — the panel divider between the slide list and the editor can be + focused with `Tab` and resized with `←`/`→`; the add-slide dialog is fully + keyboard-operable. +- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12: + title", including the skipped state), charts read out their data as a text + alternative, and the fullscreen presenter announces every slide change. + ## Theming and language - **Style profiles** control deck colours (including the source-code background, diff --git a/lib/app.dart b/lib/app.dart index 33c3564..bbc95f6 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,11 +17,28 @@ class OciDeckApp extends ConsumerWidget { final appearance = ref.watch( settingsProvider.select((s) => s.appAppearanceProfile), ); + final uiTextScale = ref.watch( + settingsProvider.select((s) => s.uiTextScale), + ); AppLocalizations.setActiveLanguageCode(languageCode); return MaterialApp( title: 'OciDeck', theme: AppTheme.fromProfile(appearance), debugShowCheckedModeBanner: false, + // Interface text scaling (WCAG 1.4.4): the user's setting multiplies + // whatever the OS already asks for. Slides themselves opt out — they + // are a fixed design canvas (see SlidePreviewWidget). + builder: (context, child) { + final media = MediaQuery.of(context); + return MediaQuery( + data: media.copyWith( + textScaler: TextScaler.linear( + (media.textScaler.scale(1.0) * uiTextScale).clamp(1.0, 2.0), + ), + ), + child: child!, + ); + }, locale: AppLocalizations.materialLocaleFor(languageCode), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: const [ diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5dcc5aa..3e68635 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2326,6 +2326,14 @@ const _dutchSourceStrings = { const _dutchSourceStringAdditions = { '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', 'Checklist': 'Task checklist', 'Voortgangsgrafiek tonen': 'Show progress chart', @@ -2432,8 +2440,34 @@ const _dutchSourceStringAdditions = { 'gerenderd.': 'rendered.', 'renderen…': 'rendering…', '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': { + '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', 'Checklist': 'Lista di controllo', 'Voortgangsgrafiek tonen': 'Mostra grafico di avanzamento', @@ -2694,8 +2728,35 @@ const _dutchSourceStringAdditions = { 'voorbereiden…': 'preparazione…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ 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': { + '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', 'Checklist': 'Checkliste', 'Voortgangsgrafiek tonen': 'Fortschrittsdiagramm anzeigen', @@ -2956,8 +3017,34 @@ const _dutchSourceStringAdditions = { 'voorbereiden…': 'vorbereiten…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ 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': { + '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', 'Checklist': 'Liste de contrôle', 'Voortgangsgrafiek tonen': 'Afficher le graphique de progression', @@ -3218,8 +3305,38 @@ const _dutchSourceStringAdditions = { 'voorbereiden…': 'préparation…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ 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': { + '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', 'Checklist': 'Lista de verificación', 'Voortgangsgrafiek tonen': 'Mostrar gráfico de progreso', @@ -3481,8 +3598,37 @@ const _dutchSourceStringAdditions = { 'voorbereiden…': 'preparando…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ 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': { + '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', 'Checklist': 'Kontrôlelist', 'Voortgangsgrafiek tonen': 'Fuortgongsgrafyk toane', @@ -3740,8 +3886,35 @@ const _dutchSourceStringAdditions = { 'voorbereiden…': 'tariede…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ 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': { + '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á', 'Checklist': 'Lista di kontrol', 'Voortgangsgrafiek tonen': 'Mustra gráfiko di progreso', @@ -3999,5 +4172,24 @@ const _dutchSourceStringAdditions = { 'voorbereiden…': 'preparando…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': '↑↓←→ 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.', }, }; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 04e4c27..e491ce4 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -364,6 +364,11 @@ class AppSettings { final String selectedAppAppearanceProfileName; final List recentFiles; + /// Scale factor for all interface text (1.0–2.0), on top of the system + /// text scaling. The slide canvas itself is never scaled: slides are a + /// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%. + final double uiTextScale; + const AppSettings({ this.languageCode = 'nl', this.homeDirectory, @@ -373,6 +378,7 @@ class AppSettings { this.appAppearanceProfiles = AppAppearanceProfile.builtIns, this.selectedAppAppearanceProfileName = 'Basic', this.recentFiles = const [], + this.uiTextScale = 1.0, }); ThemeProfile get themeProfile { @@ -424,6 +430,7 @@ class AppSettings { List? appAppearanceProfiles, String? selectedAppAppearanceProfileName, List? recentFiles, + double? uiTextScale, bool clearHomeDirectory = false, bool clearExportDirectory = false, }) { @@ -457,6 +464,7 @@ class AppSettings { selectedAppAppearanceProfileName ?? this.selectedAppAppearanceProfileName, recentFiles: recentFiles ?? this.recentFiles, + uiTextScale: uiTextScale ?? this.uiTextScale, ); } } diff --git a/lib/services/image_dedup_service.dart b/lib/services/image_dedup_service.dart new file mode 100644 index 0000000..2af529d --- /dev/null +++ b/lib/services/image_dedup_service.dart @@ -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>> findDuplicateGroups( + Iterable imagePaths, + ) async { + // Stap 1: op bestandsgrootte groeperen — verschillend groot is nooit gelijk. + final bySize = >{}; + 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 = >[]; + for (final candidates in bySize.values) { + if (candidates.length < 2) continue; + final byHash = >{}; + 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 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 values, {String separator = ' · '}) { + final merged = []; + 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(), +); diff --git a/lib/services/image_reference_service.dart b/lib/services/image_reference_service.dart new file mode 100644 index 0000000..485cabb --- /dev/null +++ b/lib/services/image_reference_service.dart @@ -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> findDeckFiles(Iterable searchDirs) async { + final found = {}; + + Future walk(Directory dir, int depth) async { + List 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> countReferences( + Iterable deckFiles, + Iterable targets, + ) async { + final wanted = {for (final t in targets) p.normalize(t)}; + final counts = {}; + 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> referencingFiles( + Iterable deckFiles, + String target, + ) async { + final wanted = p.normalize(target); + final result = {}; + 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 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(), +); diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index c8d4807..1e45fc6 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -54,9 +54,17 @@ class SettingsNotifier extends StateNotifier { ? selectedAppearance : 'Basic', recentFiles: prefs.getStringList('recentFiles') ?? [], + uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0), ); } + Future 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 addRecentFile(String path) async { final updated = [ path, diff --git a/lib/utils/table_clipboard.dart b/lib/utils/table_clipboard.dart new file mode 100644 index 0000000..4938493 --- /dev/null +++ b/lib/utils/table_clipboard.dart @@ -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>? 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>? 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>? _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 = >[]; + 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> _splitDelimited(String text, String delimiter) { + final rows = >[]; + var row = []; + 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 = []; + } 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>? _trim(List> rows) { + final kept = List>.from(rows); + while (kept.isNotEmpty && kept.last.every((c) => c.trim().isEmpty)) { + kept.removeLast(); + } + if (kept.isEmpty) return null; + final cols = kept.fold(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] : ''], + ]; +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 01df09c..6ea141f 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -138,6 +138,47 @@ List _imageUsages(WidgetRef ref, String absolutePath) { 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 _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 _slidesForPresentationOrExport(Deck deck) { // Drop skipped slides and slides whose TLP classification is stricter than // the level chosen for this presentation/export. @@ -926,9 +967,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - '$count ${l10n.d('checklist-items uitgevinkt.')}', - ), + content: Text('$count ${l10n.d('checklist-items uitgevinkt.')}'), ), ); } @@ -947,6 +986,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { captionService: ref.read(captionServiceProvider), descriptionService: ref.read(descriptionServiceProvider), usageOf: (absolutePath) => _imageUsages(ref, absolutePath), + onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to), + openDeckFiles: [ + for (final tab in ref.read(tabsProvider).tabs) + ?tab.deckNotifier.currentState.filePath, + ], ); if (result == null) return; @@ -1444,36 +1488,43 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { }); } - return LayoutBuilder( - builder: (context, constraints) { - final maxRailWidth = (constraints.maxWidth - _minEditorWidth) - .clamp(_minSlideRailWidth, constraints.maxWidth) - .toDouble(); - final railWidth = _slideRailWidth - .clamp(_minSlideRailWidth, maxRailWidth) - .toDouble(); - if (railWidth != _slideRailWidth) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _slideRailWidth = railWidth); - }); - } + // The available width comes from MediaQuery, NOT a + // LayoutBuilder: a LayoutBuilder rebuilds this subtree during + // the layout phase, and when the slide list's keyed + // 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(); + final railWidth = _slideRailWidth + .clamp(_minSlideRailWidth, maxRailWidth) + .toDouble(); + if (railWidth != _slideRailWidth) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _slideRailWidth = railWidth); + }); + } - return Row( - children: [ - SizedBox(width: railWidth, child: const SlideListPanel()), - _ResizableDivider( - onDrag: (delta) { - setState(() { - _slideRailWidth = (_slideRailWidth + delta) - .clamp(_minSlideRailWidth, maxRailWidth) - .toDouble(); - }); - }, - ), - const Expanded(child: EditorPanel()), - ], - ); - }, + return Row( + children: [ + SizedBox( + width: railWidth, + child: SlideListPanel(railWidth: railWidth), + ), + _ResizableDivider( + onDrag: (delta) { + setState(() { + _slideRailWidth = (_slideRailWidth + delta) + .clamp(_minSlideRailWidth, maxRailWidth) + .toDouble(); + }); + }, + ), + const Expanded(child: EditorPanel()), + ], ); }, ), @@ -1721,36 +1772,67 @@ class _ResizableDivider extends StatefulWidget { } class _ResizableDividerState extends State<_ResizableDivider> { + static const double _keyboardStep = 24; + bool _hovered = false; bool _dragging = false; + bool _focused = false; + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + widget.onDrag(-_keyboardStep); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + widget.onDrag(_keyboardStep); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } @override Widget build(BuildContext context) { final l10n = context.l10n; - final active = _hovered || _dragging; - return MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => setState(() => _dragging = true), - onHorizontalDragEnd: (_) => setState(() => _dragging = false), - onHorizontalDragCancel: () => setState(() => _dragging = false), - onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx), - child: Tooltip( - message: l10n.d( - 'Sleep om de slide-preview breder of smaller te maken', - ), - child: SizedBox( - width: 9, - child: Center( - child: AnimatedContainer( - duration: const Duration(milliseconds: 90), - width: active ? 3 : 1, - color: active - ? Theme.of(context).colorScheme.secondary - : Theme.of(context).colorScheme.outlineVariant, + final active = _hovered || _dragging || _focused; + // Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys + // move it, and focus is shown with the same highlight as hovering + // (WCAG 2.4.7). Screen readers see it as an adjustable element. + return Focus( + onKeyEvent: _onKeyEvent, + onFocusChange: (focused) => setState(() => _focused = focused), + child: Semantics( + slider: true, + label: l10n.d('Breedte van het slidepaneel'), + hint: l10n.d('Pijltjestoetsen passen de breedte aan'), + onIncrease: () => widget.onDrag(_keyboardStep), + onDecrease: () => widget.onDrag(-_keyboardStep), + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragEnd: (_) => setState(() => _dragging = false), + onHorizontalDragCancel: () => setState(() => _dragging = false), + onHorizontalDragUpdate: (details) => + widget.onDrag(details.delta.dx), + child: Tooltip( + message: l10n.d( + 'Sleep om de slide-preview breder of smaller te maken', + ), + child: SizedBox( + width: 9, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 90), + width: active ? 3 : 1, + color: active + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.outlineVariant, + ), + ), ), ), ), diff --git a/lib/widgets/dialogs/add_slide_dialog.dart b/lib/widgets/dialogs/add_slide_dialog.dart index 2205867..876fa46 100644 --- a/lib/widgets/dialogs/add_slide_dialog.dart +++ b/lib/widgets/dialogs/add_slide_dialog.dart @@ -15,23 +15,19 @@ class AddSlideDialog extends StatelessWidget { } static const _types = [ - (SlideType.title, Icons.title, 'Titelpagina'), - (SlideType.section, Icons.bookmark_outline, 'Tussentitel'), - (SlideType.bullets, Icons.format_list_bulleted, 'Alleen Bullets'), - (SlideType.twoBullets, Icons.view_column_outlined, 'Twee Bulletkolommen'), - ( - SlideType.bulletsImage, - Icons.view_agenda_outlined, - 'Bullets + Afbeelding', - ), - (SlideType.twoImages, Icons.auto_stories_outlined, 'Twee Afbeeldingen'), - (SlideType.image, Icons.image_outlined, 'Grote Afbeelding'), - (SlideType.video, Icons.movie_outlined, 'Video'), - (SlideType.quote, Icons.format_quote_outlined, 'Quote'), - (SlideType.table, Icons.table_chart_outlined, 'Tabel'), - (SlideType.chart, Icons.bar_chart, 'Grafiek'), - (SlideType.code, Icons.terminal, 'Broncode'), - (SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'), + (SlideType.title, 'Titelpagina'), + (SlideType.section, 'Tussentitel'), + (SlideType.bullets, 'Alleen Bullets'), + (SlideType.twoBullets, 'Twee Bulletkolommen'), + (SlideType.bulletsImage, 'Bullets + Afbeelding'), + (SlideType.twoImages, 'Twee Afbeeldingen'), + (SlideType.image, 'Grote Afbeelding'), + (SlideType.video, 'Video'), + (SlideType.quote, 'Quote'), + (SlideType.table, 'Tabel'), + (SlideType.chart, 'Grafiek'), + (SlideType.code, 'Broncode'), + (SlideType.freeMarkdown, 'Vrije Markdown'), ]; @override @@ -42,73 +38,272 @@ class AddSlideDialog extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.escape): () => Navigator.pop(context), }, - child: Focus( - autofocus: true, - child: AlertDialog( - title: Text(l10n.d('Slide type kiezen')), - content: SizedBox( - width: 400, + child: AlertDialog( + title: Text(l10n.d('Slide type kiezen')), + content: SizedBox( + width: 440, + // Reading-order tabbing through the cards; the first one takes + // focus so the dialog is fully keyboard-operable right away. + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), child: Wrap( spacing: 10, runSpacing: 10, - children: _types.map((entry) { - final (type, icon, label) = entry; - return _TypeCard( - icon: icon, - label: l10n.d(label), - onTap: () => Navigator.pop(context, type), - ); - }).toList(), + children: [ + for (var i = 0; i < _types.length; i++) + _TypeCard( + type: _types[i].$1, + label: l10n.d(_types[i].$2), + autofocus: i == 0, + onTap: () => Navigator.pop(context, _types[i].$1), + ), + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.t('cancel')), - ), - ], ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.t('cancel')), + ), + ], ), ); } } class _TypeCard extends StatelessWidget { - final IconData icon; + final SlideType type; final String label; final VoidCallback onTap; + final bool autofocus; const _TypeCard({ - required this.icon, + required this.type, required this.label, required this.onTap, + this.autofocus = false, }); @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - width: 110, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFCBD5E1)), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 28, color: AppTheme.navy), - const SizedBox(height: 8), - Text( - label, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 11), - ), - ], + return Semantics( + button: true, + child: InkWell( + onTap: onTap, + autofocus: autofocus, + borderRadius: BorderRadius.circular(8), + focusColor: AppTheme.accent.withValues(alpha: 0.14), + hoverColor: AppTheme.accent.withValues(alpha: 0.06), + child: Container( + width: 100, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFCBD5E1)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // A stylised wireframe of the layout, so the card shows what + // the slide will look like instead of an abstract icon. + ExcludeSemantics( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AspectRatio( + aspectRatio: 16 / 9, + child: CustomPaint( + painter: SlideTypePreviewPainter(type: type), + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + textAlign: TextAlign.center, + maxLines: 2, + style: const TextStyle(fontSize: 11, height: 1.15), + ), + ], + ), ), ), ); } } + +/// Paints a miniature 16:9 wireframe of a slide layout, in the spirit of the +/// layout pickers in other presentation tools: title bars, text lines, image +/// placeholders. All geometry lives on a 160×90 design canvas and is scaled +/// to whatever size the card provides. +@visibleForTesting +class SlideTypePreviewPainter extends CustomPainter { + final SlideType type; + + /// Wireframe palette: dark bars for titles, soft bars for body text. + static const _canvas = Color(0xFFF8FAFC); + static const _ink = Color(0xFF334155); + static const _soft = Color(0xFFB6C2D2); + static const _fill = Color(0xFFE2E8F0); + static const _accent = AppTheme.accent; + + const SlideTypePreviewPainter({required this.type}); + + @override + void paint(Canvas canvas, Size size) { + final u = size.width / 160; + canvas.scale(u); + canvas.drawRect(const Rect.fromLTWH(0, 0, 160, 90), _paint(_canvas)); + + switch (type) { + case SlideType.title: + _bar(canvas, 30, 34, 100, 12, _ink); + _bar(canvas, 45, 53, 70, 7, _accent); + case SlideType.section: + _bar(canvas, 16, 36, 5, 24, _accent); + _bar(canvas, 30, 38, 86, 11, _ink); + _bar(canvas, 30, 54, 52, 6, _soft); + case SlideType.bullets: + _bar(canvas, 14, 12, 84, 9, _ink); + _bullets(canvas, 14, 34, 110, 4); + case SlideType.twoBullets: + _bar(canvas, 14, 12, 84, 9, _ink); + _bullets(canvas, 14, 32, 56, 3); + _bullets(canvas, 90, 32, 56, 3); + case SlideType.bulletsImage: + _bar(canvas, 14, 12, 66, 9, _ink); + _bullets(canvas, 14, 32, 60, 3); + _imageBox(canvas, 90, 26, 56, 50); + case SlideType.twoImages: + _imageBox(canvas, 12, 16, 64, 46); + _imageBox(canvas, 84, 16, 64, 46); + _bar(canvas, 20, 68, 48, 5, _soft); + _bar(canvas, 92, 68, 48, 5, _soft); + case SlideType.image: + _imageBox(canvas, 10, 10, 140, 70); + case SlideType.video: + _imageBox(canvas, 10, 10, 140, 70, pictogram: false); + canvas.drawCircle(const Offset(80, 45), 14, _paint(Colors.white)); + final play = Path() + ..moveTo(75, 37) + ..lineTo(89, 45) + ..lineTo(75, 53) + ..close(); + canvas.drawPath(play, _paint(_ink)); + case SlideType.quote: + _quoteMark(canvas, 16, 16); + _bar(canvas, 42, 30, 96, 7, _ink); + _bar(canvas, 42, 43, 78, 7, _ink); + _bar(canvas, 42, 60, 42, 5, _accent); + case SlideType.table: + _bar(canvas, 14, 16, 132, 14, _soft, radius: 2); + final line = _paint(_ink.withValues(alpha: 0.45))..strokeWidth = 1.5; + for (var r = 1; r <= 4; r++) { + canvas.drawLine( + Offset(14, 16 + r * 14), + Offset(146, 16 + r * 14), + line, + ); + } + for (var c = 0; c <= 3; c++) { + canvas.drawLine( + Offset(14 + c * 44, 16), + Offset(14 + c * 44, 72), + line, + ); + } + case SlideType.chart: + final axis = _paint(_soft)..strokeWidth = 2; + canvas.drawLine(const Offset(20, 14), const Offset(20, 74), axis); + canvas.drawLine(const Offset(20, 74), const Offset(148, 74), axis); + _bar(canvas, 34, 50, 18, 24, _soft, radius: 2); + _bar(canvas, 64, 36, 18, 38, _accent, radius: 2); + _bar(canvas, 94, 44, 18, 30, _soft, radius: 2); + _bar(canvas, 124, 24, 18, 50, _accent, radius: 2); + case SlideType.code: + _bar(canvas, 10, 10, 140, 70, const Color(0xFF1E293B), radius: 4); + _bar(canvas, 20, 22, 44, 6, const Color(0xFF7DD3A7), radius: 3); + _bar(canvas, 30, 34, 64, 6, const Color(0xFF93B8F8), radius: 3); + _bar(canvas, 30, 46, 50, 6, const Color(0xFFE2C08D), radius: 3); + _bar(canvas, 20, 58, 32, 6, const Color(0xFF7DD3A7), radius: 3); + case SlideType.freeMarkdown: + _bar(canvas, 14, 12, 10, 9, _accent, radius: 2); + _bar(canvas, 28, 12, 62, 9, _ink); + _bar(canvas, 14, 32, 120, 6, _soft); + _bar(canvas, 14, 44, 132, 6, _soft); + _bar(canvas, 14, 56, 92, 6, _soft); + _bar(canvas, 14, 68, 110, 6, _soft); + } + } + + void _bar( + Canvas canvas, + double x, + double y, + double w, + double h, + Color color, { + double? radius, + }) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, y, w, h), + Radius.circular(radius ?? h / 2), + ), + _paint(color), + ); + } + + /// A column of bullet points: accent dot plus a soft text line. + void _bullets(Canvas canvas, double x, double y, double w, int count) { + for (var i = 0; i < count; i++) { + final dy = y + i * 13.0; + canvas.drawCircle(Offset(x + 3, dy + 3), 3, _paint(_accent)); + _bar(canvas, x + 11, dy, w * (i.isEven ? 1.0 : 0.74), 6, _soft); + } + } + + /// Image placeholder: filled box with a sun and mountains pictogram. + void _imageBox( + Canvas canvas, + double x, + double y, + double w, + double h, { + bool pictogram = true, + }) { + _bar(canvas, x, y, w, h, _fill, radius: 4); + if (!pictogram) return; + final dark = _paint(_soft); + canvas.drawCircle(Offset(x + w * 0.28, y + h * 0.30), h * 0.11, dark); + final hills = Path() + ..moveTo(x + w * 0.08, y + h * 0.88) + ..lineTo(x + w * 0.38, y + h * 0.45) + ..lineTo(x + w * 0.58, y + h * 0.72) + ..lineTo(x + w * 0.74, y + h * 0.52) + ..lineTo(x + w * 0.94, y + h * 0.88) + ..close(); + canvas.drawPath(hills, dark); + } + + /// A stylised double quotation mark. + void _quoteMark(Canvas canvas, double x, double y) { + final paint = _paint(_accent); + for (final dx in [0.0, 11.0]) { + canvas.drawCircle(Offset(x + 4 + dx, y + 8), 4, paint); + final tail = Path() + ..moveTo(x + dx, y + 8) + ..quadraticBezierTo(x + dx, y + 17, x + 7 + dx, y + 18) + ..lineTo(x + 7 + dx, y + 14) + ..quadraticBezierTo(x + 4 + dx, y + 13, x + 4 + dx, y + 8) + ..close(); + canvas.drawPath(tail, paint); + } + } + + @override + bool shouldRepaint(SlideTypePreviewPainter old) => old.type != type; +} + +Paint _paint(Color color) => Paint()..color = color; diff --git a/lib/widgets/dialogs/image_carousel_picker.dart b/lib/widgets/dialogs/image_carousel_picker.dart index 243f079..44604f7 100644 --- a/lib/widgets/dialogs/image_carousel_picker.dart +++ b/lib/widgets/dialogs/image_carousel_picker.dart @@ -5,6 +5,8 @@ import 'package:file_picker/file_picker.dart'; import 'package:path/path.dart' as p; import '../../services/caption_service.dart'; import '../../services/description_service.dart'; +import '../../services/image_dedup_service.dart'; +import '../../services/image_reference_service.dart'; import '../../services/image_service.dart'; import '../../l10n/app_localizations.dart'; @@ -19,6 +21,12 @@ class ImagePickResult { /// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden. typedef ImageUsageLookup = List 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 Function(String fromAbsolute, String toAbsolute); + /// Manier waarop de afbeeldingen worden getoond. Tussen beide kan in de /// header gewisseld worden. enum _ViewMode { @@ -38,6 +46,12 @@ class ImageCarouselPicker extends StatefulWidget { final CaptionService captionService; final DescriptionService descriptionService; final ImageUsageLookup? usageOf; + final ImageUsageReplace? onReplaceUsages; + + /// Bestandspaden van de presentaties die nu in tabs geopend zijn. Die zijn + /// al gedekt door [usageOf]; bij het scannen van decks op schijf worden ze + /// overgeslagen om dubbeltellingen te voorkomen. + final List openDeckFiles; const ImageCarouselPicker({ super.key, @@ -46,6 +60,8 @@ class ImageCarouselPicker extends StatefulWidget { required this.descriptionService, this.initialPath, this.usageOf, + this.onReplaceUsages, + this.openDeckFiles = const [], }); static Future show( @@ -55,6 +71,8 @@ class ImageCarouselPicker extends StatefulWidget { CaptionService? captionService, DescriptionService? descriptionService, ImageUsageLookup? usageOf, + ImageUsageReplace? onReplaceUsages, + List openDeckFiles = const [], }) { return showDialog( context: context, @@ -65,6 +83,8 @@ class ImageCarouselPicker extends StatefulWidget { captionService: captionService ?? CaptionService(), descriptionService: descriptionService ?? DescriptionService(), usageOf: usageOf, + onReplaceUsages: onReplaceUsages, + openDeckFiles: openDeckFiles, ), ); } @@ -101,6 +121,8 @@ class _ImageCarouselPickerState extends State { String? _descEditing; // path the description field currently edits bool _loading = true; bool _justCopied = false; // korte feedback na kopiëren naar klembord + bool _untaggedOnly = false; // toon alleen afbeeldingen zonder tags + bool _deduping = false; // duplicaten-opruimactie bezig int _hoveredIndex = -1; _ViewMode _viewMode = _ViewMode.grid; @@ -187,9 +209,15 @@ class _ImageCarouselPickerState extends State { /// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van /// [_images] (nieuwste eerst) behouden. void _applyFilter() { + final base = _untaggedOnly + ? [ + for (final path in _images) + if ((_descriptions[path] ?? '').trim().isEmpty) path, + ] + : _images; final q = _query.trim().toLowerCase(); if (q.isEmpty) { - _filtered = _images; + _filtered = base; return; } final terms = q @@ -198,9 +226,9 @@ class _ImageCarouselPickerState extends State { .toList(growable: false); final hits = <({String path, int score, int order})>[]; - for (var i = 0; i < _images.length; i++) { - final score = _relevance(_images[i], terms); - if (score > 0) hits.add((path: _images[i], score: score, order: i)); + for (var i = 0; i < base.length; i++) { + final score = _relevance(base[i], terms); + if (score > 0) hits.add((path: base[i], score: score, order: i)); } hits.sort((a, b) { final byScore = b.score.compareTo(a.score); @@ -257,6 +285,277 @@ class _ImageCarouselPickerState extends State { ); } + /// 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 _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 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 = {}; + for (final entry in plan) { + // Keeper eerst, zodat zijn eigen tekst vooraan blijft staan. + final ordered = [entry.keeper, ...entry.remove]; + final captions = [ + 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 _showDedupeDialog( + List<({String keeper, List remove})> plan, + ) { + final removeCount = plan.fold(0, (sum, e) => sum + e.remove.length); + return showDialog( + 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 _confirm() async { if (_selected == null) return; await _persistDescription(); @@ -386,11 +685,38 @@ class _ImageCarouselPickerState extends State { } } + /// Filter de deckbestanden op schijf die niet in een tab geopend zijn + /// (open decks zijn al gedekt door [ImageCarouselPicker.usageOf]). + List _withoutOpenDecks(List 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 _deleteSelected() async { final path = _selected; if (path == null) return; - final usages = widget.usageOf?.call(path) ?? const []; - final confirmed = await _showDeleteDialog(path, usages); + final usages = [...widget.usageOf?.call(path) ?? const []]; + 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; try { @@ -418,7 +744,11 @@ class _ImageCarouselPickerState extends State { _loadDescriptionForSelection(); } - Future _showDeleteDialog(String path, List usages) { + Future _showDeleteDialog( + String path, + List usages, + int slideCount, + ) { return showDialog( context: context, builder: (ctx) { @@ -470,7 +800,7 @@ class _ImageCarouselPickerState extends State { ) else ...[ Text( - '${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} ${usages.length} ${usages.length == 1 ? l10n.d("slide") : l10n.t("slides")}:', + '${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} $slideCount ${slideCount == 1 ? l10n.d("slide") : l10n.t("slides")}:', style: const TextStyle( color: Color(0xFFF0B429), fontSize: 13, @@ -664,7 +994,7 @@ class _ImageCarouselPickerState extends State { borderRadius: BorderRadius.circular(20), ), child: Text( - _query.trim().isEmpty + _query.trim().isEmpty && !_untaggedOnly ? '${_images.length}' : '${_filtered.length} / ${_images.length}', style: const TextStyle( @@ -677,6 +1007,8 @@ class _ImageCarouselPickerState extends State { const SizedBox(width: 16), Expanded(child: _buildSearchField()), const SizedBox(width: 12), + _buildUntaggedToggle(), + const SizedBox(width: 12), _buildViewToggle(), const SizedBox(width: 12), IconButton( @@ -739,6 +1071,38 @@ class _ImageCarouselPickerState extends State { ); } + /// Aan/uit-knop voor het filter "alleen afbeeldingen zonder tags". Handig om + /// te zien welke afbeeldingen nog een beschrijving/tags nodig hebben. + Widget _buildUntaggedToggle() { + final l10n = context.l10n; + return Tooltip( + message: l10n.d('Alleen afbeeldingen zonder tags tonen'), + child: GestureDetector( + onTap: _toggleUntaggedOnly, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117), + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: _untaggedOnly + ? const Color(0xFF3B82F6) + : const Color(0xFF30363D), + ), + ), + child: Icon( + Icons.label_off_outlined, + size: 17, + color: _untaggedOnly + ? const Color(0xFF60A5FA) + : const Color(0xFF6E7681), + ), + ), + ), + ); + } + /// Segmented control om tussen raster- en coverflow-weergave te wisselen. Widget _buildViewToggle() { final l10n = context.l10n; @@ -790,6 +1154,37 @@ class _ImageCarouselPickerState extends State { /// Lege staat — gedeeld door raster- en coverflow-weergave. Widget _buildEmptyState() { final l10n = context.l10n; + if (_untaggedOnly && _query.trim().isEmpty) { + return Expanded( + flex: 13, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.verified_outlined, + size: 56, + color: Color(0xFF22C55E), + ), + const SizedBox(height: 20), + Text( + l10n.d('Alle afbeeldingen hebben tags.'), + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + l10n.d('Zet het filter uit om alles weer te zien.'), + style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13), + ), + ], + ), + ), + ); + } final filtering = _query.trim().isNotEmpty; return Expanded( flex: 13, @@ -1547,6 +1942,35 @@ class _ImageCarouselPickerState extends State { ), ), const SizedBox(width: 8), + // Duplicaten opruimen (md5) + Tooltip( + message: l10n.d( + 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën', + ), + child: OutlinedButton.icon( + onPressed: _deduping || _images.length < 2 ? null : _dedupe, + icon: _deduping + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF8B949E), + ), + ) + : const Icon(Icons.layers_clear_outlined, size: 16), + label: Text(l10n.d('Duplicaten opruimen')), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF8B949E), + side: const BorderSide(color: Color(0xFF30363D)), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + ), + ), + ), + const SizedBox(width: 8), // Hint Text( l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'), diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index b087194..c0ac818 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -432,6 +432,18 @@ class _SettingsDialogState extends ConsumerState { ), ), 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')), Row( children: [ @@ -490,6 +502,42 @@ class _SettingsDialogState extends ConsumerState { ); } + /// 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( + 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() { final l10n = context.l10n; final profiles = ref.watch(settingsProvider).appAppearanceProfiles; diff --git a/lib/widgets/editors/_editor_field.dart b/lib/widgets/editors/_editor_field.dart index dd00828..87177f8 100644 --- a/lib/widgets/editors/_editor_field.dart +++ b/lib/widgets/editors/_editor_field.dart @@ -117,7 +117,7 @@ class ImageZoomControl extends StatelessWidget { child: const Icon( Icons.zoom_out, size: 16, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), ), Expanded( @@ -141,7 +141,7 @@ class ImageZoomControl extends StatelessWidget { child: const Icon( Icons.zoom_in, size: 16, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), ), const SizedBox(width: 8), @@ -153,7 +153,7 @@ class ImageZoomControl extends StatelessWidget { fontSize: 12, color: zoomed ? const Color(0xFF2563EB) - : const Color(0xFF94A3B8), + : const Color(0xFF64748B), fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal, ), textAlign: TextAlign.right, @@ -167,7 +167,7 @@ class ImageZoomControl extends StatelessWidget { onPressed: zoomed ? () => onChanged(100) : null, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), - color: const Color(0xFF94A3B8), + color: const Color(0xFF64748B), ), ), ], @@ -176,7 +176,7 @@ class ImageZoomControl extends StatelessWidget { padding: const EdgeInsets.only(left: 8, bottom: 4), child: Text( _label(context), - style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 10, color: Color(0xFF64748B)), ), ), ], @@ -226,10 +226,59 @@ class ImagePickerBar extends ConsumerWidget { captionService: captions, descriptionService: ref.read(descriptionServiceProvider), usageOf: (absolutePath) => _imageUsages(ref, absolutePath), + onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to), + openDeckFiles: [ + for (final tab in ref.read(tabsProvider).tabs) + ?tab.deckNotifier.currentState.filePath, + ], ); if (result != null) onPicked(result.path, result.caption); } + /// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar + /// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5- + /// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen. + Future _replaceImageUsages( + WidgetRef ref, + String fromAbsolute, + String toAbsolute, + ) async { + final target = p.normalize(fromAbsolute); + for (final tab in ref.read(tabsProvider).tabs) { + final notifier = tab.deckNotifier; + final deck = notifier.currentState.deck; + if (deck == null) continue; + final projectPath = deck.projectPath ?? ''; + + String resolve(String candidate) => p.normalize( + p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate), + ); + // Blijf relatief opslaan als de slide dat al deed en het nieuwe pad + // binnen het project ligt; anders absoluut. + String replacement(String candidate) { + if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute; + return p.isWithin(projectPath, toAbsolute) + ? p.relative(toAbsolute, from: projectPath) + : toAbsolute; + } + + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + var updated = slide; + if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) { + updated = updated.copyWith(imagePath: replacement(slide.imagePath)); + } + if (slide.imagePath2.isNotEmpty && + resolve(slide.imagePath2) == target) { + updated = updated.copyWith( + imagePath2: replacement(slide.imagePath2), + ); + } + if (!identical(updated, slide)) notifier.updateSlide(i, updated); + } + } + } + /// Find every open-deck slide that references [absolutePath], so we can warn /// before deleting an image that is still in use. List _imageUsages(WidgetRef ref, String absolutePath) { @@ -282,7 +331,7 @@ class ImagePickerBar extends ConsumerWidget { style: TextStyle( fontSize: 12, color: imagePath.isEmpty - ? const Color(0xFF94A3B8) + ? const Color(0xFF64748B) : const Color(0xFF334155), ), overflow: TextOverflow.ellipsis, @@ -345,7 +394,7 @@ class ImagePickerBar extends ConsumerWidget { child: IconButton( onPressed: onClear, icon: const Icon(Icons.clear, size: 18), - color: const Color(0xFF94A3B8), + color: const Color(0xFF64748B), ), ), ], @@ -447,7 +496,7 @@ class _CaptionFieldState extends State<_CaptionField> { prefixIcon: const Icon( Icons.copyright_outlined, size: 16, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), diff --git a/lib/widgets/editors/audio_attachment_editor.dart b/lib/widgets/editors/audio_attachment_editor.dart index 1fcb89d..502b08b 100644 --- a/lib/widgets/editors/audio_attachment_editor.dart +++ b/lib/widgets/editors/audio_attachment_editor.dart @@ -48,7 +48,7 @@ class AudioAttachmentEditor extends StatelessWidget { style: TextStyle( fontSize: 12, color: slide.audioPath.isEmpty - ? const Color(0xFF94A3B8) + ? const Color(0xFF64748B) : const Color(0xFF334155), ), overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/editors/bullets_editor.dart b/lib/widgets/editors/bullets_editor.dart index ed0de07..871f677 100644 --- a/lib/widgets/editors/bullets_editor.dart +++ b/lib/widgets/editors/bullets_editor.dart @@ -289,7 +289,7 @@ class _BulletsEditorState extends State { else Text( _markerForItem(i), - style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)), ), const SizedBox(width: 8), Expanded( @@ -342,7 +342,7 @@ class _BulletsEditorState extends State { icon: const Icon( Icons.remove_circle_outline, size: 18, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), onPressed: () => _removeBulletAndFocus(i), tooltip: l10n.d('Verwijder'), diff --git a/lib/widgets/editors/bullets_image_editor.dart b/lib/widgets/editors/bullets_image_editor.dart index 6e4fdb5..d18cabb 100644 --- a/lib/widgets/editors/bullets_image_editor.dart +++ b/lib/widgets/editors/bullets_image_editor.dart @@ -323,7 +323,7 @@ class _BulletsImageEditorState extends State { else Text( _markerForItem(i), - style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)), ), const SizedBox(width: 8), Expanded( @@ -372,7 +372,7 @@ class _BulletsImageEditorState extends State { icon: const Icon( Icons.remove_circle_outline, size: 18, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), onPressed: () => _removeBulletAndFocus(i), padding: const EdgeInsets.symmetric(horizontal: 4), diff --git a/lib/widgets/editors/chart_editor.dart b/lib/widgets/editors/chart_editor.dart index 80f06ed..654f36d 100644 --- a/lib/widgets/editors/chart_editor.dart +++ b/lib/widgets/editors/chart_editor.dart @@ -791,7 +791,7 @@ class _ChartEditorState extends State { decoration: BoxDecoration( color: Color( _type == ChartType.pie && c >= 2 - ? 0xFF94A3B8 + ? 0xFF64748B : int.parse( chartSeriesColor( ChartSeries( @@ -951,7 +951,7 @@ class _ChartEditorState extends State { style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), ), ); @@ -1038,7 +1038,7 @@ class _ChartEditorState extends State { key: key, onPressed: onTap, icon: Icon(icon, size: 14), - color: const Color(0xFF94A3B8), + color: const Color(0xFF64748B), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), diff --git a/lib/widgets/editors/quote_editor.dart b/lib/widgets/editors/quote_editor.dart index 5e7a5ca..9d3a180 100644 --- a/lib/widgets/editors/quote_editor.dart +++ b/lib/widgets/editors/quote_editor.dart @@ -99,7 +99,7 @@ class _QuoteEditorState extends ConsumerState { l10n.d( 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.', ), - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), ), const SizedBox(height: 8), ImagePickerBar( diff --git a/lib/widgets/editors/table_editor.dart b/lib/widgets/editors/table_editor.dart index d6f76c9..09dabde 100644 --- a/lib/widgets/editors/table_editor.dart +++ b/lib/widgets/editors/table_editor.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../models/slide.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/table_clipboard.dart'; import '_editor_field.dart'; /// Editor for a table slide. Stores cells as a rectangular grid of @@ -108,6 +110,69 @@ class _TableEditorState extends State { _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.generate(_colCount, (_) => _makeCtrl('')), + ); + } + for (var i = 0; i < table.length; i++) { + for (var j = 0; j < table[i].length; j++) { + final ctrl = _cells[r + i][c + j]; + // Rewrite without notifying per cell; one _emit follows below. + ctrl.removeListener(_emit); + ctrl.text = table[i][j]; + ctrl.addListener(_emit); + } + } + }); + _emit(); + } + @override void dispose() { _title.dispose(); @@ -131,8 +196,9 @@ class _TableEditorState extends State { Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( - l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.'), - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + '${l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.')}\n' + '${l10n.d('Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.')}', + style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), ), ), _buildColumnControls(), @@ -170,7 +236,7 @@ class _TableEditorState extends State { icon: const Icon( Icons.delete_outline, size: 16, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), onPressed: _colCount > 1 ? () => _removeColumn(c) : null, tooltip: @@ -203,26 +269,31 @@ class _TableEditorState extends State { Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 3), - child: TextField( - controller: _cells[r][c], - // Meerdere regels toestaan: het veld groeit mee en Enter - // voegt een nieuwe regel toe binnen de cel. - minLines: 1, - maxLines: null, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - style: TextStyle( - fontSize: 13, - fontWeight: isHeader ? FontWeight.w600 : FontWeight.normal, - ), - decoration: InputDecoration( - isDense: true, - filled: isHeader, - fillColor: isHeader ? const Color(0xFFF1F5F9) : null, - hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + child: Focus( + onKeyEvent: (node, event) => _onCellKey(r, c, event), + child: TextField( + controller: _cells[r][c], + // Meerdere regels toestaan: het veld groeit mee en Enter + // voegt een nieuwe regel toe binnen de cel. + minLines: 1, + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + style: TextStyle( + fontSize: 13, + fontWeight: isHeader + ? FontWeight.w600 + : FontWeight.normal, + ), + decoration: InputDecoration( + isDense: true, + filled: isHeader, + fillColor: isHeader ? const Color(0xFFF1F5F9) : null, + hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), ), ), ), @@ -236,7 +307,7 @@ class _TableEditorState extends State { icon: const Icon( Icons.remove_circle_outline, size: 18, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), onPressed: _cells.length > 1 ? () => _removeRow(r) : null, tooltip: isHeader diff --git a/lib/widgets/editors/title_editor.dart b/lib/widgets/editors/title_editor.dart index 159f0a4..30a37f9 100644 --- a/lib/widgets/editors/title_editor.dart +++ b/lib/widgets/editors/title_editor.dart @@ -99,7 +99,7 @@ class _TitleEditorState extends ConsumerState { l10n.d( 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.', ), - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), ), const SizedBox(height: 8), ImagePickerBar( diff --git a/lib/widgets/editors/two_bullets_editor.dart b/lib/widgets/editors/two_bullets_editor.dart index 5933ee1..e07cd54 100644 --- a/lib/widgets/editors/two_bullets_editor.dart +++ b/lib/widgets/editors/two_bullets_editor.dart @@ -348,7 +348,7 @@ class _BulletColumnState extends State<_BulletColumn> { else Text( _markerForItem(i), - style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)), ), const SizedBox(width: 8), Expanded( @@ -399,7 +399,7 @@ class _BulletColumnState extends State<_BulletColumn> { icon: const Icon( Icons.remove_circle_outline, size: 18, - color: Color(0xFF94A3B8), + color: Color(0xFF64748B), ), onPressed: () => set.removeAndFocus((fn) => setState(fn), i), tooltip: l10n.d('Verwijder'), diff --git a/lib/widgets/editors/two_images_editor.dart b/lib/widgets/editors/two_images_editor.dart index 620305c..95a1642 100644 --- a/lib/widgets/editors/two_images_editor.dart +++ b/lib/widgets/editors/two_images_editor.dart @@ -135,7 +135,7 @@ class _TwoImagesEditorState extends ConsumerState { child: Text( 'Links ${widget.slide.imageSize > 0 ? widget.slide.imageSize : 50}% — ' 'Rechts ${100 - (widget.slide.imageSize > 0 ? widget.slide.imageSize : 50)}%', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), ), ), ], diff --git a/lib/widgets/editors/video_slide_editor.dart b/lib/widgets/editors/video_slide_editor.dart index d8a7d93..0b514e7 100644 --- a/lib/widgets/editors/video_slide_editor.dart +++ b/lib/widgets/editors/video_slide_editor.dart @@ -116,7 +116,7 @@ class _PathBox extends StatelessWidget { style: TextStyle( fontSize: 12, color: path.isEmpty - ? const Color(0xFF94A3B8) + ? const Color(0xFF64748B) : const Color(0xFF334155), ), overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart index a71094d..ea7f107 100644 --- a/lib/widgets/panels/slide_list_panel.dart +++ b/lib/widgets/panels/slide_list_panel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -19,7 +21,14 @@ import '../dialogs/slide_finder_dialog.dart'; import '../slides/slide_thumbnail.dart'; class SlideListPanel extends ConsumerStatefulWidget { - const SlideListPanel({super.key}); + /// Current width of the slide rail. When it changes (dragging the divider), + /// the slide being edited is scrolled back into view once the resize + /// settles. Passed in by the shell rather than measured with a + /// LayoutBuilder: rebuilding a ReorderableListView during layout trips its + /// overlay bookkeeping ("_RenderLayoutBuilder was mutated…"). + final double? railWidth; + + const SlideListPanel({super.key, this.railWidth}); @override ConsumerState createState() => _SlideListPanelState(); @@ -31,15 +40,35 @@ class _SlideListPanelState extends ConsumerState { final _scrollController = ScrollController(); final _focusNode = FocusNode(debugLabel: 'SlideListPanel'); final Map _slideKeys = {}; + Timer? _resizeSettleTimer; @override void dispose() { + _resizeSettleTimer?.cancel(); _searchController.dispose(); _scrollController.dispose(); _focusNode.dispose(); super.dispose(); } + /// Thumbnails are 16:9, so when the rail is resized their heights change and + /// the scroll offset no longer points at the slide being edited. Once the + /// resize settles, bring the selected slide back to the top of the list. + @override + void didUpdateWidget(covariant SlideListPanel oldWidget) { + super.didUpdateWidget(oldWidget); + final width = widget.railWidth; + final previous = oldWidget.railWidth; + if (width == null || previous == null || (width - previous).abs() < 0.5) { + return; + } + _resizeSettleTimer?.cancel(); + _resizeSettleTimer = Timer(const Duration(milliseconds: 200), () { + if (!mounted) return; + _scrollSlideToTop(ref.read(editorProvider).selectedIndex); + }); + } + /// Lower-cased, concatenated text of a slide for searching. Kept broad on /// purpose: everything you typed into the slide should make it findable. String _slideText(Slide slide) { @@ -88,7 +117,7 @@ class _SlideListPanelState extends ConsumerState { _slideKeys.removeWhere((id, _) => !ids.contains(id)); } - void _scrollSlideToTop(int index) { + void _scrollSlideToTop(int index, {int attempts = 2}) { WidgetsBinding.instance.addPostFrameCallback((_) { final deck = ref.read(deckProvider).deck; if (deck == null || @@ -100,7 +129,21 @@ class _SlideListPanelState extends ConsumerState { final keyContext = _slideKeys[deck.slides[index].id]?.currentContext; final target = keyContext?.findRenderObject(); - if (target == null) return; + if (target == null) { + // The thumbnail hasn't been built (it sits outside the viewport and + // cache). Jump close to it based on the average item height, then try + // again now that the surrounding items exist. + if (attempts <= 0) return; + final position = _scrollController.position; + final avgItem = + (position.maxScrollExtent + position.viewportDimension) / + deck.slides.length; + _scrollController.jumpTo( + (avgItem * index).clamp(0.0, position.maxScrollExtent), + ); + _scrollSlideToTop(index, attempts: attempts - 1); + return; + } final viewport = RenderAbstractViewport.maybeOf(target); if (viewport == null) return; @@ -506,6 +549,67 @@ class _SlideListPanelState extends ConsumerState { ); } + Widget _buildSlideList( + Deck deck, + bool searching, + String query, + EditorState editor, + DeckNotifier notifier, + EditorNotifier editorNotifier, + ) { + if (searching) { + return _buildFilteredList(deck, query, editor, notifier, editorNotifier); + } + return ReorderableListView.builder( + scrollController: _scrollController, + padding: const EdgeInsets.symmetric(vertical: 4), + buildDefaultDragHandles: false, + itemCount: deck.slides.length, + onReorderItem: (old, nw) { + notifier.reorderSlides(old, nw); + // Adjust selection when active slide moved + final selIdx = editor.selectedIndex; + int newSel = selIdx; + if (old == selIdx) { + newSel = nw; + } else if (old < selIdx && nw >= selIdx) { + newSel = selIdx - 1; + } else if (old > selIdx && nw <= selIdx) { + newSel = selIdx + 1; + } + editorNotifier.select(newSel.clamp(0, deck.slides.length - 1)); + }, + proxyDecorator: (child, index, animation) => + Material(color: Colors.transparent, child: child), + itemBuilder: (_, i) { + final slide = deck.slides[i]; + return SlideThumbnail( + key: _keyForSlide(slide), + slide: slide, + index: i, + isSelected: editor.selection.contains(i), + isPrimary: editor.selectedIndex == i, + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + slideCount: deck.slides.length, + tlp: deck.tlp, + onTap: () => _onSlideTap(i), + onToggleSkip: () => notifier.toggleSkip(i), + onCopyImage: () => _copySlideAsImage(slide), + onDuplicate: () { + notifier.duplicateSlide(i); + editorNotifier.select(i + 1); + }, + onDelete: () { + if (deck.slides.length <= 1) return; + notifier.removeSlide(i); + editorNotifier.clampIndex(deck.slides.length - 2); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -605,64 +709,14 @@ class _SlideListPanelState extends ConsumerState { // ── Slide list ─────────────────────────────────────────────────── Expanded( - child: searching - ? _buildFilteredList( - deck, - query, - editor, - notifier, - editorNotifier, - ) - : ReorderableListView.builder( - scrollController: _scrollController, - padding: const EdgeInsets.symmetric(vertical: 4), - buildDefaultDragHandles: false, - itemCount: deck.slides.length, - onReorderItem: (old, nw) { - notifier.reorderSlides(old, nw); - // Adjust selection when active slide moved - final selIdx = editor.selectedIndex; - int newSel = selIdx; - if (old == selIdx) { - newSel = nw; - } else if (old < selIdx && nw >= selIdx) { - newSel = selIdx - 1; - } else if (old > selIdx && nw <= selIdx) { - newSel = selIdx + 1; - } - editorNotifier.select( - newSel.clamp(0, deck.slides.length - 1), - ); - }, - proxyDecorator: (child, index, animation) => - Material(color: Colors.transparent, child: child), - itemBuilder: (_, i) { - final slide = deck.slides[i]; - return SlideThumbnail( - key: _keyForSlide(slide), - slide: slide, - index: i, - isSelected: editor.selection.contains(i), - isPrimary: editor.selectedIndex == i, - projectPath: deck.projectPath, - themeProfile: deck.themeProfile, - slideCount: deck.slides.length, - tlp: deck.tlp, - onTap: () => _onSlideTap(i), - onToggleSkip: () => notifier.toggleSkip(i), - onCopyImage: () => _copySlideAsImage(slide), - onDuplicate: () { - notifier.duplicateSlide(i); - editorNotifier.select(i + 1); - }, - onDelete: () { - if (deck.slides.length <= 1) return; - notifier.removeSlide(i); - editorNotifier.clampIndex(deck.slides.length - 2); - }, - ); - }, - ), + child: _buildSlideList( + deck, + searching, + query, + editor, + notifier, + editorNotifier, + ), ), // ── Add / Paste slide buttons ───────────────────────────────── diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index b559896..d522cc9 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:screen_retriever/screen_retriever.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -14,6 +15,7 @@ import '../../models/slide.dart'; import '../../services/markdown_service.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; +import '../slides/inline_markdown.dart'; import '../slides/slide_preview.dart'; import 'annotation_overlay.dart'; import 'audience_window.dart'; @@ -727,6 +729,22 @@ class _FullscreenPresenterState extends State { 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() { // Eerste toets/klik op een blanco scherm haalt het scherm terug. if (_blank != _Blank.none) { @@ -736,6 +754,7 @@ class _FullscreenPresenterState extends State { if (_index < widget.slides.length - 1) { setState(() => _index++); _scheduleAdvance(); + _announceSlide(); } } @@ -747,6 +766,7 @@ class _FullscreenPresenterState extends State { if (_index > 0) { setState(() => _index--); _scheduleAdvance(); + _announceSlide(); } } @@ -839,6 +859,7 @@ class _FullscreenPresenterState extends State { _gridOpen = false; }); _scheduleAdvance(); + _announceSlide(); } /// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End). @@ -851,6 +872,7 @@ class _FullscreenPresenterState extends State { if (target == _index) return; setState(() => _index = target); _scheduleAdvance(); + _announceSlide(); } /// Verplaats de rastercursor en houd 'm in beeld. diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index 021e24c..8da485d 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -186,22 +186,27 @@ class SlidePreviewWidget extends StatelessWidget { // falls back to Flutter's broken default — red letters with a yellow // underline — which is exactly what showed up in exports. Wrapping here // guarantees identical results in the preview and the export. - return _ChecklistInteractionHost( - enabled: presentationMode && onChecklistItemToggle != null, - onToggle: onChecklistItemToggle, - child: Directionality( - textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: TextStyle( - color: _hexColor(themeProfile.textColor), - decoration: TextDecoration.none, - fontWeight: FontWeight.normal, - fontStyle: FontStyle.normal, - ), - child: _SlideLinkScope( - onTapLink: onLinkTap, - hasBottomTlp: hasBottomRightTlp, - child: _buildSlide(), + // 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, + onToggle: onChecklistItemToggle, + child: Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: TextStyle( + color: _hexColor(themeProfile.textColor), + decoration: TextDecoration.none, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + ), + child: _SlideLinkScope( + onTapLink: onLinkTap, + hasBottomTlp: hasBottomRightTlp, + child: _buildSlide(), + ), ), ), ), @@ -707,7 +712,7 @@ class _BulletsPreview extends StatelessWidget { font: font, subtitle: subtitle, subtitleSize: subtitleSize, - maxScale: _kSplitBulletsMaxScale, + maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), listStyle: slide.listStyle, ); @@ -1072,7 +1077,7 @@ class _TwoBulletsPreview extends StatelessWidget { spacing: spacing, bulletGap: bulletGap, font: font, - maxScale: _kBulletsMaxScale, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), listStyle: slide.listStyle, ); final rightScale = _bulletsFitScale( @@ -1086,7 +1091,7 @@ class _TwoBulletsPreview extends StatelessWidget { spacing: spacing, bulletGap: bulletGap, font: font, - maxScale: _kBulletsMaxScale, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), listStyle: slide.listStyle, ); // Treat both columns as one composition: the busiest column determines @@ -1247,7 +1252,7 @@ class _BulletsImagePreview extends StatelessWidget { spacing: spacing, bulletGap: bulletGap, font: font, - maxScale: _kBulletsMaxScale, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), listStyle: slide.listStyle, ); @@ -1505,34 +1510,34 @@ class _ChecklistProgress extends StatelessWidget { ), ), SizedBox(height: w * 0.008), - MouseRegion( - key: const ValueKey('checklist-progress-checked'), - onEnter: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = true, - onExit: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = null, - child: Text( - '${context.l10n.d('Afgevinkt')} $checkedPercent%', - style: labelStyle, - ), - ), - MouseRegion( - key: const ValueKey('checklist-progress-unchecked'), - onEnter: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = false, - onExit: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = null, - child: Text( - '${context.l10n.d('Niet afgevinkt')} $openPercent%', - style: labelStyle.copyWith( - color: textColor.withValues(alpha: 0.7), + MouseRegion( + key: const ValueKey('checklist-progress-checked'), + onEnter: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = true, + onExit: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = null, + child: Text( + '${context.l10n.d('Afgevinkt')} $checkedPercent%', + style: labelStyle, + ), + ), + MouseRegion( + key: const ValueKey('checklist-progress-unchecked'), + onEnter: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = false, + onExit: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = null, + child: Text( + '${context.l10n.d('Niet afgevinkt')} $openPercent%', + style: labelStyle.copyWith( + color: textColor.withValues(alpha: 0.7), + ), + ), ), - ), - ), ], ), ); @@ -1776,6 +1781,19 @@ const double _kBulletsMaxScale = 3.2; /// visually timid unless they are allowed to grow a little further. const double _kSplitBulletsMaxScale = 4.35; +/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a +/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's +/// 960pt-wide canvas). Presentation-design guidance consistently puts body +/// text at 24–32pt — beyond that it stops aiding readability and starts +/// competing with the title. The fit scale multiplies title and bullets +/// alike, so capping the bullet size also keeps the hierarchy intact. +const double _kBulletMaxFontFraction = 0.0335; + +/// The largest auto-fit scale that keeps bullets at or under +/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound. +double _bulletScaleCap(double w, double bulletSize, double layoutMax) => + math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize); + /// Line height used for bullet body text, shared by rendering and measuring. const double _kBulletLineHeight = 1.16; @@ -2872,6 +2890,36 @@ class _ChartPreviewState extends State<_ChartPreview> { 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 Widget build(BuildContext context) { 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 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( color: _hexColor(profile.slideBackgroundColor), 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( font, TextStyle( @@ -3234,16 +3308,30 @@ class _ChartPreviewState extends State<_ChartPreview> { if (i < 0 || i >= n) return const SizedBox.shrink(); // Show as many labels as fit without colliding: keep at least // [minSlot] of horizontal room per label, then thin them out - // evenly based on the actual pixel spacing between points. - final spacing = n > 1 - ? meta.parentAxisSize / (n - 1) - : meta.parentAxisSize; + // evenly based on the actual pixel spacing between points. Line + // charts spread n points over n-1 intervals; bar groups are laid + // out spaceEvenly, which puts their centres (axis + groupWidth) / + // (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 step = math.max(1, (minSlot / spacing).ceil()); 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(); - 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( padding: EdgeInsets.only(top: w * 0.008), child: SizedBox( @@ -3275,6 +3363,17 @@ class _ChartPreviewState extends State<_ChartPreview> { 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) { final groups = []; for (var xi = 0; xi < spec.x.length; xi++) { @@ -3287,10 +3386,7 @@ class _ChartPreviewState extends State<_ChartPreview> { BarChartRodData( toY: spec.series[si].data[xi], color: _seriesDisplayColor(spec.series[si], si), - width: (w * 0.032 / spec.series.length).clamp( - w * 0.008, - w * 0.022, - ), + width: _barRodWidth(spec), borderRadius: BorderRadius.vertical( top: Radius.circular(w * 0.006), ), @@ -3308,8 +3404,11 @@ class _ChartPreviewState extends State<_ChartPreview> { BarChartData( minY: _minY(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, - titlesData: _titles(spec, textColor), + titlesData: _titles(spec, textColor, bars: true), gridData: _grid(textColor), borderData: FlBorderData(show: false), extraLinesData: _boundLines(spec), @@ -3537,19 +3636,25 @@ class _ChartPreviewState extends State<_ChartPreview> { final scale = radarScale(spec); 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( builder: (context, constraints) { - // Reserve a slim column on the right for the scale legend, then keep - // the chart square so fl_chart's centre/radius stay predictable. + // Reserve a slim column on the right for the scale legend; the rest + // of the area is shared between the spider and its axis labels. final legendWidth = w * 0.075; - final available = constraints.maxWidth - legendWidth - w * 0.02; - final side = math.max( + final boxW = math.max( 0.0, - math.min(available, constraints.maxHeight), + constraints.maxWidth - legendWidth - w * 0.02, ); - final labelBand = side * 0.23; - final chartSide = math.max(0.0, side - labelBand * 2); + final boxH = constraints.maxHeight; + 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( crossAxisAlignment: CrossAxisAlignment.center, @@ -3557,8 +3662,8 @@ class _ChartPreviewState extends State<_ChartPreview> { Expanded( child: Center( child: SizedBox( - width: side, - height: side, + width: boxW, + height: boxH, child: Stack( children: [ for (var i = 0; i < spec.x.length; i++) @@ -3566,12 +3671,12 @@ class _ChartPreviewState extends State<_ChartPreview> { label: spec.x[i], index: i, count: spec.x.length, - side: side, + layout: layout, textColor: textColor, ), Positioned( - left: labelBand, - top: labelBand, + left: (boxW - chartSide) / 2, + top: (boxH - chartSide) / 2, width: chartSide, height: chartSide, child: Stack( @@ -3725,57 +3830,149 @@ class _ChartPreviewState extends State<_ChartPreview> { ); } + TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont( + font, + TextStyle( + fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, + height: 1.05, + color: textColor.withValues(alpha: 0.88), + 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 rects, List 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 = []; + final sizes = []; + 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 = []; + final aligns = []; + 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 side, + required ({ + double chartSide, + List rects, + List aligns, + int maxLines, + }) + layout, 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); - + final rect = layout.rects[index]; 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, - TextStyle( - fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, - height: 1.05, - color: textColor.withValues(alpha: 0.88), - fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, - ), - ), - ), + 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), ), ); } diff --git a/lib/widgets/slides/slide_thumbnail.dart b/lib/widgets/slides/slide_thumbnail.dart index c2da7c0..51d5433 100644 --- a/lib/widgets/slides/slide_thumbnail.dart +++ b/lib/widgets/slides/slide_thumbnail.dart @@ -55,204 +55,222 @@ class SlideThumbnail extends ConsumerWidget { // Actieve slide krijgt een dikkere rand dan de overige geselecteerde. final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0; - return GestureDetector( - onTap: onTap, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - border: Border.all(color: borderColor, width: borderWidth), - color: isSelected ? const Color(0xFF2A2F3B) : const Color(0xFF252830), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Mini slide preview - ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(5), - ), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Stack( - fit: StackFit.expand, - children: [ - // Overgeslagen slides worden gedimd weergegeven. - Opacity( - opacity: skipped ? 0.32 : 1, - child: SlidePreviewWidget( - slide: slide, - projectPath: projectPath, - themeProfile: themeProfile, - slideNumber: index + 1, - slideCount: slideCount, - tlp: tlp, - ), + // Eén beknopt label per kaart voor schermlezers: nummer, titel (of type) + // en status. De mini-preview eronder is puur visueel en zou anders de + // volledige slide-inhoud per thumbnail laten voorlezen. + final title = slide.title.trim(); + final semanticLabel = + '${l10n.d('Slide')} ${index + 1}/$slideCount: ' + '${title.isNotEmpty ? title : l10n.d(slide.type.label)}' + '${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}'; + + return Semantics( + button: true, + selected: isSelected, + label: semanticLabel, + child: GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: borderColor, width: borderWidth), + color: isSelected + ? const Color(0xFF2A2F3B) + : const Color(0xFF252830), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Mini slide preview + ExcludeSemantics( + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(5), + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + // Overgeslagen slides worden gedimd weergegeven. + Opacity( + opacity: skipped ? 0.32 : 1, + child: SlidePreviewWidget( + slide: slide, + projectPath: projectPath, + themeProfile: themeProfile, + slideNumber: index + 1, + slideCount: slideCount, + tlp: tlp, + ), + ), + if (skipped) + Positioned( + top: 4, + left: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xCC8A6D3B), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.visibility_off_outlined, + size: 10, + color: Colors.white, + ), + const SizedBox(width: 3), + Text( + l10n.d('Overgeslagen'), + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], ), - if (skipped) - Positioned( - top: 4, - left: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: const Color(0xCC8A6D3B), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.visibility_off_outlined, - size: 10, - color: Colors.white, - ), - const SizedBox(width: 3), - Text( - l10n.d('Overgeslagen'), - style: const TextStyle( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.w600, - ), - ), - ], + ), + ), + ), + // Footer: slide number, type label, action buttons + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: isSelected + ? AppTheme.accent + : const Color(0xFF4A4F5B), + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, ), ), ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + l10n.d(slide.type.label), + style: const TextStyle( + color: Color(0xFF94A3B8), + fontSize: 9, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // Drag handle + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon( + Icons.drag_handle, + size: 14, + color: Color(0xFF64748B), + ), + ), + ), + // Snelle overslaan-toggle + SizedBox( + width: 20, + height: 20, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 14, + splashRadius: 12, + tooltip: skipped + ? l10n.d('Weer tonen bij presenteren/exporteren') + : l10n.d('Overslaan bij presenteren/exporteren'), + icon: Icon( + skipped + ? Icons.visibility_off + : Icons.visibility_outlined, + color: skipped + ? const Color(0xFFD4A24E) + : const Color(0xFF64748B), + ), + onPressed: onToggleSkip, + ), + ), + // Context menu + SizedBox( + width: 20, + height: 20, + child: PopupMenuButton( + icon: const Icon( + Icons.more_vert, + color: Color(0xFF64748B), + size: 14, + ), + padding: EdgeInsets.zero, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'copy', + child: Text(l10n.d('Kopiëren')), + ), + PopupMenuItem( + value: 'copy_image', + child: Text(l10n.d('Kopieer als afbeelding')), + ), + PopupMenuItem( + value: 'duplicate', + child: Text(l10n.d('Dupliceren')), + ), + PopupMenuItem( + value: 'skip', + child: Text( + skipped + ? l10n.d('Niet meer overslaan') + : l10n.d('Overslaan'), + ), + ), + PopupMenuItem( + value: 'delete', + child: Text( + l10n.d('Verwijderen'), + style: const TextStyle(color: Colors.red), + ), + ), + ], + onSelected: (v) { + if (v == 'copy') { + ref.read(slideClipboardProvider.notifier).state = + slide; + } + if (v == 'copy_image') onCopyImage(); + if (v == 'duplicate') onDuplicate(); + if (v == 'skip') onToggleSkip(); + if (v == 'delete') onDelete(); + }, + ), + ), ], ), ), - ), - // Footer: slide number, type label, action buttons - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Row( - children: [ - Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: isSelected - ? AppTheme.accent - : const Color(0xFF4A4F5B), - borderRadius: BorderRadius.circular(9), - ), - child: Center( - child: Text( - '${index + 1}', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - l10n.d(slide.type.label), - style: const TextStyle( - color: Color(0xFF94A3B8), - fontSize: 9, - ), - overflow: TextOverflow.ellipsis, - ), - ), - // Drag handle - ReorderableDragStartListener( - index: index, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 2), - child: Icon( - Icons.drag_handle, - size: 14, - color: Color(0xFF64748B), - ), - ), - ), - // Snelle overslaan-toggle - SizedBox( - width: 20, - height: 20, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 14, - splashRadius: 12, - tooltip: skipped - ? l10n.d('Weer tonen bij presenteren/exporteren') - : l10n.d('Overslaan bij presenteren/exporteren'), - icon: Icon( - skipped - ? Icons.visibility_off - : Icons.visibility_outlined, - color: skipped - ? const Color(0xFFD4A24E) - : const Color(0xFF64748B), - ), - onPressed: onToggleSkip, - ), - ), - // Context menu - SizedBox( - width: 20, - height: 20, - child: PopupMenuButton( - icon: const Icon( - Icons.more_vert, - color: Color(0xFF64748B), - size: 14, - ), - padding: EdgeInsets.zero, - itemBuilder: (_) => [ - PopupMenuItem( - value: 'copy', - child: Text(l10n.d('Kopiëren')), - ), - PopupMenuItem( - value: 'copy_image', - child: Text(l10n.d('Kopieer als afbeelding')), - ), - PopupMenuItem( - value: 'duplicate', - child: Text(l10n.d('Dupliceren')), - ), - PopupMenuItem( - value: 'skip', - child: Text( - skipped - ? l10n.d('Niet meer overslaan') - : l10n.d('Overslaan'), - ), - ), - PopupMenuItem( - value: 'delete', - child: Text( - l10n.d('Verwijderen'), - style: const TextStyle(color: Colors.red), - ), - ), - ], - onSelected: (v) { - if (v == 'copy') { - ref.read(slideClipboardProvider.notifier).state = - slide; - } - if (v == 'copy_image') onCopyImage(); - if (v == 'duplicate') onDuplicate(); - if (v == 'skip') onToggleSkip(); - if (v == 'delete') onDelete(); - }, - ), - ), - ], - ), - ), - ], + ], + ), ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 4f1cb30..644b0df 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,7 +130,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index b97f15b..c0f323b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: archive: ^4.0.9 video_player: ^2.11.1 characters: ^1.3.0 + crypto: ^3.0.0 url_launcher: ^6.3.0 desktop_drop: ^0.7.1 image: ^4.8.0 diff --git a/test/add_slide_dialog_test.dart b/test/add_slide_dialog_test.dart new file mode 100644 index 0000000..1171673 --- /dev/null +++ b/test/add_slide_dialog_test.dart @@ -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 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(find.byType(CustomPaint)) + .map((p) => p.painter) + .whereType() + .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); + }); +} diff --git a/test/chart_preview_test.dart b/test/chart_preview_test.dart index c61e2fd..6fc4486 100644 --- a/test/chart_preview_test.dart +++ b/test/chart_preview_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -327,13 +329,27 @@ void main() { await tester.pumpWidget(_host(spec, presentationMode: true)); await tester.pump(); + // fl_chart draws the spider at 0.8 × half the (square) widget side; the + // labels hug the polygon, so they may overlap the widget's corners but + // must stay off the polygon itself (its apothem) and off each other. final radarRect = tester.getRect(find.byType(RadarChart)); + final center = radarRect.center; + final radius = radarRect.width / 2 * 0.8; + final apothem = radius * math.cos(math.pi / spec.x.length); + double distanceToRect(Offset c, Rect r) { + final nearest = Offset( + c.dx.clamp(r.left, r.right), + c.dy.clamp(r.top, r.bottom), + ); + return (c - nearest).distance; + } + final labelRects = [ for (var i = 0; i < spec.x.length; i++) tester.getRect(find.byKey(ValueKey('radar-axis-label-$i'))), ]; for (final rect in labelRects) { - expect(rect.overlaps(radarRect), isFalse); + expect(distanceToRect(center, rect), greaterThan(apothem * 0.98)); } for (var i = 0; i < labelRects.length; i++) { for (var j = i + 1; j < labelRects.length; j++) { @@ -533,6 +549,67 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('charts expose their data as a text alternative', (tester) async { + final handle = tester.ensureSemantics(); + const spec = ChartSpec( + type: ChartType.bar, + title: 'Omzet', + x: ['Q1', 'Q2'], + series: [ + ChartSeries(name: '2026', data: [10, 14]), + ], + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + // WCAG 1.1.1: the chart carries a label with type, title and values. + expect( + find.bySemanticsLabel('Grafiek (Staaf): Omzet. 2026: Q1 10, Q2 14'), + findsOneWidget, + ); + handle.dispose(); + }); + + testWidgets('bar chart x-axis labels never run through each other', ( + tester, + ) async { + // Six groups: enough that the label slots are narrower than the clamp, + // which used to overlap because the spacing was computed with n-1 + // intervals while bar groups each occupy a full nth of the axis. + const labels = [ + 'Strategische koers', + 'Operationele basis', + 'Innovatievermogen', + 'Mensen en cultuur', + 'Financiële ruimte', + 'Digitale veiligheid', + ]; + const spec = ChartSpec( + type: ChartType.bar, + x: labels, + series: [ + ChartSeries(name: 'Score', data: [3, 4, 5, 2, 4, 3]), + ], + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + final rects = [ + for (final label in labels) + if (find.text(label).evaluate().isNotEmpty) + tester.getRect(find.text(label).first), + ]; + expect(rects.length, greaterThanOrEqualTo(2)); + for (var i = 0; i < rects.length; i++) { + for (var j = i + 1; j < rects.length; j++) { + expect(rects[i].overlaps(rects[j]), isFalse); + } + } + expect(tester.takeException(), isNull); + }); + testWidgets( 'pie shows at most two series and keeps labels inside the slide', (tester) async { diff --git a/test/image_dedup_service_test.dart b/test/image_dedup_service_test.dart new file mode 100644 index 0000000..17cfe29 --- /dev/null +++ b/test/image_dedup_service_test.dart @@ -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 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, '', ' ']), ''); + }); + }); +} diff --git a/test/image_reference_service_test.dart b/test/image_reference_service_test.dart new file mode 100644 index 0000000..511161b --- /dev/null +++ b/test/image_reference_service_test.dart @@ -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'); + }, + ); + }); +} diff --git a/test/slide_list_panel_test.dart b/test/slide_list_panel_test.dart new file mode 100644 index 0000000..85b2e0b --- /dev/null +++ b/test/slide_list_panel_test.dart @@ -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(320); + addTearDown(width.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: ValueListenableBuilder( + 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(); + }); +} diff --git a/test/table_clipboard_test.dart b/test/table_clipboard_test.dart new file mode 100644 index 0000000..dd4b9ff --- /dev/null +++ b/test/table_clipboard_test.dart @@ -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); + }); + }); +} diff --git a/test/table_editor_test.dart b/test/table_editor_test.dart new file mode 100644 index 0000000..779c181 --- /dev/null +++ b/test/table_editor_test.dart @@ -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 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 {'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', ''], + ['', ''], + ]); + }); +} diff --git a/test/two_bullets_preview_test.dart b/test/two_bullets_preview_test.dart index 12e59b9..1bab233 100644 --- a/test/two_bullets_preview_test.dart +++ b/test/two_bullets_preview_test.dart @@ -81,6 +81,32 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('short bullet lists grow but respect the body-text maximum', ( + tester, + ) async { + final slide = Slide.create( + SlideType.bullets, + ).copyWith(title: 'Kort', bullets: const ['Eén punt', 'Twee punten']); + + await tester.pumpWidget(_host(slide)); + await tester.pump(); + + final size = tester + .widget( + find.byWidgetPredicate( + (x) => x is InlineMarkdownText && x.text == 'Eén punt', + ), + ) + .style + .fontSize!; + // Auto-fit still grows the text beyond its design size (w * 0.026)… + expect(size, greaterThan(800 * 0.026)); + // …but never past ≈32pt-on-a-16:9-deck (w * 0.0335), so the body text + // keeps a clear distance from the title. + expect(size, lessThanOrEqualTo(800 * 0.0335 + 0.1)); + expect(tester.takeException(), isNull); + }); + testWidgets('bullets slide renders an optional subheading below the title', ( tester, ) async { diff --git a/test/ui_text_scale_test.dart b/test/ui_text_scale_test.dart new file mode 100644 index 0000000..4063e69 --- /dev/null +++ b/test/ui_text_scale_test.dart @@ -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); + }); +}