Merge pull request 'feature/app-theming-and-code-slides' (#2) from feature/app-theming-and-code-slides into main
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run

Reviewed-on: #2
This commit is contained in:
Brenno de Winter 2026-06-08 12:31:03 +00:00
commit bd6db5cbd7
27 changed files with 3959 additions and 548 deletions

View file

@ -8,13 +8,24 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- **Source-code slides** — a dark "code sheet" with per-language syntax
highlighting, stored as a fenced code block.
- **Charts** — bar, line, and pie chart slides. Data is entered in an in-app grid
or imported from CSV; the spec is stored as JSON in a ```chart block. Data can
stay inline or be linked to a CSV in a separate `data/` directory. Rendered
natively in-app (preview, presenter, PDF, PPTX) and as self-contained SVG in
the HTML export.
- **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
the block in a single colour (e.g. green on black for a CRT-terminal look). The
code is sized to fill the panel — larger when there's room, smaller for long
fragments.
- **Charts** — bar, line, pie, and **spider/radar** chart slides. Data is entered
in an in-app grid or imported from CSV; the spec is stored as JSON in a ```chart
block. Data can stay inline or be linked to a CSV in a separate `data/`
directory. Rendered natively in-app (preview, presenter, PDF, PPTX) and as
self-contained SVG in the HTML export.
- Optional **min/max**: horizontal reference lines on bar/line charts, or a
fixed scale on spider/radar charts shown as a small legend beside the figure.
- **Legend hover** highlights the matching series (or pie slice). Line-chart
tooltips attach to the dot under the cursor (showing every overlapping dot),
and spider/radar points show a tooltip on hover too.
- **Custom theme colours** — every style-profile colour can be entered as a custom
hex value in addition to the presets.
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
Protocol level; slides classified stricter than the level the deck is shown at
are withheld when presenting and exporting.

View file

@ -9,8 +9,8 @@ Built with Flutter for macOS, Windows, and Linux.
## Features
- **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, source code, charts, and free-form Markdown.
- **Source-code slides** — a dark "code sheet" with syntax highlighting per language, stored as a fenced code block.
- **Charts** — bar, line, and pie charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory.
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
@ -20,7 +20,7 @@ Built with Flutter for macOS, Windows, and Linux.
- **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.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — customizable deck style profiles and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
## Requirements

View file

@ -135,6 +135,10 @@ JSON heeft deze velden (met standaardwaarden):
| `titleBackgroundColor` | `#1C2B47` | Achtergrond titelslide. |
| `titleTextColor` | `#FFFFFF` | Tekst op titel-/sectieslide. |
| `sectionBackgroundColor` | `#2E7D64` | Achtergrond sectieslide. |
| `codeBackgroundColor` | `#282C34` | Achtergrond van broncode-slides. |
| `codeTextColor` | `#ABB2BF` | Tekstkleur van broncode-slides. |
| `codeHighlightSyntax` | `true` | Syntaxkleuring aan/uit. Uit = alles in één kleur (bijv. groen op zwart voor een CRT-look). |
| `codeFontFamily` | `monospace` | Lettertype van broncode-slides (bijv. `Courier New`). |
| `logoPath` | `null` | Pad naar logo (relatief in `logos/`). |
| `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. |
| `logoSize` | `96` | Logogrootte in px. |
@ -314,15 +318,31 @@ openen wordt die weer ingelezen.
````markdown
```chart
{
"type": "bar", // bar | line | pie
"type": "bar", // bar | line | pie | radar
"title": "Omzet",
"source": "data/omzet.csv", // optioneel; anders inline x/series
"x": ["Q1", "Q2"],
"series": [ { "name": "2025", "data": [10, 14] } ]
"rowColors": ["#003399", "#FFCC00"], // optioneel; kleur per label (cirkel/radar)
"minBound": 0, // optioneel; niet bij pie
"maxBound": 20, // optioneel; niet bij pie
"series": [ { "name": "2025", "data": [10, 14], "color": "#2563EB" } ]
}
```
````
Velden:
- `type``bar`, `line`, `pie` of `radar` (spider). Standaard `bar`.
- `x` — labels; bij `pie`/`radar` zijn dit de segmenten/assen (radar heeft er
minstens drie nodig).
- `series` — genoemde reeksen met `data` (uitgelijnd op `x`) en optioneel een
`color` (hex). `pie` toont maximaal de eerste twee reeksen.
- `rowColors` — optionele kleur per label (gebruikt door `pie`/`radar`).
- `minBound` / `maxBound` — optioneel en alleen voor niet-`pie`. Bij `bar`/`line`
zijn het horizontale **referentielijnen**; bij `radar` bepalen ze de **schaal**
(binnenste/buitenste ring) met een gelijkmatige verdeling. Worden weggelaten
bij `pie`.
### Afbeeldingsgrootte (`imageSize`)
Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het
achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd

View file

@ -20,8 +20,9 @@ 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**, and **free Markdown**.
Each type has a dedicated editor on the left and a live preview on the right.
**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.
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
`[links](…)`). Free-Markdown slides also render fenced code with syntax
@ -30,19 +31,32 @@ highlighting and `$…$` / `$$…$$` LaTeX math.
### Source-code slides
Choose a programming language for syntax highlighting (or "plain text") and paste
your code. It renders as a dark "code sheet". Stored as a fenced code block in the
Markdown.
your code. It renders as a "code sheet" whose background, text colour and
**monospace font** come from the active **style profile** (e.g. Courier). Turn
**syntax colouring** off to show the whole block in a single colour — e.g. bright
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.
### Charts
Pick a type (**bar**, **line**, **pie**) and a title, then enter data in the grid:
the first column is the labels, each further column is a named series. Use **Row**
and **Series** to add data; the small ✕ removes a row/column.
Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then
enter data in the grid: the first column is the labels, each further column is a
named series. Use **Row** and **Series** to add data; the small ✕ removes a
row/column. Each series and (for pie/radar) each label can be given its own colour.
- **CSV import** — click **CSV importeren**. You can either keep the data **in the
slide** (inline) or store it **as a CSV file**. A linked CSV lives in the deck's
`data/` directory and stays the source of truth (edit it in a spreadsheet); the
grid then shows it read-only until you **Ontkoppelen** (unlink).
- **Min/max** (optional, bar/line/radar) — on bar and line charts these draw
horizontal **reference lines**; on a spider/radar chart they fix the **scale**
(centre to outer ring), shown as evenly spaced values in a small legend beside
the chart. Leave them empty to scale automatically.
- **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.
- Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
HTML export.
@ -100,8 +114,11 @@ Export to:
## Theming and language
- **Style profiles** control deck colours, fonts, logo, and footer. The bundled
Marp theme is `assets/themes/ocideck.css`.
- **Style profiles** control deck colours (including the source-code background,
text, font and an optional syntax-colouring toggle), fonts, logo, and footer.
Every colour can be picked from the presets or entered as a custom hex value. The
Colours and Logo tabs show which profile you're editing. The bundled Marp theme
is `assets/themes/ocideck.css`.
- **App appearance** (including a dark interface) is configurable in settings.
- The interface is available in Dutch, English, Italian, German, French, Spanish,
Frisian, and Papiamento.

View file

@ -1,59 +0,0 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View file

@ -1,59 +0,0 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View file

@ -2326,6 +2326,7 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = {
'en': {
'Annuleren': 'Cancel',
'Afbeelding': 'Image',
'Broncode': 'Source code',
'Bullet': 'Bullet',
@ -2341,6 +2342,7 @@ const _dutchSourceStringAdditions = {
'Staaf': 'Bar',
'Lijn': 'Line',
'Cirkel': 'Pie',
'Spider': 'Spider',
'CSV importeren': 'Import CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)':
'Data (CSV: first row = series names, first column = labels)',
@ -2354,6 +2356,35 @@ const _dutchSourceStringAdditions = {
'Label': 'Label',
'Rij': 'Row',
'Reeks': 'Series',
'Kleur van reeks': 'Series color',
'Kleur van rij': 'Row color',
'Hexkleur': 'Hex color',
'Sorteren': 'Sort',
'Oplopend sorteren': 'Sort ascending',
'Aflopend sorteren': 'Sort descending',
'Toepassen': 'Apply',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Pie charts show at most the first two series; the labels form the slices.',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'A spider chart needs at least three labels (axes); each series forms a shape.',
'Een spider-diagram heeft minstens drie labels nodig':
'A spider chart needs at least three labels',
'Minimumlijn (optioneel)': 'Minimum line (optional)',
'Maximumlijn (optioneel)': 'Maximum line (optional)',
'Schaalminimum (optioneel)': 'Scale minimum (optional)',
'Schaalmaximum (optioneel)': 'Scale maximum (optional)',
'geen': 'none',
'Broncode achtergrond': 'Code background',
'Broncode tekst': 'Code text',
'Syntaxkleuring': 'Syntax colouring',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Off = everything in one colour (e.g. green on black for a CRT screen).',
'Eigen kleur (hex)': 'Custom colour (hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'For example #33FF33 for a CRT-green screen.',
'Onderdeel van stijlprofiel ': 'Part of style profile ',
'Broncode lettertype': 'Code font',
'Systeem (monospace)': 'System (monospace)',
'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)',
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
@ -2371,6 +2402,36 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparing…',
},
'it': {
'Annuleren': 'Annulla',
'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un grafico radar richiede almeno tre etichette (assi); ogni serie forma una superficie.',
'Een spider-diagram heeft minstens drie labels nodig':
'Un grafico radar richiede almeno tre etichette',
'Minimumlijn (optioneel)': 'Linea minima (facoltativa)',
'Maximumlijn (optioneel)': 'Linea massima (facoltativa)',
'Schaalminimum (optioneel)': 'Scala minima (facoltativa)',
'Schaalmaximum (optioneel)': 'Scala massima (facoltativa)',
'geen': 'nessuno',
'Broncode achtergrond': 'Sfondo del codice',
'Broncode tekst': 'Testo del codice',
'Syntaxkleuring': 'Colorazione della sintassi',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Off = tutto in un solo colore (es. verde su nero per uno schermo CRT).',
'Eigen kleur (hex)': 'Colore personalizzato (hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'Ad esempio #33FF33 per uno schermo verde CRT.',
'Onderdeel van stijlprofiel ': 'Parte del profilo di stile ',
'Broncode lettertype': 'Font del codice',
'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Colore della serie',
'Kleur van rij': 'Colore della riga',
'Hexkleur': 'Colore esadecimale',
'Sorteren': 'Ordina',
'Oplopend sorteren': 'Ordina in modo crescente',
'Aflopend sorteren': 'Ordina in modo decrescente',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'I grafici a torta mostrano al massimo le prime due serie; le etichette formano i segmenti.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2570,6 +2631,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'de': {
'Annuleren': 'Abbrechen',
'Spider': 'Netz',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Ein Netzdiagramm braucht mindestens drei Beschriftungen (Achsen); jede Reihe bildet eine Fläche.',
'Een spider-diagram heeft minstens drie labels nodig':
'Ein Netzdiagramm braucht mindestens drei Beschriftungen',
'Minimumlijn (optioneel)': 'Minimumlinie (optional)',
'Maximumlijn (optioneel)': 'Maximumlinie (optional)',
'Schaalminimum (optioneel)': 'Skalenminimum (optional)',
'Schaalmaximum (optioneel)': 'Skalenmaximum (optional)',
'geen': 'keine',
'Broncode achtergrond': 'Code-Hintergrund',
'Broncode tekst': 'Code-Text',
'Syntaxkleuring': 'Syntaxfärbung',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Aus = alles in einer Farbe (z. B. Grün auf Schwarz für einen CRT-Bildschirm).',
'Eigen kleur (hex)': 'Eigene Farbe (Hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.',
'Onderdeel van stijlprofiel ': 'Teil des Stilprofils ',
'Broncode lettertype': 'Code-Schriftart',
'Systeem (monospace)': 'System (monospace)',
'Kleur van reeks': 'Reihenfarbe',
'Kleur van rij': 'Zeilenfarbe',
'Hexkleur': 'Hex-Farbe',
'Sorteren': 'Sortieren',
'Oplopend sorteren': 'Aufsteigend sortieren',
'Aflopend sorteren': 'Absteigend sortieren',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Kreisdiagramme zeigen höchstens die ersten zwei Reihen; die Beschriftungen bilden die Segmente.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2770,6 +2861,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'fr': {
'Annuleren': 'Annuler',
'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un graphique radar nécessite au moins trois étiquettes (axes); chaque série forme une surface.',
'Een spider-diagram heeft minstens drie labels nodig':
'Un graphique radar nécessite au moins trois étiquettes',
'Minimumlijn (optioneel)': 'Ligne minimale (facultatif)',
'Maximumlijn (optioneel)': 'Ligne maximale (facultatif)',
'Schaalminimum (optioneel)': 'Échelle minimale (facultatif)',
'Schaalmaximum (optioneel)': 'Échelle maximale (facultatif)',
'geen': 'aucune',
'Broncode achtergrond': 'Fond du code',
'Broncode tekst': 'Texte du code',
'Syntaxkleuring': 'Coloration syntaxique',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Désactivé = tout en une seule couleur (p. ex. vert sur noir pour un écran CRT).',
'Eigen kleur (hex)': 'Couleur personnalisée (hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'Par exemple #33FF33 pour un écran vert CRT.',
'Onderdeel van stijlprofiel ': 'Fait partie du profil de style ',
'Broncode lettertype': 'Police du code',
'Systeem (monospace)': 'Système (monospace)',
'Kleur van reeks': 'Couleur de la série',
'Kleur van rij': 'Couleur de la ligne',
'Hexkleur': 'Couleur hexadécimale',
'Sorteren': 'Trier',
'Oplopend sorteren': 'Trier par ordre croissant',
'Aflopend sorteren': 'Trier par ordre décroissant',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Les graphiques en secteurs affichent au maximum les deux premières séries ; les libellés forment les segments.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2970,6 +3091,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'es': {
'Annuleren': 'Cancelar',
'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un gráfico radar necesita al menos tres etiquetas (ejes); cada serie forma una superficie.',
'Een spider-diagram heeft minstens drie labels nodig':
'Un gráfico radar necesita al menos tres etiquetas',
'Minimumlijn (optioneel)': 'Línea mínima (opcional)',
'Maximumlijn (optioneel)': 'Línea máxima (opcional)',
'Schaalminimum (optioneel)': 'Escala mínima (opcional)',
'Schaalmaximum (optioneel)': 'Escala máxima (opcional)',
'geen': 'ninguno',
'Broncode achtergrond': 'Fondo del código',
'Broncode tekst': 'Texto del código',
'Syntaxkleuring': 'Coloreado de sintaxis',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Desactivado = todo en un solo color (p. ej. verde sobre negro para una pantalla CRT).',
'Eigen kleur (hex)': 'Color personalizado (hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'Por ejemplo #33FF33 para una pantalla verde CRT.',
'Onderdeel van stijlprofiel ': 'Parte del perfil de estilo ',
'Broncode lettertype': 'Fuente del código',
'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Color de la serie',
'Kleur van rij': 'Color de la fila',
'Hexkleur': 'Color hexadecimal',
'Sorteren': 'Ordenar',
'Oplopend sorteren': 'Ordenar ascendente',
'Aflopend sorteren': 'Ordenar descendente',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Los gráficos circulares muestran como máximo las dos primeras series; las etiquetas forman los segmentos.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -3170,6 +3321,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'fy': {
'Annuleren': 'Annulearje',
'Spider': 'Spider',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'In spiderdiagram hat op syn minst trije labels (assen) nedich; eltse rige foarmet in flak.',
'Een spider-diagram heeft minstens drie labels nodig':
'In spiderdiagram hat op syn minst trije labels nedich',
'Minimumlijn (optioneel)': 'Minimumline (opsjoneel)',
'Maximumlijn (optioneel)': 'Maksimumline (opsjoneel)',
'Schaalminimum (optioneel)': 'Skaalminimum (opsjoneel)',
'Schaalmaximum (optioneel)': 'Skaalmaksimum (opsjoneel)',
'geen': 'gjin',
'Broncode achtergrond': 'Boarnekoade eftergrûn',
'Broncode tekst': 'Boarnekoade tekst',
'Syntaxkleuring': 'Syntakskleuring',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Ut = alles yn ien kleur (bgl. grien op swart foar in CRT-skerm).',
'Eigen kleur (hex)': 'Eigen kleur (hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'Bygelyks #33FF33 foar in CRT-grien skerm.',
'Onderdeel van stijlprofiel ': 'Underdiel fan stylprofyl ',
'Broncode lettertype': 'Boarnekoade lettertype',
'Systeem (monospace)': 'Systeem (monospace)',
'Kleur van reeks': 'Rigekleur',
'Kleur van rij': 'Rijekleur',
'Hexkleur': 'Hekskleur',
'Sorteren': 'Sortearje',
'Oplopend sorteren': 'Oprinnend sortearje',
'Aflopend sorteren': 'Ôfrinnend sortearje',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Sirkeldiagrammen litte maksimaal de earste twa rigen sjen; de labels foarmje de segminten.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -3367,6 +3548,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'pap': {
'Annuleren': 'Kanselá',
'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un grafiko radar mester di por lo ménos tres etiketa (ehe); kada serie ta forma un superfisie.',
'Een spider-diagram heeft minstens drie labels nodig':
'Un grafiko radar mester di por lo ménos tres etiketa',
'Minimumlijn (optioneel)': 'Liña mínimo (opshonal)',
'Maximumlijn (optioneel)': 'Liña máksimo (opshonal)',
'Schaalminimum (optioneel)': 'Eskala mínimo (opshonal)',
'Schaalmaximum (optioneel)': 'Eskala máksimo (opshonal)',
'geen': 'niun',
'Broncode achtergrond': 'Fondo di kódigo',
'Broncode tekst': 'Teksto di kódigo',
'Syntaxkleuring': 'Koloreashon di sintaksis',
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
'Apagá = tur kos den un solo koló (p.e. berde riba pretu pa un pantaya CRT).',
'Eigen kleur (hex)': 'Koló propio (hex)',
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
'Por ehèmpel #33FF33 pa un pantaya berde CRT.',
'Onderdeel van stijlprofiel ': 'Parti di e perfil di estilo ',
'Broncode lettertype': 'Tipo di lèter di kódigo',
'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Koló di serie',
'Kleur van rij': 'Koló di liña',
'Hexkleur': 'Koló hexadecimal',
'Sorteren': 'Ordená',
'Oplopend sorteren': 'Ordená subiendu',
'Aflopend sorteren': 'Ordená bahando',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Gráfikonan circular ta mustra máximo e promé dos serienan; e labelnan ta forma e segmentonan.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',

View file

@ -4,8 +4,21 @@ import 'dart:convert';
/// data files stay tidily in one place separate from images/media.
const String chartDataDirName = 'data';
const List<String> chartColorPalette = [
'#003399',
'#FFCC00',
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Supported chart kinds for a chart slide.
enum ChartType { bar, line, pie }
enum ChartType { bar, line, pie, radar }
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
(t) => t.name == name,
@ -16,19 +29,44 @@ ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
class ChartSeries {
final String name;
final List<double> data;
const ChartSeries({required this.name, required this.data});
final String? color;
const ChartSeries({required this.name, required this.data, this.color});
Map<String, dynamic> toJson() => {'name': name, 'data': data};
Map<String, dynamic> toJson({bool includeData = true}) => {
'name': name,
if (includeData) 'data': data,
if (color != null) 'color': color,
};
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
factory ChartSeries.fromJson(Map<String, dynamic> json) {
final color = normalizeChartColor(json['color']?.toString());
return ChartSeries(
name: (json['name'] ?? '').toString(),
color: color,
data: [
for (final v in (json['data'] as List? ?? const []))
(v as num?)?.toDouble() ?? 0,
],
);
}
}
String? normalizeChartColor(String? value) {
if (value == null) return null;
final raw = value.trim().toUpperCase();
final normalized = raw.startsWith('#') ? raw : '#$raw';
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized) ? normalized : null;
}
String chartSeriesColor(ChartSeries series, int index) =>
normalizeChartColor(series.color) ??
chartColorPalette[index % chartColorPalette.length];
String chartRowColor(ChartSpec spec, int index) => index < spec.rowColors.length
? normalizeChartColor(spec.rowColors[index]) ??
chartColorPalette[index % chartColorPalette.length]
: chartColorPalette[index % chartColorPalette.length];
/// The full chart specification, stored as JSON inside a ```chart fenced block.
///
/// Small charts keep their data inline; data-driven charts instead point at an
@ -40,31 +78,58 @@ class ChartSpec {
final String title;
final String? source;
final List<String> x;
final List<String?> rowColors;
final List<ChartSeries> series;
/// Optional horizontal reference lines drawn across the plot so it is clear
/// where a data point sits relative to a threshold. Only meaningful for bar
/// and line charts (ignored for pie); either may be left null.
final double? minBound;
final double? maxBound;
const ChartSpec({
this.type = ChartType.bar,
this.title = '',
this.source,
this.x = const [],
this.rowColors = const [],
this.series = const [],
this.minBound,
this.maxBound,
});
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
/// Whether the optional [minBound]/[maxBound] apply. On bar/line they are
/// horizontal threshold lines; on radar they fix the scale (centre/outer
/// ring). Pie charts have no axis, so they never use bounds.
bool get supportsBounds => type != ChartType.pie;
/// True only where bounds render as horizontal threshold *lines*.
bool get supportsBoundLines =>
type == ChartType.bar || type == ChartType.line;
ChartSpec copyWith({
ChartType? type,
String? title,
String? source,
bool clearSource = false,
List<String>? x,
List<String?>? rowColors,
List<ChartSeries>? series,
double? minBound,
bool clearMinBound = false,
double? maxBound,
bool clearMaxBound = false,
}) => ChartSpec(
type: type ?? this.type,
title: title ?? this.title,
source: clearSource ? null : (source ?? this.source),
x: x ?? this.x,
rowColors: rowColors ?? this.rowColors,
series: series ?? this.series,
minBound: clearMinBound ? null : (minBound ?? this.minBound),
maxBound: clearMaxBound ? null : (maxBound ?? this.maxBound),
);
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
@ -78,7 +143,13 @@ class ChartSpec {
type: _chartTypeFromName(data['type'] as String?),
title: (data['title'] ?? '').toString(),
source: (src == null || src.isEmpty) ? null : src,
minBound: (data['minBound'] as num?)?.toDouble(),
maxBound: (data['maxBound'] as num?)?.toDouble(),
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
rowColors: [
for (final value in (data['rowColors'] as List? ?? const []))
normalizeChartColor(value?.toString()),
],
series: [
for (final s in (data['series'] as List? ?? const []))
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
@ -96,12 +167,23 @@ class ChartSpec {
final map = <String, dynamic>{'type': type.name};
if (title.isNotEmpty) map['title'] = title;
if (source != null) map['source'] = source;
if (supportsBounds) {
if (minBound != null) map['minBound'] = minBound;
if (maxBound != null) map['maxBound'] = maxBound;
}
final dropData = forStorage && source != null;
if (rowColors.any((color) => color != null)) {
map['rowColors'] = rowColors;
}
if (!dropData) {
if (x.isNotEmpty) map['x'] = x;
if (series.isNotEmpty) {
map['series'] = [for (final s in series) s.toJson()];
}
} else if (series.any((series) => series.color != null)) {
map['series'] = [
for (final series in series) series.toJson(includeData: false),
];
}
return const JsonEncoder.withIndent(' ').convert(map);
}
@ -109,7 +191,28 @@ class ChartSpec {
/// Return a copy with x/series taken from [csv]; keeps [source].
ChartSpec withCsv(String csv) {
final parsed = parseCsv(csv);
return copyWith(x: parsed.$1, series: parsed.$2);
final colorsByLabel = x.isEmpty
? const <String, String?>{}
: <String, String?>{
for (var i = 0; i < x.length; i++)
x[i]: i < rowColors.length ? rowColors[i] : null,
};
return copyWith(
x: parsed.$1,
rowColors: [
for (var i = 0; i < parsed.$1.length; i++)
colorsByLabel[parsed.$1[i]] ??
(i < rowColors.length ? rowColors[i] : null),
],
series: [
for (var i = 0; i < parsed.$2.length; i++)
ChartSeries(
name: parsed.$2[i].name,
data: parsed.$2[i].data,
color: i < series.length ? series[i].color : null,
),
],
);
}
}

View file

@ -8,6 +8,21 @@ class ThemeProfile {
final String titleBackgroundColor;
final String titleTextColor;
final String sectionBackgroundColor;
/// Colours for code (broncode) slides. Defaults mirror the atom-one-dark
/// editor look. Set e.g. black background + bright green text with
/// [codeHighlightSyntax] off for a classic CRT terminal feel.
final String codeBackgroundColor;
final String codeTextColor;
/// When false, code is shown monochrome in [codeTextColor] (no per-token
/// syntax colours) required for a believable single-colour CRT screen.
final bool codeHighlightSyntax;
/// Monospace font family for code slides. `monospace` uses the system default;
/// e.g. `Courier New` for a typewriter look.
final String codeFontFamily;
final String? logoPath;
final String logoPosition;
final int logoSize;
@ -40,6 +55,10 @@ class ThemeProfile {
this.titleBackgroundColor = '#1C2B47',
this.titleTextColor = '#FFFFFF',
this.sectionBackgroundColor = '#2E7D64',
this.codeBackgroundColor = '#282C34',
this.codeTextColor = '#ABB2BF',
this.codeHighlightSyntax = true,
this.codeFontFamily = 'monospace',
this.logoPath,
this.logoPosition = 'bottom-right',
this.logoSize = 96,
@ -70,6 +89,10 @@ class ThemeProfile {
String? titleBackgroundColor,
String? titleTextColor,
String? sectionBackgroundColor,
String? codeBackgroundColor,
String? codeTextColor,
bool? codeHighlightSyntax,
String? codeFontFamily,
String? logoPath,
String? logoPosition,
int? logoSize,
@ -92,6 +115,10 @@ class ThemeProfile {
titleTextColor: titleTextColor ?? this.titleTextColor,
sectionBackgroundColor:
sectionBackgroundColor ?? this.sectionBackgroundColor,
codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor,
codeTextColor: codeTextColor ?? this.codeTextColor,
codeHighlightSyntax: codeHighlightSyntax ?? this.codeHighlightSyntax,
codeFontFamily: codeFontFamily ?? this.codeFontFamily,
logoPath: clearLogo ? null : (logoPath ?? this.logoPath),
logoPosition: logoPosition ?? this.logoPosition,
logoSize: logoSize ?? this.logoSize,
@ -116,6 +143,10 @@ class ThemeProfile {
'titleBackgroundColor': titleBackgroundColor,
'titleTextColor': titleTextColor,
'sectionBackgroundColor': sectionBackgroundColor,
'codeBackgroundColor': codeBackgroundColor,
'codeTextColor': codeTextColor,
'codeHighlightSyntax': codeHighlightSyntax,
'codeFontFamily': codeFontFamily,
'logoPath': logoPath,
'logoPosition': logoPosition,
'logoSize': logoSize,
@ -146,6 +177,11 @@ class ThemeProfile {
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
sectionBackgroundColor:
json['sectionBackgroundColor'] as String? ?? '#2E7D64',
codeBackgroundColor:
json['codeBackgroundColor'] as String? ?? '#282C34',
codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF',
codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true,
codeFontFamily: json['codeFontFamily'] as String? ?? 'monospace',
logoPath: json['logoPath'] as String?,
logoPosition: json['logoPosition'] as String? ?? 'bottom-right',
logoSize: (json['logoSize'] as num?)?.round() ?? 96,
@ -343,6 +379,17 @@ class AppSettings {
'Courier New',
];
/// Monospace families offered for code slides. `monospace` is the system
/// default; the rest are common typewriter/coding faces.
static const codeFonts = [
'monospace',
'Courier New',
'Menlo',
'Consolas',
'Roboto Mono',
'Cascadia Code',
];
AppSettings copyWith({
String? languageCode,
String? homeDirectory,

View file

@ -44,6 +44,7 @@ class FileService {
final ImageService _img;
final ThemeProfile Function() _themeProfile;
final String Function() _languageCode;
final String? Function() _homeDirectory;
final CaptionService _captions = CaptionService();
FileService(
@ -51,9 +52,30 @@ class FileService {
this._img,
this._themeProfile, {
String Function()? languageCode,
}) : _languageCode = languageCode ?? (() => 'nl');
String? Function()? homeDirectory,
}) : _languageCode = languageCode ?? (() => 'nl'),
_homeDirectory = homeDirectory ?? (() => null);
ThemeProfile get currentThemeProfile => _themeProfile();
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
ThemeProfile resolveThemeProfile(
ThemeProfile profile, {
String? projectPath,
}) {
final logoPath = profile.logoPath;
if (logoPath == null || logoPath.trim().isEmpty || p.isAbsolute(logoPath)) {
return profile;
}
final bases = [?projectPath, ?_homeDirectory()];
for (final base in bases) {
final candidate = p.normalize(p.join(base, logoPath));
if (File(candidate).existsSync()) {
return profile.copyWith(logoPath: candidate);
}
}
return profile;
}
String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text);

View file

@ -48,7 +48,7 @@ class MarpHtmlService {
for (final slide in marpSlides(deckMarkdown)) {
sections
..write('<section class="slide"><script type="text/markdown">')
..write(_guard(renderChartBlocks(slide)))
..write(_guard(renderChartBlocks(slide, theme: theme)))
..write('</script></section>');
}
@ -110,23 +110,12 @@ class MarpHtmlService {
multiLine: true,
);
static const List<String> _chartPalette = [
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
/// exported HTML renders charts without any JS chart library.
static String renderChartBlocks(String slideMarkdown) {
static String renderChartBlocks(String slideMarkdown, {ThemeProfile? theme}) {
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
final spec = ChartSpec.parse(m.group(1)!);
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
return '\n<div class="chart">${_chartSvg(spec, theme)}</div>\n';
});
}
@ -135,52 +124,128 @@ class MarpHtmlService {
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
static String _color(int i) => _chartPalette[i % _chartPalette.length];
static String _color(ChartSpec spec, int i, ThemeProfile? theme) {
final series = spec.series[i];
if (series.color == null && i == 0 && theme != null) {
return theme.accentColor;
}
return chartSeriesColor(series, i);
}
static String _chartSvg(ChartSpec spec) {
static String _chartSvg(ChartSpec spec, ThemeProfile? theme) {
if (!spec.hasInlineData) {
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
}
final textColor = theme?.textColor ?? '#111827';
final titleBackground = theme?.titleBackgroundColor ?? '#F8FAFC';
final titleColor = theme?.titleTextColor ?? textColor;
final accent = theme?.accentColor ?? '#2563EB';
final b = StringBuffer()
..write(
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
'font-family="inherit" width="100%">',
);
if (spec.title.isNotEmpty) {
b.write(
'<text x="400" y="34" text-anchor="middle" font-size="26" '
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
);
}
// Legend (multi-series, non-pie).
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
var plotTop = top;
if (spec.type != ChartType.pie && spec.series.length > 1) {
var lx = 60.0;
for (var i = 0; i < spec.series.length; i++) {
final title = spec.title.length > 52
? '${spec.title.substring(0, 51)}'
: spec.title;
b
..write(
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>',
'<rect x="38" y="12" width="724" height="44" rx="9" '
'fill="$titleBackground"/>',
)
..write(
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</text>',
'<rect x="38" y="12" width="7" height="44" rx="3" fill="$accent"/>',
)
..write(
'<text x="62" y="41" font-size="23" font-weight="bold" '
'fill="$titleColor">${_esc(title)}</text>',
);
lx += 30 + spec.series[i].name.length * 9 + 24;
}
plotTop = top + 28;
}
final plotTop = spec.title.isNotEmpty ? 68.0 : 20.0;
switch (spec.type) {
case ChartType.bar:
_barSvg(b, spec, plotTop);
_barSvg(b, spec, plotTop, theme);
case ChartType.line:
_lineSvg(b, spec, plotTop);
_lineSvg(b, spec, plotTop, theme);
case ChartType.pie:
_pieSvg(b, spec, plotTop);
final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
case ChartType.radar:
_radarSvg(b, spec, plotTop, theme, textColor);
}
if (spec.type == ChartType.pie) {
_pieLegendSvg(b, spec, textColor);
} else {
_legendSvg(b, spec, theme, textColor);
}
b.write('</svg>');
return b.toString();
}
static void _legendSvg(
StringBuffer b,
ChartSpec spec,
ThemeProfile? theme,
String textColor,
) {
final count = math.min(spec.series.length, 6);
final cellWidth = 720.0 / count;
for (var i = 0; i < count; i++) {
final rawName = spec.series[i].name.isEmpty
? 'Reeks ${i + 1}'
: spec.series[i].name;
final name = rawName.length > 12
? '${rawName.substring(0, 11)}'
: rawName;
final x = 40 + i * cellWidth;
b
..write(
'<rect x="$x" y="414" width="${cellWidth - 8}" height="24" rx="12" '
'fill="$textColor" fill-opacity=".05"/>',
)
..write(
'<circle cx="${x + 13}" cy="426" r="5" '
'fill="${_color(spec, i, theme)}"/>',
)
..write(
'<text x="${x + 24}" y="431" font-size="13" font-weight="600" '
'fill="$textColor">${_esc(name)}</text>',
);
}
}
static void _pieLegendSvg(StringBuffer b, ChartSpec spec, String textColor) {
const maxColumns = 6;
final columns = math.min(spec.x.length, maxColumns);
final rows = (spec.x.length / maxColumns).ceil().clamp(1, 3);
final cellWidth = 720.0 / columns;
final startY = 414.0 - (rows - 1) * 28;
for (var i = 0; i < spec.x.length; i++) {
final row = i ~/ maxColumns;
if (row >= rows) break;
final column = i % maxColumns;
final x = 40 + column * cellWidth;
final y = startY + row * 28;
final label = spec.x[i].length > 12
? '${spec.x[i].substring(0, 11)}'
: spec.x[i];
b
..write(
'<rect x="$x" y="$y" width="${cellWidth - 8}" height="24" rx="12" '
'fill="$textColor" fill-opacity=".05"/>',
)
..write(
'<circle cx="${x + 13}" cy="${y + 12}" r="5" '
'fill="${chartRowColor(spec, i)}"/>',
)
..write(
'<text x="${x + 24}" y="${y + 17}" font-size="13" font-weight="600" '
'fill="$textColor">${_esc(label)}</text>',
);
}
}
static double _maxY(ChartSpec spec) {
var m = 0.0;
for (final s in spec.series) {
@ -188,9 +253,44 @@ class MarpHtmlService {
if (v > m) m = v;
}
}
if (spec.supportsBounds) {
for (final b in [spec.minBound, spec.maxBound]) {
if (b != null && b > m) m = b;
}
}
return m <= 0 ? 1 : m * 1.15;
}
/// Draw the optional min/max threshold lines (bar/line only) as dashed rules.
static void _boundLinesSvg(
StringBuffer b,
ChartSpec spec,
double left,
double top,
double right,
double bottom,
double maxY,
) {
if (!spec.supportsBoundLines) return;
void draw(double? value, String color, String prefix) {
if (value == null || value < 0 || value > maxY) return;
final y = bottom - (bottom - top) * (value / maxY);
b
..write(
'<line x1="$left" y1="$y" x2="$right" y2="$y" stroke="$color" '
'stroke-width="2.5" stroke-dasharray="8 5"/>',
)
..write(
'<text x="${right - 4}" y="${y - 5}" text-anchor="end" '
'font-size="14" font-weight="700" fill="$color">'
'$prefix ${_num(value)}</text>',
);
}
draw(spec.minBound, '#F59E0B', 'min');
draw(spec.maxBound, '#EF4444', 'max');
}
static String _num(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
@ -217,16 +317,27 @@ class MarpHtmlService {
}
// X labels.
final n = spec.x.length;
final step = math.max(1, (n / 8).ceil());
for (var i = 0; i < n; i++) {
if (i != n - 1 && i % step != 0) continue;
final x = left + (right - left) * (i + 0.5) / n;
final label = spec.x[i].length > 10
? '${spec.x[i].substring(0, 9)}'
: spec.x[i];
b.write(
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
'<text x="$x" y="${bottom + 20}" text-anchor="middle" '
'font-size="13" fill="#334155">${_esc(label)}</text>',
);
}
}
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
static void _barSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
) {
const left = 60.0, right = 770.0, bottom = 382.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
@ -241,14 +352,21 @@ class MarpHtmlService {
final h = (bottom - top) * (v / maxY);
final x = gx + barW * si;
b.write(
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
'<rect x="$x" y="${bottom - h}" width="${barW * 0.86}" height="$h" '
'rx="5" fill="${_color(spec, si, theme)}"/>',
);
}
}
_boundLinesSvg(b, spec, left, top, right, bottom, maxY);
}
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
static void _lineSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
) {
const left = 60.0, right = 770.0, bottom = 382.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
@ -260,54 +378,222 @@ class MarpHtmlService {
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
].join(' ');
b.write(
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
'<polyline points="$pts" fill="none" '
'stroke="${_color(spec, si, theme)}" stroke-width="4" '
'stroke-linecap="round" stroke-linejoin="round"/>',
);
for (var i = 0; i < data.length; i++) {
b.write(
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
'<circle cx="${px(i)}" cy="${py(data[i])}" r="5" '
'fill="${_color(spec, si, theme)}" stroke="white" stroke-width="2"/>',
);
}
}
_boundLinesSvg(b, spec, left, top, right, bottom, maxY);
}
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
final series = spec.series.first;
final total = series.data.fold<double>(0, (a, v) => a + v);
const cx = 250.0, cy = 240.0, r = 150.0;
var angle = -90.0; // start at top
for (var i = 0; i < series.data.length; i++) {
final frac = total > 0 ? series.data[i] / total : 0;
static void _pieSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme, {
required double bottom,
}) {
final count = math.min(spec.series.length, 2);
final columns = count;
final rows = (count / columns).ceil();
final cellWidth = 720.0 / columns;
final cellHeight = (bottom - top) / rows;
final radius = math.min(cellWidth * 0.25, cellHeight * 0.42);
for (var xi = 0; xi < count; xi++) {
final col = xi % columns;
final row = xi ~/ columns;
final cellLeft = 40 + cellWidth * col;
final cx = cellLeft + cellWidth * 0.36;
final cy = top + cellHeight * (row + 0.5);
final series = spec.series[xi];
final values = [
for (var labelIndex = 0; labelIndex < spec.x.length; labelIndex++)
labelIndex < series.data.length && series.data[labelIndex] > 0
? series.data[labelIndex]
: 0.0,
];
final total = values.fold<double>(0, (a, v) => a + v);
var angle = -90.0;
for (var labelIndex = 0; labelIndex < values.length; labelIndex++) {
final frac = total > 0 ? values[labelIndex] / total : 0;
final sweep = frac * 360;
if (sweep <= 0) continue;
final a0 = angle * math.pi / 180;
final a1 = (angle + sweep) * math.pi / 180;
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0);
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1);
final x0 = cx + radius * math.cos(a0);
final y0 = cy + radius * math.sin(a0);
final x1 = cx + radius * math.cos(a1);
final y1 = cy + radius * math.sin(a1);
final large = sweep > 180 ? 1 : 0;
b.write(
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>',
'<path d="M$cx,$cy L$x0,$y0 A$radius,$radius 0 $large,1 '
'$x1,$y1 Z" '
'fill="${chartRowColor(spec, labelIndex)}" '
'stroke="white" '
'stroke-width="2"/>',
);
angle += sweep;
}
// Legend on the right.
var ly = 120.0;
for (var i = 0; i < spec.x.length && i < series.data.length; i++) {
b
..write('<circle cx="$cx" cy="$cy" r="${radius * 0.43}" fill="white"/>')
..write(
'<text x="${cellLeft + cellWidth * 0.66}" y="${cy + 5}" '
'font-size="14" font-weight="700">'
'${_esc(_shortChartLabel(series.name.isEmpty ? 'Reeks ${xi + 1}' : series.name))}</text>',
);
}
}
static void _radarSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
String textColor,
) {
final n = spec.x.length;
if (n < 3 || spec.series.isEmpty) return;
const bottom = 382.0;
const cx = 400.0;
final cy = (top + bottom) / 2;
final radius = math.min(170.0, (bottom - top) / 2 - 34);
final (lo, hi, ticks) = _radarScale(spec);
final span = (hi - lo) == 0 ? 1.0 : (hi - lo);
double angle(int i) => -math.pi / 2 + 2 * math.pi * i / n;
// Concentric grid rings, evenly spaced across the [lo, hi] scale.
for (var ring = 1; ring <= ticks; ring++) {
final rr = radius * ring / ticks;
final pts = [
for (var i = 0; i < n; i++)
'${cx + rr * math.cos(angle(i))},${cy + rr * math.sin(angle(i))}',
].join(' ');
b.write(
'<polygon points="$pts" fill="none" stroke="#e2e8f0" '
'stroke-width="1"/>',
);
}
// Scale legend on the right: hi at the top down to lo at the bottom, so the
// figure itself stays clean.
final legendX = cx + radius + 30;
for (var k = ticks; k >= 0; k--) {
final value = lo + span * k / ticks;
final y = (cy - radius) + (2 * radius) * (ticks - k) / ticks;
b
..write(
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>',
'<line x1="$legendX" y1="$y" x2="${legendX + 8}" y2="$y" '
'stroke="#cbd5e1" stroke-width="1"/>',
)
..write(
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>',
'<text x="${legendX + 12}" y="${y + 4}" font-size="12" '
'fill="#94a3b8">${_num(value)}</text>',
);
}
// Spokes and axis labels.
for (var i = 0; i < n; i++) {
final c = math.cos(angle(i));
final s = math.sin(angle(i));
b.write(
'<line x1="$cx" y1="$cy" x2="${cx + radius * c}" '
'y2="${cy + radius * s}" stroke="#e2e8f0" stroke-width="1"/>',
);
final anchor = c > 0.3 ? 'start' : (c < -0.3 ? 'end' : 'middle');
final label = spec.x[i].length > 12
? '${spec.x[i].substring(0, 11)}'
: spec.x[i];
b.write(
'<text x="${cx + (radius + 18) * c}" y="${cy + (radius + 18) * s + 4}" '
'text-anchor="$anchor" font-size="13" fill="$textColor">'
'${_esc(label)}</text>',
);
}
// One filled polygon per series.
for (var si = 0; si < spec.series.length; si++) {
final data = spec.series[si].data;
final pts = [
for (var i = 0; i < n; i++)
() {
final v = i < data.length ? data[i] : 0.0;
final rr = radius * ((v - lo) / span).clamp(0.0, 1.0);
return '${cx + rr * math.cos(angle(i))},'
'${cy + rr * math.sin(angle(i))}';
}(),
].join(' ');
final color = _color(spec, si, theme);
b.write(
'<polygon points="$pts" fill="$color" fill-opacity="0.16" '
'stroke="$color" stroke-width="3" stroke-linejoin="round"/>',
);
ly += 28;
}
}
/// Radar scale shared with the live preview: honour optional min/max bounds,
/// otherwise round the data range to a tidy scale with an even tick count.
static (double, double, int) _radarScale(ChartSpec spec) {
var dataMin = 0.0;
var dataMax = 0.0;
var seen = false;
for (final s in spec.series) {
for (final v in s.data) {
if (!seen) {
dataMin = v;
dataMax = v;
seen = true;
} else {
if (v < dataMin) dataMin = v;
if (v > dataMax) dataMax = v;
}
}
}
if (!seen) {
dataMin = 0;
dataMax = 1;
}
final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0);
final rawHi = spec.maxBound ?? dataMax;
final range = (rawHi - rawLo).abs();
final r = range <= 0 ? 1.0 : range;
final rawStep = r / 4;
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
final norm = rawStep / mag;
final niceNorm = norm < 1.5
? 1.0
: norm < 3
? 2.0
: norm < 7
? 5.0
: 10.0;
final step = niceNorm * mag;
final lo = spec.minBound ?? (rawLo / step).floorToDouble() * step;
var hi = spec.maxBound ?? (rawHi / step).ceilToDouble() * step;
if (hi <= lo) hi = lo + step;
final ticks = math.max(2, ((hi - lo) / step).round());
return (lo, hi, ticks);
}
static String _shortChartLabel(String value) =>
value.length > 13 ? '${value.substring(0, 12)}' : value;
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
/// accent colours, table colours and font. The EB Garamond font is embedded
/// (base64) so it renders offline; other fonts resolve to system families.
Future<String> _themedCss(ThemeProfile t) async {
final fontFace = await _ebGaramondFontFace(t.fontFamily);
final family = _cssFontStack(t.fontFamily);
final codePrefix = t.codeFontFamily == 'monospace'
? ''
: "'${t.codeFontFamily}',";
final codeFamily =
'${codePrefix}SFMono-Regular,Consolas,"Liberation Mono",monospace';
return '$fontFace\n'
'*{box-sizing:border-box}'
'html,body{margin:0;padding:0}'
@ -320,9 +606,11 @@ class MarpHtmlService {
'.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}'
'.slide a{color:${t.accentColor}}'
'.slide p,.slide li{font-size:24px;line-height:1.45}'
'.slide pre{background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;'
'padding:16px;overflow:auto;font-size:18px}'
'.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}'
'.slide pre{background:${t.codeBackgroundColor};color:${t.codeTextColor};'
'border:1px solid ${t.codeTextColor}38;border-radius:6px;'
'padding:16px;overflow:auto;font-size:18px;font-family:$codeFamily}'
'.slide pre code{color:${t.codeTextColor};background:transparent}'
'.slide code{font-family:$codeFamily}'
'.slide pre.mermaid{background:transparent;border:0;text-align:center}'
'.slide img{max-width:100%}'
'.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;'

View file

@ -25,6 +25,7 @@ final fileServiceProvider = Provider<FileService>((ref) {
ref.read(imageServiceProvider),
() => ref.read(settingsProvider).themeProfile,
languageCode: () => ref.read(settingsProvider).languageCode,
homeDirectory: () => ref.read(settingsProvider).homeDirectory,
);
});
@ -128,8 +129,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
/// Load a deck that was already parsed (used by the tab manager).
void loadDeck(Deck deck, {String? filePath}) {
final resolvedDeck = deck.copyWith(
themeProfile: _file.resolveThemeProfile(
deck.themeProfile,
projectPath: deck.projectPath,
),
);
_clearHistory();
state = DeckState(deck: deck, filePath: filePath, isDirty: false);
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
}
Future<void> openDeck({String? initialDirectory}) async {
@ -414,7 +421,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
void updateThemeProfile(ThemeProfile profile) {
final deck = state.deck;
if (deck == null) return;
_mutate(deck.copyWith(themeProfile: profile));
_mutate(
deck.copyWith(
themeProfile: _file.resolveThemeProfile(
profile,
projectPath: deck.projectPath,
),
),
);
}
/// Update the (separate) annotation layer. Kept out of the undo/redo history

View file

@ -882,11 +882,47 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
/// A banner shown on tabs that edit the active style profile, so it is clear
/// these settings belong to the loaded profile (and which one).
Widget _profileScopeBanner() {
final name = _themeProfile.name;
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border(left: BorderSide(color: AppTheme.accent, width: 3)),
),
child: Row(
children: [
Icon(Icons.style_outlined, size: 16, color: AppTheme.accent),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: context.l10n.d('Onderdeel van stijlprofiel ')),
TextSpan(
text: '$name',
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
style: const TextStyle(fontSize: 12, color: Color(0xFF334155)),
),
),
],
),
);
}
Widget _colorsTab() {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_profileScopeBanner(),
_sectionTitle(l10n.d('Kleuren')),
_colorSetting(
l10n.d('Achtergrond slides'),
@ -939,6 +975,68 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
(v) =>
_themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v),
),
const SizedBox(height: 24),
_sectionTitle(l10n.d('Broncode')),
_colorSetting(
l10n.d('Broncode achtergrond'),
_themeProfile.codeBackgroundColor,
(v) =>
_themeProfile = _themeProfile.copyWith(codeBackgroundColor: v),
),
const SizedBox(height: 12),
_colorSetting(
l10n.d('Broncode tekst'),
_themeProfile.codeTextColor,
(v) => _themeProfile = _themeProfile.copyWith(codeTextColor: v),
),
const SizedBox(height: 6),
SwitchListTile(
value: _themeProfile.codeHighlightSyntax,
onChanged: (v) => setState(() {
_themeProfile = _themeProfile.copyWith(codeHighlightSyntax: v);
_profileTouched = true;
}),
title: Text(
l10n.d('Syntaxkleuring'),
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
l10n.d(
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: AppSettings.codeFonts.contains(_themeProfile.codeFontFamily)
? _themeProfile.codeFontFamily
: 'monospace',
decoration: InputDecoration(
labelText: l10n.d('Broncode lettertype'),
isDense: true,
),
items: [
for (final f in AppSettings.codeFonts)
DropdownMenuItem(
value: f,
child: Text(
f == 'monospace' ? l10n.d('Systeem (monospace)') : f,
style: TextStyle(fontFamily: f),
),
),
],
onChanged: (v) {
if (v == null) return;
setState(() {
_themeProfile = _themeProfile.copyWith(codeFontFamily: v);
_profileTouched = true;
});
},
),
const SizedBox(height: 18),
_stylePreview(),
],
@ -950,6 +1048,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_profileScopeBanner(),
_sectionTitle(l10n.d('Logo')),
Row(
children: [
@ -1149,18 +1248,143 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
for (final color in _colorPresets)
_colorSwatch(
color,
selected: value == color,
selected: value.toUpperCase() == color,
onTap: () => setState(() {
onChanged(color);
_profileTouched = true;
}),
),
// Show the current value as a selected swatch when it isn't one of
// the presets (e.g. a hand-entered CRT green).
if (!_colorPresets.contains(value.toUpperCase()))
_colorSwatch(
value,
selected: true,
onTap: () => _editCustomColor(value, onChanged),
),
_customColorButton(value, onChanged),
],
),
],
);
}
Widget _customColorButton(String value, ValueChanged<String> onChanged) {
return Tooltip(
message: context.l10n.d('Eigen kleur (hex)'),
child: InkWell(
onTap: () => _editCustomColor(value, onChanged),
borderRadius: BorderRadius.circular(8),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFCBD5E1)),
),
child: const Icon(
Icons.tune,
size: 18,
color: Color(0xFF64748B),
),
),
),
);
}
Future<void> _editCustomColor(
String initial,
ValueChanged<String> onChanged,
) async {
final picked = await _pickHexColor(initial);
if (picked == null || !mounted) return;
setState(() {
onChanged(picked);
_profileTouched = true;
});
}
Future<String?> _pickHexColor(String initial) {
final controller = TextEditingController(text: initial);
String? normalize(String raw) {
final up = raw.trim().toUpperCase();
final hex = up.startsWith('#') ? up : '#$up';
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(hex) ? hex : null;
}
final l10n = context.l10n;
return showDialog<String>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
final normalized = normalize(controller.text);
return AlertDialog(
title: Text(l10n.d('Eigen kleur (hex)')),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _parseColor(normalized ?? '#FFFFFF'),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFCBD5E1)),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.d('Hexkleur'),
hintText: '#33FF33',
isDense: true,
border: const OutlineInputBorder(),
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[#0-9a-fA-F]'),
),
LengthLimitingTextInputFormatter(7),
],
onChanged: (_) => setDialogState(() {}),
onSubmitted: (_) {
final ok = normalize(controller.text);
if (ok != null) Navigator.pop(context, ok);
},
),
),
],
),
const SizedBox(height: 8),
Text(
l10n.d('Bijvoorbeeld #33FF33 voor een CRT-groen scherm.'),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.d('Annuleren')),
),
FilledButton(
onPressed: normalized == null
? null
: () => Navigator.pop(context, normalized),
child: Text(l10n.d('Toepassen')),
),
],
);
},
),
).whenComplete(controller.dispose);
}
Widget _colorSwatch(
String color, {
required bool selected,

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
@ -31,19 +32,23 @@ class ChartEditor extends StatefulWidget {
class _ChartEditorState extends State<ChartEditor> {
late final TextEditingController _title;
late final TextEditingController _minBound;
late final TextEditingController _maxBound;
late ChartType _type;
String? _source;
// Editable grid model (strings while editing).
List<String> _xLabels = [];
List<String?> _rowColors = [];
List<String> _seriesNames = [];
List<String?> _seriesColors = [];
List<List<String>> _values = []; // [row][col]
// Bumped on structural changes so cell fields rebuild with fresh values.
int _rev = 0;
static const _labelW = 130.0;
static const _cellW = 96.0;
static const _minLabelW = 238.0;
static const _minCellW = 150.0;
@override
void initState() {
@ -53,13 +58,31 @@ class _ChartEditorState extends State<ChartEditor> {
_source = spec.source;
_title = TextEditingController(text: spec.title);
_title.addListener(_emit);
_minBound = TextEditingController(text: _fmtBound(spec.minBound));
_maxBound = TextEditingController(text: _fmtBound(spec.maxBound));
_minBound.addListener(_emit);
_maxBound.addListener(_emit);
_loadFromSpec(spec);
}
bool get _supportsBounds => _type != ChartType.pie;
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
static double? _parseBound(String raw) {
final text = raw.trim().replaceAll(',', '.');
return text.isEmpty ? null : double.tryParse(text);
}
void _loadFromSpec(ChartSpec spec) {
if (spec.hasInlineData) {
_seriesNames = [for (final s in spec.series) s.name];
_seriesColors = [for (final s in spec.series) s.color];
_xLabels = List<String>.from(spec.x);
_rowColors = [
for (var i = 0; i < spec.x.length; i++)
i < spec.rowColors.length ? spec.rowColors[i] : null,
];
_values = [
for (var r = 0; r < spec.x.length; r++)
[
@ -70,7 +93,9 @@ class _ChartEditorState extends State<ChartEditor> {
} else {
// Sensible empty starting grid.
_seriesNames = ['Reeks 1'];
_seriesColors = [null];
_xLabels = ['', '', ''];
_rowColors = [null, null, null];
_values = List.generate(3, (_) => ['']);
}
}
@ -81,6 +106,8 @@ class _ChartEditorState extends State<ChartEditor> {
@override
void dispose() {
_title.dispose();
_minBound.dispose();
_maxBound.dispose();
super.dispose();
}
@ -89,6 +116,7 @@ class _ChartEditorState extends State<ChartEditor> {
for (var c = 0; c < _seriesNames.length; c++)
ChartSeries(
name: _seriesNames[c],
color: _seriesColors[c],
data: [
for (var r = 0; r < _values.length; r++)
double.tryParse(
@ -105,7 +133,10 @@ class _ChartEditorState extends State<ChartEditor> {
title: _title.text,
source: _source,
x: List<String>.from(_xLabels),
rowColors: List<String?>.from(_rowColors),
series: series,
minBound: _supportsBounds ? _parseBound(_minBound.text) : null,
maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null,
);
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
}
@ -114,6 +145,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _addColumn() {
_seriesNames.add('Reeks ${_seriesNames.length + 1}');
_seriesColors.add(null);
for (final row in _values) {
row.add('');
}
@ -124,6 +156,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _removeColumn(int c) {
if (_seriesNames.length <= 1) return;
_seriesNames.removeAt(c);
_seriesColors.removeAt(c);
for (final row in _values) {
if (c < row.length) row.removeAt(c);
}
@ -133,6 +166,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _addRow() {
_xLabels.add('');
_rowColors.add(null);
_values.add(List<String>.filled(_seriesNames.length, '', growable: true));
_bump();
_emit();
@ -141,6 +175,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _removeRow(int r) {
if (_xLabels.length <= 1) return;
_xLabels.removeAt(r);
_rowColors.removeAt(r);
_values.removeAt(r);
_bump();
_emit();
@ -199,12 +234,26 @@ class _ChartEditorState extends State<ChartEditor> {
setState(() {
_source = source;
_xLabels = parsed.$1.isEmpty ? [''] : parsed.$1;
_rowColors = [
for (var i = 0; i < _xLabels.length; i++)
i < _rowColors.length ? _rowColors[i] : null,
];
_seriesNames = parsed.$2.isEmpty
? ['Reeks 1']
: [for (final s in parsed.$2) s.name];
_seriesColors = [
for (var i = 0; i < _seriesNames.length; i++)
i < _seriesColors.length ? _seriesColors[i] : null,
];
_values = [
for (var r = 0; r < _xLabels.length; r++)
[for (final s in parsed.$2) r < s.data.length ? _fmt(s.data[r]) : ''],
if (parsed.$2.isEmpty)
['']
else
[
for (final s in parsed.$2)
r < s.data.length ? _fmt(s.data[r]) : '',
],
];
_rev++;
});
@ -216,6 +265,170 @@ class _ChartEditorState extends State<ChartEditor> {
_emit();
}
void _moveRow(int from, int to) {
if (to < 0 || to >= _xLabels.length || from == to) return;
setState(() {
final label = _xLabels.removeAt(from);
final color = _rowColors.removeAt(from);
final values = _values.removeAt(from);
_xLabels.insert(to, label);
_rowColors.insert(to, color);
_values.insert(to, values);
_rev++;
});
_emit();
}
void _sortRows({int? column, required bool ascending}) {
final indices = List<int>.generate(_xLabels.length, (i) => i);
indices.sort((a, b) {
int result;
if (column == null) {
result = _xLabels[a].toLowerCase().compareTo(_xLabels[b].toLowerCase());
} else {
final av =
double.tryParse(_values[a][column].replaceAll(',', '.')) ?? 0;
final bv =
double.tryParse(_values[b][column].replaceAll(',', '.')) ?? 0;
result = av.compareTo(bv);
}
return ascending ? result : -result;
});
setState(() {
final labels = [for (final i in indices) _xLabels[i]];
final colors = [for (final i in indices) _rowColors[i]];
final values = [for (final i in indices) _values[i]];
_xLabels = labels;
_rowColors = colors;
_values = values;
_rev++;
});
_emit();
}
Future<void> _pickSeriesColor(int index) async {
final selected = await _pickColor(
initial:
_seriesColors[index] ??
chartSeriesColor(ChartSeries(name: '', data: const []), index),
title: context.l10n.d('Kleur van reeks'),
);
if (selected == null || !mounted) return;
setState(() => _seriesColors[index] = selected);
_emit();
}
Future<void> _pickRowColor(int index) async {
final selected = await _pickColor(
initial:
_rowColors[index] ??
chartColorPalette[index % chartColorPalette.length],
title: context.l10n.d('Kleur van rij'),
);
if (selected == null || !mounted) return;
setState(() => _rowColors[index] = selected);
_emit();
}
Future<String?> _pickColor({
required String initial,
required String title,
}) async {
final controller = TextEditingController(text: initial);
final selected = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: StatefulBuilder(
builder: (context, setDialogState) => SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final hex in chartColorPalette)
_colorChoice(
hex,
selected: normalizeChartColor(controller.text) == hex,
onTap: () {
controller.text = hex;
setDialogState(() {});
},
),
],
),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
labelText: context.l10n.d('Hexkleur'),
hintText: '#2563EB',
border: const OutlineInputBorder(),
isDense: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[#0-9a-fA-F]')),
LengthLimitingTextInputFormatter(7),
],
onChanged: (_) => setDialogState(() {}),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.d('Annuleren')),
),
FilledButton(
onPressed: normalizeChartColor(controller.text) == null
? null
: () => Navigator.pop(
context,
normalizeChartColor(controller.text),
),
child: Text(context.l10n.d('Toepassen')),
),
],
),
);
controller.dispose();
return selected;
}
Widget _colorChoice(
String hex, {
required bool selected,
required VoidCallback onTap,
}) {
final color = Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: selected ? const Color(0xFF0F172A) : Colors.white,
width: selected ? 3 : 2,
),
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 3)],
),
child: selected
? const Icon(Icons.check, size: 18, color: Colors.white)
: null,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@ -256,6 +469,10 @@ class _ChartEditorState extends State<ChartEditor> {
value: ChartType.pie,
child: Text(l10n.d('Cirkel')),
),
DropdownMenuItem(
value: ChartType.radar,
child: Text(l10n.d('Spider')),
),
],
onChanged: (v) {
if (v == null) return;
@ -271,6 +488,54 @@ class _ChartEditorState extends State<ChartEditor> {
),
],
),
if (_type == ChartType.pie)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.d(
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
),
),
if (_type == ChartType.radar)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.d(
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
),
),
if (_supportsBounds)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _boundField(
key: const ValueKey('chart-min-bound'),
controller: _minBound,
label: _type == ChartType.radar
? l10n.d('Schaalminimum (optioneel)')
: l10n.d('Minimumlijn (optioneel)'),
),
),
const SizedBox(width: 12),
Expanded(
child: _boundField(
key: const ValueKey('chart-max-bound'),
controller: _maxBound,
label: _type == ChartType.radar
? l10n.d('Schaalmaximum (optioneel)')
: l10n.d('Maximumlijn (optioneel)'),
),
),
],
),
),
if (linked)
Padding(
padding: const EdgeInsets.only(top: 8),
@ -297,12 +562,20 @@ class _ChartEditorState extends State<ChartEditor> {
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
return SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _grid(enabled: !linked),
child: _grid(
enabled: !linked,
availableWidth: availableWidth,
),
),
);
},
),
),
if (!linked) ...[
const SizedBox(height: 8),
@ -327,23 +600,83 @@ class _ChartEditorState extends State<ChartEditor> {
);
}
Widget _grid({required bool enabled}) {
Widget _grid({required bool enabled, required double availableWidth}) {
final cols = _seriesNames.length;
return Column(
const trailingWidth = 40.0;
final labelWidth = math.max(_minLabelW, availableWidth * 0.28);
final remaining = availableWidth - labelWidth - trailingWidth;
final cellWidth = math.max(_minCellW, remaining / cols);
final gridWidth = math.max(
availableWidth,
labelWidth + cellWidth * cols + trailingWidth,
);
return SizedBox(
key: const ValueKey('chart-grid'),
width: gridWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: empty label cell + series name fields.
Row(
children: [
SizedBox(
width: _labelW,
child: _headerHint(context.l10n.d('Label')),
),
for (var c = 0; c < cols; c++)
SizedBox(
width: _cellW,
width: labelWidth,
child: Row(
children: [
Expanded(child: _headerHint(context.l10n.d('Label'))),
_sortButton(column: null, enabled: enabled),
],
),
),
for (var c = 0; c < cols; c++)
Container(
key: ValueKey('chart-series-column-$c'),
width: cellWidth,
color: _type == ChartType.pie && c >= 2
? const Color(0xFFE2E8F0)
: null,
child: Row(
children: [
IconButton(
onPressed: enabled ? () => _pickSeriesColor(c) : null,
tooltip: context.l10n.d('Kleur van reeks'),
icon: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Color(
_type == ChartType.pie && c >= 2
? 0xFF94A3B8
: int.parse(
chartSeriesColor(
ChartSeries(
name: '',
data: const [],
color: _seriesColors[c],
),
c,
).substring(1),
radix: 16,
) |
0xFF000000,
),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: const [
BoxShadow(
color: Color(0x330F172A),
blurRadius: 2,
),
],
),
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 32,
),
),
Expanded(
child: _cell(
key: ValueKey('s-$_rev-$c'),
@ -351,8 +684,10 @@ class _ChartEditorState extends State<ChartEditor> {
enabled: enabled,
onChanged: (v) => _seriesNames[c] = v,
bold: true,
muted: _type == ChartType.pie && c >= 2,
),
),
_sortButton(column: c, enabled: enabled),
if (enabled && cols > 1)
_iconBtn(Icons.close, () => _removeColumn(c)),
],
@ -368,7 +703,25 @@ class _ChartEditorState extends State<ChartEditor> {
child: Row(
children: [
SizedBox(
width: _labelW,
width: labelWidth,
child: Row(
children: [
IconButton(
key: ValueKey('chart-row-color-$r'),
onPressed: enabled ? () => _pickRowColor(r) : null,
tooltip: context.l10n.d('Kleur van rij'),
icon: _colorDot(
_rowColors[r] ??
chartColorPalette[r % chartColorPalette.length],
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 26,
minHeight: 32,
),
),
Expanded(
child: _cell(
key: ValueKey('x-$_rev-$r'),
value: _xLabels[r],
@ -376,14 +729,35 @@ class _ChartEditorState extends State<ChartEditor> {
onChanged: (v) => _xLabels[r] = v,
),
),
if (enabled) ...[
_iconBtn(
Icons.keyboard_arrow_up,
r == 0 ? null : () => _moveRow(r, r - 1),
key: ValueKey('chart-row-up-$r'),
),
_iconBtn(
Icons.keyboard_arrow_down,
r == _xLabels.length - 1
? null
: () => _moveRow(r, r + 1),
key: ValueKey('chart-row-down-$r'),
),
],
],
),
),
for (var c = 0; c < cols; c++)
SizedBox(
width: _cellW,
Container(
width: cellWidth,
color: _type == ChartType.pie && c >= 2
? const Color(0xFFE2E8F0)
: null,
child: _cell(
key: ValueKey('v-$_rev-$r-$c'),
value: c < _values[r].length ? _values[r][c] : '',
enabled: enabled,
number: true,
muted: _type == ChartType.pie && c >= 2,
onChanged: (v) {
while (_values[r].length <= c) {
_values[r].add('');
@ -398,9 +772,35 @@ class _ChartEditorState extends State<ChartEditor> {
),
),
],
),
);
}
Widget _boundField({
required Key key,
required TextEditingController controller,
required String label,
}) => TextField(
key: key,
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]')),
],
style: const TextStyle(fontSize: 12),
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
hintText: context.l10n.d('geen'),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
border: const OutlineInputBorder(),
),
);
Widget _headerHint(String text) => Padding(
padding: const EdgeInsets.only(left: 4, bottom: 4),
child: Text(
@ -413,6 +813,40 @@ class _ChartEditorState extends State<ChartEditor> {
),
);
Widget _sortButton({required int? column, required bool enabled}) {
return PopupMenuButton<bool>(
key: ValueKey('chart-sort-${column ?? 'label'}'),
enabled: enabled,
tooltip: context.l10n.d('Sorteren'),
icon: const Icon(Icons.sort, size: 15, color: Color(0xFF64748B)),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 28),
itemBuilder: (context) => [
PopupMenuItem(
value: true,
child: Text(context.l10n.d('Oplopend sorteren')),
),
PopupMenuItem(
value: false,
child: Text(context.l10n.d('Aflopend sorteren')),
),
],
onSelected: (ascending) =>
_sortRows(column: column, ascending: ascending),
);
}
Widget _colorDot(String hex) => Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 2)],
),
);
Widget _cell({
required Key key,
required String value,
@ -420,6 +854,7 @@ class _ChartEditorState extends State<ChartEditor> {
required ValueChanged<String> onChanged,
bool number = false,
bool bold = false,
bool muted = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
@ -440,17 +875,24 @@ class _ChartEditorState extends State<ChartEditor> {
style: TextStyle(
fontSize: 12,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
color: muted ? const Color(0xFF64748B) : null,
),
decoration: const InputDecoration(
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
border: OutlineInputBorder(),
filled: muted,
fillColor: muted ? const Color(0xFFF1F5F9) : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: const OutlineInputBorder(),
),
),
);
}
Widget _iconBtn(IconData icon, VoidCallback onTap) => IconButton(
Widget _iconBtn(IconData icon, VoidCallback? onTap, {Key? key}) => IconButton(
key: key,
onPressed: onTap,
icon: Icon(icon, size: 14),
color: const Color(0xFF94A3B8),

View file

@ -168,6 +168,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
slideNumber: _index + 1,
slideCount: _slides.length,
tlp: _tlp,
presentationMode: true,
enableMedia: true,
autoplayMedia: true,
// Audio finishing on the beamer drives the presenter's

View file

@ -1288,6 +1288,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
slideNumber: _index + 1,
slideCount: widget.slides.length,
tlp: widget.tlp,
presentationMode: true,
// Tijdens het presenteren speelt media en starten audio/video
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
// schermmodus speelt de media op het beamervenster, niet hier,
@ -1411,6 +1412,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
slide: nextSlide,
projectPath: widget.projectPath,
themeProfile: widget.themeProfile,
presentationMode: true,
),
)
: Container(

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,7 @@ void main() {
'SLIDES',
'Slide',
'slide',
'Spider',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')

166
test/chart_editor_test.dart Normal file
View file

@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/editors/chart_editor.dart';
Widget _host(Slide slide, ValueChanged<Slide> onUpdate) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
width: 900,
height: 650,
child: ChartEditor(slide: slide, onUpdate: onUpdate),
),
),
);
}
void main() {
testWidgets('chart grid fills the available editor width', (tester) async {
const spec = ChartSpec(
x: ['A', 'B'],
series: [
ChartSeries(name: 'Waarde', data: [10, 20]),
],
);
final slide = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
await tester.pumpWidget(_host(slide, (_) {}));
await tester.pump();
final gridWidth = tester
.getSize(find.byKey(const ValueKey('chart-grid')))
.width;
expect(gridWidth, greaterThanOrEqualTo(760));
expect(tester.takeException(), isNull);
});
testWidgets('moving a row keeps its values and color together', (
tester,
) async {
const spec = ChartSpec(
x: ['B', 'A'],
rowColors: ['#EF4444', '#10B981'],
series: [
ChartSeries(name: 'Waarde', data: [20, 10]),
],
);
var updated = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
await tester.pumpWidget(_host(updated, (slide) => updated = slide));
await tester.tap(find.byKey(const ValueKey('chart-row-up-1')));
await tester.pump();
final result = ChartSpec.parse(updated.customMarkdown);
expect(result.x, ['A', 'B']);
expect(result.rowColors, ['#10B981', '#EF4444']);
expect(result.series.single.data, [10, 20]);
});
testWidgets('sorting a value column moves complete rows', (tester) async {
const spec = ChartSpec(
x: ['A', 'B', 'C'],
rowColors: ['#003399', '#FFCC00', '#EF4444'],
series: [
ChartSeries(name: 'Waarde', data: [30, 10, 20]),
],
);
var updated = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
await tester.pumpWidget(_host(updated, (slide) => updated = slide));
await tester.tap(find.byKey(const ValueKey('chart-sort-0')));
await tester.pumpAndSettle();
await tester.tap(find.text('Oplopend sorteren'));
await tester.pump();
final result = ChartSpec.parse(updated.customMarkdown);
expect(result.x, ['B', 'C', 'A']);
expect(result.rowColors, ['#FFCC00', '#EF4444', '#003399']);
expect(result.series.single.data, [10, 20, 30]);
});
testWidgets('pie dims the third series without disabling its input', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.pie,
x: ['A'],
series: [
ChartSeries(name: 'Een', data: [1]),
ChartSeries(name: 'Twee', data: [2]),
ChartSeries(name: 'Drie', data: [3]),
],
);
final slide = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
await tester.pumpWidget(_host(slide, (_) {}));
await tester.pump();
final column = tester.widget<Container>(
find.byKey(const ValueKey('chart-series-column-2')),
);
expect(column.color, const Color(0xFFE2E8F0));
final input = tester.widget<TextFormField>(
find.byKey(const ValueKey('v-0-0-2')),
);
expect(input.enabled, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('bound fields are offered for bar/line and emit min/max', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.bar,
x: ['A'],
series: [
ChartSeries(name: 'Waarde', data: [10]),
],
);
var updated = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
await tester.pumpWidget(_host(updated, (slide) => updated = slide));
await tester.pump();
expect(find.byKey(const ValueKey('chart-min-bound')), findsOneWidget);
expect(find.byKey(const ValueKey('chart-max-bound')), findsOneWidget);
await tester.enterText(
find.byKey(const ValueKey('chart-max-bound')),
'20',
);
await tester.pump();
expect(ChartSpec.parse(updated.customMarkdown).maxBound, 20);
});
testWidgets('bound fields are hidden for a pie chart', (tester) async {
const spec = ChartSpec(
type: ChartType.pie,
x: ['A'],
series: [
ChartSeries(name: 'Een', data: [1]),
],
);
final slide = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
await tester.pumpWidget(_host(slide, (_) {}));
await tester.pump();
expect(find.byKey(const ValueKey('chart-min-bound')), findsNothing);
expect(find.byKey(const ValueKey('chart-max-bound')), findsNothing);
});
}

View file

@ -0,0 +1,539 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
Widget _host(ChartSpec spec, {bool presentationMode = false}) {
return MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock()),
presentationMode: presentationMode,
),
),
),
),
);
}
void main() {
testWidgets('chart title stays above the plot area', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet per kwartaal',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2026', data: [10, 14]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final titleBottom = tester.getBottomLeft(find.text(spec.title)).dy;
final plotTop = tester.getTopLeft(find.byType(BarChart)).dy;
expect(titleBottom, lessThan(plotTop));
expect(tester.takeException(), isNull);
});
testWidgets('pie renders one chart per series with labels as slices', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.pie,
title: 'Verdeling',
x: ['Team A', 'Team B'],
series: [
ChartSeries(name: 'Gereed', data: [70, 40], color: '#10B981'),
ChartSeries(name: 'Open', data: [30, 60], color: '#EF4444'),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(PieChart), findsNWidgets(2));
expect(find.text('Team A'), findsOneWidget);
expect(find.text('Team B'), findsOneWidget);
expect(find.text('Gereed'), findsOneWidget);
expect(find.text('Open'), findsOneWidget);
final pieRect = tester.getRect(find.byType(PieChart).first);
final titleRect = tester.getRect(find.text('Gereed'));
expect(titleRect.left, greaterThan(pieRect.center.dx));
expect(tester.takeException(), isNull);
});
testWidgets('bar chart uses most of the available vertical plot area', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Compacte titel',
x: ['A', 'B', 'C'],
series: [
ChartSeries(name: 'Waarde', data: [10, 20, 15]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(tester.getSize(find.byType(BarChart)).height, greaterThan(260));
expect(tester.takeException(), isNull);
});
testWidgets('chart surface fills the remaining slide height', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Titel',
x: ['A', 'B'],
series: [
ChartSeries(name: 'Waarde', data: [10, 20]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final slide = tester.getRect(find.byType(SlidePreviewWidget));
final surface = tester.getRect(find.byKey(const ValueKey('chart-surface')));
expect(surface.height, greaterThan(slide.height * 0.72));
expect(slide.bottom - surface.bottom, lessThan(slide.height * 0.04));
expect(tester.takeException(), isNull);
});
testWidgets('bar and line hover tooltips show labels and values', (
tester,
) async {
const barSpec = ChartSpec(
type: ChartType.bar,
x: ['Januari'],
series: [
ChartSeries(name: 'Omzet', data: [42]),
],
);
await tester.pumpWidget(_host(barSpec));
final bar = tester.widget<BarChart>(find.byType(BarChart));
final barItem = bar.data.barTouchData.touchTooltipData.getTooltipItem(
bar.data.barGroups.single,
0,
bar.data.barGroups.single.barRods.single,
0,
);
expect(barItem?.text, 'Januari\nOmzet: 42');
const lineSpec = ChartSpec(
type: ChartType.line,
x: ['Februari'],
series: [
ChartSeries(name: 'Bezoekers', data: [17.5]),
],
);
await tester.pumpWidget(_host(lineSpec));
final line = tester.widget<LineChart>(find.byType(LineChart));
final spot = LineBarSpot(
line.data.lineBarsData.single,
0,
line.data.lineBarsData.single.spots.single,
);
final lineItems = line.data.lineTouchData.touchTooltipData.getTooltipItems([
spot,
]);
expect(lineItems.single?.text, 'Februari\nBezoekers: 17.5');
});
testWidgets('line tooltip uses true distance and shows every nearby dot', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.line,
x: ['Q1'],
series: [
ChartSeries(name: 'Alpha', data: [10], color: '#2563EB'),
ChartSeries(name: 'Beta', data: [10], color: '#EF4444'),
ChartSeries(name: 'Gamma', data: [10], color: '#10B981'),
],
);
await tester.pumpWidget(_host(spec));
final line = tester.widget<LineChart>(find.byType(LineChart));
final touch = line.data.lineTouchData;
// Proximity is Euclidean (x AND y), not the x-only default.
expect(touch.distanceCalculator(Offset.zero, const Offset(3, 4)), 5);
expect(touch.touchSpotThreshold, greaterThan(0));
final spots = [
for (var i = 0; i < 3; i++)
LineBarSpot(
line.data.lineBarsData[i],
i,
line.data.lineBarsData[i].spots.single,
),
];
final items = touch.touchTooltipData.getTooltipItems(spots);
// All overlapping dots are shown (none filtered out).
expect(items.length, 3);
expect(items.whereType<LineTooltipItem>().length, 3);
expect(items[0]?.text, 'Q1\nAlpha: 10');
expect(items[2]?.text, 'Q1\nGamma: 10');
// A crowded stack uses a smaller font than a single tooltip.
final single = touch.touchTooltipData.getTooltipItems([spots.first]);
expect(
items[0]!.textStyle.fontSize!,
lessThan(single.single!.textStyle.fontSize!),
);
});
testWidgets('pie hover shows the underlying category value', (tester) async {
const spec = ChartSpec(
type: ChartType.pie,
x: ['Gereed', 'Open'],
series: [
ChartSeries(name: 'Status', data: [70, 30]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final pie = tester.widget<PieChart>(find.byType(PieChart));
final section = pie.data.sections.first;
pie.data.pieTouchData.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
PieTouchResponse(
touchLocation: Offset.zero,
touchedSection: PieTouchedSection(section, 0, 0, section.radius),
),
);
await tester.pump();
expect(find.byKey(const ValueKey('pie-hover-tooltip')), findsOneWidget);
expect(find.text('Gereed: 70'), findsOneWidget);
});
testWidgets('bar chart draws the configured min/max bound lines', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.bar,
x: ['Q1'],
series: [
ChartSeries(name: 'Omzet', data: [10]),
],
minBound: 5,
maxBound: 20,
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final bar = tester.widget<BarChart>(find.byType(BarChart));
final ys = bar.data.extraLinesData.horizontalLines.map((l) => l.y).toList();
expect(ys, containsAll(<double>[5, 20]));
// The max bound widens the axis so the line stays inside the plot.
expect(bar.data.maxY, greaterThanOrEqualTo(20));
expect(tester.takeException(), isNull);
});
testWidgets('hovering a legend entry fades the other series', (tester) async {
const spec = ChartSpec(
type: ChartType.line,
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: 'Alpha', data: [10, 12], color: '#2563EB'),
ChartSeries(name: 'Beta', data: [8, 9], color: '#EF4444'),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
var line = tester.widget<LineChart>(find.byType(LineChart));
expect(line.data.lineBarsData[0].color!.a, 1.0);
expect(line.data.lineBarsData[1].color!.a, 1.0);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.text('Alpha')));
await tester.pumpAndSettle();
line = tester.widget<LineChart>(find.byType(LineChart));
expect(line.data.lineBarsData[0].color!.a, 1.0); // hovered stays solid
expect(line.data.lineBarsData[1].color!.a, lessThan(1.0)); // other fades
expect(tester.takeException(), isNull);
});
testWidgets('radar chart renders a polygon per series with axis labels', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Snelheid', 'Kracht', 'Uithouding'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5], color: '#2563EB'),
ChartSeries(name: 'Beta', data: [5, 2, 3], color: '#EF4444'),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
// Two visible series plus one invisible scale anchor.
expect(radar.data.dataSets.length, 3);
expect(radar.data.dataSets.first.dataEntries.map((e) => e.value), [3, 4, 5]);
expect(radar.data.dataSets.last.fillColor, Colors.transparent);
// The spoke labels are supplied through getTitle (canvas-painted).
expect(radar.data.getTitle!(0, 0).text, 'Snelheid');
expect(radar.data.getTitle!(2, 0).text, 'Uithouding');
// The series legend is shown as real text widgets.
expect(find.text('Alpha'), findsOneWidget);
expect(find.text('Beta'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar honours an explicit min/max scale with even ticks', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C', 'D'],
series: [
ChartSeries(name: 'Score', data: [2, 4, 3, 5]),
],
minBound: 0,
maxBound: 10,
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
expect(radar.data.isMinValueAtCenter, isTrue);
// The hidden anchor pins the scale to [0, 10].
final anchor = radar.data.dataSets.last.dataEntries.map((e) => e.value);
expect(anchor.reduce((a, b) => a < b ? a : b), 0);
expect(anchor.reduce((a, b) => a > b ? a : b), 10);
// The scale is shown in the side legend (0..10), not painted in the chart.
expect(radar.data.ticksTextStyle?.color, Colors.transparent);
expect(find.text('0'), findsWidgets);
expect(find.text('10'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar shows a tooltip for the hovered point', (tester) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Snelheid', 'Kracht', 'Uithouding'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5]),
ChartSeries(name: 'Beta', data: [5, 2, 3]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
final touch = radar.data.radarTouchData;
expect(touch.enabled, isTrue);
// Simulate hovering the second axis of the Beta series.
final dataSet = radar.data.dataSets[1];
final entry = dataSet.dataEntries[1];
touch.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
RadarTouchResponse(
touchLocation: const Offset(20, 20),
touchedSpot: RadarTouchedSpot(
dataSet,
1,
entry,
1,
const FlSpot(1, 2),
const Offset(20, 20),
),
),
);
await tester.pump();
expect(find.text('Kracht\nBeta: 2'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar ignores touches on the invisible scale anchor', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
final anchorIndex = radar.data.dataSets.length - 1; // the anchor dataset
final anchor = radar.data.dataSets[anchorIndex];
radar.data.radarTouchData.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
RadarTouchResponse(
touchLocation: Offset.zero,
touchedSpot: RadarTouchedSpot(
anchor,
anchorIndex,
anchor.dataEntries.first,
0,
const FlSpot(0, 0),
Offset.zero,
),
),
);
await tester.pump();
// No tooltip for the anchor: only the legend value "5" exists.
expect(find.textContaining(': '), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('radar chart asks for at least three labels', (tester) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Een', 'Twee'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(RadarChart), findsNothing);
expect(
find.text('Een spider-diagram heeft minstens drie labels nodig'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('presentation mode enlarges chart labels', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
x: ['Categorie'],
series: [
ChartSeries(name: 'Waarde', data: [10]),
],
);
await tester.pumpWidget(_host(spec));
final normal = tester.widget<Text>(find.text('Categorie').first);
final normalSize = normal.style!.fontSize!;
expect(normalSize, lessThanOrEqualTo(12));
await tester.pumpWidget(_host(spec, presentationMode: true));
final presented = tester.widget<Text>(find.text('Categorie').first);
expect(presented.style!.fontSize!, greaterThan(normalSize));
});
testWidgets('dense axis labels are thinned and stay inside the slide', (
tester,
) async {
const labels = [
'Januari bijzonder lang',
'Februari bijzonder lang',
'Maart bijzonder lang',
'April bijzonder lang',
'Mei bijzonder lang',
'Juni bijzonder lang',
'Juli bijzonder lang',
'Augustus bijzonder lang',
'September bijzonder lang',
'Oktober bijzonder lang',
'November bijzonder lang',
'December bijzonder lang',
];
const spec = ChartSpec(
type: ChartType.line,
x: labels,
series: [
ChartSeries(
name: 'Waarde',
data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final visibleLabels = [
for (final label in labels)
if (find.text(label).evaluate().isNotEmpty) label,
];
expect(visibleLabels.length, lessThanOrEqualTo(8));
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
for (final label in visibleLabels) {
final rect = tester.getRect(find.text(label).first);
expect(slideRect.contains(rect.topLeft), isTrue);
expect(slideRect.contains(rect.bottomRight), isTrue);
}
expect(tester.takeException(), isNull);
});
testWidgets(
'pie shows at most two series and keeps labels inside the slide',
(tester) async {
const spec = ChartSpec(
type: ChartType.pie,
title: 'Veel gegevens',
x: [
'Een uitzonderlijk lang eerste label',
'Een uitzonderlijk lang tweede label',
'Een uitzonderlijk lang derde label',
'Een uitzonderlijk lang vierde label',
'Een uitzonderlijk lang vijfde label',
'Een uitzonderlijk lang zesde label',
],
series: [
ChartSeries(name: 'Een', data: [1, 2, 3, 4, 5, 6]),
ChartSeries(name: 'Twee', data: [2, 3, 4, 5, 6, 7]),
ChartSeries(name: 'Drie', data: [3, 4, 5, 6, 7, 8]),
ChartSeries(name: 'Vier', data: [4, 5, 6, 7, 8, 9]),
ChartSeries(name: 'Vijf', data: [5, 6, 7, 8, 9, 10]),
ChartSeries(name: 'Zes', data: [6, 7, 8, 9, 10, 11]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(PieChart), findsNWidgets(2));
expect(find.text('Drie'), findsNothing);
final legendTop = tester.getTopLeft(find.text(spec.x.first)).dy;
for (final chart in tester.widgetList<PieChart>(find.byType(PieChart))) {
final box = tester.renderObject<RenderBox>(find.byWidget(chart));
final bottom = box.localToGlobal(Offset(0, box.size.height)).dy;
expect(bottom, lessThanOrEqualTo(legendTop));
}
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
for (final label in spec.x) {
final rect = tester.getRect(find.text(label));
expect(slideRect.contains(rect.topLeft), isTrue);
expect(slideRect.contains(rect.bottomRight), isTrue);
}
expect(tester.takeException(), isNull);
},
);
}

View file

@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
void main() {
test('chart palette starts with the EU flag colors', () {
expect(chartColorPalette.take(2), ['#003399', '#FFCC00']);
});
group('parseCsv', () {
test('reads header series names and labelled rows', () {
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
@ -24,16 +28,19 @@ void main() {
type: ChartType.line,
title: 'Omzet',
x: ['Q1', 'Q2'],
rowColors: ['#003399', '#FFCC00'],
series: [
ChartSeries(name: '2025', data: [10, 14]),
ChartSeries(name: '2025', data: [10, 14], color: '#EF4444'),
],
);
final back = ChartSpec.parse(spec.toBlock());
expect(back.type, ChartType.line);
expect(back.title, 'Omzet');
expect(back.x, ['Q1', 'Q2']);
expect(back.rowColors, ['#003399', '#FFCC00']);
expect(back.series.single.name, '2025');
expect(back.series.single.data, [10, 14]);
expect(back.series.single.color, '#EF4444');
expect(back.hasInlineData, isTrue);
});
@ -43,13 +50,16 @@ void main() {
title: 'Omzet',
source: 'data/omzet.csv',
x: ['Q1', 'Q2'],
rowColors: ['#003399', '#FFCC00'],
series: [
ChartSeries(name: '2025', data: [10, 14]),
ChartSeries(name: '2025', data: [10, 14], color: '#10B981'),
],
);
final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
expect(stored.source, 'data/omzet.csv');
expect(stored.hasInlineData, isFalse);
expect(stored.rowColors, ['#003399', '#FFCC00']);
expect(stored.series.single.color, '#10B981');
// The in-app/full form keeps the data.
final full = ChartSpec.parse(spec.toBlock());
@ -57,12 +67,35 @@ void main() {
});
test('withCsv fills x/series and keeps the source', () {
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv');
const spec = ChartSpec(
type: ChartType.bar,
source: 'data/o.csv',
rowColors: ['#003399', '#FFCC00'],
series: [ChartSeries(name: 'oud', data: [], color: '#10B981')],
);
final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
expect(filled.source, 'data/o.csv');
expect(filled.x, ['Jan', 'Feb']);
expect(filled.series.map((s) => s.name), ['A', 'B']);
expect(filled.series[1].data, [2, 4]);
expect(filled.series[0].color, '#10B981');
expect(filled.series[1].color, isNull);
expect(filled.rowColors, ['#003399', '#FFCC00']);
});
test('invalid colors are ignored while valid colors are normalized', () {
final valid = ChartSeries.fromJson({
'name': 'A',
'data': [1],
'color': 'ef4444',
});
final invalid = ChartSeries.fromJson({
'name': 'B',
'data': [2],
'color': 'red',
});
expect(valid.color, '#EF4444');
expect(invalid.color, isNull);
});
test('parse is tolerant of malformed JSON', () {
@ -70,5 +103,71 @@ void main() {
expect(spec.type, ChartType.bar);
expect(spec.hasInlineData, isFalse);
});
test('round-trips optional min/max bound lines for bar/line', () {
const spec = ChartSpec(
type: ChartType.line,
x: ['Q1'],
series: [ChartSeries(name: 'A', data: [10])],
minBound: 5,
maxBound: 20,
);
final back = ChartSpec.parse(spec.toBlock());
expect(back.minBound, 5);
expect(back.maxBound, 20);
});
test('bounds are dropped from a pie chart', () {
const spec = ChartSpec(
type: ChartType.pie,
x: ['Q1'],
series: [ChartSeries(name: 'A', data: [10])],
minBound: 5,
maxBound: 20,
);
expect(spec.supportsBounds, isFalse);
final back = ChartSpec.parse(spec.toBlock());
expect(back.minBound, isNull);
expect(back.maxBound, isNull);
});
test('round-trips a spider/radar chart type', () {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Snelheid', 'Kracht', 'Uithouding'],
series: [
ChartSeries(name: 'A', data: [3, 4, 5]),
],
);
final back = ChartSpec.parse(spec.toBlock());
expect(back.type, ChartType.radar);
expect(back.x, ['Snelheid', 'Kracht', 'Uithouding']);
expect(back.series.single.data, [3, 4, 5]);
});
test('radar keeps bounds as a scale but never draws bound lines', () {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C'],
series: [ChartSeries(name: 'A', data: [1, 2, 3])],
minBound: 1,
maxBound: 5,
);
expect(spec.supportsBounds, isTrue);
expect(spec.supportsBoundLines, isFalse);
final back = ChartSpec.parse(spec.toBlock());
expect(back.minBound, 1);
expect(back.maxBound, 5);
});
test('bar/line draw bound lines but pie does not', () {
const bar = ChartSpec(type: ChartType.bar);
const line = ChartSpec(type: ChartType.line);
const pie = ChartSpec(type: ChartType.pie);
expect(bar.supportsBoundLines, isTrue);
expect(line.supportsBoundLines, isTrue);
expect(pie.supportsBoundLines, isFalse);
expect(pie.supportsBounds, isFalse);
});
});
}

157
test/code_preview_test.dart Normal file
View file

@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
Widget _host(Slide slide, ThemeProfile profile) {
return Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(slide: slide, themeProfile: profile),
),
),
);
}
Color _hex(String hex) =>
Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000);
void main() {
testWidgets('code slide paints the themed background colour', (tester) async {
final slide = Slide.create(
SlideType.code,
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
const profile = ThemeProfile(
codeBackgroundColor: '#000000',
codeTextColor: '#33FF33',
);
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
// The code panel uses the themed background somewhere in its decoration.
final painted = tester.widgetList<Container>(find.byType(Container)).where((
c,
) {
final d = c.decoration;
return d is BoxDecoration && d.color == _hex('#000000');
});
expect(painted, isNotEmpty);
expect(tester.takeException(), isNull);
});
testWidgets('syntax highlighting on uses HighlightView for a known language', (
tester,
) async {
final slide = Slide.create(
SlideType.code,
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
const profile = ThemeProfile(codeHighlightSyntax: true);
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
expect(find.byType(HighlightView), findsOneWidget);
});
testWidgets('syntax highlighting off renders monochrome (CRT) text', (
tester,
) async {
final slide = Slide.create(
SlideType.code,
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
const profile = ThemeProfile(
codeBackgroundColor: '#000000',
codeTextColor: '#33FF33',
codeHighlightSyntax: false,
);
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
// No per-token highlighting; the code is one flat colour.
expect(find.byType(HighlightView), findsNothing);
final codeText = tester.widget<Text>(find.text('void main() {}'));
expect(codeText.style?.color, _hex('#33FF33'));
expect(tester.takeException(), isNull);
});
testWidgets('short code is enlarged to use the space; long code shrinks', (
tester,
) async {
const profile = ThemeProfile(codeHighlightSyntax: false);
const short = 'x';
await tester.pumpWidget(
_host(
Slide.create(SlideType.code).copyWith(customMarkdown: short),
profile,
),
);
await tester.pump();
final shortSize = tester.widget<Text>(find.text(short)).style!.fontSize!;
final long = List.generate(
40,
(i) => 'final someRatherLongVariableName$i = compute($i);',
).join('\n');
await tester.pumpWidget(
_host(
Slide.create(SlideType.code).copyWith(customMarkdown: long),
profile,
),
);
await tester.pump();
final longSize = tester.widget<Text>(find.text(long)).style!.fontSize!;
// A tiny snippet is scaled up to fill; a big one is scaled down to fit.
expect(longSize, lessThan(shortSize));
expect(tester.takeException(), isNull);
});
testWidgets('the slide title sits above the code panel, not inside it', (
tester,
) async {
final slide = Slide.create(
SlideType.code,
).copyWith(title: 'Voorbeeld', customMarkdown: 'print("hi")');
const profile = ThemeProfile(
titleTextColor: '#FFFFFF',
titleBackgroundColor: '#1C2B47',
codeBackgroundColor: '#000000',
codeTextColor: '#33FF33',
codeHighlightSyntax: false,
);
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
// The title is rendered above the code panel rather than inside it.
final titleBottom = tester.getBottomLeft(find.text('Voorbeeld')).dy;
final codeTop = tester.getTopLeft(find.text('print("hi")')).dy;
expect(titleBottom, lessThanOrEqualTo(codeTop));
expect(tester.takeException(), isNull);
});
testWidgets('code uses the chosen monospace font family', (tester) async {
final slide = Slide.create(
SlideType.code,
).copyWith(customMarkdown: 'void main() {}');
const profile = ThemeProfile(
codeFontFamily: 'Courier New',
codeHighlightSyntax: false,
);
await tester.pumpWidget(_host(slide, profile));
await tester.pump();
final codeText = tester.widget<Text>(find.text('void main() {}'));
expect(codeText.style?.fontFamily, 'Courier New');
expect(tester.takeException(), isNull);
});
}

View file

@ -1,4 +1,7 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/file_service.dart';
@ -23,6 +26,36 @@ void main() {
expect(n.state.isDirty, isTrue);
});
test('loadDeck resolves a relative logo for an unsaved recovered deck', () {
final temp = Directory.systemTemp.createTempSync(
'ocideck_recovered_logo_test_',
);
addTearDown(() => temp.deleteSync(recursive: true));
final logo = File('${temp.path}/logos/client.png')
..createSync(recursive: true)
..writeAsBytesSync([1, 2, 3]);
final md = MarkdownService();
final file = FileService(
md,
ImageService(),
() => const ThemeProfile(),
homeDirectory: () => temp.path,
);
final notifier = DeckNotifier(md, file);
notifier.loadDeck(
Deck(
title: 'Hersteld',
themeProfile: const ThemeProfile(logoPath: 'logos/client.png'),
slides: [Slide.create(SlideType.title)],
),
);
expect(notifier.state.filePath, isNull);
expect(notifier.state.deck!.projectPath, isNull);
expect(notifier.state.deck!.themeProfile.logoPath, logo.path);
});
test('addSlide inserts right after the given index', () {
final n = _notifier()..newDeck('D');
n.addSlide(SlideType.bullets); // appended -> index 1

View file

@ -37,4 +37,29 @@ void main() {
expect(saved.themeProfile.logoPath, 'logos/client.png');
expect(await File(p.join(temp.path, 'logos', 'client.png')).exists(), true);
});
test(
'current theme resolves a relative logo from the home directory',
() async {
final temp = await Directory.systemTemp.createTemp(
'ocideck_theme_logo_test_',
);
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final logo = File(p.join(temp.path, 'logos', 'client.png'));
await logo.parent.create(recursive: true);
await logo.writeAsBytes([1, 2, 3]);
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(logoPath: 'logos/client.png'),
homeDirectory: () => temp.path,
);
expect(service.currentThemeProfile.logoPath, logo.path);
},
);
}

View file

@ -5,6 +5,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
@ -51,6 +52,30 @@ Future<({int width, int height, Uint8List bytes})> _capture(
}
void main() {
testWidgets('a missing small logo does not overflow its bounds', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
child: SlidePreviewWidget(
slide: Slide.create(SlideType.bullets),
themeProfile: const ThemeProfile(
logoPath: '/path/that/does/not/exist.png',
logoSize: 36,
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('twoImages paints both the left and right images', (
tester,
) async {

View file

@ -98,6 +98,24 @@ void main() {}
expect(html, isNot(contains('data:font/ttf;base64,')));
});
test('code blocks use the themed code colours in the export CSS', () async {
final service = MarpHtmlService(
loadAsset: _diskLoader,
loadBytes: _diskBytes,
);
const theme = ThemeProfile(
codeBackgroundColor: '#000000',
codeTextColor: '#33FF33',
codeFontFamily: 'Courier New',
);
final html = await service.build('```dart\nvoid main() {}\n```', theme: theme);
expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
expect(html, contains('.slide pre code{color:#33FF33'));
// The chosen code font is used (with a monospace fallback chain).
expect(html, contains("font-family:'Courier New',"));
});
test('EB Garamond theme embeds the font for offline rendering', () async {
final service = MarpHtmlService(
loadAsset: _diskLoader,
@ -110,4 +128,117 @@ void main() {}
expect(html, contains('data:font/ttf;base64,'));
expect(html, contains("'EB Garamond'"));
});
test('pie chart SVG renders every series and label', () {
const slide = '''
```chart
{
"type": "pie",
"x": ["Team A", "Team B"],
"series": [
{"name": "Gereed", "color": "#10B981", "data": [70, 40]},
{"name": "Open", "color": "#EF4444", "data": [30, 60]}
]
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('Team A'));
expect(html, contains('Team B'));
expect(html, contains('Gereed'));
expect(html, contains('Open'));
expect(html, contains('#003399'));
expect(html, contains('#FFCC00'));
});
test('pie chart SVG renders at most two series', () {
const slide = '''
```chart
{
"type": "pie",
"x": ["A", "B"],
"series": [
{"name": "Een", "data": [1, 2]},
{"name": "Twee", "data": [2, 3]},
{"name": "Drie", "data": [3, 4]}
]
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('Een'));
expect(html, contains('Twee'));
expect(html, isNot(contains('Drie')));
});
test('bar chart SVG draws optional min/max bound lines with labels', () {
const slide = '''
```chart
{
"type": "bar",
"x": ["Q1", "Q2"],
"series": [{"name": "Omzet", "data": [10, 14]}],
"minBound": 5,
"maxBound": 20
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('stroke-dasharray'));
expect(html, contains('min 5'));
expect(html, contains('max 20'));
});
test('pie chart SVG never draws bound lines', () {
const slide = '''
```chart
{
"type": "pie",
"x": ["A", "B"],
"series": [{"name": "Een", "data": [1, 2]}],
"minBound": 5,
"maxBound": 20
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, isNot(contains('stroke-dasharray')));
expect(html, isNot(contains('min 5')));
});
test('radar chart SVG draws a polygon per series with axis labels', () {
const slide = '''
```chart
{
"type": "radar",
"x": ["Snelheid", "Kracht", "Uithouding"],
"series": [
{"name": "A", "color": "#2563EB", "data": [3, 4, 5]},
{"name": "B", "color": "#EF4444", "data": [5, 2, 3]}
]
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('<polygon'));
expect(html, contains('Snelheid'));
expect(html, contains('Kracht'));
expect(html, contains('Uithouding'));
// Both series are drawn with their colours.
expect(html, contains('fill="#2563EB"'));
expect(html, contains('fill="#EF4444"'));
// The series legend is shown (not a pie legend).
expect(html, contains('A'));
expect(html, contains('B'));
});
}

View file

@ -15,6 +15,29 @@ Future<SettingsNotifier> _loadedNotifier() async {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('ThemeProfile round-trips the code styling through JSON', () {
const profile = ThemeProfile(
codeBackgroundColor: '#000000',
codeTextColor: '#33FF33',
codeHighlightSyntax: false,
codeFontFamily: 'Courier New',
);
final back = ThemeProfile.fromJson(profile.toJson());
expect(back.codeBackgroundColor, '#000000');
expect(back.codeTextColor, '#33FF33');
expect(back.codeHighlightSyntax, isFalse);
expect(back.codeFontFamily, 'Courier New');
});
test('ThemeProfile code styling defaults to the atom-one-dark look', () {
// Older decks without the fields fall back to the dark editor defaults.
final back = ThemeProfile.fromJson(const {'name': 'Legacy'});
expect(back.codeBackgroundColor, '#282C34');
expect(back.codeTextColor, '#ABB2BF');
expect(back.codeHighlightSyntax, isTrue);
expect(back.codeFontFamily, 'monospace');
});
test('starts with a single default profile', () async {
final notifier = await _loadedNotifier();
expect(notifier.state.themeProfiles, hasLength(1));