Compare commits
9 commits
815f5f2cee
...
6bf85773b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bf85773b0 | |||
|
|
2c4a6f7358 | ||
|
|
47b2555dc5 | ||
|
|
56932a2dda | ||
|
|
4f595d1340 | ||
|
|
c6190dc31b | ||
|
|
e86d30e75a | ||
|
|
68725341a7 | ||
|
|
280934d331 |
59 changed files with 4025 additions and 573 deletions
61
CHANGELOG.md
61
CHANGELOG.md
|
|
@ -8,6 +8,18 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Duplicate clean-up in the image library** — a footer button finds
|
||||
byte-identical images by md5 checksum, keeps one file per group (preferring
|
||||
the one used in slides, then the oldest), merges the tags/descriptions and
|
||||
captions of the copies onto it, repoints slides that used a copy — in open
|
||||
decks and in `.md` presentations on disk that are not currently open — and
|
||||
deletes the copies after confirmation.
|
||||
- **Untagged-images filter in the image library** — a toggle next to the search
|
||||
box shows only images without a description/tags, making it easy to see which
|
||||
ones still need attention.
|
||||
- **Delete warning covers decks on disk** — deleting an image from the library
|
||||
now also warns when presentations that are not currently open still
|
||||
reference it.
|
||||
- **Source-code slides** — a "code sheet" with per-language syntax highlighting,
|
||||
stored as a fenced code block. Background, text colour and monospace font are
|
||||
part of the style profile, with a syntax-colouring toggle; turning it off renders
|
||||
|
|
@ -37,6 +49,26 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
|
|||
live to the beamer, and persisted in a `<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 (100–200%, Settings → General →
|
||||
Accessibility) that scales all editor text; slides themselves keep their
|
||||
fixed design size.
|
||||
- The panel divider is focusable and **keyboard-resizable** (arrow keys), with
|
||||
a visible focus state, and presents itself to screen readers as a slider.
|
||||
- **Screen-reader support**: slide thumbnails announce one concise label
|
||||
("Slide 3/12: title") instead of their full content; charts expose their
|
||||
type, title, and underlying values as a text alternative; the presenter
|
||||
announces each slide change.
|
||||
- Improved contrast for hint/label text in the editors.
|
||||
- Project documentation: contributing guide, security policy, architecture and
|
||||
build notes, user guide, keyboard-shortcut reference, third-party notices, and
|
||||
the EUPL-1.2 licence text.
|
||||
|
|
@ -47,11 +79,36 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
|
|||
separate from the slide title.
|
||||
- Slide text auto-sizing now measures with the deck's own font, so text grows to
|
||||
use the available space more accurately instead of staying smaller than needed.
|
||||
- The two bullet columns now scale **independently**, so a column with few items
|
||||
is no longer shrunk down to the size of a crowded one beside it.
|
||||
- The two bullet columns are measured **independently** and then rendered at a
|
||||
**shared size** set by the busiest column, so the two columns always look
|
||||
typographically related. Dense two-column slides spend less height on the
|
||||
title, headings, and gaps so the list items themselves render larger.
|
||||
- Slide transitions in the presenter no longer flash a black frame (neighbour
|
||||
images are precached and `gaplessPlayback` is enabled) — important for
|
||||
recording.
|
||||
- **Spider/radar charts** now use the available space: axis labels are measured
|
||||
and placed snugly around the polygon (up to three lines, full remaining
|
||||
width), so the diagram renders considerably larger and long labels stay
|
||||
readable instead of being truncated.
|
||||
- Bullet auto-fit now stops growing at ≈32 pt (on a 16:9 deck) — the upper end
|
||||
of the 24–32 pt range presentation-design guidance recommends for body text —
|
||||
so slides with few bullets no longer render body text that competes with the
|
||||
title.
|
||||
- After resizing the slide panel (dragging the divider or resizing the window),
|
||||
the list scrolls the slide being edited back into view.
|
||||
|
||||
### Fixed
|
||||
- Hover on charts (tooltips, legend highlight) now works on a second screen:
|
||||
macOS only delivered mouse-moved events to the key window, so the borderless
|
||||
beamer window never saw them; the stuck hover state after the pointer left a
|
||||
window is gone for the same reason.
|
||||
- Bar-chart x-axis labels could run through each other: the spacing maths now
|
||||
matches how bar groups are actually laid out, and the final label shrinks to
|
||||
the real gap when it sits closer than a full step.
|
||||
- A crash in the slide list ("A _RenderLayoutBuilder was mutated…") when its
|
||||
keyed items were rebuilt during layout — both the resize-detection inside the
|
||||
panel and the shell's width computation now avoid LayoutBuilders above the
|
||||
reorderable list.
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ Built with Flutter for macOS, Windows, and Linux.
|
|||
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
|
||||
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
|
||||
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
||||
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata.
|
||||
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file).
|
||||
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
|
||||
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
|
||||
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
|
||||
- **Accessibility** — WCAG 2.1-oriented: interface text scaling up to 200%, keyboard-operable panel divider and dialogs, screen-reader labels for slides and charts (charts read out their data), and slide-change announcements while presenting.
|
||||
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
|
||||
- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
|
||||
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
|
||||
|
|
@ -69,7 +70,9 @@ lib/
|
|||
services/ # Markdown, export, file, image, caption, recovery, rasterizer
|
||||
state/ # Riverpod providers (deck, editor, settings, tabs, clipboard)
|
||||
widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter
|
||||
l10n/ # AppLocalizations (8 languages)
|
||||
theme/ # App theming
|
||||
utils/ # Small shared helpers (clipboard table parsing, URL launching)
|
||||
```
|
||||
|
||||
State is managed with [Riverpod](https://riverpod.dev/).
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
|
|||
lib/
|
||||
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
||||
services/ # markdown, file, export, image, caption, description,
|
||||
# image_dedup (md5 duplicates), image_reference (.md rewrites),
|
||||
# recovery, rasterizer, marp_html, annotation_codec
|
||||
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
|
||||
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
|
||||
l10n/ # AppLocalizations (8 languages)
|
||||
theme/ # app theming
|
||||
utils/ # small shared helpers (clipboard table parsing, URL launching)
|
||||
```
|
||||
|
||||
## Data model
|
||||
|
|
@ -90,10 +92,12 @@ hence the vendored multi-window fork below.
|
|||
|
||||
## Sidecars (separate layers)
|
||||
|
||||
To keep the `.md` pure Marp, three kinds of data live beside it (see
|
||||
To keep the `.md` pure Marp, four kinds of data live beside it (see
|
||||
`FILE_FORMAT.md` §6):
|
||||
|
||||
- **Captions** — `.ocideck_captions.json` (per image, in `images/`).
|
||||
- **Descriptions/tags** — `.ocideck_descriptions.json` (searchable image
|
||||
metadata, used by the library's search and the untagged filter).
|
||||
- **Annotations** — `<name>.ink.json` (`services/annotation_codec.dart`).
|
||||
- **Linked chart data** — `data/*.csv` (the living source for a chart).
|
||||
|
||||
|
|
@ -105,8 +109,12 @@ Two upstream plugins are forked into `third_party/` and wired via `pubspec.yaml`
|
|||
- **`desktop_multi_window`** (MixinNetwork) — published 0.3.0 dropped the native
|
||||
window-geometry API. The fork adds macOS `window_setFrame`,
|
||||
`window_coverScreen` (borderless fill of a chosen screen), and `window_close`,
|
||||
exposed on `WindowController`. This is what makes the dual-screen audience
|
||||
window possible.
|
||||
exposed on `WindowController`. It also tracks the mouse for **non-key
|
||||
windows** (matched by `macos/Runner/MainFlutterWindow.swift` for the main
|
||||
window): macOS only delivers mouse-moved events to the key window by default,
|
||||
and the borderless audience window deliberately never becomes key, so chart
|
||||
tooltips and hover states would otherwise never appear on the beamer. This is
|
||||
what makes the dual-screen audience window possible.
|
||||
- **`screen_retriever_macos`** (leanflutter) — a packaging fix for recent
|
||||
Xcode/CocoaPods.
|
||||
|
||||
|
|
|
|||
|
|
@ -382,6 +382,13 @@ Bijschriften worden op **twee** plaatsen bewaard:
|
|||
```
|
||||
Een lege caption verwijdert de sleutel; een leeg bestand wordt verwijderd.
|
||||
|
||||
Naast de captions bestaat er een tweede, gelijkvormige sidecar
|
||||
`.ocideck_descriptions.json` voor **beschrijvingen/tags**: doorzoekbare
|
||||
vrije tekst per afbeelding, gebruikt door het zoekveld en het
|
||||
"zonder tags"-filter van de afbeeldingenbibliotheek (en samengevoegd bij het
|
||||
opruimen van md5-duplicaten). Zelfde formaat en dezelfde leeg-opruimregels als
|
||||
de captions-sidecar.
|
||||
|
||||
### 6.2 Annotatielaag (`<naam>.ink.json`)
|
||||
|
||||
Vrije-hand-annotaties (pen, markeerstift) die tijdens het presenteren worden
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@
|
|||
| `Ctrl/Cmd + Shift + Z` | Redo |
|
||||
| `Ctrl + Y` | Redo (alternative) |
|
||||
| `Ctrl/Cmd + H` | Find & replace |
|
||||
| `Ctrl/Cmd + V` (in a table cell) | Paste a spreadsheet/CSV/markdown selection as a table (also `Shift + Insert`) |
|
||||
| `Tab` to the panel divider, then `←` / `→` | Resize the slide panel |
|
||||
|
||||
In the **add-slide dialog**, `Tab` moves between the type cards, `Enter` picks
|
||||
the focused one, and `Esc` cancels.
|
||||
|
||||
## Fullscreen presenter
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ Marp tools.
|
|||
Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
|
||||
bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
|
||||
**audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or
|
||||
spider/radar), and **free Markdown**. Each type has a dedicated editor on the left
|
||||
and a live preview on the right.
|
||||
spider/radar), and **free Markdown**. Each card in the chooser shows a miniature
|
||||
wireframe of the layout, and the dialog works entirely with the keyboard
|
||||
(`Tab`/`Enter` to choose, `Esc` to cancel). Each type has a dedicated editor on
|
||||
the left and a live preview on the right.
|
||||
|
||||
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
|
||||
`[links](…)`). Free-Markdown slides also render fenced code with syntax
|
||||
|
|
@ -38,6 +40,16 @@ green on black for a classic CRT-terminal look. The text is sized to fill the
|
|||
panel — larger when there's room, smaller for long fragments. Stored as a fenced
|
||||
code block in the Markdown.
|
||||
|
||||
### Tables
|
||||
|
||||
The first row is the header. Press `Enter` inside a cell for a new line within
|
||||
that cell. To bring in existing data, **paste a table into any cell** with
|
||||
`Ctrl/Cmd+V` (or `Shift+Insert`): a selection copied from a spreadsheet (Excel,
|
||||
Numbers, LibreOffice Calc, Google Sheets), CSV text (comma- or
|
||||
semicolon-separated), or a markdown table fills the grid from that cell onward,
|
||||
adding rows and columns as needed. Ordinary text — even a sentence with a comma
|
||||
in it — still pastes into just the one cell.
|
||||
|
||||
### Charts
|
||||
|
||||
Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then
|
||||
|
|
@ -56,10 +68,34 @@ row/column. Each series and (for pie/radar) each label can be given its own colo
|
|||
- **Reading values** — hovering a legend entry highlights its series (or pie
|
||||
slice). On a line chart the tooltip belongs to the dot under the cursor and
|
||||
shows every overlapping dot at once; on a spider/radar chart hovering a point
|
||||
shows its value in a tooltip too.
|
||||
shows its value in a tooltip too. For screen readers every chart also carries
|
||||
a text alternative with its type, title, and the values per series.
|
||||
- Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
|
||||
HTML export.
|
||||
|
||||
## Image library
|
||||
|
||||
Image fields open a library that shows every image found in the deck's
|
||||
directories, with a grid and a coverflow view, search, and a preview pane. Per
|
||||
image you can store a **caption** (source/credit line, shown on the slide) and a
|
||||
searchable **description** — in practice your tags. The search box matches file
|
||||
names and descriptions.
|
||||
|
||||
- **Filter untagged images** — the label toggle next to the search box shows
|
||||
only images that have no description/tags yet, so you can see at a glance
|
||||
which ones still need attention.
|
||||
- **Clean up duplicates** — the button in the footer finds byte-identical
|
||||
images by md5 checksum. Per group one file is kept (preferring the one used
|
||||
in slides, then the oldest), tags and captions of the copies are merged onto
|
||||
it, slides that referenced a copy are repointed to the kept file, and the
|
||||
copies are deleted — after a confirmation that lists exactly what will
|
||||
happen. References are updated in the open decks *and* in `.md`
|
||||
presentations found on disk in the search directories, so presentations
|
||||
that are not currently open keep working too.
|
||||
- **Deleting an image** warns when it is still in use — in open decks (per
|
||||
slide) and in presentations on disk that are not currently open (per file,
|
||||
marked "not open").
|
||||
|
||||
## Per-slide options
|
||||
|
||||
Below each editor you can set:
|
||||
|
|
@ -112,6 +148,21 @@ Export to:
|
|||
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
||||
assets, to hand the whole deck to someone else.
|
||||
|
||||
## Accessibility
|
||||
|
||||
OciDeck aims for WCAG 2.1 in the editor:
|
||||
|
||||
- **Interface text size** — Settings → General → Accessibility offers 100–200%
|
||||
text scaling for the whole editing environment, on top of what the operating
|
||||
system asks for. Slides keep their fixed 16:9 design size, so what you see is
|
||||
still exactly what you present and export.
|
||||
- **Keyboard** — the panel divider between the slide list and the editor can be
|
||||
focused with `Tab` and resized with `←`/`→`; the add-slide dialog is fully
|
||||
keyboard-operable.
|
||||
- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12:
|
||||
title", including the skipped state), charts read out their data as a text
|
||||
alternative, and the fullscreen presenter announces every slide change.
|
||||
|
||||
## Theming and language
|
||||
|
||||
- **Style profiles** control deck colours (including the source-code background,
|
||||
|
|
|
|||
48
lib/app.dart
48
lib/app.dart
|
|
@ -3,8 +3,10 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'state/settings_provider.dart';
|
||||
import 'state/consent_provider.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'widgets/app_shell.dart';
|
||||
import 'widgets/dialogs/consent_dialog.dart';
|
||||
|
||||
class OciDeckApp extends ConsumerWidget {
|
||||
const OciDeckApp({super.key});
|
||||
|
|
@ -17,11 +19,28 @@ class OciDeckApp extends ConsumerWidget {
|
|||
final appearance = ref.watch(
|
||||
settingsProvider.select((s) => s.appAppearanceProfile),
|
||||
);
|
||||
final uiTextScale = ref.watch(
|
||||
settingsProvider.select((s) => s.uiTextScale),
|
||||
);
|
||||
AppLocalizations.setActiveLanguageCode(languageCode);
|
||||
return MaterialApp(
|
||||
title: 'OciDeck',
|
||||
theme: AppTheme.fromProfile(appearance),
|
||||
debugShowCheckedModeBanner: false,
|
||||
// Interface text scaling (WCAG 1.4.4): the user's setting multiplies
|
||||
// whatever the OS already asks for. Slides themselves opt out — they
|
||||
// are a fixed design canvas (see SlidePreviewWidget).
|
||||
builder: (context, child) {
|
||||
final media = MediaQuery.of(context);
|
||||
return MediaQuery(
|
||||
data: media.copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
(media.textScaler.scale(1.0) * uiTextScale).clamp(1.0, 2.0),
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
locale: AppLocalizations.materialLocaleFor(languageCode),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
|
|
@ -30,7 +49,34 @@ class OciDeckApp extends ConsumerWidget {
|
|||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
home: const AppShell(),
|
||||
home: const _ConsentGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConsentGate extends ConsumerWidget {
|
||||
const _ConsentGate();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final consent = ref.watch(consentProvider);
|
||||
|
||||
if (consent.isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!consent.hasAccepted) {
|
||||
// Plain Scaffold inside the app's existing MaterialApp — that one already
|
||||
// supplies the theme and the AppLocalizations delegate, so context.l10n
|
||||
// resolves here. A nested MaterialApp would start a fresh Localizations
|
||||
// scope without our delegate and the consent text would render blank.
|
||||
return const Scaffold(
|
||||
body: Center(child: ConsentDialog()),
|
||||
);
|
||||
}
|
||||
|
||||
return const AppShell();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1348,6 +1348,18 @@ 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',
|
||||
|
|
@ -1543,6 +1555,18 @@ 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',
|
||||
|
|
@ -1737,6 +1761,18 @@ const _dutchSourceStrings = {
|
|||
'P public · H legende · G vue d’ensemble · 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 d’ensemble · 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',
|
||||
|
|
@ -1932,6 +1968,18 @@ 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',
|
||||
|
|
@ -2127,6 +2175,18 @@ 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',
|
||||
|
|
@ -2321,11 +2381,31 @@ 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',
|
||||
|
|
@ -2432,8 +2512,58 @@ 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',
|
||||
|
|
@ -2694,8 +2824,45 @@ 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',
|
||||
|
|
@ -2956,8 +3123,44 @@ 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',
|
||||
|
|
@ -3218,8 +3421,48 @@ 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',
|
||||
|
|
@ -3481,8 +3724,47 @@ 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',
|
||||
|
|
@ -3740,8 +4022,45 @@ 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',
|
||||
|
|
@ -3999,5 +4318,51 @@ 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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -364,6 +364,11 @@ class AppSettings {
|
|||
final String selectedAppAppearanceProfileName;
|
||||
final List<String> recentFiles;
|
||||
|
||||
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
||||
/// text scaling. The slide canvas itself is never scaled: slides are a
|
||||
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
|
||||
final double uiTextScale;
|
||||
|
||||
const AppSettings({
|
||||
this.languageCode = 'nl',
|
||||
this.homeDirectory,
|
||||
|
|
@ -373,6 +378,7 @@ class AppSettings {
|
|||
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||
this.selectedAppAppearanceProfileName = 'Basic',
|
||||
this.recentFiles = const [],
|
||||
this.uiTextScale = 1.0,
|
||||
});
|
||||
|
||||
ThemeProfile get themeProfile {
|
||||
|
|
@ -424,6 +430,7 @@ class AppSettings {
|
|||
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||
String? selectedAppAppearanceProfileName,
|
||||
List<String>? recentFiles,
|
||||
double? uiTextScale,
|
||||
bool clearHomeDirectory = false,
|
||||
bool clearExportDirectory = false,
|
||||
}) {
|
||||
|
|
@ -457,6 +464,7 @@ class AppSettings {
|
|||
selectedAppAppearanceProfileName ??
|
||||
this.selectedAppAppearanceProfileName,
|
||||
recentFiles: recentFiles ?? this.recentFiles,
|
||||
uiTextScale: uiTextScale ?? this.uiTextScale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ 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,
|
||||
|
|
@ -167,8 +173,12 @@ class FileService {
|
|||
if (!await file.exists()) return null;
|
||||
raw = await file.readAsString();
|
||||
}
|
||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||
if (deck == null) return null;
|
||||
final parsed = _md.parseDeck(raw, filePath: filePath);
|
||||
if (parsed == null) return null;
|
||||
// The file carries only content; apply the active style profile on open.
|
||||
final deck = parsed.copyWith(
|
||||
themeProfile: activeProfileFor(projectPath: parsed.projectPath),
|
||||
);
|
||||
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
|
||||
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||
if (content == null) {
|
||||
|
|
|
|||
97
lib/services/image_dedup_service.dart
Normal file
97
lib/services/image_dedup_service.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum,
|
||||
/// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op
|
||||
/// grootte gegroepeerd; alleen gelijke groottes worden daadwerkelijk gehasht,
|
||||
/// dus grote bibliotheken blijven snel.
|
||||
class ImageDedupService {
|
||||
/// Groepeer [imagePaths] op identieke inhoud (md5). Elke teruggegeven groep
|
||||
/// bevat twee of meer paden naar byte-voor-byte gelijke bestanden, in de
|
||||
/// volgorde waarin ze in [imagePaths] stonden. Onleesbare bestanden worden
|
||||
/// stilletjes overgeslagen.
|
||||
Future<List<List<String>>> findDuplicateGroups(
|
||||
Iterable<String> imagePaths,
|
||||
) async {
|
||||
// Stap 1: op bestandsgrootte groeperen — verschillend groot is nooit gelijk.
|
||||
final bySize = <int, List<String>>{};
|
||||
for (final path in imagePaths) {
|
||||
try {
|
||||
final size = File(path).statSync().size;
|
||||
bySize.putIfAbsent(size, () => []).add(path);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Stap 2: alleen binnen gelijke groottes de md5 berekenen.
|
||||
final groups = <List<String>>[];
|
||||
for (final candidates in bySize.values) {
|
||||
if (candidates.length < 2) continue;
|
||||
final byHash = <String, List<String>>{};
|
||||
for (final path in candidates) {
|
||||
try {
|
||||
final digest = await md5.bind(File(path).openRead()).single;
|
||||
byHash.putIfAbsent(digest.toString(), () => []).add(path);
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final group in byHash.values) {
|
||||
if (group.length >= 2) groups.add(group);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// Kies binnen een groep duplicaten het pad dat behouden blijft. Voorkeur:
|
||||
/// het meest in slides gebruikte bestand, daarna het oudste (vermoedelijk
|
||||
/// het origineel), daarna de volgorde in de groep.
|
||||
String chooseKeeper(
|
||||
List<String> group, {
|
||||
int Function(String path)? usageCountOf,
|
||||
}) {
|
||||
DateTime modifiedOf(String path) {
|
||||
try {
|
||||
return File(path).statSync().modified;
|
||||
} catch (_) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
}
|
||||
|
||||
var keeper = group.first;
|
||||
var keeperUsages = usageCountOf?.call(keeper) ?? 0;
|
||||
var keeperModified = modifiedOf(keeper);
|
||||
for (final candidate in group.skip(1)) {
|
||||
final usages = usageCountOf?.call(candidate) ?? 0;
|
||||
final modified = modifiedOf(candidate);
|
||||
final wins =
|
||||
usages > keeperUsages ||
|
||||
(usages == keeperUsages && modified.isBefore(keeperModified));
|
||||
if (wins) {
|
||||
keeper = candidate;
|
||||
keeperUsages = usages;
|
||||
keeperModified = modified;
|
||||
}
|
||||
}
|
||||
return keeper;
|
||||
}
|
||||
|
||||
/// Voeg metadata-teksten (tags/beschrijvingen of opmerkingen/captions) van
|
||||
/// duplicaten samen tot één waarde: unieke, niet-lege teksten gescheiden
|
||||
/// door [separator]. Een tekst die al letterlijk in een eerdere voorkomt
|
||||
/// (zoals dezelfde tag op beide duplicaten) wordt niet herhaald.
|
||||
String mergeMetadata(Iterable<String?> values, {String separator = ' · '}) {
|
||||
final merged = <String>[];
|
||||
for (final value in values) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) continue;
|
||||
final isDuplicate = merged.any(
|
||||
(existing) => existing.toLowerCase().contains(text.toLowerCase()),
|
||||
);
|
||||
if (!isDuplicate) merged.add(text);
|
||||
}
|
||||
return merged.join(separator);
|
||||
}
|
||||
}
|
||||
|
||||
final imageDedupServiceProvider = Provider<ImageDedupService>(
|
||||
(_) => ImageDedupService(),
|
||||
);
|
||||
168
lib/services/image_reference_service.dart
Normal file
168
lib/services/image_reference_service.dart
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Vindt en herschrijft afbeeldingsverwijzingen (``) in
|
||||
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
|
||||
/// ook presentaties mee die nu niet geopend zijn.
|
||||
class ImageReferenceService {
|
||||
/// Zelfde mappen als FileService.scanPresentations overslaat.
|
||||
static const _ignoredDirs = {
|
||||
'images',
|
||||
'logos',
|
||||
'themes',
|
||||
'node_modules',
|
||||
'build',
|
||||
'.git',
|
||||
'.dart_tool',
|
||||
};
|
||||
static const _maxDepth = 4;
|
||||
|
||||
/// Markdown-afbeelding: ``.
|
||||
static final _imageRef = RegExp(r'!\[([^\]]*)\]\(([^)\n]+)\)');
|
||||
|
||||
/// Zoek recursief alle `.md`-bestanden onder [searchDirs] (begrensd op
|
||||
/// diepte, asset- en verborgen mappen worden overgeslagen). Dubbele treffers
|
||||
/// via overlappende zoekpaden worden één keer teruggegeven.
|
||||
Future<List<String>> findDeckFiles(Iterable<String> searchDirs) async {
|
||||
final found = <String>{};
|
||||
|
||||
Future<void> walk(Directory dir, int depth) async {
|
||||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = await dir.list(followLinks: false).toList();
|
||||
} catch (_) {
|
||||
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 '';
|
||||
});
|
||||
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(),
|
||||
);
|
||||
|
|
@ -11,7 +11,19 @@ const _uuid = Uuid();
|
|||
class MarkdownService {
|
||||
// ── Generation ──────────────────────────────────────────────────────────────
|
||||
|
||||
String generateDeck(Deck deck, {bool inlineChartData = false}) {
|
||||
/// Serialise a deck to Marp markdown.
|
||||
///
|
||||
/// The styling (the [ThemeProfile]) is deliberately NOT written to the file:
|
||||
/// a saved `.md` holds only the content (the "base"), and the app applies the
|
||||
/// active style profile when it opens the deck. [inlineStyleProfile] re-adds
|
||||
/// the profile for transient, non-file payloads — currently only the markdown
|
||||
/// streamed to the audience (beamer) window, which has no other way to learn
|
||||
/// the styling. It must stay false for anything written to disk.
|
||||
String generateDeck(
|
||||
Deck deck, {
|
||||
bool inlineChartData = false,
|
||||
bool inlineStyleProfile = false,
|
||||
}) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('---');
|
||||
buf.writeln('marp: true');
|
||||
|
|
@ -42,9 +54,11 @@ class MarkdownService {
|
|||
if (deck.tlp != TlpLevel.none) {
|
||||
buf.writeln('tlp: ${deck.tlp.key}');
|
||||
}
|
||||
buf.writeln(
|
||||
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
|
||||
);
|
||||
if (inlineStyleProfile) {
|
||||
buf.writeln(
|
||||
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
|
||||
);
|
||||
}
|
||||
buf.writeln('---');
|
||||
buf.writeln();
|
||||
|
||||
|
|
|
|||
67
lib/state/consent_provider.dart
Normal file
67
lib/state/consent_provider.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,13 +127,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
state = DeckState(deck: deck, isDirty: true);
|
||||
}
|
||||
|
||||
/// Load a deck that was already parsed (used by the tab manager).
|
||||
/// Load a deck that was already parsed (used by the tab manager). Styling is
|
||||
/// not taken from the deck/markdown but from the active style profile, so an
|
||||
/// opened or recovered deck always picks up the current look.
|
||||
void loadDeck(Deck deck, {String? filePath}) {
|
||||
final resolvedDeck = deck.copyWith(
|
||||
themeProfile: _file.resolveThemeProfile(
|
||||
deck.themeProfile,
|
||||
projectPath: deck.projectPath,
|
||||
),
|
||||
themeProfile: _file.activeProfileFor(projectPath: deck.projectPath),
|
||||
);
|
||||
_clearHistory();
|
||||
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
|
||||
|
|
@ -499,8 +498,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
/// Returns false if parsing fails (content is preserved).
|
||||
bool applyMarkdown(String markdown) {
|
||||
final deck = _md.parseDeck(markdown, filePath: state.filePath);
|
||||
if (deck == null) return false;
|
||||
final parsed = _md.parseDeck(markdown, filePath: state.filePath);
|
||||
if (parsed == null) return false;
|
||||
// The markdown carries only content; keep the deck's current styling rather
|
||||
// than resetting it to the default profile the parser returns.
|
||||
final current = state.deck;
|
||||
final deck = current == null
|
||||
? parsed
|
||||
: parsed.copyWith(themeProfile: current.themeProfile);
|
||||
_mutate(deck); // discrete stap → ook ongedaan te maken
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,17 @@ 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,
|
||||
|
|
|
|||
123
lib/utils/table_clipboard.dart
Normal file
123
lib/utils/table_clipboard.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/// Recognises tabular clipboard content so a paste into one table cell can
|
||||
/// fill a whole grid.
|
||||
///
|
||||
/// Spreadsheets (Excel, Numbers, LibreOffice Calc, Google Sheets) put
|
||||
/// tab-separated text on the clipboard on macOS, Linux and Windows alike, so
|
||||
/// TSV is the primary format. CSV with a comma or semicolon (the Dutch/European
|
||||
/// list separator) and markdown tables are recognised as well.
|
||||
library;
|
||||
|
||||
/// Parses [text] as a table, or returns null when it does not look tabular —
|
||||
/// in that case the paste should go into the single cell as usual.
|
||||
///
|
||||
/// Detection is deliberately conservative for ambiguous formats: a tab is
|
||||
/// always a column break (no one types tabs into a cell), but commas and
|
||||
/// semicolons only count when every line yields the same column count, so a
|
||||
/// pasted sentence with a comma stays plain text.
|
||||
List<List<String>>? parseClipboardTable(String text) {
|
||||
final normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||
if (normalized.trim().isEmpty) return null;
|
||||
|
||||
final markdown = _parseMarkdownTable(normalized);
|
||||
if (markdown != null) return markdown;
|
||||
|
||||
if (normalized.contains('\t')) {
|
||||
return _trim(_splitDelimited(normalized, '\t'));
|
||||
}
|
||||
|
||||
// CSV variants: require at least two rows with a consistent column count of
|
||||
// two or more (checked before any padding, so prose with stray commas does
|
||||
// not qualify); prefer the separator that yields the wider table.
|
||||
List<List<String>>? best;
|
||||
for (final delimiter in const [';', ',']) {
|
||||
if (!normalized.contains(delimiter)) continue;
|
||||
final rows = _splitDelimited(normalized, delimiter);
|
||||
while (rows.isNotEmpty && rows.last.every((c) => c.trim().isEmpty)) {
|
||||
rows.removeLast();
|
||||
}
|
||||
if (rows.length < 2) continue;
|
||||
final cols = rows.first.length;
|
||||
if (cols < 2 || rows.any((r) => r.length != cols)) continue;
|
||||
if (best == null || cols > best.first.length) best = rows;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Markdown table: every non-empty line framed by pipes. The `|---|---|`
|
||||
/// separator row is dropped.
|
||||
List<List<String>>? _parseMarkdownTable(String text) {
|
||||
final lines = [
|
||||
for (final line in text.split('\n'))
|
||||
if (line.trim().isNotEmpty) line.trim(),
|
||||
];
|
||||
if (lines.isEmpty || lines.any((l) => !l.startsWith('|'))) return null;
|
||||
|
||||
final rows = <List<String>>[];
|
||||
for (final line in lines) {
|
||||
var body = line.substring(1);
|
||||
if (body.endsWith('|')) body = body.substring(0, body.length - 1);
|
||||
final cells = body.split('|').map((c) => c.trim()).toList();
|
||||
// Alignment/separator row (|---|:--:|) carries no data.
|
||||
if (cells.every((c) => RegExp(r'^:?-{2,}:?$').hasMatch(c))) continue;
|
||||
rows.add(cells);
|
||||
}
|
||||
if (rows.isEmpty || rows.first.length < 2) return null;
|
||||
return _trim(rows);
|
||||
}
|
||||
|
||||
/// Splits [text] into rows/cells on newlines and [delimiter], honouring
|
||||
/// double-quoted fields ("" escapes a quote) so cells from spreadsheets may
|
||||
/// contain the delimiter or even line breaks.
|
||||
List<List<String>> _splitDelimited(String text, String delimiter) {
|
||||
final rows = <List<String>>[];
|
||||
var row = <String>[];
|
||||
final cell = StringBuffer();
|
||||
var quoted = false;
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final ch = text[i];
|
||||
if (quoted) {
|
||||
if (ch == '"') {
|
||||
if (i + 1 < text.length && text[i + 1] == '"') {
|
||||
cell.write('"');
|
||||
i++;
|
||||
} else {
|
||||
quoted = false;
|
||||
}
|
||||
} else {
|
||||
cell.write(ch);
|
||||
}
|
||||
} else if (ch == '"' && cell.isEmpty) {
|
||||
quoted = true;
|
||||
} else if (ch == delimiter) {
|
||||
row.add(cell.toString());
|
||||
cell.clear();
|
||||
} else if (ch == '\n') {
|
||||
row.add(cell.toString());
|
||||
cell.clear();
|
||||
rows.add(row);
|
||||
row = <String>[];
|
||||
} else {
|
||||
cell.write(ch);
|
||||
}
|
||||
}
|
||||
row.add(cell.toString());
|
||||
rows.add(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// Drops empty trailing rows (from the trailing newline spreadsheets add) and
|
||||
/// pads every row to the same column count. Returns null when the result is a
|
||||
/// single lone cell — that is not a table.
|
||||
List<List<String>>? _trim(List<List<String>> rows) {
|
||||
final kept = List<List<String>>.from(rows);
|
||||
while (kept.isNotEmpty && kept.last.every((c) => c.trim().isEmpty)) {
|
||||
kept.removeLast();
|
||||
}
|
||||
if (kept.isEmpty) return null;
|
||||
final cols = kept.fold<int>(0, (m, r) => r.length > m ? r.length : m);
|
||||
if (cols < 2 && kept.length < 2) return null;
|
||||
return [
|
||||
for (final row in kept)
|
||||
[for (var c = 0; c < cols; c++) c < row.length ? row[c] : ''],
|
||||
];
|
||||
}
|
||||
|
|
@ -138,6 +138,47 @@ 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.
|
||||
|
|
@ -926,9 +967,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$count ${l10n.d('checklist-items uitgevinkt.')}',
|
||||
),
|
||||
content: Text('$count ${l10n.d('checklist-items uitgevinkt.')}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -947,6 +986,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
captionService: ref.read(captionServiceProvider),
|
||||
descriptionService: ref.read(descriptionServiceProvider),
|
||||
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
|
||||
onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to),
|
||||
openDeckFiles: [
|
||||
for (final tab in ref.read(tabsProvider).tabs)
|
||||
?tab.deckNotifier.currentState.filePath,
|
||||
],
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
|
|
@ -1444,36 +1488,43 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
});
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxRailWidth = (constraints.maxWidth - _minEditorWidth)
|
||||
.clamp(_minSlideRailWidth, constraints.maxWidth)
|
||||
.toDouble();
|
||||
final railWidth = _slideRailWidth
|
||||
.clamp(_minSlideRailWidth, maxRailWidth)
|
||||
.toDouble();
|
||||
if (railWidth != _slideRailWidth) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _slideRailWidth = railWidth);
|
||||
});
|
||||
}
|
||||
// The available width comes from MediaQuery, NOT a
|
||||
// LayoutBuilder: a LayoutBuilder rebuilds this subtree during
|
||||
// the layout phase, and when the slide list's keyed
|
||||
// ReorderableListView items get reparented in that pass their
|
||||
// overlay children are activated outside an active layout —
|
||||
// "A _RenderLayoutBuilder was mutated in performLayout". The
|
||||
// body row spans the window, so the window width is equivalent.
|
||||
final bodyWidth = MediaQuery.sizeOf(ctx).width;
|
||||
final maxRailWidth = (bodyWidth - _minEditorWidth)
|
||||
.clamp(_minSlideRailWidth, bodyWidth)
|
||||
.toDouble();
|
||||
final railWidth = _slideRailWidth
|
||||
.clamp(_minSlideRailWidth, maxRailWidth)
|
||||
.toDouble();
|
||||
if (railWidth != _slideRailWidth) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _slideRailWidth = railWidth);
|
||||
});
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: railWidth, child: const SlideListPanel()),
|
||||
_ResizableDivider(
|
||||
onDrag: (delta) {
|
||||
setState(() {
|
||||
_slideRailWidth = (_slideRailWidth + delta)
|
||||
.clamp(_minSlideRailWidth, maxRailWidth)
|
||||
.toDouble();
|
||||
});
|
||||
},
|
||||
),
|
||||
const Expanded(child: EditorPanel()),
|
||||
],
|
||||
);
|
||||
},
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: railWidth,
|
||||
child: SlideListPanel(railWidth: railWidth),
|
||||
),
|
||||
_ResizableDivider(
|
||||
onDrag: (delta) {
|
||||
setState(() {
|
||||
_slideRailWidth = (_slideRailWidth + delta)
|
||||
.clamp(_minSlideRailWidth, maxRailWidth)
|
||||
.toDouble();
|
||||
});
|
||||
},
|
||||
),
|
||||
const Expanded(child: EditorPanel()),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -1721,36 +1772,67 @@ class _ResizableDivider extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ResizableDividerState extends State<_ResizableDivider> {
|
||||
static const double _keyboardStep = 24;
|
||||
|
||||
bool _hovered = false;
|
||||
bool _dragging = false;
|
||||
bool _focused = false;
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyUpEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
widget.onDrag(-_keyboardStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
widget.onDrag(_keyboardStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final active = _hovered || _dragging;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (_) => setState(() => _dragging = true),
|
||||
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
|
||||
onHorizontalDragCancel: () => setState(() => _dragging = false),
|
||||
onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx),
|
||||
child: Tooltip(
|
||||
message: l10n.d(
|
||||
'Sleep om de slide-preview breder of smaller te maken',
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 9,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 90),
|
||||
width: active ? 3 : 1,
|
||||
color: active
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
final active = _hovered || _dragging || _focused;
|
||||
// Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys
|
||||
// move it, and focus is shown with the same highlight as hovering
|
||||
// (WCAG 2.4.7). Screen readers see it as an adjustable element.
|
||||
return Focus(
|
||||
onKeyEvent: _onKeyEvent,
|
||||
onFocusChange: (focused) => setState(() => _focused = focused),
|
||||
child: Semantics(
|
||||
slider: true,
|
||||
label: l10n.d('Breedte van het slidepaneel'),
|
||||
hint: l10n.d('Pijltjestoetsen passen de breedte aan'),
|
||||
onIncrease: () => widget.onDrag(_keyboardStep),
|
||||
onDecrease: () => widget.onDrag(-_keyboardStep),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (_) => setState(() => _dragging = true),
|
||||
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
|
||||
onHorizontalDragCancel: () => setState(() => _dragging = false),
|
||||
onHorizontalDragUpdate: (details) =>
|
||||
widget.onDrag(details.delta.dx),
|
||||
child: Tooltip(
|
||||
message: l10n.d(
|
||||
'Sleep om de slide-preview breder of smaller te maken',
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 9,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 90),
|
||||
width: active ? 3 : 1,
|
||||
color: active
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,23 +15,19 @@ class AddSlideDialog extends StatelessWidget {
|
|||
}
|
||||
|
||||
static const _types = [
|
||||
(SlideType.title, Icons.title, 'Titelpagina'),
|
||||
(SlideType.section, Icons.bookmark_outline, 'Tussentitel'),
|
||||
(SlideType.bullets, Icons.format_list_bulleted, 'Alleen Bullets'),
|
||||
(SlideType.twoBullets, Icons.view_column_outlined, 'Twee Bulletkolommen'),
|
||||
(
|
||||
SlideType.bulletsImage,
|
||||
Icons.view_agenda_outlined,
|
||||
'Bullets + Afbeelding',
|
||||
),
|
||||
(SlideType.twoImages, Icons.auto_stories_outlined, 'Twee Afbeeldingen'),
|
||||
(SlideType.image, Icons.image_outlined, 'Grote Afbeelding'),
|
||||
(SlideType.video, Icons.movie_outlined, 'Video'),
|
||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
||||
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
||||
(SlideType.code, Icons.terminal, 'Broncode'),
|
||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
||||
(SlideType.title, 'Titelpagina'),
|
||||
(SlideType.section, 'Tussentitel'),
|
||||
(SlideType.bullets, 'Alleen Bullets'),
|
||||
(SlideType.twoBullets, 'Twee Bulletkolommen'),
|
||||
(SlideType.bulletsImage, 'Bullets + Afbeelding'),
|
||||
(SlideType.twoImages, 'Twee Afbeeldingen'),
|
||||
(SlideType.image, 'Grote Afbeelding'),
|
||||
(SlideType.video, 'Video'),
|
||||
(SlideType.quote, 'Quote'),
|
||||
(SlideType.table, 'Tabel'),
|
||||
(SlideType.chart, 'Grafiek'),
|
||||
(SlideType.code, 'Broncode'),
|
||||
(SlideType.freeMarkdown, 'Vrije Markdown'),
|
||||
];
|
||||
|
||||
@override
|
||||
|
|
@ -42,73 +38,272 @@ class AddSlideDialog extends StatelessWidget {
|
|||
const SingleActivator(LogicalKeyboardKey.escape): () =>
|
||||
Navigator.pop(context),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: AlertDialog(
|
||||
title: Text(l10n.d('Slide type kiezen')),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: AlertDialog(
|
||||
title: Text(l10n.d('Slide type kiezen')),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
// Reading-order tabbing through the cards; the first one takes
|
||||
// focus so the dialog is fully keyboard-operable right away.
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: _types.map((entry) {
|
||||
final (type, icon, label) = entry;
|
||||
return _TypeCard(
|
||||
icon: icon,
|
||||
label: l10n.d(label),
|
||||
onTap: () => Navigator.pop(context, type),
|
||||
);
|
||||
}).toList(),
|
||||
children: [
|
||||
for (var i = 0; i < _types.length; i++)
|
||||
_TypeCard(
|
||||
type: _types[i].$1,
|
||||
label: l10n.d(_types[i].$2),
|
||||
autofocus: i == 0,
|
||||
onTap: () => Navigator.pop(context, _types[i].$1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.t('cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.t('cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final SlideType type;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool autofocus;
|
||||
|
||||
const _TypeCard({
|
||||
required this.icon,
|
||||
required this.type,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 110,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFCBD5E1)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 28, color: AppTheme.navy),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
autofocus: autofocus,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
focusColor: AppTheme.accent.withValues(alpha: 0.14),
|
||||
hoverColor: AppTheme.accent.withValues(alpha: 0.06),
|
||||
child: Container(
|
||||
width: 100,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFCBD5E1)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// A stylised wireframe of the layout, so the card shows what
|
||||
// the slide will look like instead of an abstract icon.
|
||||
ExcludeSemantics(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: CustomPaint(
|
||||
painter: SlideTypePreviewPainter(type: type),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(fontSize: 11, height: 1.15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints a miniature 16:9 wireframe of a slide layout, in the spirit of the
|
||||
/// layout pickers in other presentation tools: title bars, text lines, image
|
||||
/// placeholders. All geometry lives on a 160×90 design canvas and is scaled
|
||||
/// to whatever size the card provides.
|
||||
@visibleForTesting
|
||||
class SlideTypePreviewPainter extends CustomPainter {
|
||||
final SlideType type;
|
||||
|
||||
/// Wireframe palette: dark bars for titles, soft bars for body text.
|
||||
static const _canvas = Color(0xFFF8FAFC);
|
||||
static const _ink = Color(0xFF334155);
|
||||
static const _soft = Color(0xFFB6C2D2);
|
||||
static const _fill = Color(0xFFE2E8F0);
|
||||
static const _accent = AppTheme.accent;
|
||||
|
||||
const SlideTypePreviewPainter({required this.type});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final u = size.width / 160;
|
||||
canvas.scale(u);
|
||||
canvas.drawRect(const Rect.fromLTWH(0, 0, 160, 90), _paint(_canvas));
|
||||
|
||||
switch (type) {
|
||||
case SlideType.title:
|
||||
_bar(canvas, 30, 34, 100, 12, _ink);
|
||||
_bar(canvas, 45, 53, 70, 7, _accent);
|
||||
case SlideType.section:
|
||||
_bar(canvas, 16, 36, 5, 24, _accent);
|
||||
_bar(canvas, 30, 38, 86, 11, _ink);
|
||||
_bar(canvas, 30, 54, 52, 6, _soft);
|
||||
case SlideType.bullets:
|
||||
_bar(canvas, 14, 12, 84, 9, _ink);
|
||||
_bullets(canvas, 14, 34, 110, 4);
|
||||
case SlideType.twoBullets:
|
||||
_bar(canvas, 14, 12, 84, 9, _ink);
|
||||
_bullets(canvas, 14, 32, 56, 3);
|
||||
_bullets(canvas, 90, 32, 56, 3);
|
||||
case SlideType.bulletsImage:
|
||||
_bar(canvas, 14, 12, 66, 9, _ink);
|
||||
_bullets(canvas, 14, 32, 60, 3);
|
||||
_imageBox(canvas, 90, 26, 56, 50);
|
||||
case SlideType.twoImages:
|
||||
_imageBox(canvas, 12, 16, 64, 46);
|
||||
_imageBox(canvas, 84, 16, 64, 46);
|
||||
_bar(canvas, 20, 68, 48, 5, _soft);
|
||||
_bar(canvas, 92, 68, 48, 5, _soft);
|
||||
case SlideType.image:
|
||||
_imageBox(canvas, 10, 10, 140, 70);
|
||||
case SlideType.video:
|
||||
_imageBox(canvas, 10, 10, 140, 70, pictogram: false);
|
||||
canvas.drawCircle(const Offset(80, 45), 14, _paint(Colors.white));
|
||||
final play = Path()
|
||||
..moveTo(75, 37)
|
||||
..lineTo(89, 45)
|
||||
..lineTo(75, 53)
|
||||
..close();
|
||||
canvas.drawPath(play, _paint(_ink));
|
||||
case SlideType.quote:
|
||||
_quoteMark(canvas, 16, 16);
|
||||
_bar(canvas, 42, 30, 96, 7, _ink);
|
||||
_bar(canvas, 42, 43, 78, 7, _ink);
|
||||
_bar(canvas, 42, 60, 42, 5, _accent);
|
||||
case SlideType.table:
|
||||
_bar(canvas, 14, 16, 132, 14, _soft, radius: 2);
|
||||
final line = _paint(_ink.withValues(alpha: 0.45))..strokeWidth = 1.5;
|
||||
for (var r = 1; r <= 4; r++) {
|
||||
canvas.drawLine(
|
||||
Offset(14, 16 + r * 14),
|
||||
Offset(146, 16 + r * 14),
|
||||
line,
|
||||
);
|
||||
}
|
||||
for (var c = 0; c <= 3; c++) {
|
||||
canvas.drawLine(
|
||||
Offset(14 + c * 44, 16),
|
||||
Offset(14 + c * 44, 72),
|
||||
line,
|
||||
);
|
||||
}
|
||||
case SlideType.chart:
|
||||
final axis = _paint(_soft)..strokeWidth = 2;
|
||||
canvas.drawLine(const Offset(20, 14), const Offset(20, 74), axis);
|
||||
canvas.drawLine(const Offset(20, 74), const Offset(148, 74), axis);
|
||||
_bar(canvas, 34, 50, 18, 24, _soft, radius: 2);
|
||||
_bar(canvas, 64, 36, 18, 38, _accent, radius: 2);
|
||||
_bar(canvas, 94, 44, 18, 30, _soft, radius: 2);
|
||||
_bar(canvas, 124, 24, 18, 50, _accent, radius: 2);
|
||||
case SlideType.code:
|
||||
_bar(canvas, 10, 10, 140, 70, const Color(0xFF1E293B), radius: 4);
|
||||
_bar(canvas, 20, 22, 44, 6, const Color(0xFF7DD3A7), radius: 3);
|
||||
_bar(canvas, 30, 34, 64, 6, const Color(0xFF93B8F8), radius: 3);
|
||||
_bar(canvas, 30, 46, 50, 6, const Color(0xFFE2C08D), radius: 3);
|
||||
_bar(canvas, 20, 58, 32, 6, const Color(0xFF7DD3A7), radius: 3);
|
||||
case SlideType.freeMarkdown:
|
||||
_bar(canvas, 14, 12, 10, 9, _accent, radius: 2);
|
||||
_bar(canvas, 28, 12, 62, 9, _ink);
|
||||
_bar(canvas, 14, 32, 120, 6, _soft);
|
||||
_bar(canvas, 14, 44, 132, 6, _soft);
|
||||
_bar(canvas, 14, 56, 92, 6, _soft);
|
||||
_bar(canvas, 14, 68, 110, 6, _soft);
|
||||
}
|
||||
}
|
||||
|
||||
void _bar(
|
||||
Canvas canvas,
|
||||
double x,
|
||||
double y,
|
||||
double w,
|
||||
double h,
|
||||
Color color, {
|
||||
double? radius,
|
||||
}) {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x, y, w, h),
|
||||
Radius.circular(radius ?? h / 2),
|
||||
),
|
||||
_paint(color),
|
||||
);
|
||||
}
|
||||
|
||||
/// A column of bullet points: accent dot plus a soft text line.
|
||||
void _bullets(Canvas canvas, double x, double y, double w, int count) {
|
||||
for (var i = 0; i < count; i++) {
|
||||
final dy = y + i * 13.0;
|
||||
canvas.drawCircle(Offset(x + 3, dy + 3), 3, _paint(_accent));
|
||||
_bar(canvas, x + 11, dy, w * (i.isEven ? 1.0 : 0.74), 6, _soft);
|
||||
}
|
||||
}
|
||||
|
||||
/// Image placeholder: filled box with a sun and mountains pictogram.
|
||||
void _imageBox(
|
||||
Canvas canvas,
|
||||
double x,
|
||||
double y,
|
||||
double w,
|
||||
double h, {
|
||||
bool pictogram = true,
|
||||
}) {
|
||||
_bar(canvas, x, y, w, h, _fill, radius: 4);
|
||||
if (!pictogram) return;
|
||||
final dark = _paint(_soft);
|
||||
canvas.drawCircle(Offset(x + w * 0.28, y + h * 0.30), h * 0.11, dark);
|
||||
final hills = Path()
|
||||
..moveTo(x + w * 0.08, y + h * 0.88)
|
||||
..lineTo(x + w * 0.38, y + h * 0.45)
|
||||
..lineTo(x + w * 0.58, y + h * 0.72)
|
||||
..lineTo(x + w * 0.74, y + h * 0.52)
|
||||
..lineTo(x + w * 0.94, y + h * 0.88)
|
||||
..close();
|
||||
canvas.drawPath(hills, dark);
|
||||
}
|
||||
|
||||
/// A stylised double quotation mark.
|
||||
void _quoteMark(Canvas canvas, double x, double y) {
|
||||
final paint = _paint(_accent);
|
||||
for (final dx in [0.0, 11.0]) {
|
||||
canvas.drawCircle(Offset(x + 4 + dx, y + 8), 4, paint);
|
||||
final tail = Path()
|
||||
..moveTo(x + dx, y + 8)
|
||||
..quadraticBezierTo(x + dx, y + 17, x + 7 + dx, y + 18)
|
||||
..lineTo(x + 7 + dx, y + 14)
|
||||
..quadraticBezierTo(x + 4 + dx, y + 13, x + 4 + dx, y + 8)
|
||||
..close();
|
||||
canvas.drawPath(tail, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(SlideTypePreviewPainter old) => old.type != type;
|
||||
}
|
||||
|
||||
Paint _paint(Color color) => Paint()..color = color;
|
||||
|
|
|
|||
195
lib/widgets/dialogs/consent_dialog.dart
Normal file
195
lib/widgets/dialogs/consent_dialog.dart
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import 'package:file_picker/file_picker.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
import '../../services/caption_service.dart';
|
||||
import '../../services/description_service.dart';
|
||||
import '../../services/image_dedup_service.dart';
|
||||
import '../../services/image_reference_service.dart';
|
||||
import '../../services/image_service.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
|
|
@ -19,6 +21,12 @@ class ImagePickResult {
|
|||
/// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden.
|
||||
typedef ImageUsageLookup = List<String> Function(String absolutePath);
|
||||
|
||||
/// Vervangt in alle open decks elke slideverwijzing naar [fromAbsolute] door
|
||||
/// [toAbsolute]. Gebruikt bij het opruimen van duplicaten, zodat slides niet
|
||||
/// leeg raken wanneer hun kopie wordt verwijderd.
|
||||
typedef ImageUsageReplace =
|
||||
Future<void> Function(String fromAbsolute, String toAbsolute);
|
||||
|
||||
/// Manier waarop de afbeeldingen worden getoond. Tussen beide kan in de
|
||||
/// header gewisseld worden.
|
||||
enum _ViewMode {
|
||||
|
|
@ -38,6 +46,12 @@ class ImageCarouselPicker extends StatefulWidget {
|
|||
final CaptionService captionService;
|
||||
final DescriptionService descriptionService;
|
||||
final ImageUsageLookup? usageOf;
|
||||
final ImageUsageReplace? onReplaceUsages;
|
||||
|
||||
/// Bestandspaden van de presentaties die nu in tabs geopend zijn. Die zijn
|
||||
/// al gedekt door [usageOf]; bij het scannen van decks op schijf worden ze
|
||||
/// overgeslagen om dubbeltellingen te voorkomen.
|
||||
final List<String> openDeckFiles;
|
||||
|
||||
const ImageCarouselPicker({
|
||||
super.key,
|
||||
|
|
@ -46,6 +60,8 @@ class ImageCarouselPicker extends StatefulWidget {
|
|||
required this.descriptionService,
|
||||
this.initialPath,
|
||||
this.usageOf,
|
||||
this.onReplaceUsages,
|
||||
this.openDeckFiles = const [],
|
||||
});
|
||||
|
||||
static Future<ImagePickResult?> show(
|
||||
|
|
@ -55,6 +71,8 @@ class ImageCarouselPicker extends StatefulWidget {
|
|||
CaptionService? captionService,
|
||||
DescriptionService? descriptionService,
|
||||
ImageUsageLookup? usageOf,
|
||||
ImageUsageReplace? onReplaceUsages,
|
||||
List<String> openDeckFiles = const [],
|
||||
}) {
|
||||
return showDialog<ImagePickResult>(
|
||||
context: context,
|
||||
|
|
@ -65,6 +83,8 @@ class ImageCarouselPicker extends StatefulWidget {
|
|||
captionService: captionService ?? CaptionService(),
|
||||
descriptionService: descriptionService ?? DescriptionService(),
|
||||
usageOf: usageOf,
|
||||
onReplaceUsages: onReplaceUsages,
|
||||
openDeckFiles: openDeckFiles,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -101,6 +121,8 @@ class _ImageCarouselPickerState extends State<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;
|
||||
|
||||
|
|
@ -187,9 +209,15 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
/// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van
|
||||
/// [_images] (nieuwste eerst) behouden.
|
||||
void _applyFilter() {
|
||||
final base = _untaggedOnly
|
||||
? [
|
||||
for (final path in _images)
|
||||
if ((_descriptions[path] ?? '').trim().isEmpty) path,
|
||||
]
|
||||
: _images;
|
||||
final q = _query.trim().toLowerCase();
|
||||
if (q.isEmpty) {
|
||||
_filtered = _images;
|
||||
_filtered = base;
|
||||
return;
|
||||
}
|
||||
final terms = q
|
||||
|
|
@ -198,9 +226,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
.toList(growable: false);
|
||||
|
||||
final hits = <({String path, int score, int order})>[];
|
||||
for (var i = 0; i < _images.length; i++) {
|
||||
final score = _relevance(_images[i], terms);
|
||||
if (score > 0) hits.add((path: _images[i], score: score, order: i));
|
||||
for (var i = 0; i < base.length; i++) {
|
||||
final score = _relevance(base[i], terms);
|
||||
if (score > 0) hits.add((path: base[i], score: score, order: i));
|
||||
}
|
||||
hits.sort((a, b) {
|
||||
final byScore = b.score.compareTo(a.score);
|
||||
|
|
@ -257,6 +285,277 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Zet het "alleen zonder tags"-filter aan of uit, zodat snel te zien is
|
||||
/// welke afbeeldingen nog geen beschrijving/tags hebben.
|
||||
void _toggleUntaggedOnly() {
|
||||
setState(() {
|
||||
_untaggedOnly = !_untaggedOnly;
|
||||
_applyFilter();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _syncCoverToSelection(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Zoek byte-identieke afbeeldingen (md5), laat de gebruiker bevestigen en
|
||||
/// ruim ze op: per groep blijft één bestand staan, tags/beschrijvingen en
|
||||
/// opmerkingen/captions worden samengevoegd en slides die een verwijderde
|
||||
/// kopie gebruikten gaan naar het behouden bestand wijzen — zowel in open
|
||||
/// presentaties als in .md-bestanden op schijf binnen de zoekmappen.
|
||||
Future<void> _dedupe() async {
|
||||
await _persistDescription();
|
||||
setState(() => _deduping = true);
|
||||
final dedup = ImageDedupService();
|
||||
final refs = ImageReferenceService();
|
||||
final groups = await dedup.findDuplicateGroups(_images);
|
||||
if (!mounted) return;
|
||||
if (groups.isEmpty) {
|
||||
setState(() => _deduping = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.d('Geen dubbele afbeeldingen gevonden.')),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ook presentaties op schijf tellen mee: zo blijft bij voorkeur het
|
||||
// bestand staan waar de meeste slides (open of niet) naar wijzen. Open
|
||||
// decks worden via usageOf geteld en hier overgeslagen.
|
||||
final deckFiles = await refs.findDeckFiles(widget.searchPaths);
|
||||
final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [
|
||||
for (final group in groups) ...group,
|
||||
]);
|
||||
if (!mounted) return;
|
||||
|
||||
final plan = <({String keeper, List<String> remove})>[
|
||||
for (final group in groups)
|
||||
() {
|
||||
final keeper = dedup.chooseKeeper(
|
||||
group,
|
||||
usageCountOf: (path) =>
|
||||
(widget.usageOf?.call(path).length ?? 0) +
|
||||
(diskCounts[p.normalize(path)] ?? 0),
|
||||
);
|
||||
return (
|
||||
keeper: keeper,
|
||||
remove: [
|
||||
for (final path in group)
|
||||
if (path != keeper) path,
|
||||
],
|
||||
);
|
||||
}(),
|
||||
];
|
||||
|
||||
final confirmed = await _showDedupeDialog(plan);
|
||||
if (confirmed != true) {
|
||||
if (mounted) setState(() => _deduping = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
final updatedDeckFiles = <String>{};
|
||||
for (final entry in plan) {
|
||||
// Keeper eerst, zodat zijn eigen tekst vooraan blijft staan.
|
||||
final ordered = [entry.keeper, ...entry.remove];
|
||||
final captions = <String?>[
|
||||
for (final path in ordered) await widget.captionService.getCaption(path),
|
||||
];
|
||||
final mergedCaption = dedup.mergeMetadata(captions);
|
||||
final mergedDescription = dedup.mergeMetadata(
|
||||
[for (final path in ordered) _descriptions[path]],
|
||||
separator: ', ',
|
||||
);
|
||||
if (mergedCaption.isNotEmpty) {
|
||||
await widget.captionService.saveCaption(entry.keeper, mergedCaption);
|
||||
}
|
||||
if (mergedDescription.isNotEmpty) {
|
||||
_descriptions[entry.keeper] = mergedDescription;
|
||||
await widget.descriptionService.saveDescription(
|
||||
entry.keeper,
|
||||
mergedDescription,
|
||||
);
|
||||
}
|
||||
for (final path in entry.remove) {
|
||||
await widget.onReplaceUsages?.call(path, entry.keeper);
|
||||
// Ook niet-geopende presentaties op schijf laten meewijzen.
|
||||
for (final deckFile in deckFiles) {
|
||||
final updated = await refs.replaceReferences(
|
||||
deckFile,
|
||||
path,
|
||||
entry.keeper,
|
||||
);
|
||||
if (updated) updatedDeckFiles.add(deckFile);
|
||||
}
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (_) {}
|
||||
await widget.captionService.saveCaption(path, '');
|
||||
await widget.descriptionService.removeDescription(path);
|
||||
_descriptions.remove(path);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final removedSet = {for (final entry in plan) ...entry.remove};
|
||||
setState(() {
|
||||
_images = [
|
||||
for (final path in _images)
|
||||
if (!removedSet.contains(path)) path,
|
||||
];
|
||||
_descEditing = null;
|
||||
if (_selected != null && removedSet.contains(_selected)) {
|
||||
_selected = plan
|
||||
.firstWhere((entry) => entry.remove.contains(_selected))
|
||||
.keeper;
|
||||
}
|
||||
_deduping = false;
|
||||
_applyFilter();
|
||||
});
|
||||
await _loadCaptionForSelection();
|
||||
_loadDescriptionForSelection();
|
||||
if (!mounted) return;
|
||||
final l10n = context.l10n;
|
||||
final removedText = removed == 1
|
||||
? l10n.d('1 dubbele afbeelding verwijderd.')
|
||||
: '$removed ${l10n.d('dubbele afbeeldingen verwijderd.')}';
|
||||
final filesText = updatedDeckFiles.isEmpty
|
||||
? ''
|
||||
: updatedDeckFiles.length == 1
|
||||
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
|
||||
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$removedText$filesText')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _showDedupeDialog(
|
||||
List<({String keeper, List<String> remove})> plan,
|
||||
) {
|
||||
final removeCount = plan.fold(0, (sum, e) => sum + e.remove.length);
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final l10n = ctx.l10n;
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFF161B22),
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.layers_clear_outlined,
|
||||
color: Color(0xFF60A5FA),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${l10n.d('Dubbele afbeeldingen opruimen?')} ($removeCount)',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.d(
|
||||
'Van elke groep blijft één bestand staan. Tags en opmerkingen worden samengevoegd en slides die een kopie gebruiken verwijzen daarna naar het behouden bestand — ook in presentaties die nu niet geopend zijn.',
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8B949E),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final entry in plan) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 14,
|
||||
color: Color(0xFF22C55E),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.basename(entry.keeper),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCDD9E5),
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final path in entry.remove)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 13,
|
||||
color: Color(0xFFE5746E),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.basename(path),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8B949E),
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF8B949E),
|
||||
),
|
||||
child: Text(l10n.t('cancel')),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
icon: const Icon(Icons.layers_clear_outlined, size: 16),
|
||||
label: Text(l10n.d('Opruimen')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF238636),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
if (_selected == null) return;
|
||||
await _persistDescription();
|
||||
|
|
@ -386,11 +685,38 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Filter de deckbestanden op schijf die niet in een tab geopend zijn
|
||||
/// (open decks zijn al gedekt door [ImageCarouselPicker.usageOf]).
|
||||
List<String> _withoutOpenDecks(List<String> deckFiles) {
|
||||
final open = {for (final f in widget.openDeckFiles) p.normalize(f)};
|
||||
return [
|
||||
for (final f in deckFiles)
|
||||
if (!open.contains(p.normalize(f))) f,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _deleteSelected() async {
|
||||
final path = _selected;
|
||||
if (path == null) return;
|
||||
final usages = widget.usageOf?.call(path) ?? const [];
|
||||
final confirmed = await _showDeleteDialog(path, usages);
|
||||
final usages = [...widget.usageOf?.call(path) ?? const <String>[]];
|
||||
var slideCount = usages.length;
|
||||
// Ook niet-geopende presentaties op schijf meenemen in de waarschuwing.
|
||||
final refs = ImageReferenceService();
|
||||
final onDisk = await refs.referencingFiles(
|
||||
_withoutOpenDecks(await refs.findDeckFiles(widget.searchPaths)),
|
||||
path,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final notOpen = context.l10n.d('niet geopend');
|
||||
for (final entry in onDisk.entries) {
|
||||
slideCount += entry.value;
|
||||
usages.add(
|
||||
entry.value == 1
|
||||
? '${p.basename(entry.key)} · $notOpen'
|
||||
: '${p.basename(entry.key)} · ${entry.value}× · $notOpen',
|
||||
);
|
||||
}
|
||||
final confirmed = await _showDeleteDialog(path, usages, slideCount);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
|
|
@ -418,7 +744,11 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
_loadDescriptionForSelection();
|
||||
}
|
||||
|
||||
Future<bool?> _showDeleteDialog(String path, List<String> usages) {
|
||||
Future<bool?> _showDeleteDialog(
|
||||
String path,
|
||||
List<String> usages,
|
||||
int slideCount,
|
||||
) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
|
|
@ -470,7 +800,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
)
|
||||
else ...[
|
||||
Text(
|
||||
'${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} ${usages.length} ${usages.length == 1 ? l10n.d("slide") : l10n.t("slides")}:',
|
||||
'${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} $slideCount ${slideCount == 1 ? l10n.d("slide") : l10n.t("slides")}:',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF0B429),
|
||||
fontSize: 13,
|
||||
|
|
@ -664,7 +994,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_query.trim().isEmpty
|
||||
_query.trim().isEmpty && !_untaggedOnly
|
||||
? '${_images.length}'
|
||||
: '${_filtered.length} / ${_images.length}',
|
||||
style: const TextStyle(
|
||||
|
|
@ -677,6 +1007,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
const SizedBox(width: 16),
|
||||
Expanded(child: _buildSearchField()),
|
||||
const SizedBox(width: 12),
|
||||
_buildUntaggedToggle(),
|
||||
const SizedBox(width: 12),
|
||||
_buildViewToggle(),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
|
|
@ -739,6 +1071,38 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Aan/uit-knop voor het filter "alleen afbeeldingen zonder tags". Handig om
|
||||
/// te zien welke afbeeldingen nog een beschrijving/tags nodig hebben.
|
||||
Widget _buildUntaggedToggle() {
|
||||
final l10n = context.l10n;
|
||||
return Tooltip(
|
||||
message: l10n.d('Alleen afbeeldingen zonder tags tonen'),
|
||||
child: GestureDetector(
|
||||
onTap: _toggleUntaggedOnly,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: _untaggedOnly
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFF30363D),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.label_off_outlined,
|
||||
size: 17,
|
||||
color: _untaggedOnly
|
||||
? const Color(0xFF60A5FA)
|
||||
: const Color(0xFF6E7681),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Segmented control om tussen raster- en coverflow-weergave te wisselen.
|
||||
Widget _buildViewToggle() {
|
||||
final l10n = context.l10n;
|
||||
|
|
@ -790,6 +1154,37 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
/// Lege staat — gedeeld door raster- en coverflow-weergave.
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = context.l10n;
|
||||
if (_untaggedOnly && _query.trim().isEmpty) {
|
||||
return Expanded(
|
||||
flex: 13,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.verified_outlined,
|
||||
size: 56,
|
||||
color: Color(0xFF22C55E),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l10n.d('Alle afbeeldingen hebben tags.'),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCDD9E5),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.d('Zet het filter uit om alles weer te zien.'),
|
||||
style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final filtering = _query.trim().isNotEmpty;
|
||||
return Expanded(
|
||||
flex: 13,
|
||||
|
|
@ -1547,6 +1942,35 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Duplicaten opruimen (md5)
|
||||
Tooltip(
|
||||
message: l10n.d(
|
||||
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën',
|
||||
),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _deduping || _images.length < 2 ? null : _dedupe,
|
||||
icon: _deduping
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF8B949E),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.layers_clear_outlined, size: 16),
|
||||
label: Text(l10n.d('Duplicaten opruimen')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF8B949E),
|
||||
side: const BorderSide(color: Color(0xFF30363D)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Hint
|
||||
Text(
|
||||
l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../models/settings.dart';
|
||||
import '../../state/settings_provider.dart';
|
||||
import '../../state/tabs_provider.dart';
|
||||
import '../../state/consent_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
|
|
@ -191,7 +192,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
: profiles.first.name;
|
||||
|
||||
return DefaultTabController(
|
||||
length: 5,
|
||||
length: 6,
|
||||
child: AlertDialog(
|
||||
title: Text(l10n.t('settings')),
|
||||
content: SizedBox(
|
||||
|
|
@ -223,6 +224,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
icon: const Icon(Icons.image_outlined),
|
||||
text: l10n.t('settingsLogo'),
|
||||
),
|
||||
Tab(
|
||||
icon: const Icon(Icons.privacy_tip_outlined),
|
||||
text: l10n.d('Privacy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -234,6 +239,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
_tabBody(_styleTab(profiles, dropdownValue)),
|
||||
_tabBody(_colorsTab()),
|
||||
_tabBody(_logoTab()),
|
||||
_tabBody(_privacyTab()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -432,6 +438,18 @@ 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: [
|
||||
|
|
@ -490,6 +508,42 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Dropdown with interface text-scale steps (WCAG 1.4.4 asks for up to
|
||||
/// 200%). The stored value snaps to the nearest offered step.
|
||||
Widget _uiTextScaleField() {
|
||||
final l10n = context.l10n;
|
||||
const steps = [1.0, 1.15, 1.3, 1.5, 1.75, 2.0];
|
||||
final current = ref.watch(settingsProvider.select((s) => s.uiTextScale));
|
||||
final value = steps.reduce(
|
||||
(a, b) => (a - current).abs() <= (b - current).abs() ? a : b,
|
||||
);
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.d('Tekstgrootte van de interface'),
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.text_increase, size: 18),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<double>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
isDense: true,
|
||||
items: [
|
||||
for (final step in steps)
|
||||
DropdownMenuItem(
|
||||
value: step,
|
||||
child: Text('${(step * 100).round()}%'),
|
||||
),
|
||||
],
|
||||
onChanged: (scale) {
|
||||
if (scale == null) return;
|
||||
ref.read(settingsProvider.notifier).setUiTextScale(scale);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _appearanceTab() {
|
||||
final l10n = context.l10n;
|
||||
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
|
||||
|
|
@ -1575,4 +1629,83 @@ 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class ImageZoomControl extends StatelessWidget {
|
|||
child: const Icon(
|
||||
Icons.zoom_out,
|
||||
size: 16,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
|
@ -141,7 +141,7 @@ class ImageZoomControl extends StatelessWidget {
|
|||
child: const Icon(
|
||||
Icons.zoom_in,
|
||||
size: 16,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -153,7 +153,7 @@ class ImageZoomControl extends StatelessWidget {
|
|||
fontSize: 12,
|
||||
color: zoomed
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFF94A3B8),
|
||||
: const Color(0xFF64748B),
|
||||
fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
|
|
@ -167,7 +167,7 @@ class ImageZoomControl extends StatelessWidget {
|
|||
onPressed: zoomed ? () => onChanged(100) : null,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
color: const Color(0xFF94A3B8),
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -176,7 +176,7 @@ class ImageZoomControl extends StatelessWidget {
|
|||
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||||
child: Text(
|
||||
_label(context),
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF64748B)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -226,10 +226,59 @@ class ImagePickerBar extends ConsumerWidget {
|
|||
captionService: captions,
|
||||
descriptionService: ref.read(descriptionServiceProvider),
|
||||
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
|
||||
onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to),
|
||||
openDeckFiles: [
|
||||
for (final tab in ref.read(tabsProvider).tabs)
|
||||
?tab.deckNotifier.currentState.filePath,
|
||||
],
|
||||
);
|
||||
if (result != null) onPicked(result.path, result.caption);
|
||||
}
|
||||
|
||||
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
|
||||
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
|
||||
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
|
||||
Future<void> _replaceImageUsages(
|
||||
WidgetRef ref,
|
||||
String fromAbsolute,
|
||||
String toAbsolute,
|
||||
) async {
|
||||
final target = p.normalize(fromAbsolute);
|
||||
for (final tab in ref.read(tabsProvider).tabs) {
|
||||
final notifier = tab.deckNotifier;
|
||||
final deck = notifier.currentState.deck;
|
||||
if (deck == null) continue;
|
||||
final projectPath = deck.projectPath ?? '';
|
||||
|
||||
String resolve(String candidate) => p.normalize(
|
||||
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
|
||||
);
|
||||
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
|
||||
// binnen het project ligt; anders absoluut.
|
||||
String replacement(String candidate) {
|
||||
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
|
||||
return p.isWithin(projectPath, toAbsolute)
|
||||
? p.relative(toAbsolute, from: projectPath)
|
||||
: toAbsolute;
|
||||
}
|
||||
|
||||
for (var i = 0; i < deck.slides.length; i++) {
|
||||
final slide = deck.slides[i];
|
||||
var updated = slide;
|
||||
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
|
||||
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
|
||||
}
|
||||
if (slide.imagePath2.isNotEmpty &&
|
||||
resolve(slide.imagePath2) == target) {
|
||||
updated = updated.copyWith(
|
||||
imagePath2: replacement(slide.imagePath2),
|
||||
);
|
||||
}
|
||||
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find every open-deck slide that references [absolutePath], so we can warn
|
||||
/// before deleting an image that is still in use.
|
||||
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
||||
|
|
@ -282,7 +331,7 @@ class ImagePickerBar extends ConsumerWidget {
|
|||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: imagePath.isEmpty
|
||||
? const Color(0xFF94A3B8)
|
||||
? const Color(0xFF64748B)
|
||||
: const Color(0xFF334155),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -345,7 +394,7 @@ class ImagePickerBar extends ConsumerWidget {
|
|||
child: IconButton(
|
||||
onPressed: onClear,
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
color: const Color(0xFF94A3B8),
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -447,7 +496,7 @@ class _CaptionFieldState extends State<_CaptionField> {
|
|||
prefixIcon: const Icon(
|
||||
Icons.copyright_outlined,
|
||||
size: 16,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class AudioAttachmentEditor extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: slide.audioPath.isEmpty
|
||||
? const Color(0xFF94A3B8)
|
||||
? const Color(0xFF64748B)
|
||||
: const Color(0xFF334155),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
|
|||
else
|
||||
Text(
|
||||
_markerForItem(i),
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
|
|
@ -342,7 +342,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
|
|||
icon: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
size: 18,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
onPressed: () => _removeBulletAndFocus(i),
|
||||
tooltip: l10n.d('Verwijder'),
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
|
|||
else
|
||||
Text(
|
||||
_markerForItem(i),
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
|
|
@ -372,7 +372,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
|
|||
icon: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
size: 18,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
onPressed: () => _removeBulletAndFocus(i),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
|
|
|
|||
|
|
@ -791,7 +791,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
decoration: BoxDecoration(
|
||||
color: Color(
|
||||
_type == ChartType.pie && c >= 2
|
||||
? 0xFF94A3B8
|
||||
? 0xFF64748B
|
||||
: int.parse(
|
||||
chartSeriesColor(
|
||||
ChartSeries(
|
||||
|
|
@ -951,7 +951,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -1038,7 +1038,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
key: key,
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, size: 14),
|
||||
color: const Color(0xFF94A3B8),
|
||||
color: const Color(0xFF64748B),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class _QuoteEditorState extends ConsumerState<QuoteEditor> {
|
|||
l10n.d(
|
||||
'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.',
|
||||
),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ImagePickerBar(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/table_clipboard.dart';
|
||||
import '_editor_field.dart';
|
||||
|
||||
/// Editor for a table slide. Stores cells as a rectangular grid of
|
||||
|
|
@ -108,6 +110,69 @@ class _TableEditorState extends State<TableEditor> {
|
|||
_emit();
|
||||
}
|
||||
|
||||
/// Intercepts the paste shortcut on a cell: Cmd+V (macOS), Ctrl+V
|
||||
/// (Windows/Linux) and Shift+Insert (Windows/Linux). The clipboard can only
|
||||
/// be read asynchronously, so the event is always claimed and [_pasteIntoCell]
|
||||
/// decides between a table fill and a plain in-cell paste.
|
||||
KeyEventResult _onCellKey(int r, int c, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
final pasteCombo =
|
||||
(event.logicalKey == LogicalKeyboardKey.keyV &&
|
||||
(keys.isControlPressed || keys.isMetaPressed)) ||
|
||||
(event.logicalKey == LogicalKeyboardKey.insert && keys.isShiftPressed);
|
||||
if (!pasteCombo) return KeyEventResult.ignored;
|
||||
Clipboard.getData(Clipboard.kTextPlain).then((data) {
|
||||
final text = data?.text;
|
||||
if (text == null || text.isEmpty || !mounted) return;
|
||||
_pasteIntoCell(r, c, text);
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// Tabular clipboard content (a spreadsheet selection, CSV, a markdown
|
||||
/// table) fills the grid starting at cell (r, c), growing it as needed;
|
||||
/// anything else is pasted into the cell at the cursor as usual.
|
||||
void _pasteIntoCell(int r, int c, String text) {
|
||||
final table = parseClipboardTable(text);
|
||||
if (table == null) {
|
||||
final ctrl = _cells[r][c];
|
||||
final value = ctrl.text;
|
||||
final sel = ctrl.selection;
|
||||
final start = sel.isValid ? sel.start : value.length;
|
||||
final end = sel.isValid ? sel.end : value.length;
|
||||
ctrl.value = TextEditingValue(
|
||||
text: value.replaceRange(start, end, text),
|
||||
selection: TextSelection.collapsed(offset: start + text.length),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
final neededCols = c + table.first.length;
|
||||
final neededRows = r + table.length;
|
||||
while (_colCount < neededCols) {
|
||||
for (final row in _cells) {
|
||||
row.add(_makeCtrl(''));
|
||||
}
|
||||
}
|
||||
while (_cells.length < neededRows) {
|
||||
_cells.add(
|
||||
List<TextEditingController>.generate(_colCount, (_) => _makeCtrl('')),
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < table.length; i++) {
|
||||
for (var j = 0; j < table[i].length; j++) {
|
||||
final ctrl = _cells[r + i][c + j];
|
||||
// Rewrite without notifying per cell; one _emit follows below.
|
||||
ctrl.removeListener(_emit);
|
||||
ctrl.text = table[i][j];
|
||||
ctrl.addListener(_emit);
|
||||
}
|
||||
}
|
||||
});
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
|
|
@ -131,8 +196,9 @@ class _TableEditorState extends State<TableEditor> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.'),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
'${l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.')}\n'
|
||||
'${l10n.d('Tip: plak met Cmd/Ctrl+V een tabel uit je spreadsheet in een cel om de hele tabel te vullen.')}',
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||
),
|
||||
),
|
||||
_buildColumnControls(),
|
||||
|
|
@ -170,7 +236,7 @@ class _TableEditorState extends State<TableEditor> {
|
|||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 16,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
onPressed: _colCount > 1 ? () => _removeColumn(c) : null,
|
||||
tooltip:
|
||||
|
|
@ -203,26 +269,31 @@ class _TableEditorState extends State<TableEditor> {
|
|||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: TextField(
|
||||
controller: _cells[r][c],
|
||||
// Meerdere regels toestaan: het veld groeit mee en Enter
|
||||
// voegt een nieuwe regel toe binnen de cel.
|
||||
minLines: 1,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isHeader ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: isHeader,
|
||||
fillColor: isHeader ? const Color(0xFFF1F5F9) : null,
|
||||
hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) => _onCellKey(r, c, event),
|
||||
child: TextField(
|
||||
controller: _cells[r][c],
|
||||
// Meerdere regels toestaan: het veld groeit mee en Enter
|
||||
// voegt een nieuwe regel toe binnen de cel.
|
||||
minLines: 1,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isHeader
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: isHeader,
|
||||
fillColor: isHeader ? const Color(0xFFF1F5F9) : null,
|
||||
hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -236,7 +307,7 @@ class _TableEditorState extends State<TableEditor> {
|
|||
icon: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
size: 18,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
onPressed: _cells.length > 1 ? () => _removeRow(r) : null,
|
||||
tooltip: isHeader
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class _TitleEditorState extends ConsumerState<TitleEditor> {
|
|||
l10n.d(
|
||||
'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.',
|
||||
),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ImagePickerBar(
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ class _BulletColumnState extends State<_BulletColumn> {
|
|||
else
|
||||
Text(
|
||||
_markerForItem(i),
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
|
|
@ -399,7 +399,7 @@ class _BulletColumnState extends State<_BulletColumn> {
|
|||
icon: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
size: 18,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
|
||||
tooltip: l10n.d('Verwijder'),
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ class _TwoImagesEditorState extends ConsumerState<TwoImagesEditor> {
|
|||
child: Text(
|
||||
'Links ${widget.slide.imageSize > 0 ? widget.slide.imageSize : 50}% — '
|
||||
'Rechts ${100 - (widget.slide.imageSize > 0 ? widget.slide.imageSize : 50)}%',
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class _PathBox extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: path.isEmpty
|
||||
? const Color(0xFF94A3B8)
|
||||
? const Color(0xFF64748B)
|
||||
: const Color(0xFF334155),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -19,7 +21,14 @@ import '../dialogs/slide_finder_dialog.dart';
|
|||
import '../slides/slide_thumbnail.dart';
|
||||
|
||||
class SlideListPanel extends ConsumerStatefulWidget {
|
||||
const SlideListPanel({super.key});
|
||||
/// Current width of the slide rail. When it changes (dragging the divider),
|
||||
/// the slide being edited is scrolled back into view once the resize
|
||||
/// settles. Passed in by the shell rather than measured with a
|
||||
/// LayoutBuilder: rebuilding a ReorderableListView during layout trips its
|
||||
/// overlay bookkeeping ("_RenderLayoutBuilder was mutated…").
|
||||
final double? railWidth;
|
||||
|
||||
const SlideListPanel({super.key, this.railWidth});
|
||||
|
||||
@override
|
||||
ConsumerState<SlideListPanel> createState() => _SlideListPanelState();
|
||||
|
|
@ -31,15 +40,35 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
final _scrollController = ScrollController();
|
||||
final _focusNode = FocusNode(debugLabel: 'SlideListPanel');
|
||||
final Map<String, GlobalKey> _slideKeys = {};
|
||||
Timer? _resizeSettleTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resizeSettleTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Thumbnails are 16:9, so when the rail is resized their heights change and
|
||||
/// the scroll offset no longer points at the slide being edited. Once the
|
||||
/// resize settles, bring the selected slide back to the top of the list.
|
||||
@override
|
||||
void didUpdateWidget(covariant SlideListPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final width = widget.railWidth;
|
||||
final previous = oldWidget.railWidth;
|
||||
if (width == null || previous == null || (width - previous).abs() < 0.5) {
|
||||
return;
|
||||
}
|
||||
_resizeSettleTimer?.cancel();
|
||||
_resizeSettleTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
if (!mounted) return;
|
||||
_scrollSlideToTop(ref.read(editorProvider).selectedIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/// Lower-cased, concatenated text of a slide for searching. Kept broad on
|
||||
/// purpose: everything you typed into the slide should make it findable.
|
||||
String _slideText(Slide slide) {
|
||||
|
|
@ -88,7 +117,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
_slideKeys.removeWhere((id, _) => !ids.contains(id));
|
||||
}
|
||||
|
||||
void _scrollSlideToTop(int index) {
|
||||
void _scrollSlideToTop(int index, {int attempts = 2}) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final deck = ref.read(deckProvider).deck;
|
||||
if (deck == null ||
|
||||
|
|
@ -100,7 +129,21 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
|
||||
final keyContext = _slideKeys[deck.slides[index].id]?.currentContext;
|
||||
final target = keyContext?.findRenderObject();
|
||||
if (target == null) return;
|
||||
if (target == null) {
|
||||
// The thumbnail hasn't been built (it sits outside the viewport and
|
||||
// cache). Jump close to it based on the average item height, then try
|
||||
// again now that the surrounding items exist.
|
||||
if (attempts <= 0) return;
|
||||
final position = _scrollController.position;
|
||||
final avgItem =
|
||||
(position.maxScrollExtent + position.viewportDimension) /
|
||||
deck.slides.length;
|
||||
_scrollController.jumpTo(
|
||||
(avgItem * index).clamp(0.0, position.maxScrollExtent),
|
||||
);
|
||||
_scrollSlideToTop(index, attempts: attempts - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
final viewport = RenderAbstractViewport.maybeOf(target);
|
||||
if (viewport == null) return;
|
||||
|
|
@ -506,6 +549,67 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSlideList(
|
||||
Deck deck,
|
||||
bool searching,
|
||||
String query,
|
||||
EditorState editor,
|
||||
DeckNotifier notifier,
|
||||
EditorNotifier editorNotifier,
|
||||
) {
|
||||
if (searching) {
|
||||
return _buildFilteredList(deck, query, editor, notifier, editorNotifier);
|
||||
}
|
||||
return ReorderableListView.builder(
|
||||
scrollController: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: deck.slides.length,
|
||||
onReorderItem: (old, nw) {
|
||||
notifier.reorderSlides(old, nw);
|
||||
// Adjust selection when active slide moved
|
||||
final selIdx = editor.selectedIndex;
|
||||
int newSel = selIdx;
|
||||
if (old == selIdx) {
|
||||
newSel = nw;
|
||||
} else if (old < selIdx && nw >= selIdx) {
|
||||
newSel = selIdx - 1;
|
||||
} else if (old > selIdx && nw <= selIdx) {
|
||||
newSel = selIdx + 1;
|
||||
}
|
||||
editorNotifier.select(newSel.clamp(0, deck.slides.length - 1));
|
||||
},
|
||||
proxyDecorator: (child, index, animation) =>
|
||||
Material(color: Colors.transparent, child: child),
|
||||
itemBuilder: (_, i) {
|
||||
final slide = deck.slides[i];
|
||||
return SlideThumbnail(
|
||||
key: _keyForSlide(slide),
|
||||
slide: slide,
|
||||
index: i,
|
||||
isSelected: editor.selection.contains(i),
|
||||
isPrimary: editor.selectedIndex == i,
|
||||
projectPath: deck.projectPath,
|
||||
themeProfile: deck.themeProfile,
|
||||
slideCount: deck.slides.length,
|
||||
tlp: deck.tlp,
|
||||
onTap: () => _onSlideTap(i),
|
||||
onToggleSkip: () => notifier.toggleSkip(i),
|
||||
onCopyImage: () => _copySlideAsImage(slide),
|
||||
onDuplicate: () {
|
||||
notifier.duplicateSlide(i);
|
||||
editorNotifier.select(i + 1);
|
||||
},
|
||||
onDelete: () {
|
||||
if (deck.slides.length <= 1) return;
|
||||
notifier.removeSlide(i);
|
||||
editorNotifier.clampIndex(deck.slides.length - 2);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
|
|
@ -605,64 +709,14 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
|
||||
// ── Slide list ───────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: searching
|
||||
? _buildFilteredList(
|
||||
deck,
|
||||
query,
|
||||
editor,
|
||||
notifier,
|
||||
editorNotifier,
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
scrollController: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: deck.slides.length,
|
||||
onReorderItem: (old, nw) {
|
||||
notifier.reorderSlides(old, nw);
|
||||
// Adjust selection when active slide moved
|
||||
final selIdx = editor.selectedIndex;
|
||||
int newSel = selIdx;
|
||||
if (old == selIdx) {
|
||||
newSel = nw;
|
||||
} else if (old < selIdx && nw >= selIdx) {
|
||||
newSel = selIdx - 1;
|
||||
} else if (old > selIdx && nw <= selIdx) {
|
||||
newSel = selIdx + 1;
|
||||
}
|
||||
editorNotifier.select(
|
||||
newSel.clamp(0, deck.slides.length - 1),
|
||||
);
|
||||
},
|
||||
proxyDecorator: (child, index, animation) =>
|
||||
Material(color: Colors.transparent, child: child),
|
||||
itemBuilder: (_, i) {
|
||||
final slide = deck.slides[i];
|
||||
return SlideThumbnail(
|
||||
key: _keyForSlide(slide),
|
||||
slide: slide,
|
||||
index: i,
|
||||
isSelected: editor.selection.contains(i),
|
||||
isPrimary: editor.selectedIndex == i,
|
||||
projectPath: deck.projectPath,
|
||||
themeProfile: deck.themeProfile,
|
||||
slideCount: deck.slides.length,
|
||||
tlp: deck.tlp,
|
||||
onTap: () => _onSlideTap(i),
|
||||
onToggleSkip: () => notifier.toggleSkip(i),
|
||||
onCopyImage: () => _copySlideAsImage(slide),
|
||||
onDuplicate: () {
|
||||
notifier.duplicateSlide(i);
|
||||
editorNotifier.select(i + 1);
|
||||
},
|
||||
onDelete: () {
|
||||
if (deck.slides.length <= 1) return;
|
||||
notifier.removeSlide(i);
|
||||
editorNotifier.clampIndex(deck.slides.length - 2);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildSlideList(
|
||||
deck,
|
||||
searching,
|
||||
query,
|
||||
editor,
|
||||
notifier,
|
||||
editorNotifier,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Add / Paste slide buttons ─────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ class AnnotationLayer extends StatefulWidget {
|
|||
/// Called as the laser moves (normalized), or null when it leaves.
|
||||
final ValueChanged<Offset?>? onLaserMove;
|
||||
|
||||
/// Called as the in-progress stroke grows, so a presenter can mirror the
|
||||
/// live drawing to the beamer instead of only the committed result. Carries
|
||||
/// the current partial stroke, or null when it commits or is cancelled.
|
||||
final ValueChanged<InkStroke?>? onActiveStrokeChanged;
|
||||
|
||||
const AnnotationLayer({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
|
|
@ -41,6 +46,7 @@ class AnnotationLayer extends StatefulWidget {
|
|||
this.laserPoint,
|
||||
this.onStrokesChanged,
|
||||
this.onLaserMove,
|
||||
this.onActiveStrokeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -55,6 +61,18 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
|||
bool get _drawing =>
|
||||
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
|
||||
|
||||
/// The in-progress stroke as a committable [InkStroke], or null when there is
|
||||
/// nothing being drawn. Used to mirror live drawing to the beamer.
|
||||
InkStroke? _activeStroke() {
|
||||
if (!_drawing || _active.isEmpty) return null;
|
||||
return InkStroke(
|
||||
tool: widget.tool!,
|
||||
color: widget.color,
|
||||
width: widget.width,
|
||||
points: List<Offset>.from(_active),
|
||||
);
|
||||
}
|
||||
|
||||
Offset _norm(Offset local) => _size.shortestSide == 0
|
||||
? Offset.zero
|
||||
: Offset(
|
||||
|
|
@ -65,6 +83,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
|||
void _commitActive() {
|
||||
if (_active.length < 2) {
|
||||
setState(() => _active = const []);
|
||||
widget.onActiveStrokeChanged?.call(null);
|
||||
return;
|
||||
}
|
||||
final stroke = InkStroke(
|
||||
|
|
@ -76,6 +95,8 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
|||
final next = [...widget.strokes, stroke];
|
||||
setState(() => _active = const []);
|
||||
widget.onStrokesChanged?.call(next);
|
||||
// Clear the beamer's live preview; the committed stroke arrives via strokes.
|
||||
widget.onActiveStrokeChanged?.call(null);
|
||||
}
|
||||
|
||||
void _eraseAt(Offset norm) {
|
||||
|
|
@ -95,6 +116,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
|||
case InkTool.pen:
|
||||
case InkTool.highlighter:
|
||||
setState(() => _active = [n]);
|
||||
widget.onActiveStrokeChanged?.call(_activeStroke());
|
||||
case InkTool.eraser:
|
||||
_eraseAt(n);
|
||||
case InkTool.laser:
|
||||
|
|
@ -110,7 +132,10 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
|||
switch (widget.tool) {
|
||||
case InkTool.pen:
|
||||
case InkTool.highlighter:
|
||||
if (_active.isNotEmpty) setState(() => _active = [..._active, n]);
|
||||
if (_active.isNotEmpty) {
|
||||
setState(() => _active = [..._active, n]);
|
||||
widget.onActiveStrokeChanged?.call(_activeStroke());
|
||||
}
|
||||
case InkTool.eraser:
|
||||
_eraseAt(n);
|
||||
case InkTool.laser:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
final Map<int, List<InkStroke>> _ink = {};
|
||||
int? _laserIndex;
|
||||
Offset? _laserPoint;
|
||||
// The stroke the presenter is drawing right now, mirrored live until it
|
||||
// commits (then it arrives as a normal stroke over the 'ink' channel).
|
||||
int? _activeIndex;
|
||||
InkStroke? _activeStroke;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -84,6 +88,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
_index = (m['index'] as num?)?.toInt() ?? _index;
|
||||
_blank = (m['blank'] as num?)?.toInt() ?? 0;
|
||||
_laserPoint = null; // laser never carries over to another slide
|
||||
_activeStroke = null; // nor does an in-progress stroke
|
||||
});
|
||||
case 'ink':
|
||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||
|
|
@ -92,6 +97,17 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
setState(
|
||||
() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []),
|
||||
);
|
||||
case 'inkLive':
|
||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final i = (m['index'] as num?)?.toInt();
|
||||
final s = m['stroke'];
|
||||
if (!mounted) return null;
|
||||
setState(() {
|
||||
_activeIndex = i;
|
||||
_activeStroke = s == null
|
||||
? null
|
||||
: InkStroke.fromJson(Map<String, dynamic>.from(s as Map));
|
||||
});
|
||||
case 'laser':
|
||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final i = (m['index'] as num?)?.toInt();
|
||||
|
|
@ -198,7 +214,13 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
}),
|
||||
),
|
||||
AnnotationLayer(
|
||||
strokes: _ink[_index] ?? const [],
|
||||
strokes: [
|
||||
...?_ink[_index],
|
||||
// The live in-progress stroke renders just like a committed
|
||||
// one, so the audience sees the line grow as it's drawn.
|
||||
if (_activeStroke != null && _activeIndex == _index)
|
||||
_activeStroke!,
|
||||
],
|
||||
interactive: false,
|
||||
laserPoint: _laserIndex == _index ? _laserPoint : null,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
|
@ -14,6 +15,7 @@ import '../../models/slide.dart';
|
|||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../slides/inline_markdown.dart';
|
||||
import '../slides/slide_preview.dart';
|
||||
import 'annotation_overlay.dart';
|
||||
import 'audience_window.dart';
|
||||
|
|
@ -167,6 +169,8 @@ 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',
|
||||
|
|
@ -175,6 +179,7 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
themeProfile: themeProfile,
|
||||
tlp: tlp,
|
||||
),
|
||||
inlineStyleProfile: true,
|
||||
);
|
||||
// Pre-existing annotations re-keyed by index so the beamer shows them
|
||||
// immediately (the audience window has no stable slide ids of its own).
|
||||
|
|
@ -372,6 +377,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
static const _penWidth = 0.004;
|
||||
static const _highlighterWidth = 0.022;
|
||||
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
double get _toolWidth =>
|
||||
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
||||
|
|
@ -539,6 +545,28 @@ 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);
|
||||
|
|
@ -727,6 +755,22 @@ 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) {
|
||||
|
|
@ -736,6 +780,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
if (_index < widget.slides.length - 1) {
|
||||
setState(() => _index++);
|
||||
_scheduleAdvance();
|
||||
_announceSlide();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -747,6 +792,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
if (_index > 0) {
|
||||
setState(() => _index--);
|
||||
_scheduleAdvance();
|
||||
_announceSlide();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -839,6 +885,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
_gridOpen = false;
|
||||
});
|
||||
_scheduleAdvance();
|
||||
_announceSlide();
|
||||
}
|
||||
|
||||
/// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End).
|
||||
|
|
@ -851,6 +898,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
if (target == _index) return;
|
||||
setState(() => _index = target);
|
||||
_scheduleAdvance();
|
||||
_announceSlide();
|
||||
}
|
||||
|
||||
/// Verplaats de rastercursor en houd 'm in beeld.
|
||||
|
|
@ -1384,6 +1432,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
interactive: true,
|
||||
onStrokesChanged: _onStrokesChanged,
|
||||
onLaserMove: _onLaserMove,
|
||||
onActiveStrokeChanged: _onActiveStroke,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -186,22 +186,27 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
// falls back to Flutter's broken default — red letters with a yellow
|
||||
// underline — which is exactly what showed up in exports. Wrapping here
|
||||
// guarantees identical results in the preview and the export.
|
||||
return _ChecklistInteractionHost(
|
||||
enabled: presentationMode && onChecklistItemToggle != null,
|
||||
onToggle: onChecklistItemToggle,
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: _hexColor(themeProfile.textColor),
|
||||
decoration: TextDecoration.none,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontStyle: FontStyle.normal,
|
||||
),
|
||||
child: _SlideLinkScope(
|
||||
onTapLink: onLinkTap,
|
||||
hasBottomTlp: hasBottomRightTlp,
|
||||
child: _buildSlide(),
|
||||
// The slide is a fixed 16:9 design surface whose sizes all derive from
|
||||
// its width; interface text scaling must not reflow it (the auto-fit
|
||||
// measuring assumes unscaled text), so the canvas opts out.
|
||||
return MediaQuery.withNoTextScaling(
|
||||
child: _ChecklistInteractionHost(
|
||||
enabled: presentationMode && onChecklistItemToggle != null,
|
||||
onToggle: onChecklistItemToggle,
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: _hexColor(themeProfile.textColor),
|
||||
decoration: TextDecoration.none,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontStyle: FontStyle.normal,
|
||||
),
|
||||
child: _SlideLinkScope(
|
||||
onTapLink: onLinkTap,
|
||||
hasBottomTlp: hasBottomRightTlp,
|
||||
child: _buildSlide(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -707,7 +712,7 @@ class _BulletsPreview extends StatelessWidget {
|
|||
font: font,
|
||||
subtitle: subtitle,
|
||||
subtitleSize: subtitleSize,
|
||||
maxScale: _kSplitBulletsMaxScale,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
|
||||
|
|
@ -1072,7 +1077,7 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
bulletGap: bulletGap,
|
||||
font: font,
|
||||
maxScale: _kBulletsMaxScale,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
final rightScale = _bulletsFitScale(
|
||||
|
|
@ -1086,7 +1091,7 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
bulletGap: bulletGap,
|
||||
font: font,
|
||||
maxScale: _kBulletsMaxScale,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
// Treat both columns as one composition: the busiest column determines
|
||||
|
|
@ -1247,7 +1252,7 @@ class _BulletsImagePreview extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
bulletGap: bulletGap,
|
||||
font: font,
|
||||
maxScale: _kBulletsMaxScale,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
|
||||
|
|
@ -1505,34 +1510,34 @@ class _ChecklistProgress extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
SizedBox(height: w * 0.008),
|
||||
MouseRegion(
|
||||
key: const ValueKey('checklist-progress-checked'),
|
||||
onEnter: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = true,
|
||||
onExit: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = null,
|
||||
child: Text(
|
||||
'${context.l10n.d('Afgevinkt')} $checkedPercent%',
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
key: const ValueKey('checklist-progress-unchecked'),
|
||||
onEnter: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = false,
|
||||
onExit: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = null,
|
||||
child: Text(
|
||||
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
|
||||
style: labelStyle.copyWith(
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
MouseRegion(
|
||||
key: const ValueKey('checklist-progress-checked'),
|
||||
onEnter: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = true,
|
||||
onExit: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = null,
|
||||
child: Text(
|
||||
'${context.l10n.d('Afgevinkt')} $checkedPercent%',
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
key: const ValueKey('checklist-progress-unchecked'),
|
||||
onEnter: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = false,
|
||||
onExit: interaction?.enabled != true
|
||||
? null
|
||||
: (_) => interaction!.hovered.value = null,
|
||||
child: Text(
|
||||
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
|
||||
style: labelStyle.copyWith(
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -1776,6 +1781,19 @@ const double _kBulletsMaxScale = 3.2;
|
|||
/// visually timid unless they are allowed to grow a little further.
|
||||
const double _kSplitBulletsMaxScale = 4.35;
|
||||
|
||||
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
|
||||
/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's
|
||||
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
|
||||
/// text at 24–32pt — beyond that it stops aiding readability and starts
|
||||
/// competing with the title. The fit scale multiplies title and bullets
|
||||
/// alike, so capping the bullet size also keeps the hierarchy intact.
|
||||
const double _kBulletMaxFontFraction = 0.0335;
|
||||
|
||||
/// The largest auto-fit scale that keeps bullets at or under
|
||||
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
|
||||
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
|
||||
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
|
||||
|
||||
/// Line height used for bullet body text, shared by rendering and measuring.
|
||||
const double _kBulletLineHeight = 1.16;
|
||||
|
||||
|
|
@ -2872,6 +2890,36 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
return _hexColor(chartSeriesColor(series, i));
|
||||
}
|
||||
|
||||
/// Text alternative for the chart (WCAG 1.1.1): chart type, title and the
|
||||
/// underlying values per series, so a screen reader conveys the same
|
||||
/// information the visual encodes.
|
||||
String _semanticsLabel(BuildContext context, ChartSpec spec) {
|
||||
final l10n = context.l10n;
|
||||
final typeName = switch (spec.type) {
|
||||
ChartType.bar => l10n.d('Staaf'),
|
||||
ChartType.line => l10n.d('Lijn'),
|
||||
ChartType.pie => l10n.d('Cirkel'),
|
||||
ChartType.radar => l10n.d('Spider'),
|
||||
};
|
||||
final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)');
|
||||
if (spec.title.isNotEmpty) {
|
||||
buffer.write(': ${stripInlineMarkdown(spec.title)}');
|
||||
}
|
||||
if (!spec.hasInlineData) return buffer.toString();
|
||||
for (var si = 0; si < spec.series.length; si++) {
|
||||
final series = spec.series[si];
|
||||
final name = series.name.isEmpty
|
||||
? '${l10n.d('Reeks')} ${si + 1}'
|
||||
: series.name;
|
||||
final values = [
|
||||
for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++)
|
||||
'${spec.x[xi]} ${_fmtNum(series.data[xi])}',
|
||||
];
|
||||
buffer.write('. $name: ${values.join(', ')}');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||
|
|
@ -2880,6 +2928,32 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||
final textColor = _hexColor(profile.textColor);
|
||||
|
||||
return Semantics(
|
||||
image: true,
|
||||
label: _semanticsLabel(context, spec),
|
||||
// The visual chart (axis labels, legend chips, tooltips) would read as
|
||||
// disconnected fragments; the label above carries the full story.
|
||||
child: ExcludeSemantics(
|
||||
child: _chartBody(
|
||||
context,
|
||||
spec,
|
||||
horizontalPad,
|
||||
verticalPad,
|
||||
safe,
|
||||
textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chartBody(
|
||||
BuildContext context,
|
||||
ChartSpec spec,
|
||||
double horizontalPad,
|
||||
double verticalPad,
|
||||
EdgeInsets safe,
|
||||
Color textColor,
|
||||
) {
|
||||
return Container(
|
||||
color: _hexColor(profile.slideBackgroundColor),
|
||||
child: Padding(
|
||||
|
|
@ -3201,7 +3275,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
);
|
||||
}
|
||||
|
||||
FlTitlesData _titles(ChartSpec spec, Color textColor) {
|
||||
FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) {
|
||||
final style = _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
|
|
@ -3234,16 +3308,30 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
if (i < 0 || i >= n) return const SizedBox.shrink();
|
||||
// Show as many labels as fit without colliding: keep at least
|
||||
// [minSlot] of horizontal room per label, then thin them out
|
||||
// evenly based on the actual pixel spacing between points.
|
||||
final spacing = n > 1
|
||||
? meta.parentAxisSize / (n - 1)
|
||||
: meta.parentAxisSize;
|
||||
// evenly based on the actual pixel spacing between points. Line
|
||||
// charts spread n points over n-1 intervals; bar groups are laid
|
||||
// out spaceEvenly, which puts their centres (axis + groupWidth) /
|
||||
// (n + 1) apart.
|
||||
final spacing = bars
|
||||
? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1)
|
||||
: (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize);
|
||||
final minSlot = w * 0.085 * _labelScale;
|
||||
final step = math.max(1, (minSlot / spacing).ceil());
|
||||
final lastMultiple = ((n - 1) ~/ step) * step;
|
||||
final showLast = i == n - 1 && (n - 1 - lastMultiple) > step / 2;
|
||||
final lastGap = n - 1 - lastMultiple;
|
||||
final showLast = i == n - 1 && lastGap > step / 2;
|
||||
if (i % step != 0 && !showLast) return const SizedBox.shrink();
|
||||
final slot = (step * spacing - w * 0.012).clamp(w * 0.04, w * 0.16);
|
||||
// The extra end label can sit closer than a full step to its
|
||||
// neighbour; shrink both of their slots to the real gap so they
|
||||
// never run through each other.
|
||||
var slotSteps = step.toDouble();
|
||||
if (showLast || (i == lastMultiple && lastGap > step / 2)) {
|
||||
slotSteps = math.min(slotSteps, lastGap.toDouble());
|
||||
}
|
||||
final slot = (slotSteps * spacing - w * 0.012).clamp(
|
||||
w * 0.04,
|
||||
w * 0.16,
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: w * 0.008),
|
||||
child: SizedBox(
|
||||
|
|
@ -3275,6 +3363,17 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
|
||||
);
|
||||
|
||||
/// Width of one bar rod, shared by the chart and the axis-label spacing.
|
||||
double _barRodWidth(ChartSpec spec) =>
|
||||
(w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022);
|
||||
|
||||
/// Total width of one bar group: its rods plus fl_chart's default 2px
|
||||
/// spacing between rods within a group.
|
||||
double _barGroupWidth(ChartSpec spec) {
|
||||
final rods = math.max(1, spec.series.length);
|
||||
return rods * _barRodWidth(spec) + (rods - 1) * 2;
|
||||
}
|
||||
|
||||
Widget _barChart(ChartSpec spec, Color textColor) {
|
||||
final groups = <BarChartGroupData>[];
|
||||
for (var xi = 0; xi < spec.x.length; xi++) {
|
||||
|
|
@ -3287,10 +3386,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
BarChartRodData(
|
||||
toY: spec.series[si].data[xi],
|
||||
color: _seriesDisplayColor(spec.series[si], si),
|
||||
width: (w * 0.032 / spec.series.length).clamp(
|
||||
w * 0.008,
|
||||
w * 0.022,
|
||||
),
|
||||
width: _barRodWidth(spec),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(w * 0.006),
|
||||
),
|
||||
|
|
@ -3308,8 +3404,11 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
BarChartData(
|
||||
minY: _minY(spec),
|
||||
maxY: _maxY(spec),
|
||||
// The axis-label spacing in _titles assumes this layout; keep it
|
||||
// explicit rather than relying on fl_chart's default.
|
||||
alignment: BarChartAlignment.spaceEvenly,
|
||||
barGroups: groups,
|
||||
titlesData: _titles(spec, textColor),
|
||||
titlesData: _titles(spec, textColor, bars: true),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
extraLinesData: _boundLines(spec),
|
||||
|
|
@ -3537,19 +3636,25 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
final scale = radarScale(spec);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: w * 0.03, vertical: w * 0.012),
|
||||
padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Reserve a slim column on the right for the scale legend, then keep
|
||||
// the chart square so fl_chart's centre/radius stay predictable.
|
||||
// Reserve a slim column on the right for the scale legend; the rest
|
||||
// of the area is shared between the spider and its axis labels.
|
||||
final legendWidth = w * 0.075;
|
||||
final available = constraints.maxWidth - legendWidth - w * 0.02;
|
||||
final side = math.max(
|
||||
final boxW = math.max(
|
||||
0.0,
|
||||
math.min(available, constraints.maxHeight),
|
||||
constraints.maxWidth - legendWidth - w * 0.02,
|
||||
);
|
||||
final labelBand = side * 0.23;
|
||||
final chartSide = math.max(0.0, side - labelBand * 2);
|
||||
final boxH = constraints.maxHeight;
|
||||
if (boxW <= 0 || !boxH.isFinite || boxH <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Measure every axis label and grow the spider until the labels just
|
||||
// fit between the polygon and the edges of the available area, so
|
||||
// the diagram uses the space the old fixed label bands wasted.
|
||||
final layout = _radarLabelLayout(spec, boxW, boxH, textColor);
|
||||
final chartSide = layout.chartSide;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
|
@ -3557,8 +3662,8 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: side,
|
||||
height: side,
|
||||
width: boxW,
|
||||
height: boxH,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < spec.x.length; i++)
|
||||
|
|
@ -3566,12 +3671,12 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
label: spec.x[i],
|
||||
index: i,
|
||||
count: spec.x.length,
|
||||
side: side,
|
||||
layout: layout,
|
||||
textColor: textColor,
|
||||
),
|
||||
Positioned(
|
||||
left: labelBand,
|
||||
top: labelBand,
|
||||
left: (boxW - chartSide) / 2,
|
||||
top: (boxH - chartSide) / 2,
|
||||
width: chartSide,
|
||||
height: chartSide,
|
||||
child: Stack(
|
||||
|
|
@ -3725,57 +3830,149 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
);
|
||||
}
|
||||
|
||||
TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale,
|
||||
height: 1.05,
|
||||
color: textColor.withValues(alpha: 0.88),
|
||||
fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
);
|
||||
|
||||
/// True when the vertex in [direction] gets its label placed beside the
|
||||
/// polygon (left/right) rather than above/below it.
|
||||
static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35;
|
||||
|
||||
/// Sizes the spider and places every axis label around it.
|
||||
///
|
||||
/// Each label is measured at its real text size, then the polygon radius is
|
||||
/// grown until the tightest label exactly fits between the polygon and the
|
||||
/// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the
|
||||
/// side of its (square) widget, which is what ties [chartSide] to the
|
||||
/// resulting radius.
|
||||
({double chartSide, List<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 side,
|
||||
required ({
|
||||
double chartSide,
|
||||
List<Rect> rects,
|
||||
List<TextAlign> aligns,
|
||||
int maxLines,
|
||||
})
|
||||
layout,
|
||||
required Color textColor,
|
||||
}) {
|
||||
final angle = (2 * math.pi * index / count) - math.pi / 2;
|
||||
final boxWidth = side * (count <= 4 ? 0.22 : (count <= 6 ? 0.2 : 0.17));
|
||||
final boxHeight = side * (count <= 6 ? 0.13 : 0.105);
|
||||
final center = side / 2;
|
||||
final horizontal = math.cos(angle);
|
||||
final vertical = math.sin(angle);
|
||||
final left = horizontal < -0.35
|
||||
? 0.0
|
||||
: (horizontal > 0.35 ? side - boxWidth : center - boxWidth / 2);
|
||||
final top = vertical < -0.7
|
||||
? 0.0
|
||||
: (vertical > 0.7
|
||||
? side - boxHeight
|
||||
: (center + vertical * side * 0.32 - boxHeight / 2).clamp(
|
||||
0.0,
|
||||
side - boxHeight,
|
||||
));
|
||||
final alignment = horizontal < -0.25
|
||||
? TextAlign.left
|
||||
: (horizontal > 0.25 ? TextAlign.right : TextAlign.center);
|
||||
|
||||
final rect = layout.rects[index];
|
||||
return Positioned(
|
||||
key: ValueKey('radar-axis-label-$index'),
|
||||
left: left,
|
||||
top: top,
|
||||
width: boxWidth,
|
||||
height: boxHeight,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: alignment,
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale,
|
||||
height: 1.05,
|
||||
color: textColor.withValues(alpha: 0.88),
|
||||
fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: layout.maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: layout.aligns[index],
|
||||
style: _radarLabelStyle(count, textColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,204 +55,222 @@ class SlideThumbnail extends ConsumerWidget {
|
|||
// Actieve slide krijgt een dikkere rand dan de overige geselecteerde.
|
||||
final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
color: isSelected ? const Color(0xFF2A2F3B) : const Color(0xFF252830),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Mini slide preview
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(5),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Overgeslagen slides worden gedimd weergegeven.
|
||||
Opacity(
|
||||
opacity: skipped ? 0.32 : 1,
|
||||
child: SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
slideNumber: index + 1,
|
||||
slideCount: slideCount,
|
||||
tlp: tlp,
|
||||
),
|
||||
// Eén beknopt label per kaart voor schermlezers: nummer, titel (of type)
|
||||
// en status. De mini-preview eronder is puur visueel en zou anders de
|
||||
// volledige slide-inhoud per thumbnail laten voorlezen.
|
||||
final title = slide.title.trim();
|
||||
final semanticLabel =
|
||||
'${l10n.d('Slide')} ${index + 1}/$slideCount: '
|
||||
'${title.isNotEmpty ? title : l10n.d(slide.type.label)}'
|
||||
'${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}';
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: isSelected,
|
||||
label: semanticLabel,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
color: isSelected
|
||||
? const Color(0xFF2A2F3B)
|
||||
: const Color(0xFF252830),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Mini slide preview
|
||||
ExcludeSemantics(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(5),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Overgeslagen slides worden gedimd weergegeven.
|
||||
Opacity(
|
||||
opacity: skipped ? 0.32 : 1,
|
||||
child: SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
slideNumber: index + 1,
|
||||
slideCount: slideCount,
|
||||
tlp: tlp,
|
||||
),
|
||||
),
|
||||
if (skipped)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC8A6D3B),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.visibility_off_outlined,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
l10n.d('Overgeslagen'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (skipped)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC8A6D3B),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.visibility_off_outlined,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
l10n.d('Overgeslagen'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Footer: slide number, type label, action buttons
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppTheme.accent
|
||||
: const Color(0xFF4A4F5B),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.d(slide.type.label),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF94A3B8),
|
||||
fontSize: 9,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Drag handle
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
size: 14,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Snelle overslaan-toggle
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 14,
|
||||
splashRadius: 12,
|
||||
tooltip: skipped
|
||||
? l10n.d('Weer tonen bij presenteren/exporteren')
|
||||
: l10n.d('Overslaan bij presenteren/exporteren'),
|
||||
icon: Icon(
|
||||
skipped
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility_outlined,
|
||||
color: skipped
|
||||
? const Color(0xFFD4A24E)
|
||||
: const Color(0xFF64748B),
|
||||
),
|
||||
onPressed: onToggleSkip,
|
||||
),
|
||||
),
|
||||
// Context menu
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: PopupMenuButton<String>(
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Color(0xFF64748B),
|
||||
size: 14,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'copy',
|
||||
child: Text(l10n.d('Kopiëren')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'copy_image',
|
||||
child: Text(l10n.d('Kopieer als afbeelding')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: Text(l10n.d('Dupliceren')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'skip',
|
||||
child: Text(
|
||||
skipped
|
||||
? l10n.d('Niet meer overslaan')
|
||||
: l10n.d('Overslaan'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
l10n.d('Verwijderen'),
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (v) {
|
||||
if (v == 'copy') {
|
||||
ref.read(slideClipboardProvider.notifier).state =
|
||||
slide;
|
||||
}
|
||||
if (v == 'copy_image') onCopyImage();
|
||||
if (v == 'duplicate') onDuplicate();
|
||||
if (v == 'skip') onToggleSkip();
|
||||
if (v == 'delete') onDelete();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Footer: slide number, type label, action buttons
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppTheme.accent
|
||||
: const Color(0xFF4A4F5B),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.d(slide.type.label),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF94A3B8),
|
||||
fontSize: 9,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Drag handle
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
size: 14,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Snelle overslaan-toggle
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 14,
|
||||
splashRadius: 12,
|
||||
tooltip: skipped
|
||||
? l10n.d('Weer tonen bij presenteren/exporteren')
|
||||
: l10n.d('Overslaan bij presenteren/exporteren'),
|
||||
icon: Icon(
|
||||
skipped
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility_outlined,
|
||||
color: skipped
|
||||
? const Color(0xFFD4A24E)
|
||||
: const Color(0xFF64748B),
|
||||
),
|
||||
onPressed: onToggleSkip,
|
||||
),
|
||||
),
|
||||
// Context menu
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: PopupMenuButton<String>(
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Color(0xFF64748B),
|
||||
size: 14,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'copy',
|
||||
child: Text(l10n.d('Kopiëren')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'copy_image',
|
||||
child: Text(l10n.d('Kopieer als afbeelding')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: Text(l10n.d('Dupliceren')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'skip',
|
||||
child: Text(
|
||||
skipped
|
||||
? l10n.d('Niet meer overslaan')
|
||||
: l10n.d('Overslaan'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
l10n.d('Verwijderen'),
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (v) {
|
||||
if (v == 'copy') {
|
||||
ref.read(slideClipboardProvider.notifier).state =
|
||||
slide;
|
||||
}
|
||||
if (v == 'copy_image') onCopyImage();
|
||||
if (v == 'duplicate') onDuplicate();
|
||||
if (v == 'skip') onToggleSkip();
|
||||
if (v == 'delete') onDelete();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import desktop_multi_window
|
|||
class MainFlutterWindow: NSWindow {
|
||||
override func awakeFromNib() {
|
||||
let flutterViewController = FlutterViewController()
|
||||
// Keep hover events flowing while this window is not key (e.g. when a
|
||||
// dialog or the beamer window is in front): otherwise an element that was
|
||||
// hovered when the window lost key status keeps its hover styling because
|
||||
// the matching exit event is never delivered.
|
||||
flutterViewController.mouseTrackingMode = .inActiveApp
|
||||
let windowFrame = self.frame
|
||||
self.contentViewController = flutterViewController
|
||||
self.setFrame(windowFrame, display: true)
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ dependencies:
|
|||
archive: ^4.0.9
|
||||
video_player: ^2.11.1
|
||||
characters: ^1.3.0
|
||||
crypto: ^3.0.0
|
||||
url_launcher: ^6.3.0
|
||||
desktop_drop: ^0.7.1
|
||||
image: ^4.8.0
|
||||
|
|
|
|||
86
test/add_slide_dialog_test.dart
Normal file
86
test/add_slide_dialog_test.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/widgets/dialogs/add_slide_dialog.dart';
|
||||
|
||||
void main() {
|
||||
Future<SlideType?> Function() openDialog(WidgetTester tester) {
|
||||
SlideType? picked;
|
||||
return () async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async =>
|
||||
picked = await AddSlideDialog.show(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
return picked;
|
||||
};
|
||||
}
|
||||
|
||||
testWidgets('every slide type shows a wireframe preview', (tester) async {
|
||||
await openDialog(tester)();
|
||||
final painters = tester
|
||||
.widgetList<CustomPaint>(find.byType(CustomPaint))
|
||||
.map((p) => p.painter)
|
||||
.whereType<SlideTypePreviewPainter>()
|
||||
.map((p) => p.type)
|
||||
.toSet();
|
||||
expect(painters, SlideType.values.toSet());
|
||||
});
|
||||
|
||||
testWidgets('type cards are labelled buttons (WCAG name/role)', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await openDialog(tester)();
|
||||
expect(
|
||||
tester.getSemantics(find.text('Tabel')),
|
||||
isSemantics(isButton: true, isFocusable: true, label: 'Tabel'),
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('the dialog is fully keyboard-operable', (tester) async {
|
||||
SlideType? picked;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async => picked = await AddSlideDialog.show(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The first card (title slide) is focused on open; tab moves to the
|
||||
// second card and Enter activates it.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(picked, SlideType.section);
|
||||
});
|
||||
|
||||
testWidgets('escape closes the dialog without choosing', (tester) async {
|
||||
await openDialog(tester)();
|
||||
expect(find.byType(AddSlideDialog), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AddSlideDialog), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/annotation.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/services/annotation_codec.dart';
|
||||
import 'package:ocideck/widgets/presentation/annotation_overlay.dart';
|
||||
|
||||
void main() {
|
||||
group('InkStroke JSON', () {
|
||||
|
|
@ -83,4 +85,86 @@ void main() {
|
|||
expect(back, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnnotationLayer live stroke', () {
|
||||
testWidgets('streams the in-progress stroke and clears it on commit', (
|
||||
tester,
|
||||
) async {
|
||||
final active = <InkStroke?>[];
|
||||
final committed = <List<InkStroke>>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
height: 225,
|
||||
child: AnnotationLayer(
|
||||
strokes: const [],
|
||||
tool: InkTool.pen,
|
||||
interactive: true,
|
||||
onStrokesChanged: committed.add,
|
||||
onActiveStrokeChanged: active.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(AnnotationLayer));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await gesture.moveBy(const Offset(20, 0));
|
||||
await gesture.moveBy(const Offset(20, 10));
|
||||
await tester.pump();
|
||||
|
||||
// While drawing, the partial stroke is reported with growing points.
|
||||
final partials = active.whereType<InkStroke>().toList();
|
||||
expect(partials, isNotEmpty);
|
||||
expect(partials.last.tool, InkTool.pen);
|
||||
expect(partials.last.points.length, greaterThan(1));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
// Committing clears the live preview (null) and emits the final stroke.
|
||||
expect(active.last, isNull);
|
||||
expect(committed.single.single.points.length, greaterThan(1));
|
||||
});
|
||||
|
||||
testWidgets('reports null when a tap is too short to commit', (
|
||||
tester,
|
||||
) async {
|
||||
final active = <InkStroke?>[];
|
||||
final committed = <List<InkStroke>>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
height: 225,
|
||||
child: AnnotationLayer(
|
||||
strokes: const [],
|
||||
tool: InkTool.pen,
|
||||
interactive: true,
|
||||
onStrokesChanged: committed.add,
|
||||
onActiveStrokeChanged: active.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(AnnotationLayer));
|
||||
await tester.pump();
|
||||
|
||||
// A single down/up makes no stroke, but the preview must still be cleared.
|
||||
expect(active.last, isNull);
|
||||
expect(committed, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ void main() {
|
|||
'Logo px',
|
||||
'PREVIEW',
|
||||
'Preview',
|
||||
'Privacy',
|
||||
'SLIDES',
|
||||
'Slide',
|
||||
'slide',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -327,13 +329,27 @@ void main() {
|
|||
await tester.pumpWidget(_host(spec, presentationMode: true));
|
||||
await tester.pump();
|
||||
|
||||
// fl_chart draws the spider at 0.8 × half the (square) widget side; the
|
||||
// labels hug the polygon, so they may overlap the widget's corners but
|
||||
// must stay off the polygon itself (its apothem) and off each other.
|
||||
final radarRect = tester.getRect(find.byType(RadarChart));
|
||||
final center = radarRect.center;
|
||||
final radius = radarRect.width / 2 * 0.8;
|
||||
final apothem = radius * math.cos(math.pi / spec.x.length);
|
||||
double distanceToRect(Offset c, Rect r) {
|
||||
final nearest = Offset(
|
||||
c.dx.clamp(r.left, r.right),
|
||||
c.dy.clamp(r.top, r.bottom),
|
||||
);
|
||||
return (c - nearest).distance;
|
||||
}
|
||||
|
||||
final labelRects = [
|
||||
for (var i = 0; i < spec.x.length; i++)
|
||||
tester.getRect(find.byKey(ValueKey('radar-axis-label-$i'))),
|
||||
];
|
||||
for (final rect in labelRects) {
|
||||
expect(rect.overlaps(radarRect), isFalse);
|
||||
expect(distanceToRect(center, rect), greaterThan(apothem * 0.98));
|
||||
}
|
||||
for (var i = 0; i < labelRects.length; i++) {
|
||||
for (var j = i + 1; j < labelRects.length; j++) {
|
||||
|
|
@ -533,6 +549,67 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('charts expose their data as a text alternative', (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
title: 'Omzet',
|
||||
x: ['Q1', 'Q2'],
|
||||
series: [
|
||||
ChartSeries(name: '2026', data: [10, 14]),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
// WCAG 1.1.1: the chart carries a label with type, title and values.
|
||||
expect(
|
||||
find.bySemanticsLabel('Grafiek (Staaf): Omzet. 2026: Q1 10, Q2 14'),
|
||||
findsOneWidget,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('bar chart x-axis labels never run through each other', (
|
||||
tester,
|
||||
) async {
|
||||
// Six groups: enough that the label slots are narrower than the clamp,
|
||||
// which used to overlap because the spacing was computed with n-1
|
||||
// intervals while bar groups each occupy a full nth of the axis.
|
||||
const labels = [
|
||||
'Strategische koers',
|
||||
'Operationele basis',
|
||||
'Innovatievermogen',
|
||||
'Mensen en cultuur',
|
||||
'Financiële ruimte',
|
||||
'Digitale veiligheid',
|
||||
];
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
x: labels,
|
||||
series: [
|
||||
ChartSeries(name: 'Score', data: [3, 4, 5, 2, 4, 3]),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
final rects = [
|
||||
for (final label in labels)
|
||||
if (find.text(label).evaluate().isNotEmpty)
|
||||
tester.getRect(find.text(label).first),
|
||||
];
|
||||
expect(rects.length, greaterThanOrEqualTo(2));
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
for (var j = i + 1; j < rects.length; j++) {
|
||||
expect(rects[i].overlaps(rects[j]), isFalse);
|
||||
}
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'pie shows at most two series and keeps labels inside the slide',
|
||||
(tester) async {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ void main() {
|
|||
expect(n.state.isDirty, isTrue);
|
||||
});
|
||||
|
||||
test('loadDeck resolves a relative logo for an unsaved recovered deck', () {
|
||||
test('loadDeck applies the active profile and resolves its relative logo', () {
|
||||
final temp = Directory.systemTemp.createTempSync(
|
||||
'ocideck_recovered_logo_test_',
|
||||
);
|
||||
|
|
@ -36,17 +36,19 @@ void main() {
|
|||
..writeAsBytesSync([1, 2, 3]);
|
||||
|
||||
final md = MarkdownService();
|
||||
// Styling comes from the active style profile, not from the deck/markdown.
|
||||
final file = FileService(
|
||||
md,
|
||||
ImageService(),
|
||||
() => const ThemeProfile(),
|
||||
() => const ThemeProfile(logoPath: 'logos/client.png'),
|
||||
homeDirectory: () => temp.path,
|
||||
);
|
||||
final notifier = DeckNotifier(md, file);
|
||||
notifier.loadDeck(
|
||||
Deck(
|
||||
title: 'Hersteld',
|
||||
themeProfile: const ThemeProfile(logoPath: 'logos/client.png'),
|
||||
// The deck's own profile is ignored on load.
|
||||
themeProfile: const ThemeProfile(logoPath: 'should-be-ignored.png'),
|
||||
slides: [Slide.create(SlideType.title)],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
109
test/image_dedup_service_test.dart
Normal file
109
test/image_dedup_service_test.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:ocideck/services/image_dedup_service.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
final service = ImageDedupService();
|
||||
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_dedup'));
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
String write(String name, List<int> bytes) {
|
||||
final file = File(p.join(tmp.path, name))..writeAsBytesSync(bytes);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
group('findDuplicateGroups', () {
|
||||
test('groups byte-identical files and leaves unique files out', () async {
|
||||
final a1 = write('a1.png', [1, 2, 3, 4]);
|
||||
final a2 = write('a2.png', [1, 2, 3, 4]);
|
||||
final b = write('b.png', [9, 9, 9]);
|
||||
|
||||
final groups = await service.findDuplicateGroups([a1, a2, b]);
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single, unorderedEquals([a1, a2]));
|
||||
});
|
||||
|
||||
test('same size but different content is not a duplicate', () async {
|
||||
final a = write('a.png', [1, 2, 3, 4]);
|
||||
final b = write('b.png', [4, 3, 2, 1]);
|
||||
|
||||
expect(await service.findDuplicateGroups([a, b]), isEmpty);
|
||||
});
|
||||
|
||||
test('finds multiple independent groups', () async {
|
||||
final a1 = write('a1.png', [1, 2, 3]);
|
||||
final a2 = write('a2.png', [1, 2, 3]);
|
||||
final b1 = write('b1.png', [7, 7, 7, 7]);
|
||||
final b2 = write('b2.png', [7, 7, 7, 7]);
|
||||
final b3 = write('b3.png', [7, 7, 7, 7]);
|
||||
|
||||
final groups = await service.findDuplicateGroups([a1, a2, b1, b2, b3]);
|
||||
|
||||
expect(groups, hasLength(2));
|
||||
final bySize = {for (final g in groups) g.length: g};
|
||||
expect(bySize[2], unorderedEquals([a1, a2]));
|
||||
expect(bySize[3], unorderedEquals([b1, b2, b3]));
|
||||
});
|
||||
|
||||
test('silently skips missing files', () async {
|
||||
final a1 = write('a1.png', [1, 2, 3]);
|
||||
final a2 = write('a2.png', [1, 2, 3]);
|
||||
final gone = p.join(tmp.path, 'bestaat-niet.png');
|
||||
|
||||
final groups = await service.findDuplicateGroups([a1, gone, a2]);
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single, unorderedEquals([a1, a2]));
|
||||
});
|
||||
});
|
||||
|
||||
group('chooseKeeper', () {
|
||||
test('prefers the path with the most slide usages', () {
|
||||
final a = write('a.png', [1]);
|
||||
final b = write('b.png', [1]);
|
||||
|
||||
final keeper = service.chooseKeeper(
|
||||
[a, b],
|
||||
usageCountOf: (path) => path == b ? 2 : 0,
|
||||
);
|
||||
|
||||
expect(keeper, b);
|
||||
});
|
||||
|
||||
test('falls back to the oldest file when usages are equal', () {
|
||||
final newer = write('newer.png', [1]);
|
||||
final older = write('older.png', [1]);
|
||||
File(older).setLastModifiedSync(
|
||||
DateTime.now().subtract(const Duration(days: 7)),
|
||||
);
|
||||
|
||||
expect(service.chooseKeeper([newer, older]), older);
|
||||
});
|
||||
});
|
||||
|
||||
group('mergeMetadata', () {
|
||||
test('joins unique non-empty values', () {
|
||||
expect(
|
||||
service.mergeMetadata(['boot', null, '', 'haven'], separator: ', '),
|
||||
'boot, haven',
|
||||
);
|
||||
});
|
||||
|
||||
test('drops values already contained in an earlier one', () {
|
||||
expect(
|
||||
service.mergeMetadata(['Boot in de haven', 'boot']),
|
||||
'Boot in de haven',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty string when nothing is set', () {
|
||||
expect(service.mergeMetadata([null, '', ' ']), '');
|
||||
});
|
||||
});
|
||||
}
|
||||
140
test/image_reference_service_test.dart
Normal file
140
test/image_reference_service_test.dart
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:ocideck/services/image_reference_service.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
final service = ImageReferenceService();
|
||||
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_refs'));
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
String write(String relativePath, String content) {
|
||||
final file = File(p.join(tmp.path, relativePath));
|
||||
file.parent.createSync(recursive: true);
|
||||
file.writeAsStringSync(content);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
group('findDeckFiles', () {
|
||||
test('finds .md files recursively but skips asset directories', () async {
|
||||
final deck = write('presentaties/deck.md', '# Deck');
|
||||
write('presentaties/images/notitie.md', 'hoort niet mee');
|
||||
write('presentaties/.verborgen/geheim.md', 'hoort niet mee');
|
||||
|
||||
final found = await service.findDeckFiles([tmp.path]);
|
||||
|
||||
expect(found, [p.normalize(deck)]);
|
||||
});
|
||||
|
||||
test('deduplicates hits from overlapping search paths', () async {
|
||||
final deck = write('project/deck.md', '# Deck');
|
||||
|
||||
final found = await service.findDeckFiles([
|
||||
tmp.path,
|
||||
p.join(tmp.path, 'project'),
|
||||
]);
|
||||
|
||||
expect(found, [p.normalize(deck)]);
|
||||
});
|
||||
});
|
||||
|
||||
group('countReferences', () {
|
||||
test('resolves relative paths against the deck file directory', () async {
|
||||
final img = p.join(tmp.path, 'project', 'images', 'foto.png');
|
||||
final deck = write(
|
||||
'project/deck.md',
|
||||
'\n\n---\n\n\n',
|
||||
);
|
||||
|
||||
final counts = await service.countReferences([deck], [img]);
|
||||
|
||||
expect(counts[p.normalize(img)], 2);
|
||||
});
|
||||
|
||||
test('ignores other images and web URLs', () async {
|
||||
final img = p.join(tmp.path, 'project', 'images', 'foto.png');
|
||||
final deck = write(
|
||||
'project/deck.md',
|
||||
'\n\n',
|
||||
);
|
||||
|
||||
expect(await service.countReferences([deck], [img]), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('referencingFiles', () {
|
||||
test('reports per deck file how often the image is referenced', () async {
|
||||
final img = p.join(tmp.path, 'project', 'images', 'foto.png');
|
||||
final twice = write(
|
||||
'project/deck.md',
|
||||
'\n---\n\n',
|
||||
);
|
||||
final never = write('project/anders.md', '\n');
|
||||
|
||||
final result = await service.referencingFiles([twice, never], img);
|
||||
|
||||
expect(result, {twice: 2});
|
||||
});
|
||||
});
|
||||
|
||||
group('replaceReferences', () {
|
||||
test('rewrites relative references and keeps them relative', () async {
|
||||
final from = p.join(tmp.path, 'project', 'images', 'kopie.png');
|
||||
final to = p.join(tmp.path, 'project', 'images', 'origineel.png');
|
||||
final deck = write(
|
||||
'project/deck.md',
|
||||
'# Titel\n\n\n\nTekst blijft staan.\n',
|
||||
);
|
||||
|
||||
final changed = await service.replaceReferences(deck, from, to);
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(
|
||||
File(deck).readAsStringSync(),
|
||||
'# Titel\n\n\n\nTekst blijft staan.\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('rewrites absolute references to the absolute kept path', () async {
|
||||
final from = p.join(tmp.path, 'elders', 'kopie.png');
|
||||
final to = p.join(tmp.path, 'elders', 'origineel.png');
|
||||
final deck = write('project/deck.md', '\n');
|
||||
|
||||
final changed = await service.replaceReferences(deck, from, to);
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(File(deck).readAsStringSync(), '\n');
|
||||
});
|
||||
|
||||
test('leaves the file untouched when nothing matches', () async {
|
||||
final from = p.join(tmp.path, 'project', 'images', 'kopie.png');
|
||||
final to = p.join(tmp.path, 'project', 'images', 'origineel.png');
|
||||
final deck = write('project/deck.md', '\n');
|
||||
final before = File(deck).lastModifiedSync();
|
||||
|
||||
final changed = await service.replaceReferences(deck, from, to);
|
||||
|
||||
expect(changed, isFalse);
|
||||
expect(File(deck).readAsStringSync(), '\n');
|
||||
expect(File(deck).lastModifiedSync(), before);
|
||||
});
|
||||
|
||||
test(
|
||||
'uses an absolute path when the kept file lies outside the project',
|
||||
() async {
|
||||
final from = p.join(tmp.path, 'project', 'images', 'kopie.png');
|
||||
final to = p.join(tmp.path, 'elders', 'origineel.png');
|
||||
final deck = write('project/deck.md', '\n');
|
||||
|
||||
final changed = await service.replaceReferences(deck, from, to);
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(File(deck).readAsStringSync(), '\n');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -565,8 +565,7 @@ void main() {
|
|||
);
|
||||
final deck = service.parseDeck(markdown);
|
||||
expect(deck, isNotNull);
|
||||
expect(deck!.themeProfile.footerPosition, 'center');
|
||||
expect(deck.slides[0].showFooter, isTrue);
|
||||
expect(deck!.slides[0].showFooter, isTrue);
|
||||
expect(deck.slides[1].showFooter, isFalse);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -87,12 +87,15 @@ void main() {
|
|||
closingSlideMarkdown: '# Einde\n\nDank voor jullie aandacht.',
|
||||
);
|
||||
|
||||
// The style profile only travels inside the markdown when explicitly
|
||||
// inlined (transient beamer payloads); a plain save keeps the file clean.
|
||||
final markdown = service.generateDeck(
|
||||
Deck(
|
||||
title: 'Demo',
|
||||
themeProfile: profile,
|
||||
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
|
||||
),
|
||||
inlineStyleProfile: true,
|
||||
);
|
||||
|
||||
final deck = service.parseDeck(markdown);
|
||||
|
|
@ -115,6 +118,27 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('a saved deck does not embed the style profile', () {
|
||||
final service = MarkdownService();
|
||||
final markdown = service.generateDeck(
|
||||
Deck(
|
||||
title: 'Demo',
|
||||
themeProfile: const ThemeProfile(
|
||||
name: 'Klant A',
|
||||
slideBackgroundColor: '#111827',
|
||||
),
|
||||
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
|
||||
),
|
||||
);
|
||||
|
||||
// The file is the base content only; styling stays out of it. Parsing it
|
||||
// back yields the default profile, not the one that was saved.
|
||||
expect(markdown.contains('ocideck_style_profile'), isFalse);
|
||||
final deck = service.parseDeck(markdown);
|
||||
expect(deck!.themeProfile.name, 'Standaard');
|
||||
expect(deck.themeProfile.slideBackgroundColor, isNot('#111827'));
|
||||
});
|
||||
|
||||
test('adds logo-safe class when deck profile has logo', () {
|
||||
final service = MarkdownService();
|
||||
final markdown = service.generateDeck(
|
||||
|
|
|
|||
103
test/slide_list_panel_test.dart
Normal file
103
test/slide_list_panel_test.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/state/deck_provider.dart';
|
||||
import 'package:ocideck/state/editor_provider.dart';
|
||||
import 'package:ocideck/theme/app_theme.dart';
|
||||
import 'package:ocideck/widgets/panels/slide_list_panel.dart';
|
||||
import 'package:ocideck/widgets/slides/slide_thumbnail.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('resizing the rail brings the edited slide back into view', (
|
||||
tester,
|
||||
) async {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final deckNotifier = container.read(deckProvider.notifier);
|
||||
deckNotifier.newDeck('Test');
|
||||
for (var i = 0; i < 19; i++) {
|
||||
deckNotifier.addSlide(SlideType.bullets);
|
||||
}
|
||||
container.read(editorProvider.notifier).select(12);
|
||||
|
||||
final width = ValueNotifier<double>(320);
|
||||
addTearDown(width.dispose);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: width,
|
||||
builder: (_, w, _) => SizedBox(
|
||||
width: w,
|
||||
height: 600,
|
||||
child: SlideListPanel(railWidth: w),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// The selected slide (12) sits far below the fold and nothing scrolls it
|
||||
// into view on its own.
|
||||
bool slide12Visible() => find
|
||||
.byWidgetPredicate((w) => w is SlideThumbnail && w.index == 12)
|
||||
.evaluate()
|
||||
.isNotEmpty;
|
||||
expect(slide12Visible(), isFalse);
|
||||
|
||||
// Drag the rail wider: thumbnails change height, and once the resize
|
||||
// settles the list scrolls the slide being edited back to the top.
|
||||
width.value = 240;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250)); // debounce fires
|
||||
await tester.pump(); // coarse jump near the unbuilt slide
|
||||
await tester.pump(); // precise reveal starts
|
||||
await tester.pump(const Duration(milliseconds: 200)); // animateTo settles
|
||||
|
||||
expect(slide12Visible(), isTrue);
|
||||
final rect = tester.getRect(
|
||||
find.byWidgetPredicate((w) => w is SlideThumbnail && w.index == 12),
|
||||
);
|
||||
// At the top of the list area (below the panel header).
|
||||
expect(rect.top, lessThan(120));
|
||||
});
|
||||
|
||||
testWidgets('thumbnails expose one concise semantic label per slide', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final deckNotifier = container.read(deckProvider.notifier);
|
||||
deckNotifier.newDeck('Test');
|
||||
deckNotifier.addSlide(SlideType.bullets);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: const Scaffold(
|
||||
body: SizedBox(width: 320, child: SlideListPanel(railWidth: 320)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Screen readers get "Slide n/m: title-or-type" per card, instead of the
|
||||
// full content of every mini preview.
|
||||
expect(find.bySemanticsLabel(RegExp(r'^Slide 1/2: ')), findsOneWidget);
|
||||
expect(find.bySemanticsLabel(RegExp(r'^Slide 2/2: ')), findsOneWidget);
|
||||
handle.dispose();
|
||||
});
|
||||
}
|
||||
83
test/table_clipboard_test.dart
Normal file
83
test/table_clipboard_test.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/utils/table_clipboard.dart';
|
||||
|
||||
void main() {
|
||||
group('parseClipboardTable', () {
|
||||
test('parses a spreadsheet (TSV) selection', () {
|
||||
expect(parseClipboardTable('Naam\tScore\nJan\t8\nPiet\t9'), [
|
||||
['Naam', 'Score'],
|
||||
['Jan', '8'],
|
||||
['Piet', '9'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles Windows line endings and the trailing newline', () {
|
||||
expect(parseClipboardTable('a\tb\r\nc\td\r\n'), [
|
||||
['a', 'b'],
|
||||
['c', 'd'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a single spreadsheet row is still a table', () {
|
||||
expect(parseClipboardTable('a\tb\tc'), [
|
||||
['a', 'b', 'c'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('pads ragged rows to a rectangle', () {
|
||||
expect(parseClipboardTable('a\tb\tc\nd\te'), [
|
||||
['a', 'b', 'c'],
|
||||
['d', 'e', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('quoted cells may contain delimiters and newlines', () {
|
||||
expect(parseClipboardTable('"regel 1\nregel 2"\t"met\ttab"\nx\ty'), [
|
||||
['regel 1\nregel 2', 'met\ttab'],
|
||||
['x', 'y'],
|
||||
]);
|
||||
expect(parseClipboardTable('"a ""quote"""\tb'), [
|
||||
['a "quote"', 'b'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses comma and semicolon CSV with consistent columns', () {
|
||||
expect(parseClipboardTable('Naam,Score\nJan,8'), [
|
||||
['Naam', 'Score'],
|
||||
['Jan', '8'],
|
||||
]);
|
||||
expect(parseClipboardTable('Naam;Score\nJan;8'), [
|
||||
['Naam', 'Score'],
|
||||
['Jan', '8'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('prefers the semicolon when commas are decimal separators', () {
|
||||
expect(parseClipboardTable('Prijs;Marge\n1,50;0,25\n2,00;0,30'), [
|
||||
['Prijs', 'Marge'],
|
||||
['1,50', '0,25'],
|
||||
['2,00', '0,30'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses a markdown table and drops the separator row', () {
|
||||
expect(
|
||||
parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'),
|
||||
[
|
||||
['Naam', 'Score'],
|
||||
['Jan', '8'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('plain text is not a table', () {
|
||||
expect(parseClipboardTable('gewoon wat tekst'), isNull);
|
||||
expect(parseClipboardTable('regel een\nregel twee'), isNull);
|
||||
// A sentence with a comma stays a sentence.
|
||||
expect(parseClipboardTable('hallo, wereld'), isNull);
|
||||
// Inconsistent comma counts: prose, not CSV.
|
||||
expect(parseClipboardTable('een, twee en drie\nvier, vijf, zes'), isNull);
|
||||
expect(parseClipboardTable(' '), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
66
test/table_editor_test.dart
Normal file
66
test/table_editor_test.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/widgets/editors/table_editor.dart';
|
||||
|
||||
void main() {
|
||||
Future<Slide> pasteIntoFirstCell(WidgetTester tester, String clip) async {
|
||||
var updated = Slide.create(SlideType.table);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return <String, dynamic>{'text': clip};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TableEditor(slide: updated, onUpdate: (s) => updated = s),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Field 0 is the title; the first table cell is the next TextField.
|
||||
await tester.tap(find.byType(TextField).at(1));
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
return updated;
|
||||
}
|
||||
|
||||
testWidgets('pasting a spreadsheet selection fills and grows the grid', (
|
||||
tester,
|
||||
) async {
|
||||
final updated = await pasteIntoFirstCell(
|
||||
tester,
|
||||
'Naam\tScore\nJan\t8\nPiet\t9\n',
|
||||
);
|
||||
expect(updated.tableRows, [
|
||||
['Naam', 'Score'],
|
||||
['Jan', '8'],
|
||||
['Piet', '9'],
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('pasting plain text stays inside the one cell', (tester) async {
|
||||
final updated = await pasteIntoFirstCell(tester, 'hallo wereld');
|
||||
expect(updated.tableRows, [
|
||||
['hallo wereld', ''],
|
||||
['', ''],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -81,6 +81,32 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
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 {
|
||||
|
|
|
|||
66
test/ui_text_scale_test.dart
Normal file
66
test/ui_text_scale_test.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/app.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/state/settings_provider.dart';
|
||||
import 'package:ocideck/widgets/app_shell.dart';
|
||||
import 'package:ocideck/widgets/slides/slide_preview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('the interface text-scale setting scales the editor UI', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({'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);
|
||||
});
|
||||
}
|
||||
|
|
@ -6,10 +6,18 @@ 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,
|
||||
|
|
@ -18,6 +26,7 @@ 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);
|
||||
});
|
||||
|
||||
|
|
@ -25,6 +34,7 @@ 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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,15 @@ class FlutterWindow: NSObject {
|
|||
}
|
||||
if let screen = target ?? screens.first {
|
||||
window.styleMask = [.borderless]
|
||||
window.level = .normal
|
||||
// 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.isOpaque = true
|
||||
window.setFrame(screen.frame, display: true)
|
||||
window.orderFrontRegardless()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue