App-thema’s, meerschermen, annotaties en grafiekslides #1

Merged
brenno merged 9 commits from feature/app-theming-and-code-slides into main 2026-06-07 10:40:44 +00:00
15 changed files with 1379 additions and 7 deletions
Showing only changes of commit 32ef54e037 - Show all commits

View file

@ -1179,6 +1179,19 @@ const _dutchSourceStrings = {
'Stoppen (Esc)': 'Interrompi (Esc)', 'Stoppen (Esc)': 'Interrompi (Esc)',
'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma', 'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma',
'Laser · annotaties wissen': 'Laser · cancella annotazioni', 'Laser · annotaties wissen': 'Laser · cancella annotazioni',
'Grafiek': 'Grafico',
'Type grafiek': 'Tipo di grafico',
'Staaf': 'Barre',
'Lijn': 'Linee',
'Cirkel': 'Torta',
'CSV importeren': 'Importa CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Dati (CSV: prima riga = nomi serie, prima colonna = etichette)',
'Gekoppeld aan': 'Collegato a',
'Ontkoppelen': 'Scollega',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Salvare i dati nella slide o tenerli come file CSV separato accanto alla presentazione?',
'In de slide': 'Nella slide',
'Als CSV-bestand': 'Come file CSV',
'Geen grafiekgegevens': 'Nessun dato del grafico',
'Plak of typ hier je broncode...': 'Plak of typ hier je broncode...':
'Incolla o digita qui il tuo codice sorgente...', 'Incolla o digita qui il tuo codice sorgente...',
'Overgeslagen': 'Saltata', 'Overgeslagen': 'Saltata',
@ -1355,6 +1368,19 @@ const _dutchSourceStrings = {
'Stoppen (Esc)': 'Beenden (Esc)', 'Stoppen (Esc)': 'Beenden (Esc)',
'Pen · markeerstift · gum': 'Stift · Marker · Radierer', 'Pen · markeerstift · gum': 'Stift · Marker · Radierer',
'Laser · annotaties wissen': 'Laser · Anmerkungen löschen', 'Laser · annotaties wissen': 'Laser · Anmerkungen löschen',
'Grafiek': 'Diagramm',
'Type grafiek': 'Diagrammtyp',
'Staaf': 'Balken',
'Lijn': 'Linie',
'Cirkel': 'Kreis',
'CSV importeren': 'CSV importieren',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Daten (CSV: erste Zeile = Reihennamen, erste Spalte = Beschriftungen)',
'Gekoppeld aan': 'Verknüpft mit',
'Ontkoppelen': 'Trennen',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Daten in der Folie speichern oder als separate CSV-Datei neben der Präsentation behalten?',
'In de slide': 'In der Folie',
'Als CSV-bestand': 'Als CSV-Datei',
'Geen grafiekgegevens': 'Keine Diagrammdaten',
'Plak of typ hier je broncode...': 'Plak of typ hier je broncode...':
'Quellcode hier einfügen oder eingeben...', 'Quellcode hier einfügen oder eingeben...',
'Overgeslagen': 'Übersprungen', 'Overgeslagen': 'Übersprungen',
@ -1532,6 +1558,19 @@ const _dutchSourceStrings = {
'Stoppen (Esc)': 'Arrêter (Esc)', 'Stoppen (Esc)': 'Arrêter (Esc)',
'Pen · markeerstift · gum': 'Stylo · surligneur · gomme', 'Pen · markeerstift · gum': 'Stylo · surligneur · gomme',
'Laser · annotaties wissen': 'Laser · effacer les annotations', 'Laser · annotaties wissen': 'Laser · effacer les annotations',
'Grafiek': 'Graphique',
'Type grafiek': 'Type de graphique',
'Staaf': 'Barres',
'Lijn': 'Lignes',
'Cirkel': 'Secteurs',
'CSV importeren': 'Importer un CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Données (CSV : 1re ligne = noms de séries, 1re colonne = libellés)',
'Gekoppeld aan': 'Lié à',
'Ontkoppelen': 'Dissocier',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Enregistrer les données dans la diapositive, ou les conserver dans un fichier CSV séparé à côté de la présentation ?',
'In de slide': 'Dans la diapositive',
'Als CSV-bestand': 'Comme fichier CSV',
'Geen grafiekgegevens': 'Aucune donnée de graphique',
'Plak of typ hier je broncode...': 'Plak of typ hier je broncode...':
'Collez ou tapez votre code source ici...', 'Collez ou tapez votre code source ici...',
'Overgeslagen': 'Ignorée', 'Overgeslagen': 'Ignorée',
@ -1708,6 +1747,19 @@ const _dutchSourceStrings = {
'Stoppen (Esc)': 'Detener (Esc)', 'Stoppen (Esc)': 'Detener (Esc)',
'Pen · markeerstift · gum': 'Lápiz · marcador · goma', 'Pen · markeerstift · gum': 'Lápiz · marcador · goma',
'Laser · annotaties wissen': 'Láser · borrar anotaciones', 'Laser · annotaties wissen': 'Láser · borrar anotaciones',
'Grafiek': 'Gráfico',
'Type grafiek': 'Tipo de gráfico',
'Staaf': 'Barras',
'Lijn': 'Líneas',
'Cirkel': 'Circular',
'CSV importeren': 'Importar CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Datos (CSV: primera fila = nombres de series, primera columna = etiquetas)',
'Gekoppeld aan': 'Vinculado a',
'Ontkoppelen': 'Desvincular',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': '¿Guardar los datos en la diapositiva o mantenerlos como archivo CSV separado junto a la presentación?',
'In de slide': 'En la diapositiva',
'Als CSV-bestand': 'Como archivo CSV',
'Geen grafiekgegevens': 'Sin datos de gráfico',
'Plak of typ hier je broncode...': 'Plak of typ hier je broncode...':
'Pega o escribe aquí tu código fuente...', 'Pega o escribe aquí tu código fuente...',
'Overgeslagen': 'Omitida', 'Overgeslagen': 'Omitida',
@ -1885,6 +1937,19 @@ const _dutchSourceStrings = {
'Stoppen (Esc)': 'Stopje (Esc)', 'Stoppen (Esc)': 'Stopje (Esc)',
'Pen · markeerstift · gum': 'Pen · markearstift · gom', 'Pen · markeerstift · gum': 'Pen · markearstift · gom',
'Laser · annotaties wissen': 'Laser · annotaasjes wiskje', 'Laser · annotaties wissen': 'Laser · annotaasjes wiskje',
'Grafiek': 'Grafyk',
'Type grafiek': 'Grafyktype',
'Staaf': 'Steaf',
'Lijn': 'Line',
'Cirkel': 'Sirkel',
'CSV importeren': 'CSV ymportearje',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: earste rige = rige-nammen, earste kolom = labels)',
'Gekoppeld aan': 'Keppele oan',
'Ontkoppelen': 'Ûntkeppelje',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Data yn de slide bewarje, of as los CSV-bestân neist de presintaasje hâlde?',
'In de slide': 'Yn de slide',
'Als CSV-bestand': 'As CSV-bestân',
'Geen grafiekgegevens': 'Gjin grafykgegevens',
'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...', 'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...',
'Overgeslagen': 'Oerslein', 'Overgeslagen': 'Oerslein',
'Kopiëren': 'Kopiearje', 'Kopiëren': 'Kopiearje',
@ -2062,6 +2127,19 @@ const _dutchSourceStrings = {
'Stoppen (Esc)': 'Stòp (Esc)', 'Stoppen (Esc)': 'Stòp (Esc)',
'Pen · markeerstift · gum': 'Pèn · marker · gòm', 'Pen · markeerstift · gum': 'Pèn · marker · gòm',
'Laser · annotaties wissen': 'Laser · kita anotashonnan', 'Laser · annotaties wissen': 'Laser · kita anotashonnan',
'Grafiek': 'Gráfiko',
'Type grafiek': 'Tipo di gráfiko',
'Staaf': 'Bara',
'Lijn': 'Liña',
'Cirkel': 'Sirkel',
'CSV importeren': 'Importá CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Dato (CSV: promé fila = nòmber di serie, promé kolom = etiketnan)',
'Gekoppeld aan': 'Konektá na',
'Ontkoppelen': 'Deskonektá',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Warda e dato den e slide, òf keda komo un archivo CSV separá banda di e presentashon?',
'In de slide': 'Den e slide',
'Als CSV-bestand': 'Komo archivo CSV',
'Geen grafiekgegevens': 'Sin dato di gráfiko',
'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...', 'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...',
'Overgeslagen': 'Saltá', 'Overgeslagen': 'Saltá',
'Kopiëren': 'Kopia', 'Kopiëren': 'Kopia',
@ -2228,6 +2306,19 @@ const _dutchSourceStringAdditions = {
'Stoppen (Esc)': 'Stop (Esc)', 'Stoppen (Esc)': 'Stop (Esc)',
'Pen · markeerstift · gum': 'Pen · highlighter · eraser', 'Pen · markeerstift · gum': 'Pen · highlighter · eraser',
'Laser · annotaties wissen': 'Laser · clear annotations', 'Laser · annotaties wissen': 'Laser · clear annotations',
'Grafiek': 'Chart',
'Type grafiek': 'Chart type',
'Staaf': 'Bar',
'Lijn': 'Line',
'Cirkel': 'Pie',
'CSV importeren': 'Import CSV',
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: first row = series names, first column = labels)',
'Gekoppeld aan': 'Linked to',
'Ontkoppelen': 'Unlink',
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Store the data in the slide, or keep it as a separate CSV file next to the presentation?',
'In de slide': 'In the slide',
'Als CSV-bestand': 'As a CSV file',
'Geen grafiekgegevens': 'No chart data',
'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.':

147
lib/models/chart.dart Normal file
View file

@ -0,0 +1,147 @@
import 'dart:convert';
/// Supported chart kinds for a chart slide.
enum ChartType { bar, line, pie }
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
(t) => t.name == name,
orElse: () => ChartType.bar,
);
/// One named data series (a row of values aligned to the x labels).
class ChartSeries {
final String name;
final List<double> data;
const ChartSeries({required this.name, required this.data});
Map<String, dynamic> toJson() => {'name': name, 'data': data};
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
name: (json['name'] ?? '').toString(),
data: [
for (final v in (json['data'] as List? ?? const []))
(v as num?)?.toDouble() ?? 0,
],
);
}
/// 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
/// external CSV via [source] (kept as the living source of truth and packaged
/// alongside the deck like images). When a [source] is set the inline data is
/// stripped from the markdown on save and re-hydrated from the CSV on load.
class ChartSpec {
final ChartType type;
final String title;
final String? source;
final List<String> x;
final List<ChartSeries> series;
const ChartSpec({
this.type = ChartType.bar,
this.title = '',
this.source,
this.x = const [],
this.series = const [],
});
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
ChartSpec copyWith({
ChartType? type,
String? title,
String? source,
bool clearSource = false,
List<String>? x,
List<ChartSeries>? series,
}) => ChartSpec(
type: type ?? this.type,
title: title ?? this.title,
source: clearSource ? null : (source ?? this.source),
x: x ?? this.x,
series: series ?? this.series,
);
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
/// spec on any error so a malformed block never crashes rendering.
factory ChartSpec.parse(String raw) {
try {
final data = jsonDecode(raw.trim());
if (data is! Map) return const ChartSpec();
final src = (data['source'] as String?)?.trim();
return ChartSpec(
type: _chartTypeFromName(data['type'] as String?),
title: (data['title'] ?? '').toString(),
source: (src == null || src.isEmpty) ? null : src,
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
series: [
for (final s in (data['series'] as List? ?? const []))
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
],
);
} catch (_) {
return const ChartSpec();
}
}
/// Serialize back to the pretty JSON that lives in the markdown block.
/// When [forStorage] is true and a [source] is set, the (re-hydratable)
/// inline data is omitted so the .md stays lean and the CSV stays the source.
String toBlock({bool forStorage = false}) {
final map = <String, dynamic>{'type': type.name};
if (title.isNotEmpty) map['title'] = title;
if (source != null) map['source'] = source;
final dropData = forStorage && source != null;
if (!dropData) {
if (x.isNotEmpty) map['x'] = x;
if (series.isNotEmpty) {
map['series'] = [for (final s in series) s.toJson()];
}
}
return const JsonEncoder.withIndent(' ').convert(map);
}
/// Return a copy with x/series taken from [csv]; keeps [source].
ChartSpec withCsv(String csv) {
final parsed = parseCsv(csv);
return copyWith(x: parsed.$1, series: parsed.$2);
}
}
/// Parse CSV text into (x labels, series). The first row is a header whose
/// first cell is ignored (the label column) and whose remaining cells are the
/// series names; each later row is `label, v1, v2, `.
(List<String>, List<ChartSeries>) parseCsv(String csv) {
final lines = csv
.replaceAll('\r\n', '\n')
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
if (lines.isEmpty) return (const [], const []);
List<String> cells(String line) => line.split(',').map((c) => c.trim()).toList();
final header = cells(lines.first);
final seriesNames = header.length > 1 ? header.sublist(1) : <String>[];
final x = <String>[];
final seriesData = [for (final _ in seriesNames) <double>[]];
for (final line in lines.skip(1)) {
final row = cells(line);
if (row.isEmpty) continue;
x.add(row.first);
for (var i = 0; i < seriesNames.length; i++) {
final raw = (i + 1) < row.length ? row[i + 1] : '';
seriesData[i].add(double.tryParse(raw) ?? 0);
}
}
return (
x,
[
for (var i = 0; i < seriesNames.length; i++)
ChartSeries(name: seriesNames[i], data: seriesData[i]),
],
);
}

View file

@ -16,6 +16,7 @@ enum SlideType {
table, table,
freeMarkdown, freeMarkdown,
code, code,
chart,
} }
extension SlideTypeExtension on SlideType { extension SlideTypeExtension on SlideType {
@ -45,6 +46,8 @@ extension SlideTypeExtension on SlideType {
return 'Vrije Markdown'; return 'Vrije Markdown';
case SlideType.code: case SlideType.code:
return 'Broncode'; return 'Broncode';
case SlideType.chart:
return 'Grafiek';
} }
} }
@ -74,6 +77,8 @@ extension SlideTypeExtension on SlideType {
return ''; return '';
case SlideType.code: case SlideType.code:
return 'code'; return 'code';
case SlideType.chart:
return 'chart';
} }
} }
} }

View file

@ -8,6 +8,7 @@ import 'package:flutter/services.dart' show rootBundle;
import '../models/deck.dart'; import '../models/deck.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../models/chart.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import 'annotation_codec.dart'; import 'annotation_codec.dart';
import 'caption_service.dart'; import 'caption_service.dart';
@ -146,7 +147,7 @@ class FileService {
} }
final deck = _md.parseDeck(raw, filePath: filePath); final deck = _md.parseDeck(raw, filePath: filePath);
if (deck == null) return null; if (deck == null) return null;
final hydrated = await _hydrateImageCaptions(deck); final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
// Re-attach the separate annotation layer from its sidecar, if present. // Re-attach the separate annotation layer from its sidecar, if present.
if (content == null) { if (content == null) {
final sidecar = File(_sidecarPath(filePath)); final sidecar = File(_sidecarPath(filePath));
@ -179,6 +180,75 @@ class FileService {
} }
} }
/// Load the external CSV of any chart slide that links one, inlining the data
/// into the in-memory spec so the renderer has it. The markdown on disk keeps
/// only the `source` reference (data is stripped again on save).
Future<Deck> _hydrateCharts(Deck deck) async {
if (deck.projectPath == null) return deck;
var changed = false;
final slides = <Slide>[];
for (final s in deck.slides) {
if (s.type != SlideType.chart) {
slides.add(s);
continue;
}
final spec = ChartSpec.parse(s.customMarkdown);
if (spec.source == null || spec.hasInlineData) {
slides.add(s);
continue;
}
final abs = p.isAbsolute(spec.source!)
? spec.source!
: p.join(deck.projectPath!, spec.source!);
final file = File(abs);
if (!await file.exists()) {
slides.add(s);
continue;
}
try {
final csv = await file.readAsString();
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
changed = true;
} catch (_) {
slides.add(s);
}
}
return changed ? deck.copyWith(slides: slides) : deck;
}
/// For packaging: add a chart's linked CSV under data/ and rewrite its source
/// path; if the CSV is missing, fall back to keeping the data inline.
Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) {
final spec = ChartSpec.parse(s.customMarkdown);
final src = spec.source;
if (src == null) return s;
final rel = addAsset(src, 'data');
if (rel == null) {
return s.copyWith(
customMarkdown: spec.copyWith(clearSource: true).toBlock(),
);
}
return s.copyWith(
customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true),
);
}
/// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new
/// location). A normal save is a no-op because source and dest coincide.
Future<void> _copyChartData(Deck deck, String destDir) async {
for (final s in deck.slides) {
if (s.type != SlideType.chart) continue;
final src = ChartSpec.parse(s.customMarkdown).source;
if (src == null || p.isAbsolute(src) || deck.projectPath == null) continue;
final from = File(p.join(deck.projectPath!, src));
final toPath = p.join(destDir, src);
if (from.path == toPath || !from.existsSync()) continue;
final out = File(toPath);
await out.parent.create(recursive: true);
await out.writeAsBytes(await from.readAsBytes(), flush: true);
}
}
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async { Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
final safeName = deck.title final safeName = deck.title
.replaceAll(RegExp(r'[^\w\s-]'), '') .replaceAll(RegExp(r'[^\w\s-]'), '')
@ -245,12 +315,19 @@ class FileService {
), ),
]; ];
// Chart slides link their data via a CSV path inside the JSON block; bring
// the file along under data/ and rewrite the path to match.
final packedSlides = [
for (final s in slides)
if (s.type == SlideType.chart) _packChartSlide(s, addAsset) else s,
];
final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos'); final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
final profile = logoRel != null final profile = logoRel != null
? deck.themeProfile.copyWith(logoPath: logoRel) ? deck.themeProfile.copyWith(logoPath: logoRel)
: deck.themeProfile; : deck.themeProfile;
final packDeck = deck.copyWith(slides: slides, themeProfile: profile); final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
// Markdown. // Markdown.
final markdown = _md.generateDeck(packDeck); final markdown = _md.generateDeck(packDeck);
@ -453,6 +530,9 @@ class FileService {
logoAsset.cssUrl, logoAsset.cssUrl,
); );
// Bring linked chart CSVs along when saving to a new location.
await _copyChartData(deck, dir);
final markdown = _md.generateDeck(updatedDeck); final markdown = _md.generateDeck(updatedDeck);
await File(filePath).writeAsString(markdown); await File(filePath).writeAsString(markdown);
// Annotations live in a separate sidecar so the Marp .md stays pure. // Annotations live in a separate sidecar so the Marp .md stays pure.

View file

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:characters/characters.dart'; import 'package:characters/characters.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/chart.dart';
import '../models/deck.dart'; import '../models/deck.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../models/slide.dart'; import '../models/slide.dart';
@ -10,7 +11,7 @@ const _uuid = Uuid();
class MarkdownService { class MarkdownService {
// Generation // Generation
String generateDeck(Deck deck) { String generateDeck(Deck deck, {bool inlineChartData = false}) {
final buf = StringBuffer(); final buf = StringBuffer();
buf.writeln('---'); buf.writeln('---');
buf.writeln('marp: true'); buf.writeln('marp: true');
@ -49,7 +50,13 @@ class MarkdownService {
buf.writeln('---'); buf.writeln('---');
buf.writeln(); buf.writeln();
} }
buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile)); buf.write(
generateSlide(
deck.slides[i],
themeProfile: deck.themeProfile,
inlineChartData: inlineChartData,
),
);
} }
return buf.toString(); return buf.toString();
} }
@ -158,7 +165,11 @@ class MarkdownService {
return out.toString().replaceAll('<br>', '\n'); return out.toString().replaceAll('<br>', '\n');
} }
String generateSlide(Slide slide, {ThemeProfile? themeProfile}) { String generateSlide(
Slide slide, {
ThemeProfile? themeProfile,
bool inlineChartData = false,
}) {
final buf = StringBuffer(); final buf = StringBuffer();
final cssClass = slide.cssClass.isNotEmpty final cssClass = slide.cssClass.isNotEmpty
? slide.cssClass ? slide.cssClass
@ -330,6 +341,14 @@ class MarkdownService {
buf.writeln(); buf.writeln();
} }
buf.writeln('```'); buf.writeln('```');
case SlideType.chart:
// Re-serialize so inline data is dropped when the chart links a CSV
// (the .md keeps only the spec + source; the CSV stays the source).
final spec = ChartSpec.parse(slide.customMarkdown);
buf.writeln('```chart');
buf.writeln(spec.toBlock(forStorage: !inlineChartData));
buf.writeln('```');
} }
if (slide.audioPath.isNotEmpty) { if (slide.audioPath.isNotEmpty) {
@ -650,6 +669,18 @@ class MarkdownService {
); );
} }
// Chart slides carry a fenced ```chart JSON block; handle up front too.
if (cssClass.split(RegExp(r'\s+')).contains('chart')) {
return _parseChartBlock(
remaining: remaining,
cssClass: cssClass,
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
tlp: slideTlp,
);
}
final lines = remaining.split('\n'); final lines = remaining.split('\n');
String h1 = ''; String h1 = '';
String h2 = ''; String h2 = '';
@ -908,4 +939,66 @@ class MarkdownService {
tlp: tlp, tlp: tlp,
); );
} }
/// Parse a `<!-- _class: chart -->` slide: the fenced ```chart JSON block and
/// an optional `<audio>`. The JSON is kept verbatim in [Slide.customMarkdown].
Slide _parseChartBlock({
required String remaining,
required String cssClass,
required String notes,
required double advanceDuration,
required bool skipped,
TlpLevel tlp = TlpLevel.none,
}) {
final lines = remaining.split('\n');
final json = <String>[];
String audioPath = '';
bool audioAutoplay = false;
bool inFence = false;
for (final line in lines) {
final fence = RegExp(r'^\s*```').hasMatch(line);
if (fence) {
inFence = !inFence;
continue;
}
if (inFence) {
json.add(line);
continue;
}
final t = line.trim();
if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != 'chart' &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: SlideType.chart,
customMarkdown: json.join('\n').trim(),
audioPath: audioPath,
audioAutoplay: audioAutoplay,
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
tlp: tlp,
);
}
} }

View file

@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import '../models/chart.dart';
import '../models/settings.dart'; import '../models/settings.dart';
/// Builds a single, self-contained HTML file from a deck's Marp Markdown. /// Builds a single, self-contained HTML file from a deck's Marp Markdown.
@ -46,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(slide)) ..write(_guard(renderChartBlocks(slide)))
..write('</script></section>'); ..write('</script></section>');
} }
@ -101,6 +103,206 @@ class MarpHtmlService {
.replaceAll('</script', r'<\/script') .replaceAll('</script', r'<\/script')
.replaceAll('</SCRIPT', r'<\/SCRIPT'); .replaceAll('</SCRIPT', r'<\/SCRIPT');
// Charts inline SVG
static final RegExp _chartFence = RegExp(
r'```chart[ \t]*\n([\s\S]*?)\n```',
multiLine: true,
);
static const List<String> _chartPalette = [
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
/// exported HTML renders charts without any JS chart library.
static String renderChartBlocks(String slideMarkdown) {
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
final spec = ChartSpec.parse(m.group(1)!);
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
});
}
static String _esc(String s) => s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
static String _color(int i) =>
_chartPalette[i % _chartPalette.length];
static String _chartSvg(ChartSpec spec) {
if (!spec.hasInlineData) {
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
}
final b = StringBuffer()
..write(
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
'font-family="inherit" width="100%">',
);
if (spec.title.isNotEmpty) {
b.write(
'<text x="400" y="34" text-anchor="middle" font-size="26" '
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
);
}
// Legend (multi-series, non-pie).
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
var plotTop = top;
if (spec.type != ChartType.pie && spec.series.length > 1) {
var lx = 60.0;
for (var i = 0; i < spec.series.length; i++) {
b
..write(
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>',
)
..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;
}
switch (spec.type) {
case ChartType.bar:
_barSvg(b, spec, plotTop);
case ChartType.line:
_lineSvg(b, spec, plotTop);
case ChartType.pie:
_pieSvg(b, spec, plotTop);
}
b.write('</svg>');
return b.toString();
}
static double _maxY(ChartSpec spec) {
var m = 0.0;
for (final s in spec.series) {
for (final v in s.data) {
if (v > m) m = v;
}
}
return m <= 0 ? 1 : m * 1.15;
}
static String _num(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
static void _axes(
StringBuffer b,
ChartSpec spec,
double left,
double top,
double right,
double bottom,
double maxY,
) {
// Horizontal gridlines + y labels.
for (var i = 0; i <= 4; i++) {
final y = bottom - (bottom - top) * i / 4;
final val = maxY * i / 4;
b
..write(
'<line x1="$left" y1="$y" x2="$right" y2="$y" stroke="#e2e8f0" stroke-width="1"/>',
)
..write(
'<text x="${left - 8}" y="${y + 5}" text-anchor="end" font-size="14" fill="#64748b">${_num(val)}</text>',
);
}
// X labels.
final n = spec.x.length;
for (var i = 0; i < n; i++) {
final x = left + (right - left) * (i + 0.5) / n;
b.write(
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
);
}
}
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
final groupW = (right - left) / n;
final sCount = spec.series.length;
final barW = (groupW * 0.7) / sCount;
for (var xi = 0; xi < n; xi++) {
final gx = left + groupW * xi + groupW * 0.15;
for (var si = 0; si < sCount; si++) {
if (xi >= spec.series[si].data.length) continue;
final v = spec.series[si].data[xi];
final h = (bottom - top) * (v / maxY);
final x = gx + barW * si;
b.write(
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
);
}
}
}
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
double px(int i) => left + (right - left) * (i + 0.5) / n;
double py(double v) => bottom - (bottom - top) * (v / maxY);
for (var si = 0; si < spec.series.length; si++) {
final data = spec.series[si].data;
final pts = [
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
].join(' ');
b.write(
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
);
for (var i = 0; i < data.length; i++) {
b.write(
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
);
}
}
}
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
final series = spec.series.first;
final total = series.data.fold<double>(0, (a, v) => a + v);
const cx = 250.0, cy = 240.0, r = 150.0;
var angle = -90.0; // start at top
for (var i = 0; i < series.data.length; i++) {
final frac = total > 0 ? series.data[i] / total : 0;
final sweep = frac * 360;
final a0 = angle * math.pi / 180;
final a1 = (angle + sweep) * math.pi / 180;
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0);
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1);
final large = sweep > 180 ? 1 : 0;
b.write(
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>',
);
angle += sweep;
}
// Legend on the right.
var ly = 120.0;
for (var i = 0; i < spec.x.length && i < series.data.length; i++) {
b
..write(
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>',
)
..write(
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>',
);
ly += 28;
}
}
/// 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.

View file

@ -1002,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
exportService: widget.exportService, exportService: widget.exportService,
tlp: deck.tlp, tlp: deck.tlp,
exportDirectory: ref.read(settingsProvider).exportDirectory, exportDirectory: ref.read(settingsProvider).exportDirectory,
// Inline chart data so the HTML export can render charts standalone,
// even when a chart links an external CSV.
markdown: ref markdown: ref
.read(markdownServiceProvider) .read(markdownServiceProvider)
.generateDeck(deck.copyWith(slides: slides)), .generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
); );
} }

View file

@ -29,6 +29,7 @@ class AddSlideDialog extends StatelessWidget {
(SlideType.video, Icons.movie_outlined, 'Video'), (SlideType.video, Icons.movie_outlined, 'Video'),
(SlideType.quote, Icons.format_quote_outlined, 'Quote'), (SlideType.quote, Icons.format_quote_outlined, 'Quote'),
(SlideType.table, Icons.table_chart_outlined, 'Tabel'), (SlideType.table, Icons.table_chart_outlined, 'Tabel'),
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
(SlideType.code, Icons.terminal, 'Broncode'), (SlideType.code, Icons.terminal, 'Broncode'),
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'), (SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
]; ];

View file

@ -0,0 +1,265 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../l10n/app_localizations.dart';
import '../../models/chart.dart';
import '../../models/slide.dart';
import '_editor_field.dart';
/// Editor for a chart slide: type, title, and the data as a CSV-style table.
/// Data can be typed/pasted, imported from a CSV (inline), or linked to a
/// CSV file kept next to the deck (the living source).
class ChartEditor extends StatefulWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
final String? projectPath;
const ChartEditor({
super.key,
required this.slide,
required this.onUpdate,
this.projectPath,
});
@override
State<ChartEditor> createState() => _ChartEditorState();
}
class _ChartEditorState extends State<ChartEditor> {
late final TextEditingController _title;
late final TextEditingController _csv;
late ChartType _type;
String? _source;
@override
void initState() {
super.initState();
final spec = ChartSpec.parse(widget.slide.customMarkdown);
_type = spec.type;
_source = spec.source;
_title = TextEditingController(text: spec.title);
_title.addListener(_emit);
_csv = TextEditingController(text: _specToCsv(spec));
_csv.addListener(_emit);
}
@override
void dispose() {
_title.dispose();
_csv.dispose();
super.dispose();
}
/// Render the spec's inline data back to the CSV-style table text.
static String _specToCsv(ChartSpec spec) {
if (!spec.hasInlineData) return '';
final header = ['', ...spec.series.map((s) => s.name)].join(', ');
final rows = <String>[header];
for (var i = 0; i < spec.x.length; i++) {
rows.add(
[
spec.x[i],
...spec.series.map(
(s) => i < s.data.length ? _fmt(s.data[i]) : '',
),
].join(', '),
);
}
return rows.join('\n');
}
static String _fmt(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
void _emit() {
final parsed = parseCsv(_csv.text);
final spec = ChartSpec(
type: _type,
title: _title.text,
source: _source,
x: parsed.$1,
series: parsed.$2,
);
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
}
Future<void> _importCsv() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
final text = file.bytes != null
? utf8.decode(file.bytes!)
: (file.path != null ? await File(file.path!).readAsString() : null);
if (text == null) return;
// Offer to keep the CSV as an external, living source when the deck is
// saved (so it can be re-edited in a spreadsheet); otherwise inline it.
var asFile = false;
if (widget.projectPath != null && mounted) {
asFile =
await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.d('CSV importeren')),
content: Text(
ctx.l10n.d(
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.d('In de slide')),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.d('Als CSV-bestand')),
),
],
),
) ??
false;
}
String? source;
if (asFile && widget.projectPath != null) {
final name = p.basename(file.name);
final dir = Directory(p.join(widget.projectPath!, 'data'));
await dir.create(recursive: true);
await File(p.join(dir.path, name)).writeAsString(text, flush: true);
source = 'data/$name';
}
setState(() {
_source = source;
_csv.text = text;
});
_emit();
}
void _unlink() {
setState(() => _source = null);
_emit();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final linked = _source != null;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EditorField(label: 'Titel (optioneel)', controller: _title),
const SizedBox(height: 16),
Row(
children: [
Text(
l10n.d('Type grafiek'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(width: 12),
DropdownButton<ChartType>(
value: _type,
isDense: true,
borderRadius: BorderRadius.circular(6),
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
items: [
DropdownMenuItem(
value: ChartType.bar,
child: Text(l10n.d('Staaf')),
),
DropdownMenuItem(
value: ChartType.line,
child: Text(l10n.d('Lijn')),
),
DropdownMenuItem(
value: ChartType.pie,
child: Text(l10n.d('Cirkel')),
),
],
onChanged: (v) {
if (v == null) return;
setState(() => _type = v);
_emit();
},
),
const Spacer(),
TextButton.icon(
onPressed: _importCsv,
icon: const Icon(Icons.upload_file, size: 16),
label: Text(l10n.d('CSV importeren')),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Text(
l10n.d('Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
],
),
if (linked)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
const Icon(Icons.link, size: 14, color: Color(0xFF0369A1)),
const SizedBox(width: 6),
Expanded(
child: Text(
'${l10n.d('Gekoppeld aan')} $_source',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF0369A1),
),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _unlink,
child: Text(l10n.d('Ontkoppelen')),
),
],
),
),
const SizedBox(height: 6),
Expanded(
child: TextField(
controller: _csv,
readOnly: linked,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
hintText: ', 2025, 2026\nQ1, 10, 12\nQ2, 14, 9',
alignLabelWithHint: true,
filled: linked,
fillColor: linked ? const Color(0xFFF1F5F9) : null,
),
),
),
],
),
);
}
}

View file

@ -12,6 +12,7 @@ import '../../l10n/app_localizations.dart';
import '../editors/bullets_editor.dart'; import '../editors/bullets_editor.dart';
import '../editors/bullets_image_editor.dart'; import '../editors/bullets_image_editor.dart';
import '../editors/audio_attachment_editor.dart'; import '../editors/audio_attachment_editor.dart';
import '../editors/chart_editor.dart';
import '../editors/code_editor.dart'; import '../editors/code_editor.dart';
import '../editors/free_markdown_editor.dart'; import '../editors/free_markdown_editor.dart';
import '../editors/image_slide_editor.dart'; import '../editors/image_slide_editor.dart';
@ -283,6 +284,13 @@ class EditorPanel extends ConsumerWidget {
slide: slide, slide: slide,
onUpdate: onUpdate, onUpdate: onUpdate,
); );
case SlideType.chart:
return ChartEditor(
key: ValueKey(slide.id),
slide: slide,
onUpdate: onUpdate,
projectPath: captionBasePath,
);
} }
} }
} }
@ -315,6 +323,8 @@ IconData _slideTypeIcon(SlideType type) {
return Icons.code; return Icons.code;
case SlideType.code: case SlideType.code:
return Icons.terminal; return Icons.terminal;
case SlideType.chart:
return Icons.bar_chart;
} }
} }

View file

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_highlight/flutter_highlight.dart'; import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart'; import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart'; import 'package:flutter_highlight/themes/atom-one-dark.dart';
@ -8,6 +9,7 @@ import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages; import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/chart.dart';
import '../../models/deck.dart'; import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
@ -329,6 +331,13 @@ class SlidePreviewWidget extends StatelessWidget {
font: fontFamily, font: fontFamily,
profile: themeProfile, profile: themeProfile,
); );
case SlideType.chart:
return _ChartPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
} }
} }
} }
@ -2153,6 +2162,347 @@ class _CodePreview extends StatelessWidget {
} }
} }
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
class _ChartPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _ChartPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
static const _palette = <int>[
0xFF2563EB,
0xFFF59E0B,
0xFF10B981,
0xFFEF4444,
0xFF8B5CF6,
0xFF06B6D4,
0xFFEC4899,
0xFF84CC16,
];
Color _seriesColor(int i) =>
i == 0 ? _hexColor(profile.accentColor) : Color(_palette[i % _palette.length]);
@override
Widget build(BuildContext context) {
final spec = ChartSpec.parse(slide.customMarkdown);
final pad = w * 0.06;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final textColor = _hexColor(profile.textColor);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (spec.title.isNotEmpty) ...[
_md(
context,
spec.title,
_applyFont(
font,
TextStyle(
fontSize: w * 0.04,
fontWeight: FontWeight.bold,
color: textColor,
),
),
linkColor: _hexColor(profile.accentColor),
),
SizedBox(height: w * 0.02),
],
if (spec.series.length > 1 && spec.type != ChartType.pie)
_legend(spec, textColor),
Expanded(
child: spec.hasInlineData
? _chart(spec, textColor)
: _placeholder(context),
),
],
),
),
);
}
Widget _legend(ChartSpec spec, Color textColor) {
return Padding(
padding: EdgeInsets.only(bottom: w * 0.015),
child: Wrap(
spacing: w * 0.02,
runSpacing: w * 0.008,
children: [
for (var i = 0; i < spec.series.length; i++)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: w * 0.018,
height: w * 0.018,
decoration: BoxDecoration(
color: _seriesColor(i),
shape: BoxShape.circle,
),
),
SizedBox(width: w * 0.008),
Text(
spec.series[i].name,
style: _applyFont(
font,
TextStyle(fontSize: w * 0.02, color: textColor),
),
),
],
),
],
),
);
}
Widget _chart(ChartSpec spec, Color textColor) {
switch (spec.type) {
case ChartType.bar:
return _barChart(spec, textColor);
case ChartType.line:
return _lineChart(spec, textColor);
case ChartType.pie:
return _pieChart(spec, textColor);
}
}
double _maxY(ChartSpec spec) {
var m = 0.0;
for (final s in spec.series) {
for (final v in s.data) {
if (v > m) m = v;
}
}
return m <= 0 ? 1 : m * 1.15;
}
FlTitlesData _titles(ChartSpec spec, Color textColor) {
final style = _applyFont(
font,
TextStyle(fontSize: w * 0.018, color: textColor.withValues(alpha: 0.8)),
);
return FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: w * 0.06,
getTitlesWidget: (value, meta) =>
Text(_fmtNum(value), style: style.copyWith(fontSize: w * 0.016)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: w * 0.05,
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= spec.x.length) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.only(top: w * 0.008),
child: Text(spec.x[i], style: style),
);
},
),
),
);
}
String _fmtNum(double v) {
if (v == v.roundToDouble()) return v.toInt().toString();
return v.toStringAsFixed(1);
}
FlGridData _grid(Color textColor) => FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (v) =>
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
);
Widget _barChart(ChartSpec spec, Color textColor) {
final groups = <BarChartGroupData>[];
for (var xi = 0; xi < spec.x.length; xi++) {
groups.add(
BarChartGroupData(
x: xi,
barRods: [
for (var si = 0; si < spec.series.length; si++)
if (xi < spec.series[si].data.length)
BarChartRodData(
toY: spec.series[si].data[xi],
color: _seriesColor(si),
width: w * 0.012,
borderRadius: BorderRadius.circular(w * 0.003),
),
],
),
);
}
return BarChart(
BarChartData(
maxY: _maxY(spec),
barGroups: groups,
titlesData: _titles(spec, textColor),
gridData: _grid(textColor),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(enabled: false),
),
duration: Duration.zero,
);
}
Widget _lineChart(ChartSpec spec, Color textColor) {
final bars = <LineChartBarData>[];
for (var si = 0; si < spec.series.length; si++) {
bars.add(
LineChartBarData(
spots: [
for (var xi = 0; xi < spec.series[si].data.length; xi++)
FlSpot(xi.toDouble(), spec.series[si].data[xi]),
],
color: _seriesColor(si),
barWidth: w * 0.004,
isCurved: false,
dotData: const FlDotData(show: true),
),
);
}
return LineChart(
LineChartData(
minY: 0,
maxY: _maxY(spec),
lineBarsData: bars,
titlesData: _titles(spec, textColor),
gridData: _grid(textColor),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
),
duration: Duration.zero,
);
}
Widget _pieChart(ChartSpec spec, Color textColor) {
// A pie uses the first series; each slice is an x label.
final series = spec.series.isNotEmpty ? spec.series.first : null;
if (series == null) return _placeholderText('');
final total = series.data.fold<double>(0, (a, b) => a + b);
final sections = <PieChartSectionData>[];
for (var i = 0; i < series.data.length; i++) {
final v = series.data[i];
final pct = total > 0 ? (v / total * 100) : 0;
sections.add(
PieChartSectionData(
value: v,
color: _seriesColor(i),
title: '${pct.toStringAsFixed(0)}%',
radius: w * 0.16,
titleStyle: _applyFont(
font,
TextStyle(
fontSize: w * 0.02,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
return Row(
children: [
Expanded(
flex: 3,
child: PieChart(
PieChartData(
sections: sections,
sectionsSpace: 1,
centerSpaceRadius: w * 0.05,
pieTouchData: PieTouchData(enabled: false),
),
duration: Duration.zero,
),
),
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < spec.x.length && i < series.data.length; i++)
Padding(
padding: EdgeInsets.symmetric(vertical: w * 0.004),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: w * 0.018,
height: w * 0.018,
decoration: BoxDecoration(
color: _seriesColor(i),
shape: BoxShape.circle,
),
),
SizedBox(width: w * 0.008),
Flexible(
child: Text(
spec.x[i],
style: _applyFont(
font,
TextStyle(fontSize: w * 0.02, color: textColor),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
],
);
}
Widget _placeholder(BuildContext context) =>
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
Widget _placeholderText(String text) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bar_chart_outlined,
size: w * 0.08,
color: const Color(0xFF94A3B8),
),
SizedBox(height: w * 0.01),
Text(
text,
style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02),
),
],
),
);
}
/// Register highlight.js language definitions once, so [HighlightView] can /// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing. /// colour any common language without throwing.
bool _highlightReady = false; bool _highlightReady = false;
@ -2377,6 +2727,8 @@ double _contentLeftInset(Slide slide, double w) {
return w * 0.07; return w * 0.07;
case SlideType.code: case SlideType.code:
return w * 0.05; return w * 0.05;
case SlideType.chart:
return w * 0.06;
case SlideType.twoBullets: case SlideType.twoBullets:
return w * 0.065; return w * 0.065;
case SlideType.table: case SlideType.table:

View file

@ -176,6 +176,14 @@ packages:
relative: true relative: true
source: path source: path
version: "0.3.0" version: "0.3.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -216,6 +224,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter

View file

@ -36,6 +36,7 @@ dependencies:
# the published 0.3.0 dropped, needed for the dual-screen presenter mode. # the published 0.3.0 dropped, needed for the dual-screen presenter mode.
desktop_multi_window: desktop_multi_window:
path: third_party/desktop_multi_window path: third_party/desktop_multi_window
fl_chart: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

74
test/chart_test.dart Normal file
View file

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
void main() {
group('parseCsv', () {
test('reads header series names and labelled rows', () {
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
expect(x, ['Q1', 'Q2']);
expect(series.map((s) => s.name), ['2025', '2026']);
expect(series[0].data, [10, 14]);
expect(series[1].data, [12, 9]);
});
test('non-numeric cells become 0', () {
final (x, series) = parseCsv(',A\nQ1,oops');
expect(x, ['Q1']);
expect(series.single.data, [0]);
});
});
group('ChartSpec', () {
test('round-trips inline data through the block JSON', () {
const spec = ChartSpec(
type: ChartType.line,
title: 'Omzet',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2025', data: [10, 14]),
],
);
final back = ChartSpec.parse(spec.toBlock());
expect(back.type, ChartType.line);
expect(back.title, 'Omzet');
expect(back.x, ['Q1', 'Q2']);
expect(back.series.single.name, '2025');
expect(back.series.single.data, [10, 14]);
expect(back.hasInlineData, isTrue);
});
test('storage form drops inline data when a source is linked', () {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet',
source: 'data/omzet.csv',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2025', data: [10, 14]),
],
);
final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
expect(stored.source, 'data/omzet.csv');
expect(stored.hasInlineData, isFalse);
// The in-app/full form keeps the data.
final full = ChartSpec.parse(spec.toBlock());
expect(full.hasInlineData, isTrue);
});
test('withCsv fills x/series and keeps the source', () {
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv');
final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
expect(filled.source, 'data/o.csv');
expect(filled.x, ['Jan', 'Feb']);
expect(filled.series.map((s) => s.name), ['A', 'B']);
expect(filled.series[1].data, [2, 4]);
});
test('parse is tolerant of malformed JSON', () {
final spec = ChartSpec.parse('{ not json');
expect(spec.type, ChartType.bar);
expect(spec.hasInlineData, isFalse);
});
});
}

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/deck.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';
@ -262,6 +263,38 @@ void main() {
expect(out.customMarkdown, code); expect(out.customMarkdown, code);
}); });
test('chart slide keeps its inline spec', () {
const block =
'{\n "type": "bar",\n "title": "Omzet",\n "x": ["Q1","Q2"],\n'
' "series": [\n {"name":"2025","data":[10,14]}\n ]\n}';
final out = _roundTrip(
Slide.create(SlideType.chart).copyWith(customMarkdown: block),
);
expect(out.type, SlideType.chart);
final spec = ChartSpec.parse(out.customMarkdown);
expect(spec.type, ChartType.bar);
expect(spec.x, ['Q1', 'Q2']);
expect(spec.series.single.data, [10, 14]);
});
test('chart slide with a source keeps only the reference in markdown', () {
const block = '{"type":"line","source":"data/omzet.csv",'
'"x":["Q1"],"series":[{"name":"2025","data":[10]}]}';
final service = MarkdownService();
final md = service.generateDeck(
Deck(
title: 'Demo',
slides: [Slide.create(SlideType.chart).copyWith(customMarkdown: block)],
),
);
// The stored markdown references the CSV but does not inline the data.
expect(md.contains('data/omzet.csv'), isTrue);
final out = service.parseDeck(md)!.slides.single;
final spec = ChartSpec.parse(out.customMarkdown);
expect(spec.source, 'data/omzet.csv');
expect(spec.hasInlineData, isFalse);
});
test('code slide without a language stays plain code', () { test('code slide without a language stays plain code', () {
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org'; const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
final out = _roundTrip( final out = _roundTrip(