Compare commits

..

No commits in common. "6bf85773b0b88cc19df333975b6a523ea9977fc8" and "815f5f2ceeacc9a7415a266fb687ffcf19f35527" have entirely different histories.

59 changed files with 571 additions and 4023 deletions

View file

@ -8,18 +8,6 @@ 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
@ -49,26 +37,6 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
live to the beamer, and persisted in a `<name>.ink.json` sidecar.
- **App theming** — customizable app appearance profiles, including a dark
interface.
- **Paste a table into a table cell** — pasting a spreadsheet selection (Excel,
Numbers, LibreOffice Calc, Google Sheets), CSV (comma or semicolon), or a
markdown table into any cell of the table editor fills the whole grid from
that cell, growing rows and columns as needed. Works with `Ctrl/Cmd+V` and
`Shift+Insert` on macOS, Windows, and Linux; plain text still pastes into the
single cell.
- **Slide-type chooser previews** — the add-slide dialog shows a miniature
wireframe of each layout (in the spirit of other presentation tools) instead
of an abstract icon, and is fully keyboard-operable (`Tab`/`Enter`/`Esc`).
- **Accessibility (WCAG 2.1)**:
- An **interface text size** setting (100200%, Settings → General →
Accessibility) that scales all editor text; slides themselves keep their
fixed design size.
- The panel divider is focusable and **keyboard-resizable** (arrow keys), with
a visible focus state, and presents itself to screen readers as a slider.
- **Screen-reader support**: slide thumbnails announce one concise label
("Slide 3/12: title") instead of their full content; charts expose their
type, title, and underlying values as a text alternative; the presenter
announces each slide change.
- Improved contrast for hint/label text in the editors.
- Project documentation: contributing guide, security policy, architecture and
build notes, user guide, keyboard-shortcut reference, third-party notices, and
the EUPL-1.2 licence text.
@ -79,36 +47,11 @@ 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 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.
- 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.
- Slide transitions in the presenter no longer flash a black frame (neighbour
images are precached and `gaplessPlayback` is enabled) — important for
recording.
- **Spider/radar charts** now use the available space: axis labels are measured
and placed snugly around the polygon (up to three lines, full remaining
width), so the diagram renders considerably larger and long labels stay
readable instead of being truncated.
- Bullet auto-fit now stops growing at ≈32 pt (on a 16:9 deck) — the upper end
of the 2432 pt range presentation-design guidance recommends for body text —
so slides with few bullets no longer render body text that competes with the
title.
- After resizing the slide panel (dragging the divider or resizing the window),
the list scrolls the slide being edited back into view.
### Fixed
- Hover on charts (tooltips, legend highlight) now works on a second screen:
macOS only delivered mouse-moved events to the key window, so the borderless
beamer window never saw them; the stuck hover state after the pointer left a
window is gone for the same reason.
- Bar-chart x-axis labels could run through each other: the spacing maths now
matches how bar groups are actually laid out, and the final label shrinks to
the real gap when it sits closer than a full step.
- A crash in the slide list ("A _RenderLayoutBuilder was mutated…") when its
keyed items were rebuilt during layout — both the resize-detection inside the
panel and the shell's width computation now avoid LayoutBuilders above the
reorderable list.
## [1.0.0]

View file

@ -16,10 +16,9 @@ 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. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file).
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata.
- **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. 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.
- **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.
- **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.
@ -70,9 +69,7 @@ lib/
services/ # Markdown, export, file, image, caption, recovery, rasterizer
state/ # Riverpod providers (deck, editor, settings, tabs, clipboard)
widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter
l10n/ # AppLocalizations (8 languages)
theme/ # App theming
utils/ # Small shared helpers (clipboard table parsing, URL launching)
```
State is managed with [Riverpod](https://riverpod.dev/).

View file

@ -16,13 +16,11 @@ 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
@ -92,12 +90,10 @@ hence the vendored multi-window fork below.
## Sidecars (separate layers)
To keep the `.md` pure Marp, four kinds of data live beside it (see
To keep the `.md` pure Marp, three kinds of data live beside it (see
`FILE_FORMAT.md` §6):
- **Captions**`.ocideck_captions.json` (per image, in `images/`).
- **Descriptions/tags**`.ocideck_descriptions.json` (searchable image
metadata, used by the library's search and the untagged filter).
- **Annotations**`<name>.ink.json` (`services/annotation_codec.dart`).
- **Linked chart data**`data/*.csv` (the living source for a chart).
@ -109,12 +105,8 @@ 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`. 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.
exposed on `WindowController`. This is what makes the dual-screen audience
window possible.
- **`screen_retriever_macos`** (leanflutter) — a packaging fix for recent
Xcode/CocoaPods.

View file

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

View file

@ -12,11 +12,6 @@
| `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

View file

@ -21,10 +21,8 @@ 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 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.
spider/radar), and **free Markdown**. 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
@ -40,16 +38,6 @@ 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
@ -68,34 +56,10 @@ 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. For screen readers every chart also carries
a text alternative with its type, title, and the values per series.
shows its value in a tooltip too.
- 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:
@ -148,21 +112,6 @@ 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 100200%
text scaling for the whole editing environment, on top of what the operating
system asks for. Slides keep their fixed 16:9 design size, so what you see is
still exactly what you present and export.
- **Keyboard** — the panel divider between the slide list and the editor can be
focused with `Tab` and resized with `←`/`→`; the add-slide dialog is fully
keyboard-operable.
- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12:
title", including the skipped state), charts read out their data as a text
alternative, and the fullscreen presenter announces every slide change.
## Theming and language
- **Style profiles** control deck colours (including the source-code background,

View file

@ -3,10 +3,8 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'l10n/app_localizations.dart';
import 'state/settings_provider.dart';
import 'state/consent_provider.dart';
import 'theme/app_theme.dart';
import 'widgets/app_shell.dart';
import 'widgets/dialogs/consent_dialog.dart';
class OciDeckApp extends ConsumerWidget {
const OciDeckApp({super.key});
@ -19,28 +17,11 @@ 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 [
@ -49,34 +30,7 @@ class OciDeckApp extends ConsumerWidget {
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const _ConsentGate(),
home: const AppShell(),
);
}
}
class _ConsentGate extends ConsumerWidget {
const _ConsentGate();
@override
Widget build(BuildContext context, WidgetRef ref) {
final consent = ref.watch(consentProvider);
if (consent.isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (!consent.hasAccepted) {
// Plain Scaffold inside the app's existing MaterialApp — that one already
// supplies the theme and the AppLocalizations delegate, so context.l10n
// resolves here. A nested MaterialApp would start a fresh Localizations
// scope without our delegate and the consent text would render blank.
return const Scaffold(
body: Center(child: ConsentDialog()),
);
}
return const AppShell();
}
}

View file

@ -1348,18 +1348,6 @@ const _dutchSourceStrings = {
'P pubblico · H legenda · G panoramica · B/W nero/bianco · R tempo · Esc stop',
'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop':
'P pubblico · H legenda · S schermo · G panoramica · B/W nero/bianco · R tempo · Esc stop',
'Akkoord gaan': 'Accetto',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Tutti i dati che inserisci in OciDeck rimangono sul tuo sistema locale e non vengono inviati a server esterni.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'L\'app non raccoglie dati personali, statistiche o dati di utilizzo. La tua privacy è la nostra priorità.',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Facendo clic su "Accetto", accetti questi termini e accetti l\'uso di OciDeck.',
'Licentie (EUPL 1.2)': 'Licenza (EUPL 1.2)',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck è un\'applicazione desktop locale. Le tue presentazioni e i tuoi dati vengono archiviati esclusivamente sul tuo computer.',
'Privacy en gebruik': 'Privacy e utilizzo',
'Toestemming ingetrokken': 'Consenso revocato',
'Toestemming intrekken': 'Revoca consenso',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Devi accettare i termini di privacy e utilizzo prima di poter utilizzare OciDeck.',
'Volledige licentie online': 'Licenza completa online',
'Welkom bij OciDeck': 'Benvenuto in OciDeck',
},
'de': {
'Geen': 'Keine',
@ -1555,18 +1543,6 @@ const _dutchSourceStrings = {
'P Publikum · H Legende · G Übersicht · B/W schwarz/weiß · R Zeit · Esc Stopp',
'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop':
'P Publikum · H Legende · S Bildschirm · G Übersicht · B/W schwarz/weiß · R Zeit · Esc Stopp',
'Akkoord gaan': 'Akzeptieren',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Alle Daten, die Sie in OciDeck eingeben, bleiben auf Ihrem lokalen System und werden nicht an externe Server gesendet.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'Die App sammelt keine persönlichen Daten, Statistiken oder Nutzungsdaten. Ihre Privatsphäre ist unsere Priorität.',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Indem Sie auf "Akzeptieren" klicken, akzeptieren Sie diese Bedingungen und stimmen der Verwendung von OciDeck zu.',
'Licentie (EUPL 1.2)': 'Lizenz (EUPL 1.2)',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck ist eine lokale Desktop-Anwendung. Ihre Präsentationen und Daten werden ausschließlich auf Ihrem Computer gespeichert.',
'Privacy en gebruik': 'Datenschutz und Verwendung',
'Toestemming ingetrokken': 'Zustimmung widerrufen',
'Toestemming intrekken': 'Zustimmung widerrufen',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Sie müssen die Datenschutz- und Nutzungsbedingungen akzeptieren, bevor Sie OciDeck verwenden können.',
'Volledige licentie online': 'Vollständige Lizenz online',
'Welkom bij OciDeck': 'Willkommen bei OciDeck',
},
'fr': {
'Geen': 'Aucun',
@ -1761,18 +1737,6 @@ const _dutchSourceStrings = {
'P public · H legende · G vue densemble · B/W noir/blanc · R temps · Esc arret',
'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop':
'P public · H legende · S ecran · G vue densemble · B/W noir/blanc · R temps · Esc arret',
'Akkoord gaan': 'Accepter',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Toutes les données que vous saisissez dans OciDeck restent sur votre système local et ne sont pas envoyées à des serveurs externes.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'L\'application ne collecte aucune donnée personnelle, statistique ou d\'utilisation. Votre confidentialité est notre priorité.',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'En cliquant sur "Accepter", vous acceptez ces conditions et acceptez l\'utilisation d\'OciDeck.',
'Licentie (EUPL 1.2)': 'Licence (EUPL 1.2)',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck est une application de bureau locale. Vos présentations et vos données sont stockées exclusivement sur votre ordinateur.',
'Privacy en gebruik': 'Confidentialité et utilisation',
'Toestemming ingetrokken': 'Consentement révoqué',
'Toestemming intrekken': 'Révoquer le consentement',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Vous devez accepter les conditions de confidentialité et d\'utilisation avant de pouvoir utiliser OciDeck.',
'Volledige licentie online': 'Licence complète en ligne',
'Welkom bij OciDeck': 'Bienvenue dans OciDeck',
},
'es': {
'Geen': 'Ninguno',
@ -1968,18 +1932,6 @@ const _dutchSourceStrings = {
'P público · H leyenda · G vista general · B/W negro/blanco · R tiempo · Esc detener',
'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop':
'P público · H leyenda · S pantalla · G vista general · B/W negro/blanco · R tiempo · Esc detener',
'Akkoord gaan': 'Aceptar',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Todos los datos que ingresa en OciDeck permanecen en su sistema local y no se envían a servidores externos.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'La aplicación no recopila datos personales, estadísticas ni datos de uso. Su privacidad es nuestra prioridad.',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Al hacer clic en "Aceptar", acepta estos términos y acepta el uso de OciDeck.',
'Licentie (EUPL 1.2)': 'Licencia (EUPL 1.2)',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck es una aplicación de escritorio local. Sus presentaciones y datos se almacenan exclusivamente en su ordenador.',
'Privacy en gebruik': 'Privacidad y uso',
'Toestemming ingetrokken': 'Consentimiento revocado',
'Toestemming intrekken': 'Revocar consentimiento',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Debe aceptar los términos de privacidad y uso antes de poder usar OciDeck.',
'Volledige licentie online': 'Licencia completa en línea',
'Welkom bij OciDeck': 'Bienvenido a OciDeck',
},
'fy': {
'Geen': 'Gjin',
@ -2175,18 +2127,6 @@ const _dutchSourceStrings = {
'P publyk · H leginda · G oersjoch · B/W swart/wyt · R tiid · Esc stop',
'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop':
'P publyk · H leginda · S skerm · G oersjoch · B/W swart/wyt · R tiid · Esc stop',
'Akkoord gaan': 'Akseptyf gean',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Alle gegevens dy\'t jo yn OciDeck ynfiere, bliuwe op jo lokale systeem en wurde net stjoerd nei eksterne servers.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'De app sammelt gjin persoanlike gegevens, statistiken of gebrûksgegevens. Jo privacy is ús prioriteit.',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Troch op "Akseptyf gean" te klikken, akseptearje jo dizze betingsten en akseptyf jo it brûken fan OciDeck.',
'Licentie (EUPL 1.2)': 'Lisintse (EUPL 1.2)',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck is in lokaal bureauprogram. Jo presintatoarjes en gegevens wurde allinnich op jo kompjûter opslein.',
'Privacy en gebruik': 'Privacy en brûk',
'Toestemming ingetrokken': 'Tastimming yntrokken',
'Toestemming intrekken': 'Tastimming yntrekke',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Jo moatte de privacy- en brûksbetingsten akseptyf gean foardat jo OciDeck brûke kûnne.',
'Volledige licentie online': 'Folsleine lisintse online',
'Welkom bij OciDeck': 'Wolkom by OciDeck',
},
'pap': {
'Geen': 'Ningun',
@ -2381,31 +2321,11 @@ const _dutchSourceStrings = {
'P públiko · H legenda · G resumen · B/W pretu/blanku · R tempu · Esc stop',
'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop':
'P públiko · H legenda · S pantalla · G resumen · B/W pretu/blanku · R tempu · Esc stop',
'Akkoord gaan': 'Akuerdo',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Tur datos ku bo ta entrá den OciDeck ta keda riba bo sistema lokal i no ta mandá pa servidor eksterno.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'E aplikashon no ta kolektá datos personal, estadístika o datos di uso. Bo privacidad ta nos prioridad.',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Dor ku bo ta klikí "Akuerdo", bo ta akseptá e termino aki i bo ta akuerdo ku e uso di OciDeck.',
'Licentie (EUPL 1.2)': 'Lisencia (EUPL 1.2)',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck ta un programa di buró lokal. Bo presentashon i datos ta almasená solamente riba bo komputer.',
'Privacy en gebruik': 'Privacidad i Uso',
'Toestemming ingetrokken': 'Aprobashon retirá',
'Toestemming intrekken': 'Retirá Aprobashon',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Bo mester akseptá e termino di privacidad i uso antes bo por usa OciDeck.',
'Volledige licentie online': 'Lisencia kompleto online',
'Welkom bij OciDeck': 'Bienvenido na OciDeck',
},
};
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',
@ -2512,58 +2432,8 @@ 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.',
'Welkom bij OciDeck': 'Welcome to OciDeck',
'Privacy en gebruik': 'Privacy and use',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.':
'OciDeck is a local desktop application. Your presentations and data are stored solely on your computer.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.':
'The app collects no personal data, no statistics, and no usage data. Your privacy is our priority.',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.':
'All data you enter in OciDeck stays on your local system and is not sent to external servers.',
'Licentie (EUPL 1.2)': 'License (EUPL 1.2)',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.':
'By clicking "Agree", you accept these terms and consent to the use of OciDeck.',
'Volledige licentie online': 'Full license online',
'Akkoord gaan': 'Agree',
'Privacy': 'Privacy',
'Toestemming': 'Consent',
'Toestemming intrekken': 'Withdraw consent',
'Toestemming intrekken?': 'Withdraw consent?',
'Intrekken': 'Withdraw',
'U hebt al toegestemd in het gebruik van OciDeck.':
'You have already consented to the use of OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'You can withdraw your consent at any time. After withdrawal you must accept these terms again.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'If you withdraw your consent, you must accept these terms again when you restart OciDeck.',
},
'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',
@ -2824,45 +2694,8 @@ 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.',
'Intrekken': 'Revoca',
'Privacy': 'Privacy',
'Toestemming': 'Consenso',
'Toestemming intrekken?': 'Revocare il consenso?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Hai già acconsentito all\'uso di OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Puoi revocare il consenso in qualsiasi momento. Dopo la revoca dovrai accettare nuovamente questi termini.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Se revochi il consenso, dovrai accettare nuovamente questi termini al riavvio di OciDeck.',
},
'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',
@ -3123,44 +2956,8 @@ 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.',
'Intrekken': 'Widerrufen',
'Privacy': 'Datenschutz',
'Toestemming': 'Zustimmung',
'Toestemming intrekken?': 'Zustimmung widerrufen?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Sie haben der Nutzung von OciDeck bereits zugestimmt.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Sie können Ihre Zustimmung jederzeit widerrufen. Nach dem Widerruf müssen Sie diese Bedingungen erneut akzeptieren.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Wenn Sie Ihre Zustimmung widerrufen, müssen Sie diese Bedingungen beim Neustart von OciDeck erneut akzeptieren.',
},
'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',
@ -3421,48 +3218,8 @@ 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.',
'Intrekken': 'Révoquer',
'Privacy': 'Confidentialité',
'Toestemming': 'Consentement',
'Toestemming intrekken?': 'Révoquer le consentement ?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Vous avez déjà consenti à l\'utilisation d\'OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Vous pouvez révoquer votre consentement à tout moment. Après la révocation, vous devrez accepter à nouveau ces conditions.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si vous révoquez votre consentement, vous devrez accepter à nouveau ces conditions au redémarrage d\'OciDeck.',
},
'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',
@ -3724,47 +3481,8 @@ 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.',
'Intrekken': 'Revocar',
'Privacy': 'Privacidad',
'Toestemming': 'Consentimiento',
'Toestemming intrekken?': '¿Revocar el consentimiento?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Ya has dado tu consentimiento para el uso de OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Puedes revocar tu consentimiento en cualquier momento. Tras la revocación, deberás aceptar de nuevo estas condiciones.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si revocas tu consentimiento, deberás aceptar de nuevo estas condiciones al reiniciar OciDeck.',
},
'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',
@ -4022,45 +3740,8 @@ 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.',
'Intrekken': 'Ynlûke',
'Privacy': 'Privacy',
'Toestemming': 'Tastimming',
'Toestemming intrekken?': 'Tastimming ynlûke?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Jo hawwe al tastimming jûn foar it gebrûk fan OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Jo kinne jo tastimming op elk momint ynlûke. Nei it ynlûken moatte jo dizze betingsten opnij akseptearje.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'As jo jo tastimming ynlûke, moatte jo dizze betingsten opnij akseptearje as jo OciDeck opnij begjinne.',
},
'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',
@ -4318,51 +3999,5 @@ 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.',
'Welkom bij OciDeck': 'Welcome to OciDeck',
'Privacy en gebruik': 'Privacy and Usage',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.':
'OciDeck is a local desktop application. Your presentations and data are stored exclusively on your computer.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.':
'The app collects no personal data, no statistics, and no usage data. Your privacy is our priority.',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.':
'All data you enter in OciDeck remains on your local system and is not sent to external servers.',
'Licentie (EUPL 1.2)': 'License (EUPL 1.2)',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.':
'By clicking "Agree", you accept these terms and agree to the use of OciDeck.',
'Volledige licentie online': 'Full license online',
'Akkoord gaan': 'Agree',
'Toestemming intrekken': 'Revoke Consent',
'Toestemming ingetrokken': 'Consent revoked',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.':
'You must accept the privacy and usage terms before you can use OciDeck.',
'Intrekken': 'Retirá',
'Privacy': 'Privasidat',
'Toestemming': 'Konsentimentu',
'Toestemming intrekken?': 'Retirá konsentimentu?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Bo a duna kaba bo konsentimentu pa uzo di OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Bo por retirá bo konsentimentu na kualkier momentu. Despues di retirá, bo tin ku aseptá e kondishonnan akí di nobo.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si bo retirá bo konsentimentu, bo tin ku aseptá e kondishonnan akí di nobo ora bo start OciDeck di nobo.',
},
};

View file

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

View file

@ -58,12 +58,6 @@ class FileService {
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
/// The user's active style profile, resolved for [projectPath]. Styling is no
/// longer read from the markdown (the file holds only content); the app
/// applies the current profile whenever a deck is opened.
ThemeProfile activeProfileFor({String? projectPath}) =>
resolveThemeProfile(_themeProfile(), projectPath: projectPath);
ThemeProfile resolveThemeProfile(
ThemeProfile profile, {
String? projectPath,
@ -173,12 +167,8 @@ class FileService {
if (!await file.exists()) return null;
raw = await file.readAsString();
}
final parsed = _md.parseDeck(raw, filePath: filePath);
if (parsed == null) return null;
// The file carries only content; apply the active style profile on open.
final deck = parsed.copyWith(
themeProfile: activeProfileFor(projectPath: parsed.projectPath),
);
final deck = _md.parseDeck(raw, filePath: filePath);
if (deck == null) return null;
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
// Re-attach the separate annotation layer from its sidecar, if present.
if (content == null) {

View file

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

View file

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

View file

@ -11,19 +11,7 @@ const _uuid = Uuid();
class MarkdownService {
// Generation
/// Serialise a deck to Marp markdown.
///
/// The styling (the [ThemeProfile]) is deliberately NOT written to the file:
/// a saved `.md` holds only the content (the "base"), and the app applies the
/// active style profile when it opens the deck. [inlineStyleProfile] re-adds
/// the profile for transient, non-file payloads currently only the markdown
/// streamed to the audience (beamer) window, which has no other way to learn
/// the styling. It must stay false for anything written to disk.
String generateDeck(
Deck deck, {
bool inlineChartData = false,
bool inlineStyleProfile = false,
}) {
String generateDeck(Deck deck, {bool inlineChartData = false}) {
final buf = StringBuffer();
buf.writeln('---');
buf.writeln('marp: true');
@ -54,11 +42,9 @@ class MarkdownService {
if (deck.tlp != TlpLevel.none) {
buf.writeln('tlp: ${deck.tlp.key}');
}
if (inlineStyleProfile) {
buf.writeln(
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
);
}
buf.writeln(
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
);
buf.writeln('---');
buf.writeln();

View file

@ -1,67 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _consentKey = 'app_consent_accepted';
final consentProvider =
NotifierProvider<ConsentNotifier, ConsentState>(() {
return ConsentNotifier();
});
class ConsentState {
final bool hasAccepted;
final bool isLoading;
const ConsentState({
required this.hasAccepted,
this.isLoading = false,
});
ConsentState copyWith({
bool? hasAccepted,
bool? isLoading,
}) {
return ConsentState(
hasAccepted: hasAccepted ?? this.hasAccepted,
isLoading: isLoading ?? this.isLoading,
);
}
}
class ConsentNotifier extends Notifier<ConsentState> {
@override
ConsentState build() {
_initialize();
return const ConsentState(hasAccepted: false, isLoading: true);
}
Future<void> _initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final hasAccepted = prefs.getBool(_consentKey) ?? false;
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false);
}
}
Future<void> acceptConsent() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_consentKey, true);
state = state.copyWith(hasAccepted: true);
} catch (e) {
state = state.copyWith(hasAccepted: true);
}
}
Future<void> revokeConsent() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_consentKey, false);
state = state.copyWith(hasAccepted: false);
} catch (e) {
state = state.copyWith(hasAccepted: false);
}
}
}

View file

@ -127,12 +127,13 @@ class DeckNotifier extends StateNotifier<DeckState> {
state = DeckState(deck: deck, isDirty: true);
}
/// Load a deck that was already parsed (used by the tab manager). Styling is
/// not taken from the deck/markdown but from the active style profile, so an
/// opened or recovered deck always picks up the current look.
/// Load a deck that was already parsed (used by the tab manager).
void loadDeck(Deck deck, {String? filePath}) {
final resolvedDeck = deck.copyWith(
themeProfile: _file.activeProfileFor(projectPath: deck.projectPath),
themeProfile: _file.resolveThemeProfile(
deck.themeProfile,
projectPath: deck.projectPath,
),
);
_clearHistory();
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
@ -498,14 +499,8 @@ class DeckNotifier extends StateNotifier<DeckState> {
/// Returns false if parsing fails (content is preserved).
bool applyMarkdown(String markdown) {
final parsed = _md.parseDeck(markdown, filePath: state.filePath);
if (parsed == null) return false;
// The markdown carries only content; keep the deck's current styling rather
// than resetting it to the default profile the parser returns.
final current = state.deck;
final deck = current == null
? parsed
: parsed.copyWith(themeProfile: current.themeProfile);
final deck = _md.parseDeck(markdown, filePath: state.filePath);
if (deck == null) return false;
_mutate(deck); // discrete stap ook ongedaan te maken
return true;
}

View file

@ -54,17 +54,9 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
? selectedAppearance
: 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [],
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
);
}
Future<void> setUiTextScale(double scale) async {
final clamped = scale.clamp(1.0, 2.0).toDouble();
state = state.copyWith(uiTextScale: clamped);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('uiTextScale', clamped);
}
Future<void> addRecentFile(String path) async {
final updated = [
path,

View file

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

View file

@ -138,47 +138,6 @@ List<String> _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<void> _replaceImageUsages(
WidgetRef ref,
String fromAbsolute,
String toAbsolute,
) async {
final target = p.normalize(fromAbsolute);
for (final tab in ref.read(tabsProvider).tabs) {
final notifier = tab.deckNotifier;
final deck = notifier.currentState.deck;
if (deck == null) continue;
final projectPath = deck.projectPath ?? '';
String resolve(String candidate) => p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
);
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
// binnen het project ligt; anders absoluut.
String replacement(String candidate) {
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
return p.isWithin(projectPath, toAbsolute)
? p.relative(toAbsolute, from: projectPath)
: toAbsolute;
}
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
var updated = slide;
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
}
if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) {
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
}
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
}
}
}
List<Slide> _slidesForPresentationOrExport(Deck deck) {
// Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export.
@ -967,7 +926,9 @@ 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.')}',
),
),
);
}
@ -986,11 +947,6 @@ 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;
@ -1488,43 +1444,36 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
});
}
// 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 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);
});
}
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()),
],
return Row(
children: [
SizedBox(width: railWidth, child: const SlideListPanel()),
_ResizableDivider(
onDrag: (delta) {
setState(() {
_slideRailWidth = (_slideRailWidth + delta)
.clamp(_minSlideRailWidth, maxRailWidth)
.toDouble();
});
},
),
const Expanded(child: EditorPanel()),
],
);
},
);
},
),
@ -1772,67 +1721,36 @@ 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 || _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,
),
),
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,
),
),
),

View file

@ -15,19 +15,23 @@ class AddSlideDialog extends StatelessWidget {
}
static const _types = [
(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'),
(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'),
];
@override
@ -38,272 +42,73 @@ class AddSlideDialog extends StatelessWidget {
const SingleActivator(LogicalKeyboardKey.escape): () =>
Navigator.pop(context),
},
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: Focus(
autofocus: true,
child: AlertDialog(
title: Text(l10n.d('Slide type kiezen')),
content: SizedBox(
width: 400,
child: Wrap(
spacing: 10,
runSpacing: 10,
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),
),
],
children: _types.map((entry) {
final (type, icon, label) = entry;
return _TypeCard(
icon: icon,
label: l10n.d(label),
onTap: () => Navigator.pop(context, type),
);
}).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.t('cancel')),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.t('cancel')),
),
],
),
);
}
}
class _TypeCard extends StatelessWidget {
final SlideType type;
final IconData icon;
final String label;
final VoidCallback onTap;
final bool autofocus;
const _TypeCard({
required this.type,
required this.icon,
required this.label,
required this.onTap,
this.autofocus = false,
});
@override
Widget build(BuildContext context) {
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),
),
],
),
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),
),
],
),
),
);
}
}
/// Paints a miniature 16:9 wireframe of a slide layout, in the spirit of the
/// layout pickers in other presentation tools: title bars, text lines, image
/// placeholders. All geometry lives on a 160×90 design canvas and is scaled
/// to whatever size the card provides.
@visibleForTesting
class SlideTypePreviewPainter extends CustomPainter {
final SlideType type;
/// Wireframe palette: dark bars for titles, soft bars for body text.
static const _canvas = Color(0xFFF8FAFC);
static const _ink = Color(0xFF334155);
static const _soft = Color(0xFFB6C2D2);
static const _fill = Color(0xFFE2E8F0);
static const _accent = AppTheme.accent;
const SlideTypePreviewPainter({required this.type});
@override
void paint(Canvas canvas, Size size) {
final u = size.width / 160;
canvas.scale(u);
canvas.drawRect(const Rect.fromLTWH(0, 0, 160, 90), _paint(_canvas));
switch (type) {
case SlideType.title:
_bar(canvas, 30, 34, 100, 12, _ink);
_bar(canvas, 45, 53, 70, 7, _accent);
case SlideType.section:
_bar(canvas, 16, 36, 5, 24, _accent);
_bar(canvas, 30, 38, 86, 11, _ink);
_bar(canvas, 30, 54, 52, 6, _soft);
case SlideType.bullets:
_bar(canvas, 14, 12, 84, 9, _ink);
_bullets(canvas, 14, 34, 110, 4);
case SlideType.twoBullets:
_bar(canvas, 14, 12, 84, 9, _ink);
_bullets(canvas, 14, 32, 56, 3);
_bullets(canvas, 90, 32, 56, 3);
case SlideType.bulletsImage:
_bar(canvas, 14, 12, 66, 9, _ink);
_bullets(canvas, 14, 32, 60, 3);
_imageBox(canvas, 90, 26, 56, 50);
case SlideType.twoImages:
_imageBox(canvas, 12, 16, 64, 46);
_imageBox(canvas, 84, 16, 64, 46);
_bar(canvas, 20, 68, 48, 5, _soft);
_bar(canvas, 92, 68, 48, 5, _soft);
case SlideType.image:
_imageBox(canvas, 10, 10, 140, 70);
case SlideType.video:
_imageBox(canvas, 10, 10, 140, 70, pictogram: false);
canvas.drawCircle(const Offset(80, 45), 14, _paint(Colors.white));
final play = Path()
..moveTo(75, 37)
..lineTo(89, 45)
..lineTo(75, 53)
..close();
canvas.drawPath(play, _paint(_ink));
case SlideType.quote:
_quoteMark(canvas, 16, 16);
_bar(canvas, 42, 30, 96, 7, _ink);
_bar(canvas, 42, 43, 78, 7, _ink);
_bar(canvas, 42, 60, 42, 5, _accent);
case SlideType.table:
_bar(canvas, 14, 16, 132, 14, _soft, radius: 2);
final line = _paint(_ink.withValues(alpha: 0.45))..strokeWidth = 1.5;
for (var r = 1; r <= 4; r++) {
canvas.drawLine(
Offset(14, 16 + r * 14),
Offset(146, 16 + r * 14),
line,
);
}
for (var c = 0; c <= 3; c++) {
canvas.drawLine(
Offset(14 + c * 44, 16),
Offset(14 + c * 44, 72),
line,
);
}
case SlideType.chart:
final axis = _paint(_soft)..strokeWidth = 2;
canvas.drawLine(const Offset(20, 14), const Offset(20, 74), axis);
canvas.drawLine(const Offset(20, 74), const Offset(148, 74), axis);
_bar(canvas, 34, 50, 18, 24, _soft, radius: 2);
_bar(canvas, 64, 36, 18, 38, _accent, radius: 2);
_bar(canvas, 94, 44, 18, 30, _soft, radius: 2);
_bar(canvas, 124, 24, 18, 50, _accent, radius: 2);
case SlideType.code:
_bar(canvas, 10, 10, 140, 70, const Color(0xFF1E293B), radius: 4);
_bar(canvas, 20, 22, 44, 6, const Color(0xFF7DD3A7), radius: 3);
_bar(canvas, 30, 34, 64, 6, const Color(0xFF93B8F8), radius: 3);
_bar(canvas, 30, 46, 50, 6, const Color(0xFFE2C08D), radius: 3);
_bar(canvas, 20, 58, 32, 6, const Color(0xFF7DD3A7), radius: 3);
case SlideType.freeMarkdown:
_bar(canvas, 14, 12, 10, 9, _accent, radius: 2);
_bar(canvas, 28, 12, 62, 9, _ink);
_bar(canvas, 14, 32, 120, 6, _soft);
_bar(canvas, 14, 44, 132, 6, _soft);
_bar(canvas, 14, 56, 92, 6, _soft);
_bar(canvas, 14, 68, 110, 6, _soft);
}
}
void _bar(
Canvas canvas,
double x,
double y,
double w,
double h,
Color color, {
double? radius,
}) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, w, h),
Radius.circular(radius ?? h / 2),
),
_paint(color),
);
}
/// A column of bullet points: accent dot plus a soft text line.
void _bullets(Canvas canvas, double x, double y, double w, int count) {
for (var i = 0; i < count; i++) {
final dy = y + i * 13.0;
canvas.drawCircle(Offset(x + 3, dy + 3), 3, _paint(_accent));
_bar(canvas, x + 11, dy, w * (i.isEven ? 1.0 : 0.74), 6, _soft);
}
}
/// Image placeholder: filled box with a sun and mountains pictogram.
void _imageBox(
Canvas canvas,
double x,
double y,
double w,
double h, {
bool pictogram = true,
}) {
_bar(canvas, x, y, w, h, _fill, radius: 4);
if (!pictogram) return;
final dark = _paint(_soft);
canvas.drawCircle(Offset(x + w * 0.28, y + h * 0.30), h * 0.11, dark);
final hills = Path()
..moveTo(x + w * 0.08, y + h * 0.88)
..lineTo(x + w * 0.38, y + h * 0.45)
..lineTo(x + w * 0.58, y + h * 0.72)
..lineTo(x + w * 0.74, y + h * 0.52)
..lineTo(x + w * 0.94, y + h * 0.88)
..close();
canvas.drawPath(hills, dark);
}
/// A stylised double quotation mark.
void _quoteMark(Canvas canvas, double x, double y) {
final paint = _paint(_accent);
for (final dx in [0.0, 11.0]) {
canvas.drawCircle(Offset(x + 4 + dx, y + 8), 4, paint);
final tail = Path()
..moveTo(x + dx, y + 8)
..quadraticBezierTo(x + dx, y + 17, x + 7 + dx, y + 18)
..lineTo(x + 7 + dx, y + 14)
..quadraticBezierTo(x + 4 + dx, y + 13, x + 4 + dx, y + 8)
..close();
canvas.drawPath(tail, paint);
}
}
@override
bool shouldRepaint(SlideTypePreviewPainter old) => old.type != type;
}
Paint _paint(Color color) => Paint()..color = color;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,6 @@
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
@ -110,69 +108,6 @@ class _TableEditorState extends State<TableEditor> {
_emit();
}
/// Intercepts the paste shortcut on a cell: Cmd+V (macOS), Ctrl+V
/// (Windows/Linux) and Shift+Insert (Windows/Linux). The clipboard can only
/// be read asynchronously, so the event is always claimed and [_pasteIntoCell]
/// decides between a table fill and a plain in-cell paste.
KeyEventResult _onCellKey(int r, int c, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
final pasteCombo =
(event.logicalKey == LogicalKeyboardKey.keyV &&
(keys.isControlPressed || keys.isMetaPressed)) ||
(event.logicalKey == LogicalKeyboardKey.insert && keys.isShiftPressed);
if (!pasteCombo) return KeyEventResult.ignored;
Clipboard.getData(Clipboard.kTextPlain).then((data) {
final text = data?.text;
if (text == null || text.isEmpty || !mounted) return;
_pasteIntoCell(r, c, text);
});
return KeyEventResult.handled;
}
/// Tabular clipboard content (a spreadsheet selection, CSV, a markdown
/// table) fills the grid starting at cell (r, c), growing it as needed;
/// anything else is pasted into the cell at the cursor as usual.
void _pasteIntoCell(int r, int c, String text) {
final table = parseClipboardTable(text);
if (table == null) {
final ctrl = _cells[r][c];
final value = ctrl.text;
final sel = ctrl.selection;
final start = sel.isValid ? sel.start : value.length;
final end = sel.isValid ? sel.end : value.length;
ctrl.value = TextEditingValue(
text: value.replaceRange(start, end, text),
selection: TextSelection.collapsed(offset: start + text.length),
);
return;
}
setState(() {
final neededCols = c + table.first.length;
final neededRows = r + table.length;
while (_colCount < neededCols) {
for (final row in _cells) {
row.add(_makeCtrl(''));
}
}
while (_cells.length < neededRows) {
_cells.add(
List<TextEditingController>.generate(_colCount, (_) => _makeCtrl('')),
);
}
for (var i = 0; i < table.length; i++) {
for (var j = 0; j < table[i].length; j++) {
final ctrl = _cells[r + i][c + j];
// Rewrite without notifying per cell; one _emit follows below.
ctrl.removeListener(_emit);
ctrl.text = table[i][j];
ctrl.addListener(_emit);
}
}
});
_emit();
}
@override
void dispose() {
_title.dispose();
@ -196,9 +131,8 @@ class _TableEditorState extends State<TableEditor> {
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
'${l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.')}\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)),
l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.'),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
_buildColumnControls(),
@ -236,7 +170,7 @@ class _TableEditorState extends State<TableEditor> {
icon: const Icon(
Icons.delete_outline,
size: 16,
color: Color(0xFF64748B),
color: Color(0xFF94A3B8),
),
onPressed: _colCount > 1 ? () => _removeColumn(c) : null,
tooltip:
@ -269,31 +203,26 @@ class _TableEditorState extends State<TableEditor> {
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
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,
),
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,
),
),
),
@ -307,7 +236,7 @@ class _TableEditorState extends State<TableEditor> {
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF64748B),
color: Color(0xFF94A3B8),
),
onPressed: _cells.length > 1 ? () => _removeRow(r) : null,
tooltip: isHeader

View file

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

View file

@ -348,7 +348,7 @@ class _BulletColumnState extends State<_BulletColumn> {
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
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(0xFF64748B),
color: Color(0xFF94A3B8),
),
onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
tooltip: l10n.d('Verwijder'),

View file

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

View file

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

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@ -21,14 +19,7 @@ import '../dialogs/slide_finder_dialog.dart';
import '../slides/slide_thumbnail.dart';
class SlideListPanel extends ConsumerStatefulWidget {
/// 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});
const SlideListPanel({super.key});
@override
ConsumerState<SlideListPanel> createState() => _SlideListPanelState();
@ -40,35 +31,15 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
final _scrollController = ScrollController();
final _focusNode = FocusNode(debugLabel: 'SlideListPanel');
final Map<String, GlobalKey> _slideKeys = {};
Timer? _resizeSettleTimer;
@override
void dispose() {
_resizeSettleTimer?.cancel();
_searchController.dispose();
_scrollController.dispose();
_focusNode.dispose();
super.dispose();
}
/// Thumbnails are 16:9, so when the rail is resized their heights change and
/// the scroll offset no longer points at the slide being edited. Once the
/// resize settles, bring the selected slide back to the top of the list.
@override
void didUpdateWidget(covariant SlideListPanel oldWidget) {
super.didUpdateWidget(oldWidget);
final width = widget.railWidth;
final previous = oldWidget.railWidth;
if (width == null || previous == null || (width - previous).abs() < 0.5) {
return;
}
_resizeSettleTimer?.cancel();
_resizeSettleTimer = Timer(const Duration(milliseconds: 200), () {
if (!mounted) return;
_scrollSlideToTop(ref.read(editorProvider).selectedIndex);
});
}
/// Lower-cased, concatenated text of a slide for searching. Kept broad on
/// purpose: everything you typed into the slide should make it findable.
String _slideText(Slide slide) {
@ -117,7 +88,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
_slideKeys.removeWhere((id, _) => !ids.contains(id));
}
void _scrollSlideToTop(int index, {int attempts = 2}) {
void _scrollSlideToTop(int index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final deck = ref.read(deckProvider).deck;
if (deck == null ||
@ -129,21 +100,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
final keyContext = _slideKeys[deck.slides[index].id]?.currentContext;
final target = keyContext?.findRenderObject();
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;
}
if (target == null) return;
final viewport = RenderAbstractViewport.maybeOf(target);
if (viewport == null) return;
@ -549,67 +506,6 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
);
}
Widget _buildSlideList(
Deck deck,
bool searching,
String query,
EditorState editor,
DeckNotifier notifier,
EditorNotifier editorNotifier,
) {
if (searching) {
return _buildFilteredList(deck, query, editor, notifier, editorNotifier);
}
return ReorderableListView.builder(
scrollController: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 4),
buildDefaultDragHandles: false,
itemCount: deck.slides.length,
onReorderItem: (old, nw) {
notifier.reorderSlides(old, nw);
// Adjust selection when active slide moved
final selIdx = editor.selectedIndex;
int newSel = selIdx;
if (old == selIdx) {
newSel = nw;
} else if (old < selIdx && nw >= selIdx) {
newSel = selIdx - 1;
} else if (old > selIdx && nw <= selIdx) {
newSel = selIdx + 1;
}
editorNotifier.select(newSel.clamp(0, deck.slides.length - 1));
},
proxyDecorator: (child, index, animation) =>
Material(color: Colors.transparent, child: child),
itemBuilder: (_, i) {
final slide = deck.slides[i];
return SlideThumbnail(
key: _keyForSlide(slide),
slide: slide,
index: i,
isSelected: editor.selection.contains(i),
isPrimary: editor.selectedIndex == i,
projectPath: deck.projectPath,
themeProfile: deck.themeProfile,
slideCount: deck.slides.length,
tlp: deck.tlp,
onTap: () => _onSlideTap(i),
onToggleSkip: () => notifier.toggleSkip(i),
onCopyImage: () => _copySlideAsImage(slide),
onDuplicate: () {
notifier.duplicateSlide(i);
editorNotifier.select(i + 1);
},
onDelete: () {
if (deck.slides.length <= 1) return;
notifier.removeSlide(i);
editorNotifier.clampIndex(deck.slides.length - 2);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@ -709,14 +605,64 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
// Slide list
Expanded(
child: _buildSlideList(
deck,
searching,
query,
editor,
notifier,
editorNotifier,
),
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);
},
);
},
),
),
// Add / Paste slide buttons

View file

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

View file

@ -47,10 +47,6 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
final Map<int, List<InkStroke>> _ink = {};
int? _laserIndex;
Offset? _laserPoint;
// The stroke the presenter is drawing right now, mirrored live until it
// commits (then it arrives as a normal stroke over the 'ink' channel).
int? _activeIndex;
InkStroke? _activeStroke;
@override
void initState() {
@ -88,7 +84,6 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
_index = (m['index'] as num?)?.toInt() ?? _index;
_blank = (m['blank'] as num?)?.toInt() ?? 0;
_laserPoint = null; // laser never carries over to another slide
_activeStroke = null; // nor does an in-progress stroke
});
case 'ink':
final m = Map<String, dynamic>.from(call.arguments as Map);
@ -97,17 +92,6 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
setState(
() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []),
);
case 'inkLive':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
final s = m['stroke'];
if (!mounted) return null;
setState(() {
_activeIndex = i;
_activeStroke = s == null
? null
: InkStroke.fromJson(Map<String, dynamic>.from(s as Map));
});
case 'laser':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
@ -214,13 +198,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
}),
),
AnnotationLayer(
strokes: [
...?_ink[_index],
// The live in-progress stroke renders just like a committed
// one, so the audience sees the line grow as it's drawn.
if (_activeStroke != null && _activeIndex == _index)
_activeStroke!,
],
strokes: _ink[_index] ?? const [],
interactive: false,
laserPoint: _laserIndex == _index ? _laserPoint : null,
),

View file

@ -3,7 +3,6 @@ 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';
@ -15,7 +14,6 @@ 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';
@ -169,8 +167,6 @@ class FullscreenPresenter extends StatefulWidget {
}) async {
// A self-contained markdown deck is the payload for the audience window; it
// carries the slides, the style profile and the TLP level in one string.
// This payload never touches disk, so it inlines the style profile the
// beamer has no other way to learn the deck's styling.
final markdown = MarkdownService().generateDeck(
Deck(
title: 'Presentatie',
@ -179,7 +175,6 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile,
tlp: tlp,
),
inlineStyleProfile: true,
);
// Pre-existing annotations re-keyed by index so the beamer shows them
// immediately (the audience window has no stable slide ids of its own).
@ -377,7 +372,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
static const _penWidth = 0.004;
static const _highlighterWidth = 0.022;
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0);
double get _toolWidth =>
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
@ -545,28 +539,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
.catchError((_) => null);
}
/// Mirror the stroke that is being drawn right now to the beamer, so the
/// audience sees a pen/highlighter line appear live instead of only after the
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
/// just keeps the in-progress preview in sync for the same slide.
void _onActiveStroke(InkStroke? stroke) {
if (widget.audienceWindow == null) return;
final now = DateTime.now();
// Throttle growth events; always send the "done" (null) event so the
// beamer drops its live preview the moment the stroke commits.
if (stroke != null &&
now.difference(_lastInkLiveSent) < const Duration(milliseconds: 33)) {
return;
}
_lastInkLiveSent = now;
audienceChannel
.invokeMethod('inkLive', {
'index': _index,
'stroke': stroke?.toJson(),
})
.catchError((_) => null);
}
/// Select a tool, or toggle it off when it is already active.
void _setTool(InkTool tool) {
setState(() => _tool = _tool == tool ? null : tool);
@ -755,22 +727,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
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) {
@ -780,7 +736,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (_index < widget.slides.length - 1) {
setState(() => _index++);
_scheduleAdvance();
_announceSlide();
}
}
@ -792,7 +747,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (_index > 0) {
setState(() => _index--);
_scheduleAdvance();
_announceSlide();
}
}
@ -885,7 +839,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_gridOpen = false;
});
_scheduleAdvance();
_announceSlide();
}
/// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End).
@ -898,7 +851,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (target == _index) return;
setState(() => _index = target);
_scheduleAdvance();
_announceSlide();
}
/// Verplaats de rastercursor en houd 'm in beeld.
@ -1432,7 +1384,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
interactive: true,
onStrokesChanged: _onStrokesChanged,
onLaserMove: _onLaserMove,
onActiveStrokeChanged: _onActiveStroke,
),
],
),

View file

@ -186,27 +186,22 @@ 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.
// 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(),
),
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(),
),
),
),
@ -712,7 +707,7 @@ class _BulletsPreview extends StatelessWidget {
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
maxScale: _kSplitBulletsMaxScale,
listStyle: slide.listStyle,
);
@ -1077,7 +1072,7 @@ class _TwoBulletsPreview extends StatelessWidget {
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
maxScale: _kBulletsMaxScale,
listStyle: slide.listStyle,
);
final rightScale = _bulletsFitScale(
@ -1091,7 +1086,7 @@ class _TwoBulletsPreview extends StatelessWidget {
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
maxScale: _kBulletsMaxScale,
listStyle: slide.listStyle,
);
// Treat both columns as one composition: the busiest column determines
@ -1252,7 +1247,7 @@ class _BulletsImagePreview extends StatelessWidget {
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
maxScale: _kBulletsMaxScale,
listStyle: slide.listStyle,
);
@ -1510,34 +1505,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),
),
),
),
],
),
);
@ -1781,19 +1776,6 @@ 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 2432pt beyond that it stops aiding readability and starts
/// competing with the title. The fit scale multiplies title and bullets
/// alike, so capping the bullet size also keeps the hierarchy intact.
const double _kBulletMaxFontFraction = 0.0335;
/// The largest auto-fit scale that keeps bullets at or under
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
/// Line height used for bullet body text, shared by rendering and measuring.
const double _kBulletLineHeight = 1.16;
@ -2890,36 +2872,6 @@ 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);
@ -2928,32 +2880,6 @@ 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(
@ -3275,7 +3201,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
);
}
FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) {
FlTitlesData _titles(ChartSpec spec, Color textColor) {
final style = _applyFont(
font,
TextStyle(
@ -3308,30 +3234,16 @@ 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. 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);
// evenly based on the actual pixel spacing between points.
final spacing = 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 lastGap = n - 1 - lastMultiple;
final showLast = i == n - 1 && lastGap > step / 2;
final showLast = i == n - 1 && (n - 1 - lastMultiple) > step / 2;
if (i % step != 0 && !showLast) return const SizedBox.shrink();
// 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,
);
final slot = (step * spacing - w * 0.012).clamp(w * 0.04, w * 0.16);
return Padding(
padding: EdgeInsets.only(top: w * 0.008),
child: SizedBox(
@ -3363,17 +3275,6 @@ 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 = <BarChartGroupData>[];
for (var xi = 0; xi < spec.x.length; xi++) {
@ -3386,7 +3287,10 @@ class _ChartPreviewState extends State<_ChartPreview> {
BarChartRodData(
toY: spec.series[si].data[xi],
color: _seriesDisplayColor(spec.series[si], si),
width: _barRodWidth(spec),
width: (w * 0.032 / spec.series.length).clamp(
w * 0.008,
w * 0.022,
),
borderRadius: BorderRadius.vertical(
top: Radius.circular(w * 0.006),
),
@ -3404,11 +3308,8 @@ 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, bars: true),
titlesData: _titles(spec, textColor),
gridData: _grid(textColor),
borderData: FlBorderData(show: false),
extraLinesData: _boundLines(spec),
@ -3636,25 +3537,19 @@ class _ChartPreviewState extends State<_ChartPreview> {
final scale = radarScale(spec);
return Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012),
padding: EdgeInsets.symmetric(horizontal: w * 0.03, vertical: w * 0.012),
child: LayoutBuilder(
builder: (context, constraints) {
// 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.
// Reserve a slim column on the right for the scale legend, then keep
// the chart square so fl_chart's centre/radius stay predictable.
final legendWidth = w * 0.075;
final boxW = math.max(
final available = constraints.maxWidth - legendWidth - w * 0.02;
final side = math.max(
0.0,
constraints.maxWidth - legendWidth - w * 0.02,
math.min(available, constraints.maxHeight),
);
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;
final labelBand = side * 0.23;
final chartSide = math.max(0.0, side - labelBand * 2);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
@ -3662,8 +3557,8 @@ class _ChartPreviewState extends State<_ChartPreview> {
Expanded(
child: Center(
child: SizedBox(
width: boxW,
height: boxH,
width: side,
height: side,
child: Stack(
children: [
for (var i = 0; i < spec.x.length; i++)
@ -3671,12 +3566,12 @@ class _ChartPreviewState extends State<_ChartPreview> {
label: spec.x[i],
index: i,
count: spec.x.length,
layout: layout,
side: side,
textColor: textColor,
),
Positioned(
left: (boxW - chartSide) / 2,
top: (boxH - chartSide) / 2,
left: labelBand,
top: labelBand,
width: chartSide,
height: chartSide,
child: Stack(
@ -3830,149 +3725,57 @@ 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<Rect> rects, List<TextAlign> aligns, int maxLines})
_radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) {
const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8
final n = spec.x.length;
final style = _radarLabelStyle(n, textColor);
final gap = w * 0.008;
final maxLines = n <= 6 ? 3 : 2;
final sideCap = math.min(boxW * 0.28, w * 0.2);
final topCap = math.min(boxW * 0.5, w * 0.3);
Size measure(String text, double maxWidth) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
maxLines: maxLines,
ellipsis: '',
)..layout(maxWidth: math.max(0.0, maxWidth));
final size = Size(painter.width, painter.height);
painter.dispose();
return size;
}
final directions = <Offset>[];
final sizes = <Size>[];
for (var i = 0; i < n; i++) {
final angle = (2 * math.pi * i / n) - math.pi / 2;
final dir = Offset(math.cos(angle), math.sin(angle));
directions.add(dir);
sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap));
}
// The largest polygon radius every label still fits next to.
var radius = radiusFactor * math.min(boxW, boxH);
for (var i = 0; i < n; i++) {
final dx = directions[i].dx.abs();
final dy = directions[i].dy.abs();
if (_radarLabelBeside(directions[i])) {
radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx);
if (dy > 0.01) {
radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy);
}
} else {
radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy);
if (dx > 0.01) {
radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx);
}
}
}
// Never let extreme labels crush the spider entirely; below this floor the
// labels get clamped (and ellipsized) instead.
final floor = 0.18 * math.min(boxW, boxH);
radius = radius.clamp(
math.min(floor, radiusFactor * math.min(boxW, boxH)),
radiusFactor * math.min(boxW, boxH),
);
final chartSide = radius / radiusFactor;
final center = Offset(boxW / 2, boxH / 2);
final rects = <Rect>[];
final aligns = <TextAlign>[];
for (var i = 0; i < n; i++) {
final dir = directions[i];
final anchor = center + dir * (radius + gap);
var size = sizes[i];
double left;
double top;
if (_radarLabelBeside(dir)) {
// Re-measure against the room actually left beside the polygon, so a
// clamped radius still produces a label that wraps inside the box.
final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx;
if (size.width > room) size = measure(spec.x[i], room);
left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width;
top = anchor.dy - size.height / 2;
aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right);
} else {
left = anchor.dx - size.width / 2;
top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy;
aligns.add(TextAlign.center);
}
rects.add(
Rect.fromLTWH(
left.clamp(0.0, math.max(0.0, boxW - size.width)),
top.clamp(0.0, math.max(0.0, boxH - size.height)),
size.width,
size.height,
),
);
}
return (
chartSide: chartSide,
rects: rects,
aligns: aligns,
maxLines: maxLines,
);
}
Widget _radarAxisLabel({
required String label,
required int index,
required int count,
required ({
double chartSide,
List<Rect> rects,
List<TextAlign> aligns,
int maxLines,
})
layout,
required double side,
required Color textColor,
}) {
final rect = layout.rects[index];
final angle = (2 * math.pi * index / count) - math.pi / 2;
final boxWidth = side * (count <= 4 ? 0.22 : (count <= 6 ? 0.2 : 0.17));
final boxHeight = side * (count <= 6 ? 0.13 : 0.105);
final center = side / 2;
final horizontal = math.cos(angle);
final vertical = math.sin(angle);
final left = horizontal < -0.35
? 0.0
: (horizontal > 0.35 ? side - boxWidth : center - boxWidth / 2);
final top = vertical < -0.7
? 0.0
: (vertical > 0.7
? side - boxHeight
: (center + vertical * side * 0.32 - boxHeight / 2).clamp(
0.0,
side - boxHeight,
));
final alignment = horizontal < -0.25
? TextAlign.left
: (horizontal > 0.25 ? TextAlign.right : TextAlign.center);
return Positioned(
key: ValueKey('radar-axis-label-$index'),
left: 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),
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,
),
),
),
),
);
}

View file

@ -55,222 +55,204 @@ class SlideThumbnail extends ConsumerWidget {
// Actieve slide krijgt een dikkere rand dan de overige geselecteerde.
final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0;
// 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,
),
),
],
),
),
),
],
),
),
),
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),
),
// Footer: slide number, type label, action buttons
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: isSelected
? AppTheme.accent
: const Color(0xFF4A4F5B),
borderRadius: BorderRadius.circular(9),
// 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,
),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
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,
),
),
],
),
),
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.d(slide.type.label),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 9,
),
overflow: TextOverflow.ellipsis,
),
),
// Drag handle
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(
Icons.drag_handle,
size: 14,
color: Color(0xFF64748B),
),
),
),
// Snelle overslaan-toggle
SizedBox(
width: 20,
height: 20,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 14,
splashRadius: 12,
tooltip: skipped
? l10n.d('Weer tonen bij presenteren/exporteren')
: l10n.d('Overslaan bij presenteren/exporteren'),
icon: Icon(
skipped
? Icons.visibility_off
: Icons.visibility_outlined,
color: skipped
? const Color(0xFFD4A24E)
: const Color(0xFF64748B),
),
onPressed: onToggleSkip,
),
),
// Context menu
SizedBox(
width: 20,
height: 20,
child: PopupMenuButton<String>(
icon: const Icon(
Icons.more_vert,
color: Color(0xFF64748B),
size: 14,
),
padding: EdgeInsets.zero,
itemBuilder: (_) => [
PopupMenuItem(
value: 'copy',
child: Text(l10n.d('Kopiëren')),
),
PopupMenuItem(
value: 'copy_image',
child: Text(l10n.d('Kopieer als afbeelding')),
),
PopupMenuItem(
value: 'duplicate',
child: Text(l10n.d('Dupliceren')),
),
PopupMenuItem(
value: 'skip',
child: Text(
skipped
? l10n.d('Niet meer overslaan')
: l10n.d('Overslaan'),
),
),
PopupMenuItem(
value: 'delete',
child: Text(
l10n.d('Verwijderen'),
style: const TextStyle(color: Colors.red),
),
),
],
onSelected: (v) {
if (v == 'copy') {
ref.read(slideClipboardProvider.notifier).state =
slide;
}
if (v == 'copy_image') onCopyImage();
if (v == 'duplicate') onDuplicate();
if (v == 'skip') onToggleSkip();
if (v == 'delete') onDelete();
},
),
),
],
),
),
],
),
),
// Footer: slide number, type label, action buttons
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: isSelected
? AppTheme.accent
: const Color(0xFF4A4F5B),
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.d(slide.type.label),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 9,
),
overflow: TextOverflow.ellipsis,
),
),
// Drag handle
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(
Icons.drag_handle,
size: 14,
color: Color(0xFF64748B),
),
),
),
// Snelle overslaan-toggle
SizedBox(
width: 20,
height: 20,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 14,
splashRadius: 12,
tooltip: skipped
? l10n.d('Weer tonen bij presenteren/exporteren')
: l10n.d('Overslaan bij presenteren/exporteren'),
icon: Icon(
skipped
? Icons.visibility_off
: Icons.visibility_outlined,
color: skipped
? const Color(0xFFD4A24E)
: const Color(0xFF64748B),
),
onPressed: onToggleSkip,
),
),
// Context menu
SizedBox(
width: 20,
height: 20,
child: PopupMenuButton<String>(
icon: const Icon(
Icons.more_vert,
color: Color(0xFF64748B),
size: 14,
),
padding: EdgeInsets.zero,
itemBuilder: (_) => [
PopupMenuItem(
value: 'copy',
child: Text(l10n.d('Kopiëren')),
),
PopupMenuItem(
value: 'copy_image',
child: Text(l10n.d('Kopieer als afbeelding')),
),
PopupMenuItem(
value: 'duplicate',
child: Text(l10n.d('Dupliceren')),
),
PopupMenuItem(
value: 'skip',
child: Text(
skipped
? l10n.d('Niet meer overslaan')
: l10n.d('Overslaan'),
),
),
PopupMenuItem(
value: 'delete',
child: Text(
l10n.d('Verwijderen'),
style: const TextStyle(color: Colors.red),
),
),
],
onSelected: (v) {
if (v == 'copy') {
ref.read(slideClipboardProvider.notifier).state =
slide;
}
if (v == 'copy_image') onCopyImage();
if (v == 'duplicate') onDuplicate();
if (v == 'skip') onToggleSkip();
if (v == 'delete') onDelete();
},
),
),
],
),
),
],
),
),
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -329,27 +327,13 @@ 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(distanceToRect(center, rect), greaterThan(apothem * 0.98));
expect(rect.overlaps(radarRect), isFalse);
}
for (var i = 0; i < labelRects.length; i++) {
for (var j = i + 1; j < labelRects.length; j++) {
@ -549,67 +533,6 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('charts expose their data as a text alternative', (tester) async {
final handle = tester.ensureSemantics();
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2026', data: [10, 14]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
// WCAG 1.1.1: the chart carries a label with type, title and values.
expect(
find.bySemanticsLabel('Grafiek (Staaf): Omzet. 2026: Q1 10, Q2 14'),
findsOneWidget,
);
handle.dispose();
});
testWidgets('bar chart x-axis labels never run through each other', (
tester,
) async {
// Six groups: enough that the label slots are narrower than the clamp,
// which used to overlap because the spacing was computed with n-1
// intervals while bar groups each occupy a full nth of the axis.
const labels = [
'Strategische koers',
'Operationele basis',
'Innovatievermogen',
'Mensen en cultuur',
'Financiële ruimte',
'Digitale veiligheid',
];
const spec = ChartSpec(
type: ChartType.bar,
x: labels,
series: [
ChartSeries(name: 'Score', data: [3, 4, 5, 2, 4, 3]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final rects = [
for (final label in labels)
if (find.text(label).evaluate().isNotEmpty)
tester.getRect(find.text(label).first),
];
expect(rects.length, greaterThanOrEqualTo(2));
for (var i = 0; i < rects.length; i++) {
for (var j = i + 1; j < rects.length; j++) {
expect(rects[i].overlaps(rects[j]), isFalse);
}
}
expect(tester.takeException(), isNull);
});
testWidgets(
'pie shows at most two series and keeps labels inside the slide',
(tester) async {

View file

@ -26,7 +26,7 @@ void main() {
expect(n.state.isDirty, isTrue);
});
test('loadDeck applies the active profile and resolves its relative logo', () {
test('loadDeck resolves a relative logo for an unsaved recovered deck', () {
final temp = Directory.systemTemp.createTempSync(
'ocideck_recovered_logo_test_',
);
@ -36,19 +36,17 @@ void main() {
..writeAsBytesSync([1, 2, 3]);
final md = MarkdownService();
// Styling comes from the active style profile, not from the deck/markdown.
final file = FileService(
md,
ImageService(),
() => const ThemeProfile(logoPath: 'logos/client.png'),
() => const ThemeProfile(),
homeDirectory: () => temp.path,
);
final notifier = DeckNotifier(md, file);
notifier.loadDeck(
Deck(
title: 'Hersteld',
// The deck's own profile is ignored on load.
themeProfile: const ThemeProfile(logoPath: 'should-be-ignored.png'),
themeProfile: const ThemeProfile(logoPath: 'logos/client.png'),
slides: [Slide.create(SlideType.title)],
),
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,66 +0,0 @@
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({'app_consent_accepted': true});
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(AppShell)),
);
TextScaler scalerAtShell() =>
MediaQuery.textScalerOf(tester.element(find.byType(AppShell)));
expect(scalerAtShell().scale(10), 10);
await container.read(settingsProvider.notifier).setUiTextScale(1.5);
await tester.pump();
expect(scalerAtShell().scale(10), 15);
// The setting is clamped to WCAG's 200%.
await container.read(settingsProvider.notifier).setUiTextScale(5.0);
await tester.pump();
expect(scalerAtShell().scale(10), 20);
});
testWidgets('the slide canvas opts out of text scaling', (tester) async {
final slide = Slide.create(
SlideType.bullets,
).copyWith(title: 'Titel', bullets: const ['Punt een']);
await tester.pumpWidget(
MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: child!,
),
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(slide: slide),
),
),
),
);
await tester.pump();
// The slide is a fixed design surface: its text must render unscaled so
// the auto-fit measuring stays truthful.
final inSlide = MediaQuery.textScalerOf(
tester.element(find.text('Punt een')),
);
expect(inSlide.scale(10), 10);
});
}

View file

@ -6,18 +6,10 @@ import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/state/tabs_provider.dart';
import 'package:ocideck/widgets/app_shell.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
// Past the consent gate so the app shell renders. We only seed the consent
// key, never wiping the whole prefs domain other tests may rely on.
SharedPreferences.setMockInitialValues({'app_consent_accepted': true});
});
testWidgets('Welcome screen shows startup logo', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
expect(
find.bySemanticsLabel('De Winter Information Solutions'),
findsOneWidget,
@ -26,7 +18,6 @@ void main() {
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
});
@ -34,7 +25,6 @@ void main() {
await tester.binding.setSurfaceSize(const Size(1600, 1000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(AppShell)),
);

View file

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

View file

@ -132,15 +132,7 @@ class FlutterWindow: NSObject {
}
if let screen = target ?? screens.first {
window.styleMask = [.borderless]
// Raise above the menu bar (.mainMenu == 24) so the macOS menu
// bar and notch area on the beamer are covered by the slide; a
// plain .normal window would sit *under* the menu bar and leave
// the Apple/Wi-Fi strip visible during the presentation. We stay
// below .popUpMenu (101) so context menus still show on top.
window.level = .statusBar
// Keep the cover in place across Spaces/displays without ever
// stealing keyboard focus from the presenter window.
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
window.level = .normal
window.isOpaque = true
window.setFrame(screen.frame, display: true)
window.orderFrontRegardless()