feature/app-theming-and-code-slides #2

Merged
brenno merged 4 commits from feature/app-theming-and-code-slides into main 2026-06-08 12:31:04 +00:00
18 changed files with 2372 additions and 443 deletions
Showing only changes of commit 67408c213c - Show all commits

View file

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

View file

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

View file

@ -2326,6 +2326,7 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = {
'en': {
'Annuleren': 'Cancel',
'Afbeelding': 'Image',
'Broncode': 'Source code',
'Bullet': 'Bullet',
@ -2354,6 +2355,15 @@ const _dutchSourceStringAdditions = {
'Label': 'Label',
'Rij': 'Row',
'Reeks': 'Series',
'Kleur van reeks': 'Series color',
'Kleur van rij': 'Row color',
'Hexkleur': 'Hex color',
'Sorteren': 'Sort',
'Oplopend sorteren': 'Sort ascending',
'Aflopend sorteren': 'Sort descending',
'Toepassen': 'Apply',
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
'Pie charts show at most the first two series; the labels form the slices.',
'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)',
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
@ -2371,6 +2381,15 @@ const _dutchSourceStringAdditions = {
'voorbereiden…': 'preparing…',
},
'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...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2570,6 +2589,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'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...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2770,6 +2798,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'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...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -2970,6 +3007,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'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...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -3170,6 +3216,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'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...',
'1 slide geïmporteerd.': '1 slide imported.',
'1 slide kopiëren naar…': 'Copy 1 slide to…',
@ -3367,6 +3422,15 @@ const _dutchSourceStringAdditions = {
'↑↓←→ navigate · Enter chooses · Double-click selects',
},
'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...',
'1 slide geïmporteerd.': '1 slide imported.',
'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.
const String chartDataDirName = 'data';
const List<String> chartColorPalette = [
'#003399',
'#FFCC00',
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Supported chart kinds for a chart slide.
enum ChartType { bar, line, pie }
@ -16,19 +29,44 @@ ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
class ChartSeries {
final String name;
final List<double> data;
const ChartSeries({required this.name, required this.data});
final String? color;
const ChartSeries({required this.name, required this.data, this.color});
Map<String, dynamic> toJson() => {'name': name, 'data': data};
Map<String, dynamic> toJson({bool includeData = true}) => {
'name': name,
if (includeData) 'data': data,
if (color != null) 'color': color,
};
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
name: (json['name'] ?? '').toString(),
data: [
for (final v in (json['data'] as List? ?? const []))
(v as num?)?.toDouble() ?? 0,
],
);
factory ChartSeries.fromJson(Map<String, dynamic> json) {
final color = normalizeChartColor(json['color']?.toString());
return ChartSeries(
name: (json['name'] ?? '').toString(),
color: color,
data: [
for (final v in (json['data'] as List? ?? const []))
(v as num?)?.toDouble() ?? 0,
],
);
}
}
String? normalizeChartColor(String? value) {
if (value == null) return null;
final raw = value.trim().toUpperCase();
final normalized = raw.startsWith('#') ? raw : '#$raw';
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized) ? normalized : null;
}
String chartSeriesColor(ChartSeries series, int index) =>
normalizeChartColor(series.color) ??
chartColorPalette[index % chartColorPalette.length];
String chartRowColor(ChartSpec spec, int index) => index < spec.rowColors.length
? normalizeChartColor(spec.rowColors[index]) ??
chartColorPalette[index % chartColorPalette.length]
: chartColorPalette[index % chartColorPalette.length];
/// The full chart specification, stored as JSON inside a ```chart fenced block.
///
/// Small charts keep their data inline; data-driven charts instead point at an
@ -40,31 +78,52 @@ class ChartSpec {
final String title;
final String? source;
final List<String> x;
final List<String?> rowColors;
final List<ChartSeries> series;
/// Optional horizontal reference lines drawn across the plot so it is clear
/// where a data point sits relative to a threshold. Only meaningful for bar
/// and line charts (ignored for pie); either may be left null.
final double? minBound;
final double? maxBound;
const ChartSpec({
this.type = ChartType.bar,
this.title = '',
this.source,
this.x = const [],
this.rowColors = const [],
this.series = const [],
this.minBound,
this.maxBound,
});
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
/// Bounds only apply to bar/line charts; a pie spec never shows them.
bool get supportsBounds => type != ChartType.pie;
ChartSpec copyWith({
ChartType? type,
String? title,
String? source,
bool clearSource = false,
List<String>? x,
List<String?>? rowColors,
List<ChartSeries>? series,
double? minBound,
bool clearMinBound = false,
double? maxBound,
bool clearMaxBound = false,
}) => ChartSpec(
type: type ?? this.type,
title: title ?? this.title,
source: clearSource ? null : (source ?? this.source),
x: x ?? this.x,
rowColors: rowColors ?? this.rowColors,
series: series ?? this.series,
minBound: clearMinBound ? null : (minBound ?? this.minBound),
maxBound: clearMaxBound ? null : (maxBound ?? this.maxBound),
);
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
@ -78,7 +137,13 @@ class ChartSpec {
type: _chartTypeFromName(data['type'] as String?),
title: (data['title'] ?? '').toString(),
source: (src == null || src.isEmpty) ? null : src,
minBound: (data['minBound'] as num?)?.toDouble(),
maxBound: (data['maxBound'] as num?)?.toDouble(),
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
rowColors: [
for (final value in (data['rowColors'] as List? ?? const []))
normalizeChartColor(value?.toString()),
],
series: [
for (final s in (data['series'] as List? ?? const []))
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
@ -96,12 +161,23 @@ class ChartSpec {
final map = <String, dynamic>{'type': type.name};
if (title.isNotEmpty) map['title'] = title;
if (source != null) map['source'] = source;
if (supportsBounds) {
if (minBound != null) map['minBound'] = minBound;
if (maxBound != null) map['maxBound'] = maxBound;
}
final dropData = forStorage && source != null;
if (rowColors.any((color) => color != null)) {
map['rowColors'] = rowColors;
}
if (!dropData) {
if (x.isNotEmpty) map['x'] = x;
if (series.isNotEmpty) {
map['series'] = [for (final s in series) s.toJson()];
}
} else if (series.any((series) => series.color != null)) {
map['series'] = [
for (final series in series) series.toJson(includeData: false),
];
}
return const JsonEncoder.withIndent(' ').convert(map);
}
@ -109,7 +185,28 @@ class ChartSpec {
/// Return a copy with x/series taken from [csv]; keeps [source].
ChartSpec withCsv(String csv) {
final parsed = parseCsv(csv);
return copyWith(x: parsed.$1, series: parsed.$2);
final colorsByLabel = x.isEmpty
? const <String, String?>{}
: <String, String?>{
for (var i = 0; i < x.length; i++)
x[i]: i < rowColors.length ? rowColors[i] : null,
};
return copyWith(
x: parsed.$1,
rowColors: [
for (var i = 0; i < parsed.$1.length; i++)
colorsByLabel[parsed.$1[i]] ??
(i < rowColors.length ? rowColors[i] : null),
],
series: [
for (var i = 0; i < parsed.$2.length; i++)
ChartSeries(
name: parsed.$2[i].name,
data: parsed.$2[i].data,
color: i < series.length ? series[i].color : null,
),
],
);
}
}

View file

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

View file

@ -48,7 +48,7 @@ class MarpHtmlService {
for (final slide in marpSlides(deckMarkdown)) {
sections
..write('<section class="slide"><script type="text/markdown">')
..write(_guard(renderChartBlocks(slide)))
..write(_guard(renderChartBlocks(slide, theme: theme)))
..write('</script></section>');
}
@ -110,23 +110,12 @@ class MarpHtmlService {
multiLine: true,
);
static const List<String> _chartPalette = [
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
/// exported HTML renders charts without any JS chart library.
static String renderChartBlocks(String slideMarkdown) {
static String renderChartBlocks(String slideMarkdown, {ThemeProfile? theme}) {
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
final spec = ChartSpec.parse(m.group(1)!);
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
return '\n<div class="chart">${_chartSvg(spec, theme)}</div>\n';
});
}
@ -135,52 +124,126 @@ class MarpHtmlService {
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
static String _color(int i) => _chartPalette[i % _chartPalette.length];
static String _color(ChartSpec spec, int i, ThemeProfile? theme) {
final series = spec.series[i];
if (series.color == null && i == 0 && theme != null) {
return theme.accentColor;
}
return chartSeriesColor(series, i);
}
static String _chartSvg(ChartSpec spec) {
static String _chartSvg(ChartSpec spec, ThemeProfile? theme) {
if (!spec.hasInlineData) {
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
}
final textColor = theme?.textColor ?? '#111827';
final titleBackground = theme?.titleBackgroundColor ?? '#F8FAFC';
final titleColor = theme?.titleTextColor ?? textColor;
final accent = theme?.accentColor ?? '#2563EB';
final b = StringBuffer()
..write(
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
'font-family="inherit" width="100%">',
);
if (spec.title.isNotEmpty) {
b.write(
'<text x="400" y="34" text-anchor="middle" font-size="26" '
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
);
}
// Legend (multi-series, non-pie).
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
var plotTop = top;
if (spec.type != ChartType.pie && spec.series.length > 1) {
var lx = 60.0;
for (var i = 0; i < spec.series.length; i++) {
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;
final title = spec.title.length > 52
? '${spec.title.substring(0, 51)}'
: spec.title;
b
..write(
'<rect x="38" y="12" width="724" height="44" rx="9" '
'fill="$titleBackground"/>',
)
..write(
'<rect x="38" y="12" width="7" height="44" rx="3" fill="$accent"/>',
)
..write(
'<text x="62" y="41" font-size="23" font-weight="bold" '
'fill="$titleColor">${_esc(title)}</text>',
);
}
final plotTop = spec.title.isNotEmpty ? 68.0 : 20.0;
switch (spec.type) {
case ChartType.bar:
_barSvg(b, spec, plotTop);
_barSvg(b, spec, plotTop, theme);
case ChartType.line:
_lineSvg(b, spec, plotTop);
_lineSvg(b, spec, plotTop, theme);
case ChartType.pie:
_pieSvg(b, spec, plotTop);
final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
}
if (spec.type == ChartType.pie) {
_pieLegendSvg(b, spec, textColor);
} else {
_legendSvg(b, spec, theme, textColor);
}
b.write('</svg>');
return b.toString();
}
static void _legendSvg(
StringBuffer b,
ChartSpec spec,
ThemeProfile? theme,
String textColor,
) {
final count = math.min(spec.series.length, 6);
final cellWidth = 720.0 / count;
for (var i = 0; i < count; i++) {
final rawName = spec.series[i].name.isEmpty
? 'Reeks ${i + 1}'
: spec.series[i].name;
final name = rawName.length > 12
? '${rawName.substring(0, 11)}'
: rawName;
final x = 40 + i * cellWidth;
b
..write(
'<rect x="$x" y="414" width="${cellWidth - 8}" height="24" rx="12" '
'fill="$textColor" fill-opacity=".05"/>',
)
..write(
'<circle cx="${x + 13}" cy="426" r="5" '
'fill="${_color(spec, i, theme)}"/>',
)
..write(
'<text x="${x + 24}" y="431" font-size="13" font-weight="600" '
'fill="$textColor">${_esc(name)}</text>',
);
}
}
static void _pieLegendSvg(StringBuffer b, ChartSpec spec, String textColor) {
const maxColumns = 6;
final columns = math.min(spec.x.length, maxColumns);
final rows = (spec.x.length / maxColumns).ceil().clamp(1, 3);
final cellWidth = 720.0 / columns;
final startY = 414.0 - (rows - 1) * 28;
for (var i = 0; i < spec.x.length; i++) {
final row = i ~/ maxColumns;
if (row >= rows) break;
final column = i % maxColumns;
final x = 40 + column * cellWidth;
final y = startY + row * 28;
final label = spec.x[i].length > 12
? '${spec.x[i].substring(0, 11)}'
: spec.x[i];
b
..write(
'<rect x="$x" y="$y" width="${cellWidth - 8}" height="24" rx="12" '
'fill="$textColor" fill-opacity=".05"/>',
)
..write(
'<circle cx="${x + 13}" cy="${y + 12}" r="5" '
'fill="${chartRowColor(spec, i)}"/>',
)
..write(
'<text x="${x + 24}" y="${y + 17}" font-size="13" font-weight="600" '
'fill="$textColor">${_esc(label)}</text>',
);
}
}
static double _maxY(ChartSpec spec) {
var m = 0.0;
for (final s in spec.series) {
@ -188,9 +251,44 @@ class MarpHtmlService {
if (v > m) m = v;
}
}
if (spec.supportsBounds) {
for (final b in [spec.minBound, spec.maxBound]) {
if (b != null && b > m) m = b;
}
}
return m <= 0 ? 1 : m * 1.15;
}
/// Draw the optional min/max threshold lines (bar/line only) as dashed rules.
static void _boundLinesSvg(
StringBuffer b,
ChartSpec spec,
double left,
double top,
double right,
double bottom,
double maxY,
) {
if (!spec.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) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
@ -217,16 +315,27 @@ class MarpHtmlService {
}
// X labels.
final n = spec.x.length;
final step = math.max(1, (n / 8).ceil());
for (var i = 0; i < n; i++) {
if (i != n - 1 && i % step != 0) continue;
final x = left + (right - left) * (i + 0.5) / n;
final label = spec.x[i].length > 10
? '${spec.x[i].substring(0, 9)}'
: spec.x[i];
b.write(
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
'<text x="$x" y="${bottom + 20}" text-anchor="middle" '
'font-size="13" fill="#334155">${_esc(label)}</text>',
);
}
}
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
static void _barSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
) {
const left = 60.0, right = 770.0, bottom = 382.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
@ -241,14 +350,21 @@ class MarpHtmlService {
final h = (bottom - top) * (v / maxY);
final x = gx + barW * si;
b.write(
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
'<rect x="$x" y="${bottom - h}" width="${barW * 0.86}" height="$h" '
'rx="5" fill="${_color(spec, si, theme)}"/>',
);
}
}
_boundLinesSvg(b, spec, left, top, right, bottom, maxY);
}
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
static void _lineSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme,
) {
const left = 60.0, right = 770.0, bottom = 382.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
@ -260,48 +376,81 @@ class MarpHtmlService {
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
].join(' ');
b.write(
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
'<polyline points="$pts" fill="none" '
'stroke="${_color(spec, si, theme)}" stroke-width="4" '
'stroke-linecap="round" stroke-linejoin="round"/>',
);
for (var i = 0; i < data.length; i++) {
b.write(
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
'<circle cx="${px(i)}" cy="${py(data[i])}" r="5" '
'fill="${_color(spec, si, theme)}" stroke="white" stroke-width="2"/>',
);
}
}
_boundLinesSvg(b, spec, left, top, right, bottom, maxY);
}
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
final series = spec.series.first;
final total = series.data.fold<double>(0, (a, v) => a + v);
const cx = 250.0, cy = 240.0, r = 150.0;
var angle = -90.0; // start at top
for (var i = 0; i < series.data.length; i++) {
final frac = total > 0 ? series.data[i] / total : 0;
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>',
static void _pieSvg(
StringBuffer b,
ChartSpec spec,
double top,
ThemeProfile? theme, {
required double bottom,
}) {
final count = math.min(spec.series.length, 2);
final columns = count;
final rows = (count / columns).ceil();
final cellWidth = 720.0 / columns;
final cellHeight = (bottom - top) / rows;
final radius = math.min(cellWidth * 0.25, cellHeight * 0.42);
for (var xi = 0; xi < count; xi++) {
final col = xi % columns;
final row = xi ~/ columns;
final cellLeft = 40 + cellWidth * col;
final cx = cellLeft + cellWidth * 0.36;
final cy = top + cellHeight * (row + 0.5);
final series = spec.series[xi];
final values = [
for (var labelIndex = 0; labelIndex < spec.x.length; labelIndex++)
labelIndex < series.data.length && series.data[labelIndex] > 0
? series.data[labelIndex]
: 0.0,
];
final total = values.fold<double>(0, (a, v) => a + v);
var angle = -90.0;
for (var labelIndex = 0; labelIndex < values.length; labelIndex++) {
final frac = total > 0 ? values[labelIndex] / total : 0;
final sweep = frac * 360;
if (sweep <= 0) continue;
final a0 = angle * math.pi / 180;
final a1 = (angle + sweep) * math.pi / 180;
final x0 = cx + 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
/// accent colours, table colours and font. The EB Garamond font is embedded
/// (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(settingsProvider).themeProfile,
languageCode: () => ref.read(settingsProvider).languageCode,
homeDirectory: () => ref.read(settingsProvider).homeDirectory,
);
});
@ -128,8 +129,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
/// Load a deck that was already parsed (used by the tab manager).
void loadDeck(Deck deck, {String? filePath}) {
final resolvedDeck = deck.copyWith(
themeProfile: _file.resolveThemeProfile(
deck.themeProfile,
projectPath: deck.projectPath,
),
);
_clearHistory();
state = DeckState(deck: deck, filePath: filePath, isDirty: false);
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
}
Future<void> openDeck({String? initialDirectory}) async {
@ -414,7 +421,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
void updateThemeProfile(ThemeProfile profile) {
final deck = state.deck;
if (deck == null) return;
_mutate(deck.copyWith(themeProfile: profile));
_mutate(
deck.copyWith(
themeProfile: _file.resolveThemeProfile(
profile,
projectPath: deck.projectPath,
),
),
);
}
/// Update the (separate) annotation layer. Kept out of the undo/redo history

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
@ -31,19 +32,23 @@ class ChartEditor extends StatefulWidget {
class _ChartEditorState extends State<ChartEditor> {
late final TextEditingController _title;
late final TextEditingController _minBound;
late final TextEditingController _maxBound;
late ChartType _type;
String? _source;
// Editable grid model (strings while editing).
List<String> _xLabels = [];
List<String?> _rowColors = [];
List<String> _seriesNames = [];
List<String?> _seriesColors = [];
List<List<String>> _values = []; // [row][col]
// Bumped on structural changes so cell fields rebuild with fresh values.
int _rev = 0;
static const _labelW = 130.0;
static const _cellW = 96.0;
static const _minLabelW = 238.0;
static const _minCellW = 150.0;
@override
void initState() {
@ -53,13 +58,29 @@ class _ChartEditorState extends State<ChartEditor> {
_source = spec.source;
_title = TextEditingController(text: spec.title);
_title.addListener(_emit);
_minBound = TextEditingController(text: _fmtBound(spec.minBound));
_maxBound = TextEditingController(text: _fmtBound(spec.maxBound));
_minBound.addListener(_emit);
_maxBound.addListener(_emit);
_loadFromSpec(spec);
}
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
static double? _parseBound(String raw) {
final text = raw.trim().replaceAll(',', '.');
return text.isEmpty ? null : double.tryParse(text);
}
void _loadFromSpec(ChartSpec spec) {
if (spec.hasInlineData) {
_seriesNames = [for (final s in spec.series) s.name];
_seriesColors = [for (final s in spec.series) s.color];
_xLabels = List<String>.from(spec.x);
_rowColors = [
for (var i = 0; i < spec.x.length; i++)
i < spec.rowColors.length ? spec.rowColors[i] : null,
];
_values = [
for (var r = 0; r < spec.x.length; r++)
[
@ -70,7 +91,9 @@ class _ChartEditorState extends State<ChartEditor> {
} else {
// Sensible empty starting grid.
_seriesNames = ['Reeks 1'];
_seriesColors = [null];
_xLabels = ['', '', ''];
_rowColors = [null, null, null];
_values = List.generate(3, (_) => ['']);
}
}
@ -81,6 +104,8 @@ class _ChartEditorState extends State<ChartEditor> {
@override
void dispose() {
_title.dispose();
_minBound.dispose();
_maxBound.dispose();
super.dispose();
}
@ -89,6 +114,7 @@ class _ChartEditorState extends State<ChartEditor> {
for (var c = 0; c < _seriesNames.length; c++)
ChartSeries(
name: _seriesNames[c],
color: _seriesColors[c],
data: [
for (var r = 0; r < _values.length; r++)
double.tryParse(
@ -105,7 +131,10 @@ class _ChartEditorState extends State<ChartEditor> {
title: _title.text,
source: _source,
x: List<String>.from(_xLabels),
rowColors: List<String?>.from(_rowColors),
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()));
}
@ -114,6 +143,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _addColumn() {
_seriesNames.add('Reeks ${_seriesNames.length + 1}');
_seriesColors.add(null);
for (final row in _values) {
row.add('');
}
@ -124,6 +154,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _removeColumn(int c) {
if (_seriesNames.length <= 1) return;
_seriesNames.removeAt(c);
_seriesColors.removeAt(c);
for (final row in _values) {
if (c < row.length) row.removeAt(c);
}
@ -133,6 +164,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _addRow() {
_xLabels.add('');
_rowColors.add(null);
_values.add(List<String>.filled(_seriesNames.length, '', growable: true));
_bump();
_emit();
@ -141,6 +173,7 @@ class _ChartEditorState extends State<ChartEditor> {
void _removeRow(int r) {
if (_xLabels.length <= 1) return;
_xLabels.removeAt(r);
_rowColors.removeAt(r);
_values.removeAt(r);
_bump();
_emit();
@ -199,12 +232,26 @@ class _ChartEditorState extends State<ChartEditor> {
setState(() {
_source = source;
_xLabels = parsed.$1.isEmpty ? [''] : parsed.$1;
_rowColors = [
for (var i = 0; i < _xLabels.length; i++)
i < _rowColors.length ? _rowColors[i] : null,
];
_seriesNames = parsed.$2.isEmpty
? ['Reeks 1']
: [for (final s in parsed.$2) s.name];
_seriesColors = [
for (var i = 0; i < _seriesNames.length; i++)
i < _seriesColors.length ? _seriesColors[i] : null,
];
_values = [
for (var r = 0; r < _xLabels.length; r++)
[for (final s in parsed.$2) r < s.data.length ? _fmt(s.data[r]) : ''],
if (parsed.$2.isEmpty)
['']
else
[
for (final s in parsed.$2)
r < s.data.length ? _fmt(s.data[r]) : '',
],
];
_rev++;
});
@ -216,6 +263,170 @@ class _ChartEditorState extends State<ChartEditor> {
_emit();
}
void _moveRow(int from, int to) {
if (to < 0 || to >= _xLabels.length || from == to) return;
setState(() {
final label = _xLabels.removeAt(from);
final color = _rowColors.removeAt(from);
final values = _values.removeAt(from);
_xLabels.insert(to, label);
_rowColors.insert(to, color);
_values.insert(to, values);
_rev++;
});
_emit();
}
void _sortRows({int? column, required bool ascending}) {
final indices = List<int>.generate(_xLabels.length, (i) => i);
indices.sort((a, b) {
int result;
if (column == null) {
result = _xLabels[a].toLowerCase().compareTo(_xLabels[b].toLowerCase());
} else {
final av =
double.tryParse(_values[a][column].replaceAll(',', '.')) ?? 0;
final bv =
double.tryParse(_values[b][column].replaceAll(',', '.')) ?? 0;
result = av.compareTo(bv);
}
return ascending ? result : -result;
});
setState(() {
final labels = [for (final i in indices) _xLabels[i]];
final colors = [for (final i in indices) _rowColors[i]];
final values = [for (final i in indices) _values[i]];
_xLabels = labels;
_rowColors = colors;
_values = values;
_rev++;
});
_emit();
}
Future<void> _pickSeriesColor(int index) async {
final selected = await _pickColor(
initial:
_seriesColors[index] ??
chartSeriesColor(ChartSeries(name: '', data: const []), index),
title: context.l10n.d('Kleur van reeks'),
);
if (selected == null || !mounted) return;
setState(() => _seriesColors[index] = selected);
_emit();
}
Future<void> _pickRowColor(int index) async {
final selected = await _pickColor(
initial:
_rowColors[index] ??
chartColorPalette[index % chartColorPalette.length],
title: context.l10n.d('Kleur van rij'),
);
if (selected == null || !mounted) return;
setState(() => _rowColors[index] = selected);
_emit();
}
Future<String?> _pickColor({
required String initial,
required String title,
}) async {
final controller = TextEditingController(text: initial);
final selected = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: StatefulBuilder(
builder: (context, setDialogState) => SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final hex in chartColorPalette)
_colorChoice(
hex,
selected: normalizeChartColor(controller.text) == hex,
onTap: () {
controller.text = hex;
setDialogState(() {});
},
),
],
),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
labelText: context.l10n.d('Hexkleur'),
hintText: '#2563EB',
border: const OutlineInputBorder(),
isDense: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[#0-9a-fA-F]')),
LengthLimitingTextInputFormatter(7),
],
onChanged: (_) => setDialogState(() {}),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.d('Annuleren')),
),
FilledButton(
onPressed: normalizeChartColor(controller.text) == null
? null
: () => Navigator.pop(
context,
normalizeChartColor(controller.text),
),
child: Text(context.l10n.d('Toepassen')),
),
],
),
);
controller.dispose();
return selected;
}
Widget _colorChoice(
String hex, {
required bool selected,
required VoidCallback onTap,
}) {
final color = Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: selected ? const Color(0xFF0F172A) : Colors.white,
width: selected ? 3 : 2,
),
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 3)],
),
child: selected
? const Icon(Icons.check, size: 18, color: Colors.white)
: null,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@ -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)
Padding(
padding: const EdgeInsets.only(top: 8),
@ -297,11 +542,19 @@ class _ChartEditorState extends State<ChartEditor> {
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _grid(enabled: !linked),
),
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
return SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _grid(
enabled: !linked,
availableWidth: availableWidth,
),
),
);
},
),
),
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;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: empty label cell + series name fields.
Row(
children: [
SizedBox(
width: _labelW,
child: _headerHint(context.l10n.d('Label')),
),
for (var c = 0; c < cols; c++)
const trailingWidth = 40.0;
final labelWidth = math.max(_minLabelW, availableWidth * 0.28);
final remaining = availableWidth - labelWidth - trailingWidth;
final cellWidth = math.max(_minCellW, remaining / cols);
final gridWidth = math.max(
availableWidth,
labelWidth + cellWidth * cols + trailingWidth,
);
return SizedBox(
key: const ValueKey('chart-grid'),
width: gridWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: empty label cell + series name fields.
Row(
children: [
SizedBox(
width: _cellW,
width: labelWidth,
child: Row(
children: [
Expanded(
child: _cell(
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)),
Expanded(child: _headerHint(context.l10n.d('Label'))),
_sortButton(column: null, enabled: enabled),
],
),
),
],
),
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(
width: _labelW,
child: _cell(
key: ValueKey('x-$_rev-$r'),
value: _xLabels[r],
enabled: enabled,
onChanged: (v) => _xLabels[r] = v,
for (var c = 0; c < cols; c++)
Container(
key: ValueKey('chart-series-column-$c'),
width: cellWidth,
color: _type == ChartType.pie && c >= 2
? const Color(0xFFE2E8F0)
: null,
child: Row(
children: [
IconButton(
onPressed: enabled ? () => _pickSeriesColor(c) : null,
tooltip: context.l10n.d('Kleur van reeks'),
icon: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Color(
_type == ChartType.pie && c >= 2
? 0xFF94A3B8
: int.parse(
chartSeriesColor(
ChartSeries(
name: '',
data: const [],
color: _seriesColors[c],
),
c,
).substring(1),
radix: 16,
) |
0xFF000000,
),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: const [
BoxShadow(
color: Color(0x330F172A),
blurRadius: 2,
),
],
),
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 32,
),
),
Expanded(
child: _cell(
key: ValueKey('s-$_rev-$c'),
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(
width: _cellW,
child: _cell(
key: ValueKey('v-$_rev-$r-$c'),
value: c < _values[r].length ? _values[r][c] : '',
enabled: enabled,
number: true,
onChanged: (v) {
while (_values[r].length <= c) {
_values[r].add('');
}
_values[r][c] = v;
},
width: labelWidth,
child: Row(
children: [
IconButton(
key: ValueKey('chart-row-color-$r'),
onPressed: enabled ? () => _pickRowColor(r) : null,
tooltip: context.l10n.d('Kleur van rij'),
icon: _colorDot(
_rowColors[r] ??
chartColorPalette[r % chartColorPalette.length],
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 26,
minHeight: 32,
),
),
Expanded(
child: _cell(
key: ValueKey('x-$_rev-$r'),
value: _xLabels[r],
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)
_iconBtn(Icons.close, () => _removeRow(r)),
],
for (var c = 0; c < cols; c++)
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(
padding: const EdgeInsets.only(left: 4, bottom: 4),
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({
required Key key,
required String value,
@ -420,6 +834,7 @@ class _ChartEditorState extends State<ChartEditor> {
required ValueChanged<String> onChanged,
bool number = false,
bool bold = false,
bool muted = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
@ -440,17 +855,24 @@ class _ChartEditorState extends State<ChartEditor> {
style: TextStyle(
fontSize: 12,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
color: muted ? const Color(0xFF64748B) : null,
),
decoration: const InputDecoration(
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
border: OutlineInputBorder(),
filled: muted,
fillColor: muted ? const Color(0xFFF1F5F9) : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: const OutlineInputBorder(),
),
),
);
}
Widget _iconBtn(IconData icon, VoidCallback onTap) => IconButton(
Widget _iconBtn(IconData icon, VoidCallback? onTap, {Key? key}) => IconButton(
key: key,
onPressed: onTap,
icon: Icon(icon, size: 14),
color: const Color(0xFF94A3B8),

View file

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

View file

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

File diff suppressed because it is too large Load diff

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

View file

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

View file

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

View file

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

View file

@ -110,4 +110,89 @@ void main() {}
expect(html, contains('data:font/ttf;base64,'));
expect(html, contains("'EB Garamond'"));
});
test('pie chart SVG renders every series and label', () {
const slide = '''
```chart
{
"type": "pie",
"x": ["Team A", "Team B"],
"series": [
{"name": "Gereed", "color": "#10B981", "data": [70, 40]},
{"name": "Open", "color": "#EF4444", "data": [30, 60]}
]
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('Team A'));
expect(html, contains('Team B'));
expect(html, contains('Gereed'));
expect(html, contains('Open'));
expect(html, contains('#003399'));
expect(html, contains('#FFCC00'));
});
test('pie chart SVG renders at most two series', () {
const slide = '''
```chart
{
"type": "pie",
"x": ["A", "B"],
"series": [
{"name": "Een", "data": [1, 2]},
{"name": "Twee", "data": [2, 3]},
{"name": "Drie", "data": [3, 4]}
]
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('Een'));
expect(html, contains('Twee'));
expect(html, isNot(contains('Drie')));
});
test('bar chart SVG draws optional min/max bound lines with labels', () {
const slide = '''
```chart
{
"type": "bar",
"x": ["Q1", "Q2"],
"series": [{"name": "Omzet", "data": [10, 14]}],
"minBound": 5,
"maxBound": 20
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, contains('stroke-dasharray'));
expect(html, contains('min 5'));
expect(html, contains('max 20'));
});
test('pie chart SVG never draws bound lines', () {
const slide = '''
```chart
{
"type": "pie",
"x": ["A", "B"],
"series": [{"name": "Een", "data": [1, 2]}],
"minBound": 5,
"maxBound": 20
}
```
''';
final html = MarpHtmlService.renderChartBlocks(slide);
expect(html, isNot(contains('stroke-dasharray')));
expect(html, isNot(contains('min 5')));
});
}