feature/app-theming-and-code-slides #2
27 changed files with 3959 additions and 548 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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…',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>');
|
.replaceAll('>', '>');
|
||||||
|
|
||||||
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;'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
166
test/chart_editor_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
539
test/chart_preview_test.dart
Normal file
539
test/chart_preview_test.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
157
test/code_preview_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue