Compare commits

...

4 commits

Author SHA1 Message Date
Brenno de Winter
e0379ade59 Refine code slides: title outside the panel, fit-to-space, font choice
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
CI / Format · Analyze · Test (pull_request) Has been cancelled
- The slide title now renders above the code panel (styled like other slide
  types) instead of inside the dark code window — it is the slide's title.
- Code is sized to fill the panel: scaled up to use spare space (capped) and
  down so long fragments still fit, instead of a small block in a big box.
- Add a per-profile monospace font for code slides (e.g. Courier), applied in
  the preview and the HTML export.
- Settings: a banner on the Colours and Logo tabs makes clear they edit the
  loaded style profile, and colour pickers now accept a custom hex value.
- Update docs and translations for the new strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:28:04 +02:00
Brenno de Winter
dd54d36a60 Move radar scale to a side legend and add point tooltips
- The radar/spider scale no longer clutters the figure: the evenly spaced
  tick values now sit in a slim legend beside the chart, both in the live
  preview and in the SVG/HTML export.
- Hovering a radar point shows a tooltip (axis, series, value) like the other
  charts; the invisible scale-anchor dataset is ignored.
- Refresh the documentation (README, user guide, file format, changelog) for
  all recent work: code-slide theming with custom hex colours, the spider/radar
  chart type, chart min/max, legend hover, and the chart tooltip behaviour.
- Drop two redundant non-null assertions in the chart preview tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:04:47 +02:00
Brenno de Winter
de4a77e2bb Add code-slide theming, radar scale, and proximity line tooltips
Code slides:
- Theme code (broncode) background and text colours, with an optional
  syntax-colouring toggle. With it off the block renders monochrome, so a
  black background + bright green gives a classic CRT-screen look.
- Colour pickers gained a custom hex entry so arbitrary colours (e.g. CRT
  green) can be set, not just presets. Exported HTML mirrors the code colours.

Radar/spider charts:
- Optional min/max now define the radar scale (centre/outer ring) instead of
  threshold lines. Evenly spaced, labelled tick rings are drawn in both the
  live preview and the SVG export so the scale is readable. A nice scale is
  derived from the data when no bounds are set.

Line chart tooltips:
- Detect the touched dot by true (x and y) distance instead of the x-only
  default, so the tooltip belongs to the point under the cursor. Overlapping
  dots all show, and the font shrinks a step when several stack.

New UI strings are translated across all supported languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:51:29 +02:00
Brenno de Winter
67408c213c Improve chart rendering and resolve theme logo paths
Charts:
- Shrink axis label fonts and thin/space x-axis labels by actual pixel
  spacing so dense or long labels no longer overlap.
- Line tooltip shows only the point nearest the cursor instead of every
  series stacked vertically.
- Hovering a legend entry highlights its element: bar/line series fade the
  others (pie expands the matching slice), in app and presentation mode.
- Add optional min/max threshold lines per bar/line chart (ignored for pie),
  editable in the chart editor and drawn in both the live preview and the
  exported SVG.

Theme:
- Resolve relative logo paths in a ThemeProfile against the project path and
  home directory so deck logos load regardless of working directory.

Tests cover bound round-trip, editor fields, SVG bounds, legend-hover fading,
and bound-line rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:18:35 +02:00
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] ## [Unreleased]
### Added ### Added
- **Source-code slides** — a dark "code sheet" with per-language syntax - **Source-code slides** — a "code sheet" with per-language syntax highlighting,
highlighting, stored as a fenced code block. stored as a fenced code block. Background, text colour and monospace font are
- **Charts** — bar, line, and pie chart slides. Data is entered in an in-app grid part of the style profile, with a syntax-colouring toggle; turning it off renders
or imported from CSV; the spec is stored as JSON in a ```chart block. Data can the block in a single colour (e.g. green on black for a CRT-terminal look). The
stay inline or be linked to a CSV in a separate `data/` directory. Rendered code is sized to fill the panel — larger when there's room, smaller for long
natively in-app (preview, presenter, PDF, PPTX) and as self-contained SVG in fragments.
the HTML export. - **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 - **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 Protocol level; slides classified stricter than the level the deck is shown at
are withheld when presenting and exporting. are withheld when presenting and exporting.

View file

@ -9,8 +9,8 @@ Built with Flutter for macOS, Windows, and Linux.
## Features ## 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. - **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. - **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, 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. - **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. - **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. - **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. - **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. - **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves. - **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit. - **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — customizable deck style profiles and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch). - **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento. - **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
## Requirements ## Requirements

View file

@ -135,6 +135,10 @@ JSON heeft deze velden (met standaardwaarden):
| `titleBackgroundColor` | `#1C2B47` | Achtergrond titelslide. | | `titleBackgroundColor` | `#1C2B47` | Achtergrond titelslide. |
| `titleTextColor` | `#FFFFFF` | Tekst op titel-/sectieslide. | | `titleTextColor` | `#FFFFFF` | Tekst op titel-/sectieslide. |
| `sectionBackgroundColor` | `#2E7D64` | Achtergrond 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/`). | | `logoPath` | `null` | Pad naar logo (relatief in `logos/`). |
| `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. | | `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. |
| `logoSize` | `96` | Logogrootte in px. | | `logoSize` | `96` | Logogrootte in px. |
@ -314,15 +318,31 @@ openen wordt die weer ingelezen.
````markdown ````markdown
```chart ```chart
{ {
"type": "bar", // bar | line | pie "type": "bar", // bar | line | pie | radar
"title": "Omzet", "title": "Omzet",
"source": "data/omzet.csv", // optioneel; anders inline x/series "source": "data/omzet.csv", // optioneel; anders inline x/series
"x": ["Q1", "Q2"], "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`) ### Afbeeldingsgrootte (`imageSize`)
Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het
achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd 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 Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
bullet columns**, **bullets + image**, **two images**, **large image**, **video**, bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
**audio**, **quote**, **table**, **source code**, **chart**, and **free Markdown**. **audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or
Each type has a dedicated editor on the left and a live preview on the right. spider/radar), and **free Markdown**. Each type has a dedicated editor on the left
and a live preview on the right.
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``, Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
`[links](…)`). Free-Markdown slides also render fenced code with syntax `[links](…)`). Free-Markdown slides also render fenced code with syntax
@ -30,19 +31,32 @@ highlighting and `$…$` / `$$…$$` LaTeX math.
### Source-code slides ### Source-code slides
Choose a programming language for syntax highlighting (or "plain text") and paste 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 your code. It renders as a "code sheet" whose background, text colour and
Markdown. **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 ### Charts
Pick a type (**bar**, **line**, **pie**) and a title, then enter data in the grid: Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then
the first column is the labels, each further column is a named series. Use **Row** enter data in the grid: the first column is the labels, each further column is a
and **Series** to add data; the small ✕ removes a row/column. 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 - **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 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 `data/` directory and stays the source of truth (edit it in a spreadsheet); the
grid then shows it read-only until you **Ontkoppelen** (unlink). 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 - Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
HTML export. HTML export.
@ -100,8 +114,11 @@ Export to:
## Theming and language ## Theming and language
- **Style profiles** control deck colours, fonts, logo, and footer. The bundled - **Style profiles** control deck colours (including the source-code background,
Marp theme is `assets/themes/ocideck.css`. 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. - **App appearance** (including a dark interface) is configurable in settings.
- The interface is available in Dutch, English, Italian, German, French, Spanish, - The interface is available in Dutch, English, Italian, German, French, Spanish,
Frisian, and Papiamento. 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 = { const _dutchSourceStringAdditions = {
'en': { 'en': {
'Annuleren': 'Cancel',
'Afbeelding': 'Image', 'Afbeelding': 'Image',
'Broncode': 'Source code', 'Broncode': 'Source code',
'Bullet': 'Bullet', 'Bullet': 'Bullet',
@ -2341,6 +2342,7 @@ const _dutchSourceStringAdditions = {
'Staaf': 'Bar', 'Staaf': 'Bar',
'Lijn': 'Line', 'Lijn': 'Line',
'Cirkel': 'Pie', 'Cirkel': 'Pie',
'Spider': 'Spider',
'CSV importeren': 'Import CSV', 'CSV importeren': 'Import CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)':
'Data (CSV: first row = series names, first column = labels)', 'Data (CSV: first row = series names, first column = labels)',
@ -2354,6 +2356,35 @@ const _dutchSourceStringAdditions = {
'Label': 'Label', 'Label': 'Label',
'Rij': 'Row', 'Rij': 'Row',
'Reeks': 'Series', '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', 'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)', 'Titel (optioneel)': 'Title (optional)',
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
@ -2371,6 +2402,36 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparing…', 'voorbereiden…': 'preparing…',
}, },
'it': { '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...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2570,6 +2631,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'de': { '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...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2770,6 +2861,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'fr': { '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...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2970,6 +3091,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'es': { '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...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -3170,6 +3321,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'fy': { '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...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -3367,6 +3548,36 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'pap': { '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...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '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. /// data files stay tidily in one place separate from images/media.
const String chartDataDirName = 'data'; 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. /// 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( ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
(t) => t.name == name, (t) => t.name == name,
@ -16,19 +29,44 @@ ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
class ChartSeries { class ChartSeries {
final String name; final String name;
final List<double> data; 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) {
name: (json['name'] ?? '').toString(), final color = normalizeChartColor(json['color']?.toString());
data: [ return ChartSeries(
for (final v in (json['data'] as List? ?? const [])) name: (json['name'] ?? '').toString(),
(v as num?)?.toDouble() ?? 0, 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. /// 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 /// Small charts keep their data inline; data-driven charts instead point at an
@ -40,31 +78,58 @@ class ChartSpec {
final String title; final String title;
final String? source; final String? source;
final List<String> x; final List<String> x;
final List<String?> rowColors;
final List<ChartSeries> series; 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({ const ChartSpec({
this.type = ChartType.bar, this.type = ChartType.bar,
this.title = '', this.title = '',
this.source, this.source,
this.x = const [], this.x = const [],
this.rowColors = const [],
this.series = const [], this.series = const [],
this.minBound,
this.maxBound,
}); });
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty; 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({ ChartSpec copyWith({
ChartType? type, ChartType? type,
String? title, String? title,
String? source, String? source,
bool clearSource = false, bool clearSource = false,
List<String>? x, List<String>? x,
List<String?>? rowColors,
List<ChartSeries>? series, List<ChartSeries>? series,
double? minBound,
bool clearMinBound = false,
double? maxBound,
bool clearMaxBound = false,
}) => ChartSpec( }) => ChartSpec(
type: type ?? this.type, type: type ?? this.type,
title: title ?? this.title, title: title ?? this.title,
source: clearSource ? null : (source ?? this.source), source: clearSource ? null : (source ?? this.source),
x: x ?? this.x, x: x ?? this.x,
rowColors: rowColors ?? this.rowColors,
series: series ?? this.series, 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 /// Parse the JSON content of a ```chart block. Tolerant: returns a default
@ -78,7 +143,13 @@ class ChartSpec {
type: _chartTypeFromName(data['type'] as String?), type: _chartTypeFromName(data['type'] as String?),
title: (data['title'] ?? '').toString(), title: (data['title'] ?? '').toString(),
source: (src == null || src.isEmpty) ? null : src, 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()], 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: [ series: [
for (final s in (data['series'] as List? ?? const [])) for (final s in (data['series'] as List? ?? const []))
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)), ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
@ -96,12 +167,23 @@ class ChartSpec {
final map = <String, dynamic>{'type': type.name}; final map = <String, dynamic>{'type': type.name};
if (title.isNotEmpty) map['title'] = title; if (title.isNotEmpty) map['title'] = title;
if (source != null) map['source'] = source; 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; final dropData = forStorage && source != null;
if (rowColors.any((color) => color != null)) {
map['rowColors'] = rowColors;
}
if (!dropData) { if (!dropData) {
if (x.isNotEmpty) map['x'] = x; if (x.isNotEmpty) map['x'] = x;
if (series.isNotEmpty) { if (series.isNotEmpty) {
map['series'] = [for (final s in series) s.toJson()]; 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); return const JsonEncoder.withIndent(' ').convert(map);
} }
@ -109,7 +191,28 @@ class ChartSpec {
/// Return a copy with x/series taken from [csv]; keeps [source]. /// Return a copy with x/series taken from [csv]; keeps [source].
ChartSpec withCsv(String csv) { ChartSpec withCsv(String csv) {
final parsed = parseCsv(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 titleBackgroundColor;
final String titleTextColor; final String titleTextColor;
final String sectionBackgroundColor; 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? logoPath;
final String logoPosition; final String logoPosition;
final int logoSize; final int logoSize;
@ -40,6 +55,10 @@ class ThemeProfile {
this.titleBackgroundColor = '#1C2B47', this.titleBackgroundColor = '#1C2B47',
this.titleTextColor = '#FFFFFF', this.titleTextColor = '#FFFFFF',
this.sectionBackgroundColor = '#2E7D64', this.sectionBackgroundColor = '#2E7D64',
this.codeBackgroundColor = '#282C34',
this.codeTextColor = '#ABB2BF',
this.codeHighlightSyntax = true,
this.codeFontFamily = 'monospace',
this.logoPath, this.logoPath,
this.logoPosition = 'bottom-right', this.logoPosition = 'bottom-right',
this.logoSize = 96, this.logoSize = 96,
@ -70,6 +89,10 @@ class ThemeProfile {
String? titleBackgroundColor, String? titleBackgroundColor,
String? titleTextColor, String? titleTextColor,
String? sectionBackgroundColor, String? sectionBackgroundColor,
String? codeBackgroundColor,
String? codeTextColor,
bool? codeHighlightSyntax,
String? codeFontFamily,
String? logoPath, String? logoPath,
String? logoPosition, String? logoPosition,
int? logoSize, int? logoSize,
@ -92,6 +115,10 @@ class ThemeProfile {
titleTextColor: titleTextColor ?? this.titleTextColor, titleTextColor: titleTextColor ?? this.titleTextColor,
sectionBackgroundColor: sectionBackgroundColor:
sectionBackgroundColor ?? this.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), logoPath: clearLogo ? null : (logoPath ?? this.logoPath),
logoPosition: logoPosition ?? this.logoPosition, logoPosition: logoPosition ?? this.logoPosition,
logoSize: logoSize ?? this.logoSize, logoSize: logoSize ?? this.logoSize,
@ -116,6 +143,10 @@ class ThemeProfile {
'titleBackgroundColor': titleBackgroundColor, 'titleBackgroundColor': titleBackgroundColor,
'titleTextColor': titleTextColor, 'titleTextColor': titleTextColor,
'sectionBackgroundColor': sectionBackgroundColor, 'sectionBackgroundColor': sectionBackgroundColor,
'codeBackgroundColor': codeBackgroundColor,
'codeTextColor': codeTextColor,
'codeHighlightSyntax': codeHighlightSyntax,
'codeFontFamily': codeFontFamily,
'logoPath': logoPath, 'logoPath': logoPath,
'logoPosition': logoPosition, 'logoPosition': logoPosition,
'logoSize': logoSize, 'logoSize': logoSize,
@ -146,6 +177,11 @@ class ThemeProfile {
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF', titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
sectionBackgroundColor: sectionBackgroundColor:
json['sectionBackgroundColor'] as String? ?? '#2E7D64', 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?, logoPath: json['logoPath'] as String?,
logoPosition: json['logoPosition'] as String? ?? 'bottom-right', logoPosition: json['logoPosition'] as String? ?? 'bottom-right',
logoSize: (json['logoSize'] as num?)?.round() ?? 96, logoSize: (json['logoSize'] as num?)?.round() ?? 96,
@ -343,6 +379,17 @@ class AppSettings {
'Courier New', '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({ AppSettings copyWith({
String? languageCode, String? languageCode,
String? homeDirectory, String? homeDirectory,

View file

@ -44,6 +44,7 @@ class FileService {
final ImageService _img; final ImageService _img;
final ThemeProfile Function() _themeProfile; final ThemeProfile Function() _themeProfile;
final String Function() _languageCode; final String Function() _languageCode;
final String? Function() _homeDirectory;
final CaptionService _captions = CaptionService(); final CaptionService _captions = CaptionService();
FileService( FileService(
@ -51,9 +52,30 @@ class FileService {
this._img, this._img,
this._themeProfile, { this._themeProfile, {
String Function()? languageCode, 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); String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text);

View file

@ -48,7 +48,7 @@ class MarpHtmlService {
for (final slide in marpSlides(deckMarkdown)) { for (final slide in marpSlides(deckMarkdown)) {
sections sections
..write('<section class="slide"><script type="text/markdown">') ..write('<section class="slide"><script type="text/markdown">')
..write(_guard(renderChartBlocks(slide))) ..write(_guard(renderChartBlocks(slide, theme: theme)))
..write('</script></section>'); ..write('</script></section>');
} }
@ -110,23 +110,12 @@ class MarpHtmlService {
multiLine: true, 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 /// Replace ```chart fenced blocks with a self-contained inline SVG, so the
/// exported HTML renders charts without any JS chart library. /// 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) { return slideMarkdown.replaceAllMapped(_chartFence, (m) {
final spec = ChartSpec.parse(m.group(1)!); 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('<', '&lt;')
.replaceAll('>', '&gt;'); .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) { if (!spec.hasInlineData) {
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>'; 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() final b = StringBuffer()
..write( ..write(
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" ' '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
'font-family="inherit" width="100%">', 'font-family="inherit" width="100%">',
); );
if (spec.title.isNotEmpty) { if (spec.title.isNotEmpty) {
b.write( final title = spec.title.length > 52
'<text x="400" y="34" text-anchor="middle" font-size="26" ' ? '${spec.title.substring(0, 51)}'
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>', : spec.title;
); b
} ..write(
// Legend (multi-series, non-pie). '<rect x="38" y="12" width="724" height="44" rx="9" '
final top = spec.title.isNotEmpty ? 56.0 : 24.0; 'fill="$titleBackground"/>',
var plotTop = top; )
if (spec.type != ChartType.pie && spec.series.length > 1) { ..write(
var lx = 60.0; '<rect x="38" y="12" width="7" height="44" rx="3" fill="$accent"/>',
for (var i = 0; i < spec.series.length; i++) { )
b ..write(
..write( '<text x="62" y="41" font-size="23" font-weight="bold" '
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>', 'fill="$titleColor">${_esc(title)}</text>',
) );
..write(
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</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) { switch (spec.type) {
case ChartType.bar: case ChartType.bar:
_barSvg(b, spec, plotTop); _barSvg(b, spec, plotTop, theme);
case ChartType.line: case ChartType.line:
_lineSvg(b, spec, plotTop); _lineSvg(b, spec, plotTop, theme);
case ChartType.pie: 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>'); b.write('</svg>');
return b.toString(); 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) { static double _maxY(ChartSpec spec) {
var m = 0.0; var m = 0.0;
for (final s in spec.series) { for (final s in spec.series) {
@ -188,9 +253,44 @@ class MarpHtmlService {
if (v > m) m = v; 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; 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) => static String _num(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1); v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
@ -217,16 +317,27 @@ class MarpHtmlService {
} }
// X labels. // X labels.
final n = spec.x.length; final n = spec.x.length;
final step = math.max(1, (n / 8).ceil());
for (var i = 0; i < n; i++) { 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 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( 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) { static void _barSvg(
const left = 60.0, right = 770.0, bottom = 400.0; StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
) {
const left = 60.0, right = 770.0, bottom = 382.0;
final maxY = _maxY(spec); final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY); _axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length; final n = spec.x.length;
@ -241,14 +352,21 @@ class MarpHtmlService {
final h = (bottom - top) * (v / maxY); final h = (bottom - top) * (v / maxY);
final x = gx + barW * si; final x = gx + barW * si;
b.write( 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) { static void _lineSvg(
const left = 60.0, right = 770.0, bottom = 400.0; StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
) {
const left = 60.0, right = 770.0, bottom = 382.0;
final maxY = _maxY(spec); final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY); _axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length; final n = spec.x.length;
@ -260,54 +378,222 @@ class MarpHtmlService {
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}', for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
].join(' '); ].join(' ');
b.write( 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++) { for (var i = 0; i < data.length; i++) {
b.write( 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) { static void _pieSvg(
final series = spec.series.first; StringBuffer b,
final total = series.data.fold<double>(0, (a, v) => a + v); ChartSpec spec,
const cx = 250.0, cy = 240.0, r = 150.0; double top,
var angle = -90.0; // start at top ThemeProfile? theme, {
for (var i = 0; i < series.data.length; i++) { required double bottom,
final frac = total > 0 ? series.data[i] / total : 0; }) {
final sweep = frac * 360; final count = math.min(spec.series.length, 2);
final a0 = angle * math.pi / 180; final columns = count;
final a1 = (angle + sweep) * math.pi / 180; final rows = (count / columns).ceil();
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0); final cellWidth = 720.0 / columns;
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1); final cellHeight = (bottom - top) / rows;
final large = sweep > 180 ? 1 : 0; final radius = math.min(cellWidth * 0.25, cellHeight * 0.42);
b.write( for (var xi = 0; xi < count; xi++) {
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>', final col = xi % columns;
); final row = xi ~/ columns;
angle += sweep; final cellLeft = 40 + cellWidth * col;
} final cx = cellLeft + cellWidth * 0.36;
// Legend on the right. final cy = top + cellHeight * (row + 0.5);
var ly = 120.0; final series = spec.series[xi];
for (var i = 0; i < spec.x.length && i < series.data.length; i++) { final values = [
b for (var labelIndex = 0; labelIndex < spec.x.length; labelIndex++)
..write( labelIndex < series.data.length && series.data[labelIndex] > 0
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>', ? series.data[labelIndex]
) : 0.0,
..write( ];
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>', 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 + 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$radius,$radius 0 $large,1 '
'$x1,$y1 Z" '
'fill="${chartRowColor(spec, labelIndex)}" '
'stroke="white" '
'stroke-width="2"/>',
);
angle += sweep;
}
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>',
); );
ly += 28;
} }
} }
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(
'<line x1="$legendX" y1="$y" x2="${legendX + 8}" y2="$y" '
'stroke="#cbd5e1" stroke-width="1"/>',
)
..write(
'<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"/>',
);
}
}
/// 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 /// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
/// accent colours, table colours and font. The EB Garamond font is embedded /// accent colours, table colours and font. The EB Garamond font is embedded
/// (base64) so it renders offline; other fonts resolve to system families. /// (base64) so it renders offline; other fonts resolve to system families.
Future<String> _themedCss(ThemeProfile t) async { Future<String> _themedCss(ThemeProfile t) async {
final fontFace = await _ebGaramondFontFace(t.fontFamily); final fontFace = await _ebGaramondFontFace(t.fontFamily);
final family = _cssFontStack(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' return '$fontFace\n'
'*{box-sizing:border-box}' '*{box-sizing:border-box}'
'html,body{margin:0;padding:0}' 'html,body{margin:0;padding:0}'
@ -320,9 +606,11 @@ class MarpHtmlService {
'.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}' '.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}'
'.slide a{color:${t.accentColor}}' '.slide a{color:${t.accentColor}}'
'.slide p,.slide li{font-size:24px;line-height:1.45}' '.slide p,.slide li{font-size:24px;line-height:1.45}'
'.slide pre{background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;' '.slide pre{background:${t.codeBackgroundColor};color:${t.codeTextColor};'
'padding:16px;overflow:auto;font-size:18px}' 'border:1px solid ${t.codeTextColor}38;border-radius:6px;'
'.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}' '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 pre.mermaid{background:transparent;border:0;text-align:center}'
'.slide img{max-width:100%}' '.slide img{max-width:100%}'
'.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;' '.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(imageServiceProvider),
() => ref.read(settingsProvider).themeProfile, () => ref.read(settingsProvider).themeProfile,
languageCode: () => ref.read(settingsProvider).languageCode, 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). /// Load a deck that was already parsed (used by the tab manager).
void loadDeck(Deck deck, {String? filePath}) { void loadDeck(Deck deck, {String? filePath}) {
final resolvedDeck = deck.copyWith(
themeProfile: _file.resolveThemeProfile(
deck.themeProfile,
projectPath: deck.projectPath,
),
);
_clearHistory(); _clearHistory();
state = DeckState(deck: deck, filePath: filePath, isDirty: false); state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
} }
Future<void> openDeck({String? initialDirectory}) async { Future<void> openDeck({String? initialDirectory}) async {
@ -414,7 +421,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
void updateThemeProfile(ThemeProfile profile) { void updateThemeProfile(ThemeProfile profile) {
final deck = state.deck; final deck = state.deck;
if (deck == null) return; 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 /// 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() { Widget _colorsTab() {
final l10n = context.l10n; final l10n = context.l10n;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_profileScopeBanner(),
_sectionTitle(l10n.d('Kleuren')), _sectionTitle(l10n.d('Kleuren')),
_colorSetting( _colorSetting(
l10n.d('Achtergrond slides'), l10n.d('Achtergrond slides'),
@ -939,6 +975,68 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
(v) => (v) =>
_themeProfile = _themeProfile.copyWith(sectionBackgroundColor: 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), const SizedBox(height: 18),
_stylePreview(), _stylePreview(),
], ],
@ -950,6 +1048,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_profileScopeBanner(),
_sectionTitle(l10n.d('Logo')), _sectionTitle(l10n.d('Logo')),
Row( Row(
children: [ children: [
@ -1149,18 +1248,143 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
for (final color in _colorPresets) for (final color in _colorPresets)
_colorSwatch( _colorSwatch(
color, color,
selected: value == color, selected: value.toUpperCase() == color,
onTap: () => setState(() { onTap: () => setState(() {
onChanged(color); onChanged(color);
_profileTouched = true; _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( Widget _colorSwatch(
String color, { String color, {
required bool selected, required bool selected,

View file

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -31,19 +32,23 @@ class ChartEditor extends StatefulWidget {
class _ChartEditorState extends State<ChartEditor> { class _ChartEditorState extends State<ChartEditor> {
late final TextEditingController _title; late final TextEditingController _title;
late final TextEditingController _minBound;
late final TextEditingController _maxBound;
late ChartType _type; late ChartType _type;
String? _source; String? _source;
// Editable grid model (strings while editing). // Editable grid model (strings while editing).
List<String> _xLabels = []; List<String> _xLabels = [];
List<String?> _rowColors = [];
List<String> _seriesNames = []; List<String> _seriesNames = [];
List<String?> _seriesColors = [];
List<List<String>> _values = []; // [row][col] List<List<String>> _values = []; // [row][col]
// Bumped on structural changes so cell fields rebuild with fresh values. // Bumped on structural changes so cell fields rebuild with fresh values.
int _rev = 0; int _rev = 0;
static const _labelW = 130.0; static const _minLabelW = 238.0;
static const _cellW = 96.0; static const _minCellW = 150.0;
@override @override
void initState() { void initState() {
@ -53,13 +58,31 @@ class _ChartEditorState extends State<ChartEditor> {
_source = spec.source; _source = spec.source;
_title = TextEditingController(text: spec.title); _title = TextEditingController(text: spec.title);
_title.addListener(_emit); _title.addListener(_emit);
_minBound = TextEditingController(text: _fmtBound(spec.minBound));
_maxBound = TextEditingController(text: _fmtBound(spec.maxBound));
_minBound.addListener(_emit);
_maxBound.addListener(_emit);
_loadFromSpec(spec); _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) { void _loadFromSpec(ChartSpec spec) {
if (spec.hasInlineData) { if (spec.hasInlineData) {
_seriesNames = [for (final s in spec.series) s.name]; _seriesNames = [for (final s in spec.series) s.name];
_seriesColors = [for (final s in spec.series) s.color];
_xLabels = List<String>.from(spec.x); _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 = [ _values = [
for (var r = 0; r < spec.x.length; r++) for (var r = 0; r < spec.x.length; r++)
[ [
@ -70,7 +93,9 @@ class _ChartEditorState extends State<ChartEditor> {
} else { } else {
// Sensible empty starting grid. // Sensible empty starting grid.
_seriesNames = ['Reeks 1']; _seriesNames = ['Reeks 1'];
_seriesColors = [null];
_xLabels = ['', '', '']; _xLabels = ['', '', ''];
_rowColors = [null, null, null];
_values = List.generate(3, (_) => ['']); _values = List.generate(3, (_) => ['']);
} }
} }
@ -81,6 +106,8 @@ class _ChartEditorState extends State<ChartEditor> {
@override @override
void dispose() { void dispose() {
_title.dispose(); _title.dispose();
_minBound.dispose();
_maxBound.dispose();
super.dispose(); super.dispose();
} }
@ -89,6 +116,7 @@ class _ChartEditorState extends State<ChartEditor> {
for (var c = 0; c < _seriesNames.length; c++) for (var c = 0; c < _seriesNames.length; c++)
ChartSeries( ChartSeries(
name: _seriesNames[c], name: _seriesNames[c],
color: _seriesColors[c],
data: [ data: [
for (var r = 0; r < _values.length; r++) for (var r = 0; r < _values.length; r++)
double.tryParse( double.tryParse(
@ -105,7 +133,10 @@ class _ChartEditorState extends State<ChartEditor> {
title: _title.text, title: _title.text,
source: _source, source: _source,
x: List<String>.from(_xLabels), x: List<String>.from(_xLabels),
rowColors: List<String?>.from(_rowColors),
series: series, series: series,
minBound: _supportsBounds ? _parseBound(_minBound.text) : null,
maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null,
); );
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock())); widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
} }
@ -114,6 +145,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _addColumn() { void _addColumn() {
_seriesNames.add('Reeks ${_seriesNames.length + 1}'); _seriesNames.add('Reeks ${_seriesNames.length + 1}');
_seriesColors.add(null);
for (final row in _values) { for (final row in _values) {
row.add(''); row.add('');
} }
@ -124,6 +156,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _removeColumn(int c) { void _removeColumn(int c) {
if (_seriesNames.length <= 1) return; if (_seriesNames.length <= 1) return;
_seriesNames.removeAt(c); _seriesNames.removeAt(c);
_seriesColors.removeAt(c);
for (final row in _values) { for (final row in _values) {
if (c < row.length) row.removeAt(c); if (c < row.length) row.removeAt(c);
} }
@ -133,6 +166,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _addRow() { void _addRow() {
_xLabels.add(''); _xLabels.add('');
_rowColors.add(null);
_values.add(List<String>.filled(_seriesNames.length, '', growable: true)); _values.add(List<String>.filled(_seriesNames.length, '', growable: true));
_bump(); _bump();
_emit(); _emit();
@ -141,6 +175,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _removeRow(int r) { void _removeRow(int r) {
if (_xLabels.length <= 1) return; if (_xLabels.length <= 1) return;
_xLabels.removeAt(r); _xLabels.removeAt(r);
_rowColors.removeAt(r);
_values.removeAt(r); _values.removeAt(r);
_bump(); _bump();
_emit(); _emit();
@ -199,12 +234,26 @@ class _ChartEditorState extends State<ChartEditor> {
setState(() { setState(() {
_source = source; _source = source;
_xLabels = parsed.$1.isEmpty ? [''] : parsed.$1; _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 _seriesNames = parsed.$2.isEmpty
? ['Reeks 1'] ? ['Reeks 1']
: [for (final s in parsed.$2) s.name]; : [for (final s in parsed.$2) s.name];
_seriesColors = [
for (var i = 0; i < _seriesNames.length; i++)
i < _seriesColors.length ? _seriesColors[i] : null,
];
_values = [ _values = [
for (var r = 0; r < _xLabels.length; r++) 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++; _rev++;
}); });
@ -216,6 +265,170 @@ class _ChartEditorState extends State<ChartEditor> {
_emit(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
@ -256,6 +469,10 @@ class _ChartEditorState extends State<ChartEditor> {
value: ChartType.pie, value: ChartType.pie,
child: Text(l10n.d('Cirkel')), child: Text(l10n.d('Cirkel')),
), ),
DropdownMenuItem(
value: ChartType.radar,
child: Text(l10n.d('Spider')),
),
], ],
onChanged: (v) { onChanged: (v) {
if (v == null) return; 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) if (linked)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
@ -297,11 +562,19 @@ class _ChartEditorState extends State<ChartEditor> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: SingleChildScrollView( child: LayoutBuilder(
child: SingleChildScrollView( builder: (context, constraints) {
scrollDirection: Axis.horizontal, final availableWidth = constraints.maxWidth;
child: _grid(enabled: !linked), return SingleChildScrollView(
), child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _grid(
enabled: !linked,
availableWidth: availableWidth,
),
),
);
},
), ),
), ),
if (!linked) ...[ if (!linked) ...[
@ -327,80 +600,207 @@ class _ChartEditorState extends State<ChartEditor> {
); );
} }
Widget _grid({required bool enabled}) { Widget _grid({required bool enabled, required double availableWidth}) {
final cols = _seriesNames.length; final cols = _seriesNames.length;
return Column( const trailingWidth = 40.0;
crossAxisAlignment: CrossAxisAlignment.start, final labelWidth = math.max(_minLabelW, availableWidth * 0.28);
children: [ final remaining = availableWidth - labelWidth - trailingWidth;
// Header row: empty label cell + series name fields. final cellWidth = math.max(_minCellW, remaining / cols);
Row( final gridWidth = math.max(
children: [ availableWidth,
SizedBox( labelWidth + cellWidth * cols + trailingWidth,
width: _labelW, );
child: _headerHint(context.l10n.d('Label')), return SizedBox(
), key: const ValueKey('chart-grid'),
for (var c = 0; c < cols; c++) width: gridWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: empty label cell + series name fields.
Row(
children: [
SizedBox( SizedBox(
width: _cellW, width: labelWidth,
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(child: _headerHint(context.l10n.d('Label'))),
child: _cell( _sortButton(column: null, enabled: enabled),
key: ValueKey('s-$_rev-$c'),
value: _seriesNames[c],
enabled: enabled,
onChanged: (v) => _seriesNames[c] = v,
bold: true,
),
),
if (enabled && cols > 1)
_iconBtn(Icons.close, () => _removeColumn(c)),
], ],
), ),
), ),
], for (var c = 0; c < cols; c++)
), Container(
const SizedBox(height: 4), key: ValueKey('chart-series-column-$c'),
// Data rows. width: cellWidth,
for (var r = 0; r < _xLabels.length; r++) color: _type == ChartType.pie && c >= 2
Padding( ? const Color(0xFFE2E8F0)
padding: const EdgeInsets.only(bottom: 4), : null,
child: Row( child: Row(
children: [ children: [
SizedBox( IconButton(
width: _labelW, onPressed: enabled ? () => _pickSeriesColor(c) : null,
child: _cell( tooltip: context.l10n.d('Kleur van reeks'),
key: ValueKey('x-$_rev-$r'), icon: Container(
value: _xLabels[r], width: 16,
enabled: enabled, height: 16,
onChanged: (v) => _xLabels[r] = v, 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'),
value: _seriesNames[c],
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)),
],
), ),
), ),
for (var c = 0; c < cols; c++) ],
),
const SizedBox(height: 4),
// Data rows.
for (var r = 0; r < _xLabels.length; r++)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
SizedBox( SizedBox(
width: _cellW, width: labelWidth,
child: _cell( child: Row(
key: ValueKey('v-$_rev-$r-$c'), children: [
value: c < _values[r].length ? _values[r][c] : '', IconButton(
enabled: enabled, key: ValueKey('chart-row-color-$r'),
number: true, onPressed: enabled ? () => _pickRowColor(r) : null,
onChanged: (v) { tooltip: context.l10n.d('Kleur van rij'),
while (_values[r].length <= c) { icon: _colorDot(
_values[r].add(''); _rowColors[r] ??
} chartColorPalette[r % chartColorPalette.length],
_values[r][c] = v; ),
}, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 26,
minHeight: 32,
),
),
Expanded(
child: _cell(
key: ValueKey('x-$_rev-$r'),
value: _xLabels[r],
enabled: enabled,
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'),
),
],
],
), ),
), ),
if (enabled && _xLabels.length > 1) for (var c = 0; c < cols; c++)
_iconBtn(Icons.close, () => _removeRow(r)), 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('');
}
_values[r][c] = v;
},
),
),
if (enabled && _xLabels.length > 1)
_iconBtn(Icons.close, () => _removeRow(r)),
],
),
), ),
), ],
], ),
); );
} }
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( Widget _headerHint(String text) => Padding(
padding: const EdgeInsets.only(left: 4, bottom: 4), padding: const EdgeInsets.only(left: 4, bottom: 4),
child: Text( 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({ Widget _cell({
required Key key, required Key key,
required String value, required String value,
@ -420,6 +854,7 @@ class _ChartEditorState extends State<ChartEditor> {
required ValueChanged<String> onChanged, required ValueChanged<String> onChanged,
bool number = false, bool number = false,
bool bold = false, bool bold = false,
bool muted = false,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
@ -440,17 +875,24 @@ class _ChartEditorState extends State<ChartEditor> {
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal, fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
color: muted ? const Color(0xFF64748B) : null,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), filled: muted,
border: OutlineInputBorder(), 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, onPressed: onTap,
icon: Icon(icon, size: 14), icon: Icon(icon, size: 14),
color: const Color(0xFF94A3B8), color: const Color(0xFF94A3B8),

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,7 @@ void main() {
'SLIDES', 'SLIDES',
'Slide', 'Slide',
'slide', 'slide',
'Spider',
}; };
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")'''); final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib') 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'; import 'package:ocideck/models/chart.dart';
void main() { void main() {
test('chart palette starts with the EU flag colors', () {
expect(chartColorPalette.take(2), ['#003399', '#FFCC00']);
});
group('parseCsv', () { group('parseCsv', () {
test('reads header series names and labelled rows', () { test('reads header series names and labelled rows', () {
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n'); final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
@ -24,16 +28,19 @@ void main() {
type: ChartType.line, type: ChartType.line,
title: 'Omzet', title: 'Omzet',
x: ['Q1', 'Q2'], x: ['Q1', 'Q2'],
rowColors: ['#003399', '#FFCC00'],
series: [ series: [
ChartSeries(name: '2025', data: [10, 14]), ChartSeries(name: '2025', data: [10, 14], color: '#EF4444'),
], ],
); );
final back = ChartSpec.parse(spec.toBlock()); final back = ChartSpec.parse(spec.toBlock());
expect(back.type, ChartType.line); expect(back.type, ChartType.line);
expect(back.title, 'Omzet'); expect(back.title, 'Omzet');
expect(back.x, ['Q1', 'Q2']); expect(back.x, ['Q1', 'Q2']);
expect(back.rowColors, ['#003399', '#FFCC00']);
expect(back.series.single.name, '2025'); expect(back.series.single.name, '2025');
expect(back.series.single.data, [10, 14]); expect(back.series.single.data, [10, 14]);
expect(back.series.single.color, '#EF4444');
expect(back.hasInlineData, isTrue); expect(back.hasInlineData, isTrue);
}); });
@ -43,13 +50,16 @@ void main() {
title: 'Omzet', title: 'Omzet',
source: 'data/omzet.csv', source: 'data/omzet.csv',
x: ['Q1', 'Q2'], x: ['Q1', 'Q2'],
rowColors: ['#003399', '#FFCC00'],
series: [ series: [
ChartSeries(name: '2025', data: [10, 14]), ChartSeries(name: '2025', data: [10, 14], color: '#10B981'),
], ],
); );
final stored = ChartSpec.parse(spec.toBlock(forStorage: true)); final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
expect(stored.source, 'data/omzet.csv'); expect(stored.source, 'data/omzet.csv');
expect(stored.hasInlineData, isFalse); expect(stored.hasInlineData, isFalse);
expect(stored.rowColors, ['#003399', '#FFCC00']);
expect(stored.series.single.color, '#10B981');
// The in-app/full form keeps the data. // The in-app/full form keeps the data.
final full = ChartSpec.parse(spec.toBlock()); final full = ChartSpec.parse(spec.toBlock());
@ -57,12 +67,35 @@ void main() {
}); });
test('withCsv fills x/series and keeps the source', () { 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'); final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
expect(filled.source, 'data/o.csv'); expect(filled.source, 'data/o.csv');
expect(filled.x, ['Jan', 'Feb']); expect(filled.x, ['Jan', 'Feb']);
expect(filled.series.map((s) => s.name), ['A', 'B']); expect(filled.series.map((s) => s.name), ['A', 'B']);
expect(filled.series[1].data, [2, 4]); 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', () { test('parse is tolerant of malformed JSON', () {
@ -70,5 +103,71 @@ void main() {
expect(spec.type, ChartType.bar); expect(spec.type, ChartType.bar);
expect(spec.hasInlineData, isFalse); 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:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart'; import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart'; import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/file_service.dart'; import 'package:ocideck/services/file_service.dart';
@ -23,6 +26,36 @@ void main() {
expect(n.state.isDirty, isTrue); 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', () { test('addSlide inserts right after the given index', () {
final n = _notifier()..newDeck('D'); final n = _notifier()..newDeck('D');
n.addSlide(SlideType.bullets); // appended -> index 1 n.addSlide(SlideType.bullets); // appended -> index 1

View file

@ -37,4 +37,29 @@ void main() {
expect(saved.themeProfile.logoPath, 'logos/client.png'); expect(saved.themeProfile.logoPath, 'logos/client.png');
expect(await File(p.join(temp.path, 'logos', 'client.png')).exists(), true); 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/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart'; import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart'; import 'package:ocideck/widgets/slides/slide_preview.dart';
@ -51,6 +52,30 @@ Future<({int width, int height, Uint8List bytes})> _capture(
} }
void main() { 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', ( testWidgets('twoImages paints both the left and right images', (
tester, tester,
) async { ) async {

View file

@ -98,6 +98,24 @@ void main() {}
expect(html, isNot(contains('data:font/ttf;base64,'))); 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 { test('EB Garamond theme embeds the font for offline rendering', () async {
final service = MarpHtmlService( final service = MarpHtmlService(
loadAsset: _diskLoader, loadAsset: _diskLoader,
@ -110,4 +128,117 @@ void main() {}
expect(html, contains('data:font/ttf;base64,')); expect(html, contains('data:font/ttf;base64,'));
expect(html, contains("'EB Garamond'")); 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() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); 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 { test('starts with a single default profile', () async {
final notifier = await _loadedNotifier(); final notifier = await _loadedNotifier();
expect(notifier.state.themeProfiles, hasLength(1)); expect(notifier.state.themeProfiles, hasLength(1));