diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 4d7193e..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -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 -} diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 4d7193e..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -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 -} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d5f29c4..892d798 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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…', diff --git a/lib/models/chart.dart b/lib/models/chart.dart index 87401e3..efb801f 100644 --- a/lib/models/chart.dart +++ b/lib/models/chart.dart @@ -4,6 +4,19 @@ import 'dart:convert'; /// data files stay tidily in one place — separate from images/media. const String chartDataDirName = 'data'; +const List 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 data; - const ChartSeries({required this.name, required this.data}); + final String? color; + const ChartSeries({required this.name, required this.data, this.color}); - Map toJson() => {'name': name, 'data': data}; + Map toJson({bool includeData = true}) => { + 'name': name, + if (includeData) 'data': data, + if (color != null) 'color': color, + }; - factory ChartSeries.fromJson(Map 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 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 x; + final List rowColors; final List 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? x, + List? rowColors, List? 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.from(s as Map)), @@ -96,12 +161,23 @@ class ChartSpec { final map = {'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 {} + : { + 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, + ), + ], + ); } } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 68e66bd..814e819 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -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); diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index fa739b4..d137768 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -48,7 +48,7 @@ class MarpHtmlService { for (final slide in marpSlides(deckMarkdown)) { sections ..write('
'); } @@ -110,23 +110,12 @@ class MarpHtmlService { multiLine: true, ); - static const List _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
${_chartSvg(spec)}
\n'; + return '\n
${_chartSvg(spec, theme)}
\n'; }); } @@ -135,52 +124,126 @@ class MarpHtmlService { .replaceAll('<', '<') .replaceAll('>', '>'); - static String _color(int i) => _chartPalette[i % _chartPalette.length]; + static String _color(ChartSpec spec, int i, ThemeProfile? theme) { + final series = spec.series[i]; + if (series.color == null && i == 0 && theme != null) { + return theme.accentColor; + } + return chartSeriesColor(series, i); + } - static String _chartSvg(ChartSpec spec) { + static String _chartSvg(ChartSpec spec, ThemeProfile? theme) { if (!spec.hasInlineData) { return ''; } + final textColor = theme?.textColor ?? '#111827'; + final titleBackground = theme?.titleBackgroundColor ?? '#F8FAFC'; + final titleColor = theme?.titleTextColor ?? textColor; + final accent = theme?.accentColor ?? '#2563EB'; final b = StringBuffer() ..write( '', ); if (spec.title.isNotEmpty) { - b.write( - '${_esc(spec.title)}', - ); - } - // 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( - '', - ) - ..write( - '${_esc(spec.series[i].name)}', - ); - 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( + '', + ) + ..write( + '', + ) + ..write( + '${_esc(title)}', + ); } + 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(''); 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( + '', + ) + ..write( + '', + ) + ..write( + '${_esc(name)}', + ); + } + } + + 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( + '', + ) + ..write( + '', + ) + ..write( + '${_esc(label)}', + ); + } + } + 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( + '', + ) + ..write( + '' + '$prefix ${_num(value)}', + ); + } + + 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( - '${_esc(spec.x[i])}', + '${_esc(label)}', ); } } - 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( - '', + '', ); } } + _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( - '', + '', ); for (var i = 0; i < data.length; i++) { b.write( - '', + '', ); } } + _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(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( - '', - ); - 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( - '', - ) - ..write( - '${_esc(spec.x[i])}', + 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(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( + '', + ); + angle += sweep; + } + b + ..write('') + ..write( + '' + '${_esc(_shortChartLabel(series.name.isEmpty ? 'Reeks ${xi + 1}' : series.name))}', ); - 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. diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart index 5301b03..949c0a9 100644 --- a/lib/state/deck_provider.dart +++ b/lib/state/deck_provider.dart @@ -25,6 +25,7 @@ final fileServiceProvider = Provider((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 { /// 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 openDeck({String? initialDirectory}) async { @@ -414,7 +421,14 @@ class DeckNotifier extends StateNotifier { 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 diff --git a/lib/widgets/editors/chart_editor.dart b/lib/widgets/editors/chart_editor.dart index 48a7236..5798837 100644 --- a/lib/widgets/editors/chart_editor.dart +++ b/lib/widgets/editors/chart_editor.dart @@ -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 { late final TextEditingController _title; + late final TextEditingController _minBound; + late final TextEditingController _maxBound; late ChartType _type; String? _source; // Editable grid model (strings while editing). List _xLabels = []; + List _rowColors = []; List _seriesNames = []; + List _seriesColors = []; List> _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 { _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.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 { } 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 { @override void dispose() { _title.dispose(); + _minBound.dispose(); + _maxBound.dispose(); super.dispose(); } @@ -89,6 +114,7 @@ class _ChartEditorState extends State { 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 { title: _title.text, source: _source, x: List.from(_xLabels), + rowColors: List.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 { 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 { 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 { void _addRow() { _xLabels.add(''); + _rowColors.add(null); _values.add(List.filled(_seriesNames.length, '', growable: true)); _bump(); _emit(); @@ -141,6 +173,7 @@ class _ChartEditorState extends State { 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 { 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 { _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.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 _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 _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 _pickColor({ + required String initial, + required String title, + }) async { + final controller = TextEditingController(text: initial); + final selected = await showDialog( + 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 { ), ], ), + 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 { ), 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 { ); } - 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 { ), ); + Widget _sortButton({required int? column, required bool enabled}) { + return PopupMenuButton( + 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 { required ValueChanged 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 { 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), diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart index 041e83c..3aab023 100644 --- a/lib/widgets/presentation/audience_window.dart +++ b/lib/widgets/presentation/audience_window.dart @@ -168,6 +168,7 @@ class _AudienceWindowAppState extends State { slideNumber: _index + 1, slideCount: _slides.length, tlp: _tlp, + presentationMode: true, enableMedia: true, autoplayMedia: true, // Audio finishing on the beamer drives the presenter's diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index f61d28d..23b0ccc 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -1288,6 +1288,7 @@ class _FullscreenPresenterState extends State { 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 { slide: nextSlide, projectPath: widget.projectPath, themeProfile: widget.themeProfile, + presentationMode: true, ), ) : Container( diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index e077c61..c5d4b5c 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter_highlight/flutter_highlight.dart'; @@ -138,6 +140,9 @@ class SlidePreviewWidget extends StatelessWidget { /// staat dit uit (handmatig starten); in de presenter aan. final bool autoplayMedia; + /// Vergroot grafieklabels voor weergave op afstand in presentatiemodus. + final bool presentationMode; + /// Wordt aangeroepen wanneer de audio van deze slide klaar is (voor de /// automatische modus van de presenter). final VoidCallback? onAudioComplete; @@ -153,6 +158,7 @@ class SlidePreviewWidget extends StatelessWidget { this.tlp = TlpLevel.none, this.enableMedia = false, this.autoplayMedia = false, + this.presentationMode = false, this.onAudioComplete, }); @@ -337,6 +343,7 @@ class SlidePreviewWidget extends StatelessWidget { w: w, font: fontFamily, profile: themeProfile, + presentationMode: presentationMode, ); } } @@ -2163,38 +2170,64 @@ class _CodePreview extends StatelessWidget { } /// Renders a chart slide (bar/line/pie) from its ```chart JSON spec. -class _ChartPreview extends StatelessWidget { +class _ChartPreview extends StatefulWidget { final Slide slide; final double w; final String font; final ThemeProfile profile; + final bool presentationMode; const _ChartPreview({ required this.slide, required this.w, required this.font, required this.profile, + required this.presentationMode, }); - static const _palette = [ - 0xFF2563EB, - 0xFFF59E0B, - 0xFF10B981, - 0xFFEF4444, - 0xFF8B5CF6, - 0xFF06B6D4, - 0xFFEC4899, - 0xFF84CC16, - ]; + @override + State<_ChartPreview> createState() => _ChartPreviewState(); +} - Color _seriesColor(int i) => i == 0 - ? _hexColor(profile.accentColor) - : Color(_palette[i % _palette.length]); +class _ChartPreviewState extends State<_ChartPreview> { + Slide get slide => widget.slide; + double get w => widget.w; + String get font => widget.font; + ThemeProfile get profile => widget.profile; + bool get presentationMode => widget.presentationMode; + + /// Legend entry the pointer is over: a series index for bar/line charts, or a + /// slice (category) index for pie charts. Null when nothing is hovered. + int? _hovered; + + void _setHover(int? index) { + if (_hovered != index) setState(() => _hovered = index); + } + + /// True when another legend entry is hovered, so [index] should fade back. + bool _dimmed(int index) => _hovered != null && _hovered != index; + + /// Series colour with legend-hover feedback: non-hovered series fade out so + /// the hovered one stands out in the plot. + Color _seriesDisplayColor(ChartSeries series, int i) { + final base = _seriesColor(series, i); + return _dimmed(i) ? base.withValues(alpha: 0.2) : base; + } + + double get _labelScale => presentationMode ? 1.12 : 1; + + Color _seriesColor(ChartSeries series, int i) { + if (series.color == null && i == 0) { + return _hexColor(profile.accentColor); + } + return _hexColor(chartSeriesColor(series, i)); + } @override Widget build(BuildContext context) { final spec = ChartSpec.parse(slide.customMarkdown); - final pad = w * 0.06; + final horizontalPad = w * 0.05; + final verticalPad = w * 0.018; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; final textColor = _hexColor(profile.textColor); @@ -2202,36 +2235,79 @@ class _ChartPreview extends StatelessWidget { color: _hexColor(profile.slideBackgroundColor), child: Padding( padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, + horizontalPad, + verticalPad + safe.top, + horizontalPad, + verticalPad + safe.bottom, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (spec.title.isNotEmpty) ...[ - _md( - context, - spec.title, - _applyFont( - font, - TextStyle( - fontSize: w * 0.04, - fontWeight: FontWeight.bold, - color: textColor, + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: w * 0.025, + vertical: w * 0.01, + ), + decoration: BoxDecoration( + color: _hexColor(profile.titleBackgroundColor), + borderRadius: BorderRadius.circular(w * 0.012), + border: Border( + left: BorderSide( + color: _hexColor(profile.accentColor), + width: w * 0.006, + ), ), ), - linkColor: _hexColor(profile.accentColor), + child: _md( + context, + spec.title, + _applyFont( + font, + TextStyle( + fontSize: w * 0.032, + height: 1.1, + fontWeight: FontWeight.bold, + color: _hexColor(profile.titleTextColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), ), - SizedBox(height: w * 0.02), + SizedBox(height: w * 0.012), ], - if (spec.series.length > 1 && spec.type != ChartType.pie) - _legend(spec, textColor), Expanded( - child: spec.hasInlineData - ? _chart(spec, textColor) - : _placeholder(context), + child: Container( + key: const ValueKey('chart-surface'), + padding: EdgeInsets.fromLTRB( + w * 0.02, + w * 0.01, + w * 0.025, + w * 0.01, + ), + decoration: BoxDecoration( + color: textColor.withValues(alpha: 0.035), + borderRadius: BorderRadius.circular(w * 0.014), + border: Border.all(color: textColor.withValues(alpha: 0.09)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: spec.hasInlineData + ? _chart(spec, textColor) + : _placeholder(context), + ), + if (spec.hasInlineData && spec.series.isNotEmpty) ...[ + SizedBox(height: w * 0.006), + spec.type == ChartType.pie + ? _pieLegend(spec, textColor) + : _legend(spec, textColor), + ], + ], + ), + ), ), ], ), @@ -2240,39 +2316,157 @@ class _ChartPreview extends StatelessWidget { } Widget _legend(ChartSpec spec, Color textColor) { - return Padding( - padding: EdgeInsets.only(bottom: w * 0.015), - child: Wrap( - spacing: w * 0.02, - runSpacing: w * 0.008, - children: [ - for (var i = 0; i < spec.series.length; i++) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: w * 0.018, - height: w * 0.018, - decoration: BoxDecoration( - color: _seriesColor(i), - shape: BoxShape.circle, + return SizedBox( + height: w * 0.03, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < spec.series.length; i++) ...[ + if (i > 0) SizedBox(width: w * 0.01), + MouseRegion( + onEnter: (_) => _setHover(i), + onExit: (_) => _setHover(null), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _dimmed(i) ? 0.4 : 1, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.01, + vertical: w * 0.004, + ), + decoration: BoxDecoration( + color: _hovered == i + ? _seriesColor(spec.series[i], i).withValues(alpha: 0.18) + : textColor.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(w), + border: Border.all( + color: _hovered == i + ? _seriesColor(spec.series[i], i) + : Colors.transparent, + width: w * 0.0015, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: w * 0.012, + height: w * 0.012, + decoration: BoxDecoration( + color: _seriesColor(spec.series[i], i), + shape: BoxShape.circle, + ), + ), + SizedBox(width: w * 0.006), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: w * 0.16), + child: Text( + spec.series[i].name.isEmpty + ? 'Reeks ${i + 1}' + : spec.series[i].name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.013, + fontWeight: FontWeight.w600, + color: textColor.withValues(alpha: 0.82), + ), + ), + ), + ), + ], + ), ), ), - SizedBox(width: w * 0.008), - Text( - spec.series[i].name, - style: _applyFont( - font, - TextStyle(fontSize: w * 0.02, color: textColor), - ), - ), - ], - ), - ], + ), + ], + ], + ), ), ); } + Widget _pieLegend(ChartSpec spec, Color textColor) { + final itemCount = math.min(spec.x.length, 18); + final columns = math.min(itemCount, presentationMode ? 4 : 6); + final rows = (itemCount / columns).ceil(); + return LayoutBuilder( + builder: (context, constraints) { + final gap = w * 0.006; + final itemWidth = + (constraints.maxWidth - gap * (columns - 1)) / columns; + return SizedBox( + height: rows * w * 0.03 * _labelScale + (rows - 1) * gap, + child: Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (var i = 0; i < itemCount; i++) + MouseRegion( + onEnter: (_) => _setHover(i), + onExit: (_) => _setHover(null), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _dimmed(i) ? 0.4 : 1, + child: Container( + width: itemWidth, + height: w * 0.03 * _labelScale, + padding: EdgeInsets.symmetric(horizontal: w * 0.008), + decoration: BoxDecoration( + color: _hovered == i + ? _hexColor( + chartRowColor(spec, i), + ).withValues(alpha: 0.18) + : textColor.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(w), + border: Border.all( + color: _hovered == i + ? _hexColor(chartRowColor(spec, i)) + : Colors.transparent, + width: w * 0.0015, + ), + ), + child: Row( + children: [ + Container( + width: w * 0.012, + height: w * 0.012, + decoration: BoxDecoration( + color: _hexColor(chartRowColor(spec, i)), + shape: BoxShape.circle, + ), + ), + SizedBox(width: w * 0.006), + Expanded( + child: Text( + spec.x[i], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.013 * _labelScale, + fontWeight: FontWeight.w600, + color: textColor.withValues(alpha: 0.82), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + Widget _chart(ChartSpec spec, Color textColor) { switch (spec.type) { case ChartType.bar: @@ -2291,13 +2485,76 @@ class _ChartPreview extends StatelessWidget { if (v > m) m = v; } } + // Keep any bound line comfortably inside the plot so its label is visible. + 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; } + double _minY(ChartSpec spec) { + var m = 0.0; + for (final s in spec.series) { + for (final v in s.data) { + 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 ? 0 : m * 1.15; + } + + /// Optional min/max threshold lines drawn across the plot (bar/line only). + ExtraLinesData _boundLines(ChartSpec spec) { + if (!spec.supportsBounds) return const ExtraLinesData(); + final dash = [ + (w * 0.018).round().clamp(4, 14), + (w * 0.01).round().clamp(3, 9), + ]; + HorizontalLine line(double value, Color color, String prefix) => + HorizontalLine( + y: value, + color: color, + strokeWidth: w * 0.0035, + dashArray: dash, + label: HorizontalLineLabel( + show: true, + alignment: Alignment.topRight, + padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002), + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.0115 * _labelScale, + color: color, + fontWeight: FontWeight.w700, + ), + ), + labelResolver: (_) => '$prefix ${_fmtNum(value)}', + ), + ); + return ExtraLinesData( + horizontalLines: [ + if (spec.minBound != null) + line(spec.minBound!, const Color(0xFFF59E0B), 'min'), + if (spec.maxBound != null) + line(spec.maxBound!, const Color(0xFFEF4444), 'max'), + ], + ); + } + FlTitlesData _titles(ChartSpec spec, Color textColor) { final style = _applyFont( font, - TextStyle(fontSize: w * 0.018, color: textColor.withValues(alpha: 0.8)), + TextStyle( + fontSize: w * 0.0115 * _labelScale, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal, + ), ); return FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), @@ -2305,21 +2562,46 @@ class _ChartPreview extends StatelessWidget { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: w * 0.06, - getTitlesWidget: (value, meta) => - Text(_fmtNum(value), style: style.copyWith(fontSize: w * 0.016)), + reservedSize: w * 0.05 * _labelScale, + getTitlesWidget: (value, meta) => Text( + _fmtNum(value), + style: style.copyWith(fontSize: w * 0.0105 * _labelScale), + ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: w * 0.05, + interval: 1, + reservedSize: w * 0.044 * _labelScale, getTitlesWidget: (value, meta) { final i = value.round(); - if (i < 0 || i >= spec.x.length) return const SizedBox.shrink(); + final n = spec.x.length; + if (i < 0 || i >= n) return const SizedBox.shrink(); + // Show as many labels as fit without colliding: keep at least + // [minSlot] of horizontal room per label, then thin them out + // evenly based on the actual pixel spacing between points. + final spacing = n > 1 + ? meta.parentAxisSize / (n - 1) + : meta.parentAxisSize; + final minSlot = w * 0.085 * _labelScale; + final step = math.max(1, (minSlot / spacing).ceil()); + final lastMultiple = ((n - 1) ~/ step) * step; + final showLast = i == n - 1 && (n - 1 - lastMultiple) > step / 2; + if (i % step != 0 && !showLast) return const SizedBox.shrink(); + final slot = (step * spacing - w * 0.012).clamp(w * 0.04, w * 0.16); return Padding( padding: EdgeInsets.only(top: w * 0.008), - child: Text(spec.x[i], style: style), + child: SizedBox( + width: slot, + child: Text( + spec.x[i], + style: style, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), ); }, ), @@ -2350,9 +2632,19 @@ class _ChartPreview extends StatelessWidget { if (xi < spec.series[si].data.length) BarChartRodData( toY: spec.series[si].data[xi], - color: _seriesColor(si), - width: w * 0.012, - borderRadius: BorderRadius.circular(w * 0.003), + color: _seriesDisplayColor(spec.series[si], si), + width: (w * 0.032 / spec.series.length).clamp( + w * 0.008, + w * 0.022, + ), + borderRadius: BorderRadius.vertical( + top: Radius.circular(w * 0.006), + ), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: _maxY(spec), + color: textColor.withValues(alpha: 0.025), + ), ), ], ), @@ -2360,12 +2652,36 @@ class _ChartPreview extends StatelessWidget { } return BarChart( BarChartData( + minY: _minY(spec), maxY: _maxY(spec), barGroups: groups, titlesData: _titles(spec, textColor), gridData: _grid(textColor), borderData: FlBorderData(show: false), - barTouchData: BarTouchData(enabled: false), + extraLinesData: _boundLines(spec), + barTouchData: BarTouchData( + enabled: true, + mouseCursorResolver: (event, response) => response?.spot == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchTooltipData: BarTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => const Color(0xFF0F172A), + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final label = group.x >= 0 && group.x < spec.x.length + ? spec.x[group.x] + : ''; + final series = rodIndex < spec.series.length + ? spec.series[rodIndex].name + : ''; + return BarTooltipItem( + '$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}', + _tooltipStyle(), + ); + }, + ), + ), ), duration: Duration.zero, ); @@ -2380,108 +2696,200 @@ class _ChartPreview extends StatelessWidget { for (var xi = 0; xi < spec.series[si].data.length; xi++) FlSpot(xi.toDouble(), spec.series[si].data[xi]), ], - color: _seriesColor(si), - barWidth: w * 0.004, - isCurved: false, - dotData: const FlDotData(show: true), + color: _seriesDisplayColor(spec.series[si], si), + barWidth: w * (_hovered == si ? 0.0065 : 0.0045), + isCurved: true, + curveSmoothness: 0.22, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( + radius: w * 0.005, + color: _seriesDisplayColor(spec.series[si], si), + strokeWidth: w * 0.0025, + strokeColor: _hexColor(profile.slideBackgroundColor), + ), + ), + belowBarData: BarAreaData( + show: true, + color: _seriesDisplayColor(spec.series[si], si).withValues( + alpha: spec.series.length == 1 ? 0.14 : 0.05, + ), + ), ), ); } return LineChart( LineChartData( - minY: 0, + minY: _minY(spec), maxY: _maxY(spec), lineBarsData: bars, titlesData: _titles(spec, textColor), gridData: _grid(textColor), borderData: FlBorderData(show: false), - lineTouchData: const LineTouchData(enabled: false), + extraLinesData: _boundLines(spec), + lineTouchData: LineTouchData( + enabled: true, + mouseCursorResolver: (event, response) => + response?.lineBarSpots?.isEmpty ?? true + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => const Color(0xFF0F172A), + getTooltipItems: (spots) { + // When several series cross the same x, fl_chart hands us one + // spot per series. Only show the value of the point closest to + // the cursor instead of stacking every series vertically. + var nearest = 0; + var best = double.infinity; + for (var k = 0; k < spots.length; k++) { + final s = spots[k]; + final d = s is TouchLineBarSpot ? s.distance : 0.0; + if (d < best) { + best = d; + nearest = k; + } + } + return [ + for (var k = 0; k < spots.length; k++) + if (k != nearest) + null + else + LineTooltipItem( + '${spots[k].spotIndex < spec.x.length ? spec.x[spots[k].spotIndex] : ''}\n' + '${spots[k].barIndex < spec.series.length && spec.series[spots[k].barIndex].name.isNotEmpty ? spec.series[spots[k].barIndex].name : 'Reeks ${spots[k].barIndex + 1}'}: ${_fmtNum(spots[k].y)}', + _tooltipStyle(), + ), + ]; + }, + ), + ), ), duration: Duration.zero, ); } Widget _pieChart(ChartSpec spec, Color textColor) { - // A pie uses the first series; each slice is an x label. - final series = spec.series.isNotEmpty ? spec.series.first : null; - if (series == null) return _placeholderText('—'); - final total = series.data.fold(0, (a, b) => a + b); - final sections = []; - for (var i = 0; i < series.data.length; i++) { - final v = series.data[i]; - final pct = total > 0 ? (v / total * 100) : 0; - sections.add( - PieChartSectionData( - value: v, - color: _seriesColor(i), - title: '${pct.toStringAsFixed(0)}%', - radius: w * 0.16, - titleStyle: _applyFont( - font, - TextStyle( - fontSize: w * 0.02, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ); + if (spec.series.isEmpty || spec.x.isEmpty) { + return _placeholderText('—'); } - return Row( - children: [ - Expanded( - flex: 3, - child: PieChart( - PieChartData( - sections: sections, - sectionsSpace: 1, - centerSpaceRadius: w * 0.05, - pieTouchData: PieTouchData(enabled: false), - ), - duration: Duration.zero, + return LayoutBuilder( + builder: (context, constraints) { + final visibleSeries = math.min(spec.series.length, 2); + final columns = visibleSeries; + const rows = 1; + final tileHeight = constraints.maxHeight / rows; + final tileWidth = constraints.maxWidth / columns; + return GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + childAspectRatio: tileWidth / tileHeight, + crossAxisSpacing: w * 0.012, + mainAxisSpacing: w * 0.008, ), - ), - Expanded( - flex: 2, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < spec.x.length && i < series.data.length; i++) - Padding( - padding: EdgeInsets.symmetric(vertical: w * 0.004), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: w * 0.018, - height: w * 0.018, - decoration: BoxDecoration( - color: _seriesColor(i), - shape: BoxShape.circle, - ), - ), - SizedBox(width: w * 0.008), - Flexible( - child: Text( - spec.x[i], - style: _applyFont( - font, - TextStyle(fontSize: w * 0.02, color: textColor), + itemCount: visibleSeries, + itemBuilder: (context, si) { + final series = spec.series[si]; + final values = [ + for (var xi = 0; xi < spec.x.length; xi++) + xi < series.data.length && series.data[xi] > 0 + ? series.data[xi] + : 0.0, + ]; + final total = values.fold(0, (a, b) => a + b); + return Row( + children: [ + Expanded( + flex: 4, + child: total <= 0 + ? Center( + child: Text( + '0', + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.025, + color: textColor.withValues(alpha: 0.5), + ), + ), ), - overflow: TextOverflow.ellipsis, + ) + : LayoutBuilder( + builder: (context, pieConstraints) { + final available = + pieConstraints.biggest.shortestSide; + final radius = (available * 0.42).clamp( + w * 0.018, + w * 0.075, + ); + return ClipRect( + child: _HoverPieChart( + externalHover: _hovered, + values: values, + labels: spec.x, + colors: [ + for (var xi = 0; xi < values.length; xi++) + _hexColor(chartRowColor(spec, xi)), + ], + radius: radius, + centerSpaceRadius: radius * 0.42, + sectionSpace: w * 0.002, + titleStyle: _applyFont( + font, + TextStyle( + fontSize: (radius * 0.18).clamp( + w * 0.009, + w * 0.013, + ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + tooltipStyle: _tooltipStyle(), + ), + ); + }, ), + ), + SizedBox(width: w * 0.008), + Expanded( + flex: 2, + child: Text( + series.name.isEmpty ? 'Reeks ${si + 1}' : series.name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.015, + height: 1.1, + fontWeight: FontWeight.w700, + color: textColor, ), - ], + ), ), ), - ], - ), - ), - ], + ], + ); + }, + ); + }, ); } + TextStyle _tooltipStyle() => _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: (w * 0.013 * _labelScale).clamp(11, 18), + height: 1.25, + fontWeight: FontWeight.w700, + ), + ); + Widget _placeholder(BuildContext context) => _placeholderText(context.l10n.d('Geen grafiekgegevens')); @@ -2504,6 +2912,121 @@ class _ChartPreview extends StatelessWidget { ); } +class _HoverPieChart extends StatefulWidget { + final List values; + final List labels; + final List colors; + final double radius; + final double centerSpaceRadius; + final double sectionSpace; + final TextStyle titleStyle; + final TextStyle tooltipStyle; + + /// Slice index highlighted from outside (e.g. hovering the legend), combined + /// with this chart's own touch hover. + final int? externalHover; + + const _HoverPieChart({ + required this.values, + required this.labels, + required this.colors, + required this.radius, + required this.centerSpaceRadius, + required this.sectionSpace, + required this.titleStyle, + required this.tooltipStyle, + this.externalHover, + }); + + @override + State<_HoverPieChart> createState() => _HoverPieChartState(); +} + +class _HoverPieChartState extends State<_HoverPieChart> { + int? _hovered; + + @override + Widget build(BuildContext context) { + final total = widget.values.fold(0, (a, b) => a + b); + final external = widget.externalHover; + final hovered = _hovered ?? (external != null && external >= 0 && external < widget.values.length ? external : null); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: PieChart( + PieChartData( + sections: [ + for (var i = 0; i < widget.values.length; i++) + PieChartSectionData( + value: widget.values[i], + color: widget.colors[i], + title: widget.values[i] / total >= 0.08 + ? '${(widget.values[i] / total * 100).round()}%' + : '', + radius: widget.radius * (hovered == i ? 1.08 : 1), + titleStyle: widget.titleStyle, + ), + ], + sectionsSpace: widget.sectionSpace, + centerSpaceRadius: widget.centerSpaceRadius, + pieTouchData: PieTouchData( + enabled: true, + mouseCursorResolver: (event, response) => + response?.touchedSection == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchCallback: (event, response) { + final next = event.isInterestedForInteractions + ? response?.touchedSection?.touchedSectionIndex + : null; + if (next != _hovered) setState(() => _hovered = next); + }, + ), + ), + duration: Duration.zero, + ), + ), + if (hovered != null && hovered >= 0 && hovered < widget.values.length) + Positioned( + top: 4, + left: 4, + right: 4, + child: IgnorePointer( + child: Center( + child: Container( + key: const ValueKey('pie-hover-tooltip'), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Color(0x33000000), blurRadius: 6), + ], + ), + child: Text( + '${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: widget.tooltipStyle, + ), + ), + ), + ), + ), + ], + ); + } +} + +String _formatChartValue(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); + /// Register highlight.js language definitions once, so [HighlightView] can /// colour any common language without throwing. bool _highlightReady = false; @@ -2903,20 +3426,39 @@ Widget _mediaPlaceholder(IconData icon, String label) { } Widget _imagePlaceholder(BuildContext context) { - return Container( + return ColoredBox( color: const Color(0xFFE2E8F0), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24), - const SizedBox(height: 4), - Text( - context.l10n.d('Afbeelding'), - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), + child: LayoutBuilder( + builder: (context, constraints) { + final shortestSide = constraints.biggest.shortestSide; + if (shortestSide < 48) { + return Center( + child: Icon( + Icons.image_outlined, + color: const Color(0xFF94A3B8), + size: shortestSide * 0.65, + ), + ); + } + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.image_outlined, + color: Color(0xFF94A3B8), + size: 24, + ), + const SizedBox(height: 4), + Text( + context.l10n.d('Afbeelding'), + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), + ), + ], ), - ], - ), + ); + }, ), ); } diff --git a/test/chart_editor_test.dart b/test/chart_editor_test.dart new file mode 100644 index 0000000..045da04 --- /dev/null +++ b/test/chart_editor_test.dart @@ -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 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( + find.byKey(const ValueKey('chart-series-column-2')), + ); + expect(column.color, const Color(0xFFE2E8F0)); + final input = tester.widget( + 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); + }); +} diff --git a/test/chart_preview_test.dart b/test/chart_preview_test.dart new file mode 100644 index 0000000..e69e4ac --- /dev/null +++ b/test/chart_preview_test.dart @@ -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(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(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(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(find.byType(BarChart)); + final ys = bar.data.extraLinesData.horizontalLines.map((l) => l.y).toList(); + expect(ys, containsAll([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(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(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(find.text('Categorie').first); + final normalSize = normal.style!.fontSize!; + expect(normalSize, lessThanOrEqualTo(12)); + + await tester.pumpWidget(_host(spec, presentationMode: true)); + final presented = tester.widget(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(find.byType(PieChart))) { + final box = tester.renderObject(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); + }, + ); +} diff --git a/test/chart_test.dart b/test/chart_test.dart index 7afb2b3..b4da63e 100644 --- a/test/chart_test.dart +++ b/test/chart_test.dart @@ -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); + }); }); } diff --git a/test/deck_provider_test.dart b/test/deck_provider_test.dart index 6c540ce..6193e47 100644 --- a/test/deck_provider_test.dart +++ b/test/deck_provider_test.dart @@ -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 diff --git a/test/file_service_test.dart b/test/file_service_test.dart index 2b55f89..68ed830 100644 --- a/test/file_service_test.dart +++ b/test/file_service_test.dart @@ -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); + }, + ); } diff --git a/test/image_slides_preview_test.dart b/test/image_slides_preview_test.dart index a2d6790..3af977d 100644 --- a/test/image_slides_preview_test.dart +++ b/test/image_slides_preview_test.dart @@ -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 { diff --git a/test/marp_html_service_test.dart b/test/marp_html_service_test.dart index eaa9605..4c7f4d6 100644 --- a/test/marp_html_service_test.dart +++ b/test/marp_html_service_test.dart @@ -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'))); + }); }