Improve chart rendering and resolve theme logo paths

Charts:
- Shrink axis label fonts and thin/space x-axis labels by actual pixel
  spacing so dense or long labels no longer overlap.
- Line tooltip shows only the point nearest the cursor instead of every
  series stacked vertically.
- Hovering a legend entry highlights its element: bar/line series fade the
  others (pie expands the matching slice), in app and presentation mode.
- Add optional min/max threshold lines per bar/line chart (ignored for pie),
  editable in the chart editor and drawn in both the live preview and the
  exported SVG.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-08 12:18:35 +02:00
parent 2d8be6f0dd
commit 67408c213c
18 changed files with 2372 additions and 443 deletions

View file

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

View file

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

View file

@ -2326,6 +2326,7 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = { const _dutchSourceStringAdditions = {
'en': { 'en': {
'Annuleren': 'Cancel',
'Afbeelding': 'Image', 'Afbeelding': 'Image',
'Broncode': 'Source code', 'Broncode': 'Source code',
'Bullet': 'Bullet', 'Bullet': 'Bullet',
@ -2354,6 +2355,15 @@ 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.',
'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 +2381,15 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparing…', 'voorbereiden…': 'preparing…',
}, },
'it': { 'it': {
'Annuleren': 'Annulla',
'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 +2589,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'de': { 'de': {
'Annuleren': 'Abbrechen',
'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 +2798,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'fr': { 'fr': {
'Annuleren': 'Annuler',
'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 +3007,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'es': { 'es': {
'Annuleren': 'Cancelar',
'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 +3216,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'fy': { 'fy': {
'Annuleren': 'Annulearje',
'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 +3422,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects', '↑↓←→ navigate · Enter chooses · Double-click selects',
}, },
'pap': { 'pap': {
'Annuleren': 'Kanselá',
'Kleur van reeks': 'Koló di serie',
'Kleur van rij': 'Koló di liña',
'Hexkleur': 'Koló hexadecimal',
'Sorteren': 'Ordená',
'Oplopend sorteren': 'Ordená subiendu',
'Aflopend sorteren': 'Ordená bahando',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Gráfikonan circular ta mustra máximo e promé dos serienan; e labelnan ta forma e segmentonan.',
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
'1 slide geïmporteerd.': '1 slide imported.', '1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…', '1 slide kopiëren naar…': 'Copy 1 slide to…',

View file

@ -4,6 +4,19 @@ 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 }
@ -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,52 @@ 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;
/// Bounds only apply to bar/line charts; a pie spec never shows them.
bool get supportsBounds => type != ChartType.pie;
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 +137,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 +161,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 +185,28 @@ class ChartSpec {
/// Return a copy with x/series taken from [csv]; keeps [source]. /// Return a copy with x/series taken from [csv]; keeps [source].
ChartSpec withCsv(String csv) { ChartSpec withCsv(String csv) {
final parsed = parseCsv(csv); final parsed = parseCsv(csv);
return copyWith(x: parsed.$1, series: parsed.$2); final colorsByLabel = x.isEmpty
? const <String, String?>{}
: <String, String?>{
for (var i = 0; i < x.length; i++)
x[i]: i < rowColors.length ? rowColors[i] : null,
};
return copyWith(
x: parsed.$1,
rowColors: [
for (var i = 0; i < parsed.$1.length; i++)
colorsByLabel[parsed.$1[i]] ??
(i < rowColors.length ? rowColors[i] : null),
],
series: [
for (var i = 0; i < parsed.$2.length; i++)
ChartSeries(
name: parsed.$2[i].name,
data: parsed.$2[i].data,
color: i < series.length ? series[i].color : null,
),
],
);
} }
} }

View file

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

View file

@ -48,7 +48,7 @@ class MarpHtmlService {
for (final slide in marpSlides(deckMarkdown)) { for (final slide in marpSlides(deckMarkdown)) {
sections sections
..write('<section class="slide"><script type="text/markdown">') ..write('<section class="slide"><script type="text/markdown">')
..write(_guard(renderChartBlocks(slide))) ..write(_guard(renderChartBlocks(slide, theme: theme)))
..write('</script></section>'); ..write('</script></section>');
} }
@ -110,23 +110,12 @@ class MarpHtmlService {
multiLine: true, multiLine: true,
); );
static const List<String> _chartPalette = [
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the /// Replace ```chart fenced blocks with a self-contained inline SVG, so the
/// exported HTML renders charts without any JS chart library. /// exported HTML renders charts without any JS chart library.
static String renderChartBlocks(String slideMarkdown) { static String renderChartBlocks(String slideMarkdown, {ThemeProfile? theme}) {
return slideMarkdown.replaceAllMapped(_chartFence, (m) { return slideMarkdown.replaceAllMapped(_chartFence, (m) {
final spec = ChartSpec.parse(m.group(1)!); final spec = ChartSpec.parse(m.group(1)!);
return '\n<div class="chart">${_chartSvg(spec)}</div>\n'; return '\n<div class="chart">${_chartSvg(spec, theme)}</div>\n';
}); });
} }
@ -135,52 +124,126 @@ class MarpHtmlService {
.replaceAll('<', '&lt;') .replaceAll('<', '&lt;')
.replaceAll('>', '&gt;'); .replaceAll('>', '&gt;');
static String _color(int i) => _chartPalette[i % _chartPalette.length]; static String _color(ChartSpec spec, int i, ThemeProfile? theme) {
final series = spec.series[i];
if (series.color == null && i == 0 && theme != null) {
return theme.accentColor;
}
return chartSeriesColor(series, i);
}
static String _chartSvg(ChartSpec spec) { static String _chartSvg(ChartSpec spec, ThemeProfile? theme) {
if (!spec.hasInlineData) { if (!spec.hasInlineData) {
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>'; return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
} }
final textColor = theme?.textColor ?? '#111827';
final titleBackground = theme?.titleBackgroundColor ?? '#F8FAFC';
final titleColor = theme?.titleTextColor ?? textColor;
final accent = theme?.accentColor ?? '#2563EB';
final b = StringBuffer() final b = StringBuffer()
..write( ..write(
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" ' '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
'font-family="inherit" width="100%">', 'font-family="inherit" width="100%">',
); );
if (spec.title.isNotEmpty) { if (spec.title.isNotEmpty) {
b.write( final title = spec.title.length > 52
'<text x="400" y="34" text-anchor="middle" font-size="26" ' ? '${spec.title.substring(0, 51)}'
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>', : spec.title;
); b
} ..write(
// Legend (multi-series, non-pie). '<rect x="38" y="12" width="724" height="44" rx="9" '
final top = spec.title.isNotEmpty ? 56.0 : 24.0; 'fill="$titleBackground"/>',
var plotTop = top; )
if (spec.type != ChartType.pie && spec.series.length > 1) { ..write(
var lx = 60.0; '<rect x="38" y="12" width="7" height="44" rx="3" fill="$accent"/>',
for (var i = 0; i < spec.series.length; i++) { )
b ..write(
..write( '<text x="62" y="41" font-size="23" font-weight="bold" '
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>', 'fill="$titleColor">${_esc(title)}</text>',
) );
..write(
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</text>',
);
lx += 30 + spec.series[i].name.length * 9 + 24;
}
plotTop = top + 28;
} }
final plotTop = spec.title.isNotEmpty ? 68.0 : 20.0;
switch (spec.type) { switch (spec.type) {
case ChartType.bar: case ChartType.bar:
_barSvg(b, spec, plotTop); _barSvg(b, spec, plotTop, theme);
case ChartType.line: case ChartType.line:
_lineSvg(b, spec, plotTop); _lineSvg(b, spec, plotTop, theme);
case ChartType.pie: case ChartType.pie:
_pieSvg(b, spec, plotTop); final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
}
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 +251,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.supportsBounds) 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 +315,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 +350,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,48 +376,81 @@ 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 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.

View file

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

View file

@ -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,29 @@ 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);
} }
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 +91,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 +104,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 +114,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 +131,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: _type == ChartType.pie ? null : _parseBound(_minBound.text),
maxBound: _type == ChartType.pie ? null : _parseBound(_maxBound.text),
); );
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock())); widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
} }
@ -114,6 +143,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 +154,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 +164,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 +173,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 +232,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 +263,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;
@ -271,6 +482,40 @@ 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.pie)
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: l10n.d('Minimumlijn (optioneel)'),
),
),
const SizedBox(width: 12),
Expanded(
child: _boundField(
key: const ValueKey('chart-max-bound'),
controller: _maxBound,
label: l10n.d('Maximumlijn (optioneel)'),
),
),
],
),
),
if (linked) if (linked)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
@ -297,11 +542,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 +580,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 +793,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 +834,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 +855,24 @@ class _ChartEditorState extends State<ChartEditor> {
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal, fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
color: muted ? const Color(0xFF64748B) : null,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), filled: muted,
border: OutlineInputBorder(), fillColor: muted ? const Color(0xFFF1F5F9) : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: const OutlineInputBorder(),
), ),
), ),
); );
} }
Widget _iconBtn(IconData icon, VoidCallback onTap) => IconButton( Widget _iconBtn(IconData icon, VoidCallback? onTap, {Key? key}) => IconButton(
key: key,
onPressed: onTap, onPressed: onTap,
icon: Icon(icon, size: 14), icon: Icon(icon, size: 14),
color: const Color(0xFF94A3B8), color: const Color(0xFF94A3B8),

View file

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

View file

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

File diff suppressed because it is too large Load diff

166
test/chart_editor_test.dart Normal file
View file

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

View file

@ -0,0 +1,340 @@
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('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('presentation mode enlarges chart labels', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
x: ['Categorie'],
series: [
ChartSeries(name: 'Waarde', data: [10]),
],
);
await tester.pumpWidget(_host(spec));
final normal = tester.widget<Text>(find.text('Categorie').first);
final normalSize = normal.style!.fontSize!;
expect(normalSize, lessThanOrEqualTo(12));
await tester.pumpWidget(_host(spec, presentationMode: true));
final presented = tester.widget<Text>(find.text('Categorie').first);
expect(presented.style!.fontSize!, greaterThan(normalSize));
});
testWidgets('dense axis labels are thinned and stay inside the slide', (
tester,
) async {
const labels = [
'Januari bijzonder lang',
'Februari bijzonder lang',
'Maart bijzonder lang',
'April bijzonder lang',
'Mei bijzonder lang',
'Juni bijzonder lang',
'Juli bijzonder lang',
'Augustus bijzonder lang',
'September bijzonder lang',
'Oktober bijzonder lang',
'November bijzonder lang',
'December bijzonder lang',
];
const spec = ChartSpec(
type: ChartType.line,
x: labels,
series: [
ChartSeries(
name: 'Waarde',
data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final visibleLabels = [
for (final label in labels)
if (find.text(label).evaluate().isNotEmpty) label,
];
expect(visibleLabels.length, lessThanOrEqualTo(8));
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
for (final label in visibleLabels) {
final rect = tester.getRect(find.text(label).first);
expect(slideRect.contains(rect.topLeft), isTrue);
expect(slideRect.contains(rect.bottomRight), isTrue);
}
expect(tester.takeException(), isNull);
});
testWidgets(
'pie shows at most two series and keeps labels inside the slide',
(tester) async {
const spec = ChartSpec(
type: ChartType.pie,
title: 'Veel gegevens',
x: [
'Een uitzonderlijk lang eerste label',
'Een uitzonderlijk lang tweede label',
'Een uitzonderlijk lang derde label',
'Een uitzonderlijk lang vierde label',
'Een uitzonderlijk lang vijfde label',
'Een uitzonderlijk lang zesde label',
],
series: [
ChartSeries(name: 'Een', data: [1, 2, 3, 4, 5, 6]),
ChartSeries(name: 'Twee', data: [2, 3, 4, 5, 6, 7]),
ChartSeries(name: 'Drie', data: [3, 4, 5, 6, 7, 8]),
ChartSeries(name: 'Vier', data: [4, 5, 6, 7, 8, 9]),
ChartSeries(name: 'Vijf', data: [5, 6, 7, 8, 9, 10]),
ChartSeries(name: 'Zes', data: [6, 7, 8, 9, 10, 11]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(PieChart), findsNWidgets(2));
expect(find.text('Drie'), findsNothing);
final legendTop = tester.getTopLeft(find.text(spec.x.first)).dy;
for (final chart in tester.widgetList<PieChart>(find.byType(PieChart))) {
final box = tester.renderObject<RenderBox>(find.byWidget(chart));
final bottom = box.localToGlobal(Offset(0, box.size.height)).dy;
expect(bottom, lessThanOrEqualTo(legendTop));
}
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
for (final label in spec.x) {
final rect = tester.getRect(find.text(label));
expect(slideRect.contains(rect.topLeft), isTrue);
expect(slideRect.contains(rect.bottomRight), isTrue);
}
expect(tester.takeException(), isNull);
},
);
}

View file

@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart'; import 'package:ocideck/models/chart.dart';
void main() { void main() {
test('chart palette starts with the EU flag colors', () {
expect(chartColorPalette.take(2), ['#003399', '#FFCC00']);
});
group('parseCsv', () { group('parseCsv', () {
test('reads header series names and labelled rows', () { test('reads header series names and labelled rows', () {
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n'); final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
@ -24,16 +28,19 @@ void main() {
type: ChartType.line, type: ChartType.line,
title: 'Omzet', title: 'Omzet',
x: ['Q1', 'Q2'], x: ['Q1', 'Q2'],
rowColors: ['#003399', '#FFCC00'],
series: [ series: [
ChartSeries(name: '2025', data: [10, 14]), ChartSeries(name: '2025', data: [10, 14], color: '#EF4444'),
], ],
); );
final back = ChartSpec.parse(spec.toBlock()); final back = ChartSpec.parse(spec.toBlock());
expect(back.type, ChartType.line); expect(back.type, ChartType.line);
expect(back.title, 'Omzet'); expect(back.title, 'Omzet');
expect(back.x, ['Q1', 'Q2']); expect(back.x, ['Q1', 'Q2']);
expect(back.rowColors, ['#003399', '#FFCC00']);
expect(back.series.single.name, '2025'); expect(back.series.single.name, '2025');
expect(back.series.single.data, [10, 14]); expect(back.series.single.data, [10, 14]);
expect(back.series.single.color, '#EF4444');
expect(back.hasInlineData, isTrue); expect(back.hasInlineData, isTrue);
}); });
@ -43,13 +50,16 @@ void main() {
title: 'Omzet', title: 'Omzet',
source: 'data/omzet.csv', source: 'data/omzet.csv',
x: ['Q1', 'Q2'], x: ['Q1', 'Q2'],
rowColors: ['#003399', '#FFCC00'],
series: [ series: [
ChartSeries(name: '2025', data: [10, 14]), ChartSeries(name: '2025', data: [10, 14], color: '#10B981'),
], ],
); );
final stored = ChartSpec.parse(spec.toBlock(forStorage: true)); final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
expect(stored.source, 'data/omzet.csv'); expect(stored.source, 'data/omzet.csv');
expect(stored.hasInlineData, isFalse); expect(stored.hasInlineData, isFalse);
expect(stored.rowColors, ['#003399', '#FFCC00']);
expect(stored.series.single.color, '#10B981');
// The in-app/full form keeps the data. // The in-app/full form keeps the data.
final full = ChartSpec.parse(spec.toBlock()); final full = ChartSpec.parse(spec.toBlock());
@ -57,12 +67,35 @@ void main() {
}); });
test('withCsv fills x/series and keeps the source', () { test('withCsv fills x/series and keeps the source', () {
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv'); const spec = ChartSpec(
type: ChartType.bar,
source: 'data/o.csv',
rowColors: ['#003399', '#FFCC00'],
series: [ChartSeries(name: 'oud', data: [], color: '#10B981')],
);
final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4'); final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
expect(filled.source, 'data/o.csv'); expect(filled.source, 'data/o.csv');
expect(filled.x, ['Jan', 'Feb']); expect(filled.x, ['Jan', 'Feb']);
expect(filled.series.map((s) => s.name), ['A', 'B']); expect(filled.series.map((s) => s.name), ['A', 'B']);
expect(filled.series[1].data, [2, 4]); expect(filled.series[1].data, [2, 4]);
expect(filled.series[0].color, '#10B981');
expect(filled.series[1].color, isNull);
expect(filled.rowColors, ['#003399', '#FFCC00']);
});
test('invalid colors are ignored while valid colors are normalized', () {
final valid = ChartSeries.fromJson({
'name': 'A',
'data': [1],
'color': 'ef4444',
});
final invalid = ChartSeries.fromJson({
'name': 'B',
'data': [2],
'color': 'red',
});
expect(valid.color, '#EF4444');
expect(invalid.color, isNull);
}); });
test('parse is tolerant of malformed JSON', () { test('parse is tolerant of malformed JSON', () {
@ -70,5 +103,32 @@ 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);
});
}); });
} }

View file

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

View file

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

View file

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

View file

@ -110,4 +110,89 @@ 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')));
});
} }