From 67408c213c56d2b05d5ae7a6496178d232a7c1f5 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Mon, 8 Jun 2026 12:18:35 +0200 Subject: [PATCH 1/4] Improve chart rendering and resolve theme logo paths Charts: - Shrink axis label fonts and thin/space x-axis labels by actual pixel spacing so dense or long labels no longer overlap. - Line tooltip shows only the point nearest the cursor instead of every series stacked vertically. - Hovering a legend entry highlights its element: bar/line series fade the others (pie expands the matching slice), in app and presentation mode. - Add optional min/max threshold lines per bar/line chart (ignored for pie), editable in the chart editor and drawn in both the live preview and the exported SVG. Theme: - Resolve relative logo paths in a ThemeProfile against the project path and home directory so deck logos load regardless of working directory. Tests cover bound round-trip, editor fields, SVG bounds, legend-hover fading, and bound-line rendering. Co-Authored-By: Claude Opus 4.8 --- .../xcshareddata/swiftpm/Package.resolved | 59 -- .../xcshareddata/swiftpm/Package.resolved | 59 -- lib/l10n/app_localizations.dart | 64 ++ lib/models/chart.dart | 117 ++- lib/services/file_service.dart | 26 +- lib/services/marp_html_service.dart | 301 ++++-- lib/state/deck_provider.dart | 18 +- lib/widgets/editors/chart_editor.dart | 562 ++++++++++-- lib/widgets/presentation/audience_window.dart | 1 + .../presentation/fullscreen_presenter.dart | 2 + lib/widgets/slides/slide_preview.dart | 866 ++++++++++++++---- test/chart_editor_test.dart | 166 ++++ test/chart_preview_test.dart | 340 +++++++ test/chart_test.dart | 66 +- test/deck_provider_test.dart | 33 + test/file_service_test.dart | 25 + test/image_slides_preview_test.dart | 25 + test/marp_html_service_test.dart | 85 ++ 18 files changed, 2372 insertions(+), 443 deletions(-) delete mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 test/chart_editor_test.dart create mode 100644 test/chart_preview_test.dart 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'))); + }); } From de4a77e2bb244a433557e4a18fc42c29f053666a Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Mon, 8 Jun 2026 13:51:29 +0200 Subject: [PATCH 2/4] Add code-slide theming, radar scale, and proximity line tooltips Code slides: - Theme code (broncode) background and text colours, with an optional syntax-colouring toggle. With it off the block renders monochrome, so a black background + bright green gives a classic CRT-screen look. - Colour pickers gained a custom hex entry so arbitrary colours (e.g. CRT green) can be set, not just presets. Exported HTML mirrors the code colours. Radar/spider charts: - Optional min/max now define the radar scale (centre/outer ring) instead of threshold lines. Evenly spaced, labelled tick rings are drawn in both the live preview and the SVG export so the scale is readable. A nice scale is derived from the data when no bounds are set. Line chart tooltips: - Detect the touched dot by true (x and y) distance instead of the x-only default, so the tooltip belongs to the point under the cursor. Overlapping dots all show, and the font shrinks a step when several stack. New UI strings are translated across all supported languages. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_localizations.dart | 126 +++++++++++ lib/models/chart.dart | 10 +- lib/models/settings.dart | 27 +++ lib/services/marp_html_service.dart | 131 ++++++++++- lib/widgets/dialogs/settings_dialog.dart | 162 +++++++++++++- lib/widgets/editors/chart_editor.dart | 30 ++- lib/widgets/slides/slide_preview.dart | 273 ++++++++++++++++++++--- test/app_localizations_test.dart | 1 + test/chart_preview_test.dart | 120 ++++++++++ test/chart_test.dart | 39 ++++ test/code_preview_test.dart | 83 +++++++ test/marp_html_service_test.dart | 43 ++++ test/settings_provider_test.dart | 20 ++ 13 files changed, 1024 insertions(+), 41 deletions(-) create mode 100644 test/code_preview_test.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 892d798..4c14c4c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2342,6 +2342,7 @@ const _dutchSourceStringAdditions = { 'Staaf': 'Bar', 'Lijn': 'Line', 'Cirkel': 'Pie', + 'Spider': 'Spider', 'CSV importeren': 'Import CSV', 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: first row = series names, first column = labels)', @@ -2364,6 +2365,23 @@ const _dutchSourceStringAdditions = { 'Toepassen': 'Apply', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'Pie charts show at most the first two series; the labels form the slices.', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'A spider chart needs at least three labels (axes); each series forms a shape.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'A spider chart needs at least three labels', + 'Minimumlijn (optioneel)': 'Minimum line (optional)', + 'Maximumlijn (optioneel)': 'Maximum line (optional)', + 'Schaalminimum (optioneel)': 'Scale minimum (optional)', + 'Schaalmaximum (optioneel)': 'Scale maximum (optional)', + 'geen': 'none', + 'Broncode achtergrond': 'Code background', + 'Broncode tekst': 'Code text', + 'Syntaxkleuring': 'Syntax colouring', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Off = everything in one colour (e.g. green on black for a CRT screen).', + 'Eigen kleur (hex)': 'Custom colour (hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'For example #33FF33 for a CRT-green screen.', 'Platte tekst': 'Plain text', 'Titel (optioneel)': 'Title (optional)', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': @@ -2382,6 +2400,24 @@ const _dutchSourceStringAdditions = { }, 'it': { 'Annuleren': 'Annulla', + 'Spider': 'Radar', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'Un grafico radar richiede almeno tre etichette (assi); ogni serie forma una superficie.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'Un grafico radar richiede almeno tre etichette', + 'Minimumlijn (optioneel)': 'Linea minima (facoltativa)', + 'Maximumlijn (optioneel)': 'Linea massima (facoltativa)', + 'Schaalminimum (optioneel)': 'Scala minima (facoltativa)', + 'Schaalmaximum (optioneel)': 'Scala massima (facoltativa)', + 'geen': 'nessuno', + 'Broncode achtergrond': 'Sfondo del codice', + 'Broncode tekst': 'Testo del codice', + 'Syntaxkleuring': 'Colorazione della sintassi', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Off = tutto in un solo colore (es. verde su nero per uno schermo CRT).', + 'Eigen kleur (hex)': 'Colore personalizzato (hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'Ad esempio #33FF33 per uno schermo verde CRT.', 'Kleur van reeks': 'Colore della serie', 'Kleur van rij': 'Colore della riga', 'Hexkleur': 'Colore esadecimale', @@ -2590,6 +2626,24 @@ const _dutchSourceStringAdditions = { }, 'de': { 'Annuleren': 'Abbrechen', + 'Spider': 'Netz', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'Ein Netzdiagramm braucht mindestens drei Beschriftungen (Achsen); jede Reihe bildet eine Fläche.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'Ein Netzdiagramm braucht mindestens drei Beschriftungen', + 'Minimumlijn (optioneel)': 'Minimumlinie (optional)', + 'Maximumlijn (optioneel)': 'Maximumlinie (optional)', + 'Schaalminimum (optioneel)': 'Skalenminimum (optional)', + 'Schaalmaximum (optioneel)': 'Skalenmaximum (optional)', + 'geen': 'keine', + 'Broncode achtergrond': 'Code-Hintergrund', + 'Broncode tekst': 'Code-Text', + 'Syntaxkleuring': 'Syntaxfärbung', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Aus = alles in einer Farbe (z. B. Grün auf Schwarz für einen CRT-Bildschirm).', + 'Eigen kleur (hex)': 'Eigene Farbe (Hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.', 'Kleur van reeks': 'Reihenfarbe', 'Kleur van rij': 'Zeilenfarbe', 'Hexkleur': 'Hex-Farbe', @@ -2799,6 +2853,24 @@ const _dutchSourceStringAdditions = { }, 'fr': { 'Annuleren': 'Annuler', + 'Spider': 'Radar', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'Un graphique radar nécessite au moins trois étiquettes (axes); chaque série forme une surface.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'Un graphique radar nécessite au moins trois étiquettes', + 'Minimumlijn (optioneel)': 'Ligne minimale (facultatif)', + 'Maximumlijn (optioneel)': 'Ligne maximale (facultatif)', + 'Schaalminimum (optioneel)': 'Échelle minimale (facultatif)', + 'Schaalmaximum (optioneel)': 'Échelle maximale (facultatif)', + 'geen': 'aucune', + 'Broncode achtergrond': 'Fond du code', + 'Broncode tekst': 'Texte du code', + 'Syntaxkleuring': 'Coloration syntaxique', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Désactivé = tout en une seule couleur (p. ex. vert sur noir pour un écran CRT).', + 'Eigen kleur (hex)': 'Couleur personnalisée (hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'Par exemple #33FF33 pour un écran vert CRT.', 'Kleur van reeks': 'Couleur de la série', 'Kleur van rij': 'Couleur de la ligne', 'Hexkleur': 'Couleur hexadécimale', @@ -3008,6 +3080,24 @@ const _dutchSourceStringAdditions = { }, 'es': { 'Annuleren': 'Cancelar', + 'Spider': 'Radar', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'Un gráfico radar necesita al menos tres etiquetas (ejes); cada serie forma una superficie.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'Un gráfico radar necesita al menos tres etiquetas', + 'Minimumlijn (optioneel)': 'Línea mínima (opcional)', + 'Maximumlijn (optioneel)': 'Línea máxima (opcional)', + 'Schaalminimum (optioneel)': 'Escala mínima (opcional)', + 'Schaalmaximum (optioneel)': 'Escala máxima (opcional)', + 'geen': 'ninguno', + 'Broncode achtergrond': 'Fondo del código', + 'Broncode tekst': 'Texto del código', + 'Syntaxkleuring': 'Coloreado de sintaxis', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Desactivado = todo en un solo color (p. ej. verde sobre negro para una pantalla CRT).', + 'Eigen kleur (hex)': 'Color personalizado (hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'Por ejemplo #33FF33 para una pantalla verde CRT.', 'Kleur van reeks': 'Color de la serie', 'Kleur van rij': 'Color de la fila', 'Hexkleur': 'Color hexadecimal', @@ -3217,6 +3307,24 @@ const _dutchSourceStringAdditions = { }, 'fy': { 'Annuleren': 'Annulearje', + 'Spider': 'Spider', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'In spiderdiagram hat op syn minst trije labels (assen) nedich; eltse rige foarmet in flak.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'In spiderdiagram hat op syn minst trije labels nedich', + 'Minimumlijn (optioneel)': 'Minimumline (opsjoneel)', + 'Maximumlijn (optioneel)': 'Maksimumline (opsjoneel)', + 'Schaalminimum (optioneel)': 'Skaalminimum (opsjoneel)', + 'Schaalmaximum (optioneel)': 'Skaalmaksimum (opsjoneel)', + 'geen': 'gjin', + 'Broncode achtergrond': 'Boarnekoade eftergrûn', + 'Broncode tekst': 'Boarnekoade tekst', + 'Syntaxkleuring': 'Syntakskleuring', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Ut = alles yn ien kleur (bgl. grien op swart foar in CRT-skerm).', + 'Eigen kleur (hex)': 'Eigen kleur (hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'Bygelyks #33FF33 foar in CRT-grien skerm.', 'Kleur van reeks': 'Rigekleur', 'Kleur van rij': 'Rijekleur', 'Hexkleur': 'Hekskleur', @@ -3423,6 +3531,24 @@ const _dutchSourceStringAdditions = { }, 'pap': { 'Annuleren': 'Kanselá', + 'Spider': 'Radar', + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': + 'Un grafiko radar mester di por lo ménos tres etiketa (ehe); kada serie ta forma un superfisie.', + 'Een spider-diagram heeft minstens drie labels nodig': + 'Un grafiko radar mester di por lo ménos tres etiketa', + 'Minimumlijn (optioneel)': 'Liña mínimo (opshonal)', + 'Maximumlijn (optioneel)': 'Liña máksimo (opshonal)', + 'Schaalminimum (optioneel)': 'Eskala mínimo (opshonal)', + 'Schaalmaximum (optioneel)': 'Eskala máksimo (opshonal)', + 'geen': 'niun', + 'Broncode achtergrond': 'Fondo di kódigo', + 'Broncode tekst': 'Teksto di kódigo', + 'Syntaxkleuring': 'Koloreashon di sintaksis', + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).': + 'Apagá = tur kos den un solo koló (p.e. berde riba pretu pa un pantaya CRT).', + 'Eigen kleur (hex)': 'Koló propio (hex)', + 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': + 'Por ehèmpel #33FF33 pa un pantaya berde CRT.', 'Kleur van reeks': 'Koló di serie', 'Kleur van rij': 'Koló di liña', 'Hexkleur': 'Koló hexadecimal', diff --git a/lib/models/chart.dart b/lib/models/chart.dart index efb801f..f07bcd2 100644 --- a/lib/models/chart.dart +++ b/lib/models/chart.dart @@ -18,7 +18,7 @@ const List chartColorPalette = [ ]; /// Supported chart kinds for a chart slide. -enum ChartType { bar, line, pie } +enum ChartType { bar, line, pie, radar } ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere( (t) => t.name == name, @@ -100,9 +100,15 @@ class ChartSpec { bool get hasInlineData => x.isNotEmpty && series.isNotEmpty; - /// Bounds only apply to bar/line charts; a pie spec never shows them. + /// Whether the optional [minBound]/[maxBound] apply. On bar/line they are + /// horizontal threshold lines; on radar they fix the scale (centre/outer + /// ring). Pie charts have no axis, so they never use bounds. bool get supportsBounds => type != ChartType.pie; + /// True only where bounds render as horizontal threshold *lines*. + bool get supportsBoundLines => + type == ChartType.bar || type == ChartType.line; + ChartSpec copyWith({ ChartType? type, String? title, diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 5257d79..96afb90 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -8,6 +8,17 @@ class ThemeProfile { final String titleBackgroundColor; final String titleTextColor; final String sectionBackgroundColor; + + /// Colours for code (broncode) slides. Defaults mirror the atom-one-dark + /// editor look. Set e.g. black background + bright green text with + /// [codeHighlightSyntax] off for a classic CRT terminal feel. + final String codeBackgroundColor; + final String codeTextColor; + + /// When false, code is shown monochrome in [codeTextColor] (no per-token + /// syntax colours) — required for a believable single-colour CRT screen. + final bool codeHighlightSyntax; + final String? logoPath; final String logoPosition; final int logoSize; @@ -40,6 +51,9 @@ class ThemeProfile { this.titleBackgroundColor = '#1C2B47', this.titleTextColor = '#FFFFFF', this.sectionBackgroundColor = '#2E7D64', + this.codeBackgroundColor = '#282C34', + this.codeTextColor = '#ABB2BF', + this.codeHighlightSyntax = true, this.logoPath, this.logoPosition = 'bottom-right', this.logoSize = 96, @@ -70,6 +84,9 @@ class ThemeProfile { String? titleBackgroundColor, String? titleTextColor, String? sectionBackgroundColor, + String? codeBackgroundColor, + String? codeTextColor, + bool? codeHighlightSyntax, String? logoPath, String? logoPosition, int? logoSize, @@ -92,6 +109,9 @@ class ThemeProfile { titleTextColor: titleTextColor ?? this.titleTextColor, sectionBackgroundColor: sectionBackgroundColor ?? this.sectionBackgroundColor, + codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor, + codeTextColor: codeTextColor ?? this.codeTextColor, + codeHighlightSyntax: codeHighlightSyntax ?? this.codeHighlightSyntax, logoPath: clearLogo ? null : (logoPath ?? this.logoPath), logoPosition: logoPosition ?? this.logoPosition, logoSize: logoSize ?? this.logoSize, @@ -116,6 +136,9 @@ class ThemeProfile { 'titleBackgroundColor': titleBackgroundColor, 'titleTextColor': titleTextColor, 'sectionBackgroundColor': sectionBackgroundColor, + 'codeBackgroundColor': codeBackgroundColor, + 'codeTextColor': codeTextColor, + 'codeHighlightSyntax': codeHighlightSyntax, 'logoPath': logoPath, 'logoPosition': logoPosition, 'logoSize': logoSize, @@ -146,6 +169,10 @@ class ThemeProfile { titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF', sectionBackgroundColor: json['sectionBackgroundColor'] as String? ?? '#2E7D64', + codeBackgroundColor: + json['codeBackgroundColor'] as String? ?? '#282C34', + codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF', + codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true, logoPath: json['logoPath'] as String?, logoPosition: json['logoPosition'] as String? ?? 'bottom-right', logoSize: (json['logoSize'] as num?)?.round() ?? 96, diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index d137768..25508c8 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -171,6 +171,8 @@ class MarpHtmlService { case ChartType.pie: final legendRows = (spec.x.length / 6).ceil().clamp(1, 3); _pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28); + case ChartType.radar: + _radarSvg(b, spec, plotTop, theme, textColor); } if (spec.type == ChartType.pie) { _pieLegendSvg(b, spec, textColor); @@ -269,7 +271,7 @@ class MarpHtmlService { double bottom, double maxY, ) { - if (!spec.supportsBounds) return; + if (!spec.supportsBoundLines) return; void draw(double? value, String color, String prefix) { if (value == null || value < 0 || value > maxY) return; final y = bottom - (bottom - top) * (value / maxY); @@ -448,6 +450,129 @@ class MarpHtmlService { } } + static void _radarSvg( + StringBuffer b, + ChartSpec spec, + double top, + ThemeProfile? theme, + String textColor, + ) { + final n = spec.x.length; + if (n < 3 || spec.series.isEmpty) return; + const bottom = 382.0; + const cx = 400.0; + final cy = (top + bottom) / 2; + final radius = math.min(170.0, (bottom - top) / 2 - 34); + final (lo, hi, ticks) = _radarScale(spec); + final span = (hi - lo) == 0 ? 1.0 : (hi - lo); + double angle(int i) => -math.pi / 2 + 2 * math.pi * i / n; + + // Concentric grid rings, evenly spaced across the [lo, hi] scale. + for (var ring = 1; ring <= ticks; ring++) { + final rr = radius * ring / ticks; + final pts = [ + for (var i = 0; i < n; i++) + '${cx + rr * math.cos(angle(i))},${cy + rr * math.sin(angle(i))}', + ].join(' '); + b.write( + '', + ); + } + + // Scale labels up the top spoke: lo at the centre, hi at the outer ring. + for (var k = 0; k <= ticks; k++) { + final value = lo + span * k / ticks; + final y = cy - radius * k / ticks; + b.write( + '${_num(value)}', + ); + } + + // Spokes and axis labels. + for (var i = 0; i < n; i++) { + final c = math.cos(angle(i)); + final s = math.sin(angle(i)); + b.write( + '', + ); + final anchor = c > 0.3 ? 'start' : (c < -0.3 ? 'end' : 'middle'); + final label = spec.x[i].length > 12 + ? '${spec.x[i].substring(0, 11)}…' + : spec.x[i]; + b.write( + '' + '${_esc(label)}', + ); + } + + // One filled polygon per series. + for (var si = 0; si < spec.series.length; si++) { + final data = spec.series[si].data; + final pts = [ + for (var i = 0; i < n; i++) + () { + final v = i < data.length ? data[i] : 0.0; + final rr = radius * ((v - lo) / span).clamp(0.0, 1.0); + return '${cx + rr * math.cos(angle(i))},' + '${cy + rr * math.sin(angle(i))}'; + }(), + ].join(' '); + final color = _color(spec, si, theme); + b.write( + '', + ); + } + } + + /// Radar scale shared with the live preview: honour optional min/max bounds, + /// otherwise round the data range to a tidy scale with an even tick count. + static (double, double, int) _radarScale(ChartSpec spec) { + var dataMin = 0.0; + var dataMax = 0.0; + var seen = false; + for (final s in spec.series) { + for (final v in s.data) { + if (!seen) { + dataMin = v; + dataMax = v; + seen = true; + } else { + if (v < dataMin) dataMin = v; + if (v > dataMax) dataMax = v; + } + } + } + if (!seen) { + dataMin = 0; + dataMax = 1; + } + final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); + final rawHi = spec.maxBound ?? dataMax; + final range = (rawHi - rawLo).abs(); + final r = range <= 0 ? 1.0 : range; + final rawStep = r / 4; + final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble(); + final norm = rawStep / mag; + final niceNorm = norm < 1.5 + ? 1.0 + : norm < 3 + ? 2.0 + : norm < 7 + ? 5.0 + : 10.0; + final step = niceNorm * mag; + final lo = spec.minBound ?? (rawLo / step).floorToDouble() * step; + var hi = spec.maxBound ?? (rawHi / step).ceilToDouble() * step; + if (hi <= lo) hi = lo + step; + final ticks = math.max(2, ((hi - lo) / step).round()); + return (lo, hi, ticks); + } + static String _shortChartLabel(String value) => value.length > 13 ? '${value.substring(0, 12)}…' : value; @@ -469,8 +594,10 @@ class MarpHtmlService { '.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}' '.slide a{color:${t.accentColor}}' '.slide p,.slide li{font-size:24px;line-height:1.45}' - '.slide pre{background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;' + '.slide pre{background:${t.codeBackgroundColor};color:${t.codeTextColor};' + 'border:1px solid ${t.codeTextColor}38;border-radius:6px;' 'padding:16px;overflow:auto;font-size:18px}' + '.slide pre code{color:${t.codeTextColor};background:transparent}' '.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}' '.slide pre.mermaid{background:transparent;border:0;text-align:center}' '.slide img{max-width:100%}' diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index 1a99fff..fb7f4a2 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -939,6 +939,41 @@ class _SettingsDialogState extends ConsumerState { (v) => _themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v), ), + const SizedBox(height: 24), + _sectionTitle(l10n.d('Broncode')), + _colorSetting( + l10n.d('Broncode achtergrond'), + _themeProfile.codeBackgroundColor, + (v) => + _themeProfile = _themeProfile.copyWith(codeBackgroundColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + l10n.d('Broncode tekst'), + _themeProfile.codeTextColor, + (v) => _themeProfile = _themeProfile.copyWith(codeTextColor: v), + ), + const SizedBox(height: 6), + SwitchListTile( + value: _themeProfile.codeHighlightSyntax, + onChanged: (v) => setState(() { + _themeProfile = _themeProfile.copyWith(codeHighlightSyntax: v); + _profileTouched = true; + }), + title: Text( + l10n.d('Syntaxkleuring'), + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + l10n.d( + 'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + ), const SizedBox(height: 18), _stylePreview(), ], @@ -1149,18 +1184,143 @@ class _SettingsDialogState extends ConsumerState { for (final color in _colorPresets) _colorSwatch( color, - selected: value == color, + selected: value.toUpperCase() == color, onTap: () => setState(() { onChanged(color); _profileTouched = true; }), ), + // Show the current value as a selected swatch when it isn't one of + // the presets (e.g. a hand-entered CRT green). + if (!_colorPresets.contains(value.toUpperCase())) + _colorSwatch( + value, + selected: true, + onTap: () => _editCustomColor(value, onChanged), + ), + _customColorButton(value, onChanged), ], ), ], ); } + Widget _customColorButton(String value, ValueChanged onChanged) { + return Tooltip( + message: context.l10n.d('Eigen kleur (hex)'), + child: InkWell( + onTap: () => _editCustomColor(value, onChanged), + borderRadius: BorderRadius.circular(8), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + child: const Icon( + Icons.tune, + size: 18, + color: Color(0xFF64748B), + ), + ), + ), + ); + } + + Future _editCustomColor( + String initial, + ValueChanged onChanged, + ) async { + final picked = await _pickHexColor(initial); + if (picked == null || !mounted) return; + setState(() { + onChanged(picked); + _profileTouched = true; + }); + } + + Future _pickHexColor(String initial) { + final controller = TextEditingController(text: initial); + String? normalize(String raw) { + final up = raw.trim().toUpperCase(); + final hex = up.startsWith('#') ? up : '#$up'; + return RegExp(r'^#[0-9A-F]{6}$').hasMatch(hex) ? hex : null; + } + + final l10n = context.l10n; + return showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) { + final normalized = normalize(controller.text); + return AlertDialog( + title: Text(l10n.d('Eigen kleur (hex)')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _parseColor(normalized ?? '#FFFFFF'), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.d('Hexkleur'), + hintText: '#33FF33', + isDense: true, + border: const OutlineInputBorder(), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[#0-9a-fA-F]'), + ), + LengthLimitingTextInputFormatter(7), + ], + onChanged: (_) => setDialogState(() {}), + onSubmitted: (_) { + final ok = normalize(controller.text); + if (ok != null) Navigator.pop(context, ok); + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + l10n.d('Bijvoorbeeld #33FF33 voor een CRT-groen scherm.'), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.d('Annuleren')), + ), + FilledButton( + onPressed: normalized == null + ? null + : () => Navigator.pop(context, normalized), + child: Text(l10n.d('Toepassen')), + ), + ], + ); + }, + ), + ).whenComplete(controller.dispose); + } + Widget _colorSwatch( String color, { required bool selected, diff --git a/lib/widgets/editors/chart_editor.dart b/lib/widgets/editors/chart_editor.dart index 5798837..5905208 100644 --- a/lib/widgets/editors/chart_editor.dart +++ b/lib/widgets/editors/chart_editor.dart @@ -65,6 +65,8 @@ class _ChartEditorState extends State { _loadFromSpec(spec); } + bool get _supportsBounds => _type != ChartType.pie; + static String _fmtBound(double? v) => v == null ? '' : _fmt(v); static double? _parseBound(String raw) { @@ -133,8 +135,8 @@ class _ChartEditorState extends State { 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), + minBound: _supportsBounds ? _parseBound(_minBound.text) : null, + maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null, ); widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock())); } @@ -467,6 +469,10 @@ class _ChartEditorState extends State { value: ChartType.pie, child: Text(l10n.d('Cirkel')), ), + DropdownMenuItem( + value: ChartType.radar, + child: Text(l10n.d('Spider')), + ), ], onChanged: (v) { if (v == null) return; @@ -492,7 +498,17 @@ class _ChartEditorState extends State { style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), ), ), - if (_type != ChartType.pie) + if (_type == ChartType.radar) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l10n.d( + 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), + ), + ), + if (_supportsBounds) Padding( padding: const EdgeInsets.only(top: 12), child: Row( @@ -502,7 +518,9 @@ class _ChartEditorState extends State { child: _boundField( key: const ValueKey('chart-min-bound'), controller: _minBound, - label: l10n.d('Minimumlijn (optioneel)'), + label: _type == ChartType.radar + ? l10n.d('Schaalminimum (optioneel)') + : l10n.d('Minimumlijn (optioneel)'), ), ), const SizedBox(width: 12), @@ -510,7 +528,9 @@ class _ChartEditorState extends State { child: _boundField( key: const ValueKey('chart-max-bound'), controller: _maxBound, - label: l10n.d('Maximumlijn (optioneel)'), + label: _type == ChartType.radar + ? l10n.d('Schaalmaximum (optioneel)') + : l10n.d('Maximumlijn (optioneel)'), ), ), ], diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index c5d4b5c..3810af3 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -2095,21 +2095,33 @@ class _CodePreview extends StatelessWidget { final lang = slide.codeLanguage.trim(); final known = lang.isNotEmpty && allLanguages.containsKey(lang); + final codeBg = _hexColor(profile.codeBackgroundColor); + final codeFg = _hexColor(profile.codeTextColor); + final mono = TextStyle( fontFamily: 'monospace', fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], fontSize: w * 0.024, height: 1.4, - color: const Color(0xFFABB2BF), // atom-one-dark voorgrond + color: codeFg, ); - // HighlightView gooit een fout bij een onbekende taal; daarom vallen we - // dan terug op platte (maar wel monospace) tekst. - final Widget codeContent = known + // HighlightView throws on an unknown language, so fall back to plain (but + // monospace) text. When syntax highlighting is off we always render plain + // text so the whole block is one colour — needed for a CRT-green screen. + final Widget codeContent = (known && profile.codeHighlightSyntax) ? HighlightView( code, language: lang, - theme: atomOneDarkTheme, + // Keep atom-one-dark's per-token colours but drop its own + // background so our themed [codeBg] shows through unchanged. + theme: { + ...atomOneDarkTheme, + 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( + backgroundColor: codeBg, + color: codeFg, + ), + }, padding: EdgeInsets.zero, textStyle: mono, ) @@ -2127,9 +2139,9 @@ class _CodePreview extends StatelessWidget { child: Container( width: double.infinity, decoration: BoxDecoration( - color: const Color(0xFF282C34), // atom-one-dark achtergrond + color: codeBg, borderRadius: BorderRadius.circular(w * 0.012), - border: Border.all(color: const Color(0xFF3A3F4B)), + border: Border.all(color: codeFg.withValues(alpha: 0.22)), ), padding: EdgeInsets.all(w * 0.03), child: Column( @@ -2144,7 +2156,7 @@ class _CodePreview extends StatelessWidget { TextStyle( fontSize: w * 0.03, fontWeight: FontWeight.bold, - color: const Color(0xFFE5E7EB), + color: codeFg, ), ), linkColor: _hexColor(profile.accentColor), @@ -2475,6 +2487,8 @@ class _ChartPreviewState extends State<_ChartPreview> { return _lineChart(spec, textColor); case ChartType.pie: return _pieChart(spec, textColor); + case ChartType.radar: + return _radarChart(spec, textColor); } } @@ -2511,7 +2525,7 @@ class _ChartPreviewState extends State<_ChartPreview> { /// Optional min/max threshold lines drawn across the plot (bar/line only). ExtraLinesData _boundLines(ChartSpec spec) { - if (!spec.supportsBounds) return const ExtraLinesData(); + if (!spec.supportsBoundLines) return const ExtraLinesData(); final dash = [ (w * 0.018).round().clamp(4, 14), (w * 0.01).round().clamp(3, 9), @@ -2729,6 +2743,10 @@ class _ChartPreviewState extends State<_ChartPreview> { extraLinesData: _boundLines(spec), lineTouchData: LineTouchData( enabled: true, + // Measure proximity to the actual dot (x *and* y), not just the + // column, so the tooltip belongs to the point under the cursor. + distanceCalculator: (touch, spot) => (touch - spot).distance, + touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(), mouseCursorResolver: (event, response) => response?.lineBarSpots?.isEmpty ?? true ? SystemMouseCursors.basic @@ -2737,30 +2755,18 @@ class _ChartPreviewState extends State<_ChartPreview> { fitInsideHorizontally: true, fitInsideVertically: true, getTooltipColor: (_) => const Color(0xFF0F172A), + // Show every dot near the cursor. When several dots sit on (almost) + // the same spot they all appear; the font shrinks to keep them + // readable when stacked. 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; - } - } + final style = _lineTooltipStyle(spots.length); 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(), - ), + for (final spot in spots) + LineTooltipItem( + '${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n' + '${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}', + style, + ), ]; }, ), @@ -2880,6 +2886,195 @@ class _ChartPreviewState extends State<_ChartPreview> { ); } + Widget _radarChart(ChartSpec spec, Color textColor) { + if (spec.x.length < 3 || spec.series.isEmpty) { + return _placeholderText( + context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'), + ); + } + final grid = textColor.withValues(alpha: 0.18); + final scale = radarScale(spec); + final bg = _hexColor(profile.slideBackgroundColor); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.06, vertical: w * 0.012), + child: LayoutBuilder( + builder: (context, constraints) { + // A square keeps fl_chart's centre/radius predictable so the tick + // labels we overlay line up exactly with its grid rings. + final side = math.min(constraints.maxWidth, constraints.maxHeight); + final centerOffset = side / 2; + final radius = centerOffset * 0.8; // matches RadarChartPainter + final tickStyle = _applyFont( + font, + TextStyle( + fontSize: w * 0.012 * _labelScale, + color: textColor.withValues(alpha: 0.6), + fontWeight: FontWeight.w600, + ), + ); + + return Center( + child: SizedBox( + width: side, + height: side, + child: Stack( + children: [ + Positioned.fill( + child: RadarChart( + RadarChartData( + dataSets: [ + for (var si = 0; si < spec.series.length; si++) + RadarDataSet( + dataEntries: [ + for (var xi = 0; xi < spec.x.length; xi++) + RadarEntry( + value: xi < spec.series[si].data.length + ? spec.series[si].data[xi] + : 0, + ), + ], + fillColor: _seriesDisplayColor( + spec.series[si], + si, + ).withValues(alpha: _dimmed(si) ? 0.04 : 0.16), + borderColor: _seriesDisplayColor( + spec.series[si], + si, + ), + borderWidth: w * (_hovered == si ? 0.0055 : 0.0035), + entryRadius: w * (_hovered == si ? 0.006 : 0.004), + ), + // Invisible anchor pinning the scale to [lo, hi] so the + // rings — and our labels — represent a fixed scale. + RadarDataSet( + dataEntries: [ + for (var xi = 0; xi < spec.x.length; xi++) + RadarEntry(value: xi == 0 ? scale.hi : scale.lo), + ], + fillColor: Colors.transparent, + borderColor: Colors.transparent, + borderWidth: 0, + entryRadius: 0, + ), + ], + radarShape: RadarShape.polygon, + radarBackgroundColor: Colors.transparent, + radarBorderData: BorderSide(color: grid, width: 1), + gridBorderData: BorderSide(color: grid, width: 1), + tickBorderData: BorderSide(color: grid, width: 1), + tickCount: scale.ticks, + isMinValueAtCenter: true, + // Hide fl_chart's own ring numbers; we draw labelled + // ticks ourselves so any min/max scale reads correctly. + ticksTextStyle: const TextStyle( + color: Colors.transparent, + fontSize: 0.001, + ), + titlePositionPercentageOffset: 0.14, + getTitle: (index, angle) => RadarChartTitle( + text: index < spec.x.length ? spec.x[index] : '', + ), + titleTextStyle: _applyFont( + font, + TextStyle( + fontSize: w * 0.0135 * _labelScale, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + radarTouchData: RadarTouchData(enabled: false), + ), + duration: Duration.zero, + ), + ), + // Evenly spaced scale labels up the top spoke: lo at centre, + // hi at the outer ring, with equal steps between. + for (var k = 0; k <= scale.ticks; k++) + Positioned( + left: centerOffset + w * 0.006, + top: + centerOffset - + radius * k / scale.ticks - + w * 0.01 * _labelScale, + child: IgnorePointer( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.004, + ), + color: bg.withValues(alpha: 0.7), + child: Text( + _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), + style: tickStyle, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + /// Resolves the radar scale: a low/high pair plus an even tick count. Honours + /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data + /// range to a tidy scale so the rings read as round numbers. + ({double lo, double hi, int ticks}) radarScale(ChartSpec spec) { + var dataMin = 0.0; + var dataMax = 0.0; + var seen = false; + for (final s in spec.series) { + for (final v in s.data) { + if (!seen) { + dataMin = v; + dataMax = v; + seen = true; + } else { + if (v < dataMin) dataMin = v; + if (v > dataMax) dataMax = v; + } + } + } + if (!seen) { + dataMin = 0; + dataMax = 1; + } + final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); + final rawHi = spec.maxBound ?? dataMax; + final nice = _niceScale(rawLo, rawHi); + final lo = spec.minBound ?? nice.lo; + var hi = spec.maxBound ?? nice.hi; + if (hi <= lo) hi = lo + nice.step; + final ticks = math.max(2, ((hi - lo) / nice.step).round()); + return (lo: lo, hi: hi, ticks: ticks); + } + + ({double lo, double hi, double step}) _niceScale(double lo, double hi) { + final range = (hi - lo).abs(); + final r = range <= 0 ? 1.0 : range; + final rawStep = r / 4; + final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble(); + final norm = rawStep / mag; + final niceNorm = norm < 1.5 + ? 1.0 + : norm < 3 + ? 2.0 + : norm < 7 + ? 5.0 + : 10.0; + final step = niceNorm * mag; + return ( + lo: (lo / step).floor() * step, + hi: (hi / step).ceil() * step, + step: step, + ); + } + TextStyle _tooltipStyle() => _applyFont( font, TextStyle( @@ -2890,6 +3085,22 @@ class _ChartPreviewState extends State<_ChartPreview> { ), ); + /// Tooltip style for line charts. Each touched dot adds two lines, so when + /// several dots overlap the font shrinks a step to keep the stack readable. + TextStyle _lineTooltipStyle(int count) { + final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0); + final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0); + return _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: (base * shrink).clamp(8.0, 18.0), + height: 1.2, + fontWeight: FontWeight.w700, + ), + ); + } + Widget _placeholder(BuildContext context) => _placeholderText(context.l10n.d('Geen grafiekgegevens')); diff --git a/test/app_localizations_test.dart b/test/app_localizations_test.dart index 768e0f4..8d27921 100644 --- a/test/app_localizations_test.dart +++ b/test/app_localizations_test.dart @@ -50,6 +50,7 @@ void main() { 'SLIDES', 'Slide', 'slide', + 'Spider', }; final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")'''); final files = Directory('lib') diff --git a/test/chart_preview_test.dart b/test/chart_preview_test.dart index e69e4ac..ec6a0c4 100644 --- a/test/chart_preview_test.dart +++ b/test/chart_preview_test.dart @@ -151,6 +151,49 @@ void main() { expect(lineItems.single?.text, 'Februari\nBezoekers: 17.5'); }); + testWidgets('line tooltip uses true distance and shows every nearby dot', ( + tester, + ) async { + const spec = ChartSpec( + type: ChartType.line, + x: ['Q1'], + series: [ + ChartSeries(name: 'Alpha', data: [10], color: '#2563EB'), + ChartSeries(name: 'Beta', data: [10], color: '#EF4444'), + ChartSeries(name: 'Gamma', data: [10], color: '#10B981'), + ], + ); + await tester.pumpWidget(_host(spec)); + final line = tester.widget(find.byType(LineChart)); + final touch = line.data.lineTouchData; + + // Proximity is Euclidean (x AND y), not the x-only default. + expect(touch.distanceCalculator(Offset.zero, const Offset(3, 4)), 5); + expect(touch.touchSpotThreshold, greaterThan(0)); + + final spots = [ + for (var i = 0; i < 3; i++) + LineBarSpot( + line.data.lineBarsData[i], + i, + line.data.lineBarsData[i].spots.single, + ), + ]; + final items = touch.touchTooltipData.getTooltipItems(spots); + // All overlapping dots are shown (none filtered out). + expect(items.length, 3); + expect(items.whereType().length, 3); + expect(items[0]?.text, 'Q1\nAlpha: 10'); + expect(items[2]?.text, 'Q1\nGamma: 10'); + + // A crowded stack uses a smaller font than a single tooltip. + final single = touch.touchTooltipData.getTooltipItems([spots.first]); + expect( + items[0]!.textStyle!.fontSize!, + lessThan(single.single!.textStyle!.fontSize!), + ); + }); + testWidgets('pie hover shows the underlying category value', (tester) async { const spec = ChartSpec( type: ChartType.pie, @@ -230,6 +273,83 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('radar chart renders a polygon per series with axis labels', ( + tester, + ) async { + const spec = ChartSpec( + type: ChartType.radar, + x: ['Snelheid', 'Kracht', 'Uithouding'], + series: [ + ChartSeries(name: 'Alpha', data: [3, 4, 5], color: '#2563EB'), + ChartSeries(name: 'Beta', data: [5, 2, 3], color: '#EF4444'), + ], + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + final radar = tester.widget(find.byType(RadarChart)); + // Two visible series plus one invisible scale anchor. + expect(radar.data.dataSets.length, 3); + expect(radar.data.dataSets.first.dataEntries.map((e) => e.value), [3, 4, 5]); + expect(radar.data.dataSets.last.fillColor, Colors.transparent); + // The spoke labels are supplied through getTitle (canvas-painted). + expect(radar.data.getTitle!(0, 0).text, 'Snelheid'); + expect(radar.data.getTitle!(2, 0).text, 'Uithouding'); + // The series legend is shown as real text widgets. + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('radar honours an explicit min/max scale with even ticks', ( + tester, + ) async { + const spec = ChartSpec( + type: ChartType.radar, + x: ['A', 'B', 'C', 'D'], + series: [ + ChartSeries(name: 'Score', data: [2, 4, 3, 5]), + ], + minBound: 0, + maxBound: 10, + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + final radar = tester.widget(find.byType(RadarChart)); + expect(radar.data.isMinValueAtCenter, isTrue); + // The hidden anchor pins the scale to [0, 10]. + final anchor = radar.data.dataSets.last.dataEntries.map((e) => e.value); + expect(anchor.reduce((a, b) => a < b ? a : b), 0); + expect(anchor.reduce((a, b) => a > b ? a : b), 10); + // Evenly spaced scale labels are drawn (0..10). + expect(find.text('0'), findsWidgets); + expect(find.text('10'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('radar chart asks for at least three labels', (tester) async { + const spec = ChartSpec( + type: ChartType.radar, + x: ['Een', 'Twee'], + series: [ + ChartSeries(name: 'Alpha', data: [3, 4]), + ], + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + expect(find.byType(RadarChart), findsNothing); + expect( + find.text('Een spider-diagram heeft minstens drie labels nodig'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('presentation mode enlarges chart labels', (tester) async { const spec = ChartSpec( type: ChartType.bar, diff --git a/test/chart_test.dart b/test/chart_test.dart index b4da63e..d3b5702 100644 --- a/test/chart_test.dart +++ b/test/chart_test.dart @@ -130,5 +130,44 @@ void main() { expect(back.minBound, isNull); expect(back.maxBound, isNull); }); + + test('round-trips a spider/radar chart type', () { + const spec = ChartSpec( + type: ChartType.radar, + x: ['Snelheid', 'Kracht', 'Uithouding'], + series: [ + ChartSeries(name: 'A', data: [3, 4, 5]), + ], + ); + final back = ChartSpec.parse(spec.toBlock()); + expect(back.type, ChartType.radar); + expect(back.x, ['Snelheid', 'Kracht', 'Uithouding']); + expect(back.series.single.data, [3, 4, 5]); + }); + + test('radar keeps bounds as a scale but never draws bound lines', () { + const spec = ChartSpec( + type: ChartType.radar, + x: ['A', 'B', 'C'], + series: [ChartSeries(name: 'A', data: [1, 2, 3])], + minBound: 1, + maxBound: 5, + ); + expect(spec.supportsBounds, isTrue); + expect(spec.supportsBoundLines, isFalse); + final back = ChartSpec.parse(spec.toBlock()); + expect(back.minBound, 1); + expect(back.maxBound, 5); + }); + + test('bar/line draw bound lines but pie does not', () { + const bar = ChartSpec(type: ChartType.bar); + const line = ChartSpec(type: ChartType.line); + const pie = ChartSpec(type: ChartType.pie); + expect(bar.supportsBoundLines, isTrue); + expect(line.supportsBoundLines, isTrue); + expect(pie.supportsBoundLines, isFalse); + expect(pie.supportsBounds, isFalse); + }); }); } diff --git a/test/code_preview_test.dart b/test/code_preview_test.dart new file mode 100644 index 0000000..c3f3eff --- /dev/null +++ b/test/code_preview_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_highlight/flutter_highlight.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +Widget _host(Slide slide, ThemeProfile profile) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget(slide: slide, themeProfile: profile), + ), + ), + ); +} + +Color _hex(String hex) => + Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000); + +void main() { + testWidgets('code slide paints the themed background colour', (tester) async { + final slide = Slide.create( + SlideType.code, + ).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}'); + const profile = ThemeProfile( + codeBackgroundColor: '#000000', + codeTextColor: '#33FF33', + ); + + await tester.pumpWidget(_host(slide, profile)); + await tester.pump(); + + // The code panel uses the themed background somewhere in its decoration. + final painted = tester.widgetList(find.byType(Container)).where(( + c, + ) { + final d = c.decoration; + return d is BoxDecoration && d.color == _hex('#000000'); + }); + expect(painted, isNotEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('syntax highlighting on uses HighlightView for a known language', ( + tester, + ) async { + final slide = Slide.create( + SlideType.code, + ).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}'); + const profile = ThemeProfile(codeHighlightSyntax: true); + + await tester.pumpWidget(_host(slide, profile)); + await tester.pump(); + + expect(find.byType(HighlightView), findsOneWidget); + }); + + testWidgets('syntax highlighting off renders monochrome (CRT) text', ( + tester, + ) async { + final slide = Slide.create( + SlideType.code, + ).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}'); + const profile = ThemeProfile( + codeBackgroundColor: '#000000', + codeTextColor: '#33FF33', + codeHighlightSyntax: false, + ); + + await tester.pumpWidget(_host(slide, profile)); + await tester.pump(); + + // No per-token highlighting; the code is one flat colour. + expect(find.byType(HighlightView), findsNothing); + final codeText = tester.widget(find.text('void main() {}')); + expect(codeText.style?.color, _hex('#33FF33')); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/marp_html_service_test.dart b/test/marp_html_service_test.dart index 4c7f4d6..8afd516 100644 --- a/test/marp_html_service_test.dart +++ b/test/marp_html_service_test.dart @@ -98,6 +98,21 @@ void main() {} expect(html, isNot(contains('data:font/ttf;base64,'))); }); + test('code blocks use the themed code colours in the export CSS', () async { + final service = MarpHtmlService( + loadAsset: _diskLoader, + loadBytes: _diskBytes, + ); + const theme = ThemeProfile( + codeBackgroundColor: '#000000', + codeTextColor: '#33FF33', + ); + final html = await service.build('```dart\nvoid main() {}\n```', theme: theme); + + expect(html, contains('.slide pre{background:#000000;color:#33FF33')); + expect(html, contains('.slide pre code{color:#33FF33')); + }); + test('EB Garamond theme embeds the font for offline rendering', () async { final service = MarpHtmlService( loadAsset: _diskLoader, @@ -195,4 +210,32 @@ void main() {} expect(html, isNot(contains('stroke-dasharray'))); expect(html, isNot(contains('min 5'))); }); + + test('radar chart SVG draws a polygon per series with axis labels', () { + const slide = ''' +```chart +{ + "type": "radar", + "x": ["Snelheid", "Kracht", "Uithouding"], + "series": [ + {"name": "A", "color": "#2563EB", "data": [3, 4, 5]}, + {"name": "B", "color": "#EF4444", "data": [5, 2, 3]} + ] +} +``` +'''; + + final html = MarpHtmlService.renderChartBlocks(slide); + + expect(html, contains(' _loadedNotifier() async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('ThemeProfile round-trips the code colours through JSON', () { + const profile = ThemeProfile( + codeBackgroundColor: '#000000', + codeTextColor: '#33FF33', + codeHighlightSyntax: false, + ); + final back = ThemeProfile.fromJson(profile.toJson()); + expect(back.codeBackgroundColor, '#000000'); + expect(back.codeTextColor, '#33FF33'); + expect(back.codeHighlightSyntax, isFalse); + }); + + test('ThemeProfile code colours default to the atom-one-dark look', () { + // Older decks without the fields fall back to the dark editor defaults. + final back = ThemeProfile.fromJson(const {'name': 'Legacy'}); + expect(back.codeBackgroundColor, '#282C34'); + expect(back.codeTextColor, '#ABB2BF'); + expect(back.codeHighlightSyntax, isTrue); + }); + test('starts with a single default profile', () async { final notifier = await _loadedNotifier(); expect(notifier.state.themeProfiles, hasLength(1)); From dd54d36a602d2607a52cf221757879209e66d165 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Mon, 8 Jun 2026 14:04:47 +0200 Subject: [PATCH 3/4] Move radar scale to a side legend and add point tooltips - The radar/spider scale no longer clutters the figure: the evenly spaced tick values now sit in a slim legend beside the chart, both in the live preview and in the SVG/HTML export. - Hovering a radar point shows a tooltip (axis, series, value) like the other charts; the invisible scale-anchor dataset is ignored. - Refresh the documentation (README, user guide, file format, changelog) for all recent work: code-slide theming with custom hex colours, the spider/radar chart type, chart min/max, legend hover, and the chart tooltip behaviour. - Drop two redundant non-null assertions in the chart preview tests. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 23 +- README.md | 6 +- docs/FILE_FORMAT.md | 23 +- docs/USER_GUIDE.md | 32 ++- lib/services/marp_html_service.dart | 21 +- lib/widgets/slides/slide_preview.dart | 330 +++++++++++++++++--------- test/chart_preview_test.dart | 85 ++++++- 7 files changed, 383 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e888c..50c1990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,22 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added -- **Source-code slides** — a dark "code sheet" with per-language syntax - highlighting, stored as a fenced code block. -- **Charts** — bar, line, and pie chart slides. Data is entered in an in-app grid - or imported from CSV; the spec is stored as JSON in a ```chart block. Data can - stay inline or be linked to a CSV in a separate `data/` directory. Rendered - natively in-app (preview, presenter, PDF, PPTX) and as self-contained SVG in - the HTML export. +- **Source-code slides** — a "code sheet" with per-language syntax highlighting, + stored as a fenced code block. Background and text colours are part of the style + profile, with a syntax-colouring toggle; turning it off renders the block in a + single colour (e.g. green on black for a CRT-terminal look). +- **Charts** — bar, line, pie, and **spider/radar** chart slides. Data is entered + in an in-app grid or imported from CSV; the spec is stored as JSON in a ```chart + block. Data can stay inline or be linked to a CSV in a separate `data/` + directory. Rendered natively in-app (preview, presenter, PDF, PPTX) and as + self-contained SVG in the HTML export. + - Optional **min/max**: horizontal reference lines on bar/line charts, or a + fixed scale on spider/radar charts shown as a small legend beside the figure. + - **Legend hover** highlights the matching series (or pie slice). Line-chart + tooltips attach to the dot under the cursor (showing every overlapping dot), + and spider/radar points show a tooltip on hover too. +- **Custom theme colours** — every style-profile colour can be entered as a custom + hex value in addition to the presets. - **Per-slide TLP classification** — each slide can carry its own Traffic Light Protocol level; slides classified stricter than the level the deck is shown at are withheld when presenting and exporting. diff --git a/README.md b/README.md index b64ea77..4c694b9 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ Built with Flutter for macOS, Windows, and Linux. ## Features - **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, source code, charts, and free-form Markdown. -- **Source-code slides** — a dark "code sheet" with syntax highlighting per language, stored as a fenced code block. -- **Charts** — bar, line, and pie charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. +- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background and text colours come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). +- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor. - **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math. - **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. - **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview. @@ -20,7 +20,7 @@ Built with Flutter for macOS, Windows, and Linux. - **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets. - **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves. - **Crash recovery** — automatic snapshots so work survives an unexpected exit. -- **Theming** — customizable deck style profiles and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch). +- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch). - **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento. ## Requirements diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index f21c7c7..92de35d 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -135,6 +135,9 @@ JSON heeft deze velden (met standaardwaarden): | `titleBackgroundColor` | `#1C2B47` | Achtergrond titelslide. | | `titleTextColor` | `#FFFFFF` | Tekst op titel-/sectieslide. | | `sectionBackgroundColor` | `#2E7D64` | Achtergrond sectieslide. | +| `codeBackgroundColor` | `#282C34` | Achtergrond van broncode-slides. | +| `codeTextColor` | `#ABB2BF` | Tekstkleur van broncode-slides. | +| `codeHighlightSyntax` | `true` | Syntaxkleuring aan/uit. Uit = alles in één kleur (bijv. groen op zwart voor een CRT-look). | | `logoPath` | `null` | Pad naar logo (relatief in `logos/`). | | `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. | | `logoSize` | `96` | Logogrootte in px. | @@ -314,15 +317,31 @@ openen wordt die weer ingelezen. ````markdown ```chart { - "type": "bar", // bar | line | pie + "type": "bar", // bar | line | pie | radar "title": "Omzet", "source": "data/omzet.csv", // optioneel; anders inline x/series "x": ["Q1", "Q2"], - "series": [ { "name": "2025", "data": [10, 14] } ] + "rowColors": ["#003399", "#FFCC00"], // optioneel; kleur per label (cirkel/radar) + "minBound": 0, // optioneel; niet bij pie + "maxBound": 20, // optioneel; niet bij pie + "series": [ { "name": "2025", "data": [10, 14], "color": "#2563EB" } ] } ``` ```` +Velden: + +- `type` — `bar`, `line`, `pie` of `radar` (spider). Standaard `bar`. +- `x` — labels; bij `pie`/`radar` zijn dit de segmenten/assen (radar heeft er + minstens drie nodig). +- `series` — genoemde reeksen met `data` (uitgelijnd op `x`) en optioneel een + `color` (hex). `pie` toont maximaal de eerste twee reeksen. +- `rowColors` — optionele kleur per label (gebruikt door `pie`/`radar`). +- `minBound` / `maxBound` — optioneel en alleen voor niet-`pie`. Bij `bar`/`line` + zijn het horizontale **referentielijnen**; bij `radar` bepalen ze de **schaal** + (binnenste/buitenste ring) met een gelijkmatige verdeling. Worden weggelaten + bij `pie`. + ### Afbeeldingsgrootte (`imageSize`) Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fe65c35..f49dc48 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -20,8 +20,9 @@ Marp tools. Add a slide and pick a type: **title**, **section** divider, **bullets**, **two bullet columns**, **bullets + image**, **two images**, **large image**, **video**, -**audio**, **quote**, **table**, **source code**, **chart**, and **free Markdown**. -Each type has a dedicated editor on the left and a live preview on the right. +**audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or +spider/radar), and **free Markdown**. Each type has a dedicated editor on the left +and a live preview on the right. Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``, `[links](…)`). Free-Markdown slides also render fenced code with syntax @@ -30,19 +31,30 @@ highlighting and `$…$` / `$$…$$` LaTeX math. ### Source-code slides Choose a programming language for syntax highlighting (or "plain text") and paste -your code. It renders as a dark "code sheet". Stored as a fenced code block in the -Markdown. +your code. It renders as a "code sheet" whose background and text colour come from +the active **style profile**. Turn **syntax colouring** off to show the whole block +in a single colour — e.g. bright green on black for a classic CRT-terminal look. +Stored as a fenced code block in the Markdown. ### Charts -Pick a type (**bar**, **line**, **pie**) and a title, then enter data in the grid: -the first column is the labels, each further column is a named series. Use **Row** -and **Series** to add data; the small ✕ removes a row/column. +Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then +enter data in the grid: the first column is the labels, each further column is a +named series. Use **Row** and **Series** to add data; the small ✕ removes a +row/column. Each series and (for pie/radar) each label can be given its own colour. - **CSV import** — click **CSV importeren**. You can either keep the data **in the slide** (inline) or store it **as a CSV file**. A linked CSV lives in the deck's `data/` directory and stays the source of truth (edit it in a spreadsheet); the grid then shows it read-only until you **Ontkoppelen** (unlink). +- **Min/max** (optional, bar/line/radar) — on bar and line charts these draw + horizontal **reference lines**; on a spider/radar chart they fix the **scale** + (centre to outer ring), shown as evenly spaced values in a small legend beside + the chart. Leave them empty to scale automatically. +- **Reading values** — hovering a legend entry highlights its series (or pie + slice). On a line chart the tooltip belongs to the dot under the cursor and + shows every overlapping dot at once; on a spider/radar chart hovering a point + shows its value in a tooltip too. - Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the HTML export. @@ -100,8 +112,10 @@ Export to: ## Theming and language -- **Style profiles** control deck colours, fonts, logo, and footer. The bundled - Marp theme is `assets/themes/ocideck.css`. +- **Style profiles** control deck colours (including the source-code background and + text, with an optional syntax-colouring toggle), fonts, logo, and footer. Every + colour can be picked from the presets or entered as a custom hex value. The + bundled Marp theme is `assets/themes/ocideck.css`. - **App appearance** (including a dark interface) is configurable in settings. - The interface is available in Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento. diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index 25508c8..712132a 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -480,14 +480,21 @@ class MarpHtmlService { ); } - // Scale labels up the top spoke: lo at the centre, hi at the outer ring. - for (var k = 0; k <= ticks; k++) { + // Scale legend on the right: hi at the top down to lo at the bottom, so the + // figure itself stays clean. + final legendX = cx + radius + 30; + for (var k = ticks; k >= 0; k--) { final value = lo + span * k / ticks; - final y = cy - radius * k / ticks; - b.write( - '${_num(value)}', - ); + final y = (cy - radius) + (2 * radius) * (ticks - k) / ticks; + b + ..write( + '', + ) + ..write( + '${_num(value)}', + ); } // Spokes and axis labels. diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index 3810af3..d7342b9 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -2212,6 +2212,10 @@ class _ChartPreviewState extends State<_ChartPreview> { /// slice (category) index for pie charts. Null when nothing is hovered. int? _hovered; + /// The radar vertex under the pointer, used to draw its tooltip. Null when not + /// hovering a point. + ({int series, int entry, double value, Offset offset})? _radarTouch; + void _setHover(int? index) { if (_hovered != index) setState(() => _hovered = index); } @@ -2894,133 +2898,247 @@ class _ChartPreviewState extends State<_ChartPreview> { } final grid = textColor.withValues(alpha: 0.18); final scale = radarScale(spec); - final bg = _hexColor(profile.slideBackgroundColor); return Padding( - padding: EdgeInsets.symmetric(horizontal: w * 0.06, vertical: w * 0.012), + padding: EdgeInsets.symmetric(horizontal: w * 0.03, vertical: w * 0.012), child: LayoutBuilder( builder: (context, constraints) { - // A square keeps fl_chart's centre/radius predictable so the tick - // labels we overlay line up exactly with its grid rings. - final side = math.min(constraints.maxWidth, constraints.maxHeight); - final centerOffset = side / 2; - final radius = centerOffset * 0.8; // matches RadarChartPainter - final tickStyle = _applyFont( - font, - TextStyle( - fontSize: w * 0.012 * _labelScale, - color: textColor.withValues(alpha: 0.6), - fontWeight: FontWeight.w600, - ), + // Reserve a slim column on the right for the scale legend, then keep + // the chart square so fl_chart's centre/radius stay predictable. + final legendWidth = w * 0.075; + final available = constraints.maxWidth - legendWidth - w * 0.02; + final side = math.max( + 0.0, + math.min(available, constraints.maxHeight), ); - return Center( - child: SizedBox( - width: side, - height: side, - child: Stack( - children: [ - Positioned.fill( - child: RadarChart( - RadarChartData( - dataSets: [ - for (var si = 0; si < spec.series.length; si++) - RadarDataSet( - dataEntries: [ - for (var xi = 0; xi < spec.x.length; xi++) - RadarEntry( - value: xi < spec.series[si].data.length - ? spec.series[si].data[xi] - : 0, + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: SizedBox( + width: side, + height: side, + child: Stack( + children: [ + Positioned.fill( + child: RadarChart( + RadarChartData( + dataSets: [ + for (var si = 0; si < spec.series.length; si++) + RadarDataSet( + dataEntries: [ + for (var xi = 0; xi < spec.x.length; xi++) + RadarEntry( + value: + xi < spec.series[si].data.length + ? spec.series[si].data[xi] + : 0, + ), + ], + fillColor: _seriesDisplayColor( + spec.series[si], + si, + ).withValues(alpha: _dimmed(si) ? 0.04 : 0.16), + borderColor: _seriesDisplayColor( + spec.series[si], + si, + ), + borderWidth: + w * (_hovered == si ? 0.0055 : 0.0035), + entryRadius: + w * (_hovered == si ? 0.006 : 0.004), ), + // Invisible anchor pinning the scale to [lo, hi] + // so the rings represent a fixed scale. + RadarDataSet( + dataEntries: [ + for (var xi = 0; xi < spec.x.length; xi++) + RadarEntry( + value: xi == 0 ? scale.hi : scale.lo, + ), + ], + fillColor: Colors.transparent, + borderColor: Colors.transparent, + borderWidth: 0, + entryRadius: 0, + ), ], - fillColor: _seriesDisplayColor( - spec.series[si], - si, - ).withValues(alpha: _dimmed(si) ? 0.04 : 0.16), - borderColor: _seriesDisplayColor( - spec.series[si], - si, + radarShape: RadarShape.polygon, + radarBackgroundColor: Colors.transparent, + radarBorderData: BorderSide(color: grid, width: 1), + gridBorderData: BorderSide(color: grid, width: 1), + tickBorderData: BorderSide(color: grid, width: 1), + tickCount: scale.ticks, + isMinValueAtCenter: true, + // The scale now lives in a side legend, so hide + // fl_chart's in-chart ring numbers. + ticksTextStyle: const TextStyle( + color: Colors.transparent, + fontSize: 0.001, + ), + titlePositionPercentageOffset: 0.14, + getTitle: (index, angle) => RadarChartTitle( + text: index < spec.x.length ? spec.x[index] : '', + ), + titleTextStyle: _applyFont( + font, + TextStyle( + fontSize: w * 0.0135 * _labelScale, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + radarTouchData: RadarTouchData( + enabled: true, + touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0) + .toDouble(), + mouseCursorResolver: (event, response) => + _radarSpotFrom(response, spec) == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchCallback: (event, response) { + final next = + event.isInterestedForInteractions + ? _radarSpotFrom(response, spec) + : null; + if (next != _radarTouch) { + setState(() => _radarTouch = next); + } + }, ), - borderWidth: w * (_hovered == si ? 0.0055 : 0.0035), - entryRadius: w * (_hovered == si ? 0.006 : 0.004), ), - // Invisible anchor pinning the scale to [lo, hi] so the - // rings — and our labels — represent a fixed scale. - RadarDataSet( - dataEntries: [ - for (var xi = 0; xi < spec.x.length; xi++) - RadarEntry(value: xi == 0 ? scale.hi : scale.lo), - ], - fillColor: Colors.transparent, - borderColor: Colors.transparent, - borderWidth: 0, - entryRadius: 0, - ), - ], - radarShape: RadarShape.polygon, - radarBackgroundColor: Colors.transparent, - radarBorderData: BorderSide(color: grid, width: 1), - gridBorderData: BorderSide(color: grid, width: 1), - tickBorderData: BorderSide(color: grid, width: 1), - tickCount: scale.ticks, - isMinValueAtCenter: true, - // Hide fl_chart's own ring numbers; we draw labelled - // ticks ourselves so any min/max scale reads correctly. - ticksTextStyle: const TextStyle( - color: Colors.transparent, - fontSize: 0.001, - ), - titlePositionPercentageOffset: 0.14, - getTitle: (index, angle) => RadarChartTitle( - text: index < spec.x.length ? spec.x[index] : '', - ), - titleTextStyle: _applyFont( - font, - TextStyle( - fontSize: w * 0.0135 * _labelScale, - color: textColor.withValues(alpha: 0.88), - fontWeight: presentationMode - ? FontWeight.w600 - : FontWeight.w500, + duration: Duration.zero, ), ), - radarTouchData: RadarTouchData(enabled: false), - ), - duration: Duration.zero, + if (_radarTouch != null) + _radarTooltip(spec, side, _radarTouch!), + ], ), ), - // Evenly spaced scale labels up the top spoke: lo at centre, - // hi at the outer ring, with equal steps between. - for (var k = 0; k <= scale.ticks; k++) - Positioned( - left: centerOffset + w * 0.006, - top: - centerOffset - - radius * k / scale.ticks - - w * 0.01 * _labelScale, - child: IgnorePointer( - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.004, - ), - color: bg.withValues(alpha: 0.7), - child: Text( - _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), - style: tickStyle, - ), - ), - ), - ), - ], + ), ), - ), + SizedBox( + width: legendWidth, + child: _radarScaleLegend(scale, textColor), + ), + ], ); }, ), ); } + /// Extract the touched real-series vertex from a radar touch response, + /// ignoring the invisible scale anchor dataset. + ({int series, int entry, double value, Offset offset})? _radarSpotFrom( + RadarTouchResponse? response, + ChartSpec spec, + ) { + final spot = response?.touchedSpot; + if (spot == null) return null; + if (spot.touchedDataSetIndex < 0 || + spot.touchedDataSetIndex >= spec.series.length) { + return null; // the anchor dataset, or out of range + } + return ( + series: spot.touchedDataSetIndex, + entry: spot.touchedRadarEntryIndex, + value: spot.touchedRadarEntry.value, + offset: spot.offset, + ); + } + + /// A small floating tooltip for the hovered radar vertex, like the other + /// charts: the axis label, the series name and the value. + Widget _radarTooltip( + ChartSpec spec, + double side, + ({int series, int entry, double value, Offset offset}) touch, + ) { + final axis = touch.entry >= 0 && touch.entry < spec.x.length + ? spec.x[touch.entry] + : ''; + final series = touch.series < spec.series.length + ? spec.series[touch.series].name + : ''; + final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series; + final onLeftHalf = touch.offset.dx <= side / 2; + return Positioned( + left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null, + right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012), + top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)), + child: IgnorePointer( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: side * 0.6), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.012, + vertical: w * 0.006, + ), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(w * 0.008), + boxShadow: const [ + BoxShadow(color: Color(0x33000000), blurRadius: 6), + ], + ), + child: Text( + '${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: _tooltipStyle(), + ), + ), + ), + ), + ); + } + + /// Vertical scale legend shown to the right of a radar chart: the tick values + /// from the outer ring (top) down to the centre (bottom), in a small font. + Widget _radarScaleLegend( + ({double lo, double hi, int ticks}) scale, + Color textColor, + ) { + final style = _applyFont( + font, + TextStyle( + fontSize: w * 0.012 * _labelScale, + color: textColor.withValues(alpha: 0.62), + fontWeight: FontWeight.w600, + ), + ); + final tickColor = textColor.withValues(alpha: 0.3); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var k = scale.ticks; k >= 0; k--) ...[ + if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: w * 0.012, height: 1, color: tickColor), + SizedBox(width: w * 0.006), + Flexible( + child: Text( + _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ); + } + /// Resolves the radar scale: a low/high pair plus an even tick count. Honours /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data /// range to a tidy scale so the rings read as round numbers. diff --git a/test/chart_preview_test.dart b/test/chart_preview_test.dart index ec6a0c4..e6eef49 100644 --- a/test/chart_preview_test.dart +++ b/test/chart_preview_test.dart @@ -189,8 +189,8 @@ void main() { // A crowded stack uses a smaller font than a single tooltip. final single = touch.touchTooltipData.getTooltipItems([spots.first]); expect( - items[0]!.textStyle!.fontSize!, - lessThan(single.single!.textStyle!.fontSize!), + items[0]!.textStyle.fontSize!, + lessThan(single.single!.textStyle.fontSize!), ); }); @@ -324,12 +324,91 @@ void main() { final anchor = radar.data.dataSets.last.dataEntries.map((e) => e.value); expect(anchor.reduce((a, b) => a < b ? a : b), 0); expect(anchor.reduce((a, b) => a > b ? a : b), 10); - // Evenly spaced scale labels are drawn (0..10). + // The scale is shown in the side legend (0..10), not painted in the chart. + expect(radar.data.ticksTextStyle?.color, Colors.transparent); expect(find.text('0'), findsWidgets); expect(find.text('10'), findsOneWidget); expect(tester.takeException(), isNull); }); + testWidgets('radar shows a tooltip for the hovered point', (tester) async { + const spec = ChartSpec( + type: ChartType.radar, + x: ['Snelheid', 'Kracht', 'Uithouding'], + series: [ + ChartSeries(name: 'Alpha', data: [3, 4, 5]), + ChartSeries(name: 'Beta', data: [5, 2, 3]), + ], + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + final radar = tester.widget(find.byType(RadarChart)); + final touch = radar.data.radarTouchData; + expect(touch.enabled, isTrue); + + // Simulate hovering the second axis of the Beta series. + final dataSet = radar.data.dataSets[1]; + final entry = dataSet.dataEntries[1]; + touch.touchCallback!( + const FlPointerHoverEvent(PointerHoverEvent()), + RadarTouchResponse( + touchLocation: const Offset(20, 20), + touchedSpot: RadarTouchedSpot( + dataSet, + 1, + entry, + 1, + const FlSpot(1, 2), + const Offset(20, 20), + ), + ), + ); + await tester.pump(); + + expect(find.text('Kracht\nBeta: 2'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('radar ignores touches on the invisible scale anchor', ( + tester, + ) async { + const spec = ChartSpec( + type: ChartType.radar, + x: ['A', 'B', 'C'], + series: [ + ChartSeries(name: 'Alpha', data: [3, 4, 5]), + ], + ); + + await tester.pumpWidget(_host(spec)); + await tester.pump(); + + final radar = tester.widget(find.byType(RadarChart)); + final anchorIndex = radar.data.dataSets.length - 1; // the anchor dataset + final anchor = radar.data.dataSets[anchorIndex]; + radar.data.radarTouchData.touchCallback!( + const FlPointerHoverEvent(PointerHoverEvent()), + RadarTouchResponse( + touchLocation: Offset.zero, + touchedSpot: RadarTouchedSpot( + anchor, + anchorIndex, + anchor.dataEntries.first, + 0, + const FlSpot(0, 0), + Offset.zero, + ), + ), + ); + await tester.pump(); + + // No tooltip for the anchor: only the legend value "5" exists. + expect(find.textContaining(': '), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('radar chart asks for at least three labels', (tester) async { const spec = ChartSpec( type: ChartType.radar, From e0379ade594e9bbc068010d0ad2d0d5e6125a00f Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Mon, 8 Jun 2026 14:28:04 +0200 Subject: [PATCH 4/4] Refine code slides: title outside the panel, fit-to-space, font choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The slide title now renders above the code panel (styled like other slide types) instead of inside the dark code window — it is the slide's title. - Code is sized to fill the panel: scaled up to use spare space (capped) and down so long fragments still fit, instead of a small block in a big box. - Add a per-profile monospace font for code slides (e.g. Courier), applied in the preview and the HTML export. - Settings: a banner on the Colours and Logo tabs makes clear they edit the loaded style profile, and colour pickers now accept a custom hex value. - Update docs and translations for the new strings. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 +- README.md | 2 +- docs/FILE_FORMAT.md | 1 + docs/USER_GUIDE.md | 19 +-- lib/l10n/app_localizations.dart | 21 ++++ lib/models/settings.dart | 20 ++++ lib/services/marp_html_service.dart | 9 +- lib/widgets/dialogs/settings_dialog.dart | 64 ++++++++++ lib/widgets/slides/slide_preview.dart | 143 ++++++++++++++++------- test/code_preview_test.dart | 74 ++++++++++++ test/marp_html_service_test.dart | 3 + test/settings_provider_test.dart | 7 +- 12 files changed, 312 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c1990..985a604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,11 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ### Added - **Source-code slides** — a "code sheet" with per-language syntax highlighting, - stored as a fenced code block. Background and text colours are part of the style - profile, with a syntax-colouring toggle; turning it off renders the block in a - single colour (e.g. green on black for a CRT-terminal look). + stored as a fenced code block. Background, text colour and monospace font are + part of the style profile, with a syntax-colouring toggle; turning it off renders + the block in a single colour (e.g. green on black for a CRT-terminal look). The + code is sized to fill the panel — larger when there's room, smaller for long + fragments. - **Charts** — bar, line, pie, and **spider/radar** chart slides. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in a ```chart block. Data can stay inline or be linked to a CSV in a separate `data/` diff --git a/README.md b/README.md index 4c694b9..30107e5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Built with Flutter for macOS, Windows, and Linux. ## Features - **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, source code, charts, and free-form Markdown. -- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background and text colours come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). +- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel. - **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor. - **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math. - **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 92de35d..9731e78 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -138,6 +138,7 @@ JSON heeft deze velden (met standaardwaarden): | `codeBackgroundColor` | `#282C34` | Achtergrond van broncode-slides. | | `codeTextColor` | `#ABB2BF` | Tekstkleur van broncode-slides. | | `codeHighlightSyntax` | `true` | Syntaxkleuring aan/uit. Uit = alles in één kleur (bijv. groen op zwart voor een CRT-look). | +| `codeFontFamily` | `monospace` | Lettertype van broncode-slides (bijv. `Courier New`). | | `logoPath` | `null` | Pad naar logo (relatief in `logos/`). | | `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. | | `logoSize` | `96` | Logogrootte in px. | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f49dc48..43939f3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -31,10 +31,12 @@ highlighting and `$…$` / `$$…$$` LaTeX math. ### Source-code slides Choose a programming language for syntax highlighting (or "plain text") and paste -your code. It renders as a "code sheet" whose background and text colour come from -the active **style profile**. Turn **syntax colouring** off to show the whole block -in a single colour — e.g. bright green on black for a classic CRT-terminal look. -Stored as a fenced code block in the Markdown. +your code. It renders as a "code sheet" whose background, text colour and +**monospace font** come from the active **style profile** (e.g. Courier). Turn +**syntax colouring** off to show the whole block in a single colour — e.g. bright +green on black for a classic CRT-terminal look. The text is sized to fill the +panel — larger when there's room, smaller for long fragments. Stored as a fenced +code block in the Markdown. ### Charts @@ -112,10 +114,11 @@ Export to: ## Theming and language -- **Style profiles** control deck colours (including the source-code background and - text, with an optional syntax-colouring toggle), fonts, logo, and footer. Every - colour can be picked from the presets or entered as a custom hex value. The - bundled Marp theme is `assets/themes/ocideck.css`. +- **Style profiles** control deck colours (including the source-code background, + text, font and an optional syntax-colouring toggle), fonts, logo, and footer. + Every colour can be picked from the presets or entered as a custom hex value. The + Colours and Logo tabs show which profile you're editing. The bundled Marp theme + is `assets/themes/ocideck.css`. - **App appearance** (including a dark interface) is configurable in settings. - The interface is available in Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento. diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4c14c4c..7adae2d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2382,6 +2382,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Custom colour (hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'For example #33FF33 for a CRT-green screen.', + 'Onderdeel van stijlprofiel ': 'Part of style profile ', + 'Broncode lettertype': 'Code font', + 'Systeem (monospace)': 'System (monospace)', 'Platte tekst': 'Plain text', 'Titel (optioneel)': 'Title (optional)', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': @@ -2418,6 +2421,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Colore personalizzato (hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'Ad esempio #33FF33 per uno schermo verde CRT.', + 'Onderdeel van stijlprofiel ': 'Parte del profilo di stile ', + 'Broncode lettertype': 'Font del codice', + 'Systeem (monospace)': 'Sistema (monospace)', 'Kleur van reeks': 'Colore della serie', 'Kleur van rij': 'Colore della riga', 'Hexkleur': 'Colore esadecimale', @@ -2644,6 +2650,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Eigene Farbe (Hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.', + 'Onderdeel van stijlprofiel ': 'Teil des Stilprofils ', + 'Broncode lettertype': 'Code-Schriftart', + 'Systeem (monospace)': 'System (monospace)', 'Kleur van reeks': 'Reihenfarbe', 'Kleur van rij': 'Zeilenfarbe', 'Hexkleur': 'Hex-Farbe', @@ -2871,6 +2880,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Couleur personnalisée (hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'Par exemple #33FF33 pour un écran vert CRT.', + 'Onderdeel van stijlprofiel ': 'Fait partie du profil de style ', + 'Broncode lettertype': 'Police du code', + 'Systeem (monospace)': 'Système (monospace)', 'Kleur van reeks': 'Couleur de la série', 'Kleur van rij': 'Couleur de la ligne', 'Hexkleur': 'Couleur hexadécimale', @@ -3098,6 +3110,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Color personalizado (hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'Por ejemplo #33FF33 para una pantalla verde CRT.', + 'Onderdeel van stijlprofiel ': 'Parte del perfil de estilo ', + 'Broncode lettertype': 'Fuente del código', + 'Systeem (monospace)': 'Sistema (monospace)', 'Kleur van reeks': 'Color de la serie', 'Kleur van rij': 'Color de la fila', 'Hexkleur': 'Color hexadecimal', @@ -3325,6 +3340,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Eigen kleur (hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'Bygelyks #33FF33 foar in CRT-grien skerm.', + 'Onderdeel van stijlprofiel ': 'Underdiel fan stylprofyl ', + 'Broncode lettertype': 'Boarnekoade lettertype', + 'Systeem (monospace)': 'Systeem (monospace)', 'Kleur van reeks': 'Rigekleur', 'Kleur van rij': 'Rijekleur', 'Hexkleur': 'Hekskleur', @@ -3549,6 +3567,9 @@ const _dutchSourceStringAdditions = { 'Eigen kleur (hex)': 'Koló propio (hex)', 'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.': 'Por ehèmpel #33FF33 pa un pantaya berde CRT.', + 'Onderdeel van stijlprofiel ': 'Parti di e perfil di estilo ', + 'Broncode lettertype': 'Tipo di lèter di kódigo', + 'Systeem (monospace)': 'Sistema (monospace)', 'Kleur van reeks': 'Koló di serie', 'Kleur van rij': 'Koló di liña', 'Hexkleur': 'Koló hexadecimal', diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 96afb90..a059f70 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -19,6 +19,10 @@ class ThemeProfile { /// syntax colours) — required for a believable single-colour CRT screen. final bool codeHighlightSyntax; + /// Monospace font family for code slides. `monospace` uses the system default; + /// e.g. `Courier New` for a typewriter look. + final String codeFontFamily; + final String? logoPath; final String logoPosition; final int logoSize; @@ -54,6 +58,7 @@ class ThemeProfile { this.codeBackgroundColor = '#282C34', this.codeTextColor = '#ABB2BF', this.codeHighlightSyntax = true, + this.codeFontFamily = 'monospace', this.logoPath, this.logoPosition = 'bottom-right', this.logoSize = 96, @@ -87,6 +92,7 @@ class ThemeProfile { String? codeBackgroundColor, String? codeTextColor, bool? codeHighlightSyntax, + String? codeFontFamily, String? logoPath, String? logoPosition, int? logoSize, @@ -112,6 +118,7 @@ class ThemeProfile { codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor, codeTextColor: codeTextColor ?? this.codeTextColor, codeHighlightSyntax: codeHighlightSyntax ?? this.codeHighlightSyntax, + codeFontFamily: codeFontFamily ?? this.codeFontFamily, logoPath: clearLogo ? null : (logoPath ?? this.logoPath), logoPosition: logoPosition ?? this.logoPosition, logoSize: logoSize ?? this.logoSize, @@ -139,6 +146,7 @@ class ThemeProfile { 'codeBackgroundColor': codeBackgroundColor, 'codeTextColor': codeTextColor, 'codeHighlightSyntax': codeHighlightSyntax, + 'codeFontFamily': codeFontFamily, 'logoPath': logoPath, 'logoPosition': logoPosition, 'logoSize': logoSize, @@ -173,6 +181,7 @@ class ThemeProfile { json['codeBackgroundColor'] as String? ?? '#282C34', codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF', codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true, + codeFontFamily: json['codeFontFamily'] as String? ?? 'monospace', logoPath: json['logoPath'] as String?, logoPosition: json['logoPosition'] as String? ?? 'bottom-right', logoSize: (json['logoSize'] as num?)?.round() ?? 96, @@ -370,6 +379,17 @@ class AppSettings { 'Courier New', ]; + /// Monospace families offered for code slides. `monospace` is the system + /// default; the rest are common typewriter/coding faces. + static const codeFonts = [ + 'monospace', + 'Courier New', + 'Menlo', + 'Consolas', + 'Roboto Mono', + 'Cascadia Code', + ]; + AppSettings copyWith({ String? languageCode, String? homeDirectory, diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index 712132a..aa509b1 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -589,6 +589,11 @@ class MarpHtmlService { Future _themedCss(ThemeProfile t) async { final fontFace = await _ebGaramondFontFace(t.fontFamily); final family = _cssFontStack(t.fontFamily); + final codePrefix = t.codeFontFamily == 'monospace' + ? '' + : "'${t.codeFontFamily}',"; + final codeFamily = + '${codePrefix}SFMono-Regular,Consolas,"Liberation Mono",monospace'; return '$fontFace\n' '*{box-sizing:border-box}' 'html,body{margin:0;padding:0}' @@ -603,9 +608,9 @@ class MarpHtmlService { '.slide p,.slide li{font-size:24px;line-height:1.45}' '.slide pre{background:${t.codeBackgroundColor};color:${t.codeTextColor};' 'border:1px solid ${t.codeTextColor}38;border-radius:6px;' - 'padding:16px;overflow:auto;font-size:18px}' + 'padding:16px;overflow:auto;font-size:18px;font-family:$codeFamily}' '.slide pre code{color:${t.codeTextColor};background:transparent}' - '.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}' + '.slide code{font-family:$codeFamily}' '.slide pre.mermaid{background:transparent;border:0;text-align:center}' '.slide img{max-width:100%}' '.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;' diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index fb7f4a2..134eab7 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -882,11 +882,47 @@ class _SettingsDialogState extends ConsumerState { ); } + /// A banner shown on tabs that edit the active style profile, so it is clear + /// these settings belong to the loaded profile (and which one). + Widget _profileScopeBanner() { + final name = _themeProfile.name; + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border(left: BorderSide(color: AppTheme.accent, width: 3)), + ), + child: Row( + children: [ + Icon(Icons.style_outlined, size: 16, color: AppTheme.accent), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: context.l10n.d('Onderdeel van stijlprofiel ')), + TextSpan( + text: '“$name”', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + style: const TextStyle(fontSize: 12, color: Color(0xFF334155)), + ), + ), + ], + ), + ); + } + Widget _colorsTab() { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _profileScopeBanner(), _sectionTitle(l10n.d('Kleuren')), _colorSetting( l10n.d('Achtergrond slides'), @@ -974,6 +1010,33 @@ class _SettingsDialogState extends ConsumerState { contentPadding: EdgeInsets.zero, dense: true, ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: AppSettings.codeFonts.contains(_themeProfile.codeFontFamily) + ? _themeProfile.codeFontFamily + : 'monospace', + decoration: InputDecoration( + labelText: l10n.d('Broncode lettertype'), + isDense: true, + ), + items: [ + for (final f in AppSettings.codeFonts) + DropdownMenuItem( + value: f, + child: Text( + f == 'monospace' ? l10n.d('Systeem (monospace)') : f, + style: TextStyle(fontFamily: f), + ), + ), + ], + onChanged: (v) { + if (v == null) return; + setState(() { + _themeProfile = _themeProfile.copyWith(codeFontFamily: v); + _profileTouched = true; + }); + }, + ), const SizedBox(height: 18), _stylePreview(), ], @@ -985,6 +1048,7 @@ class _SettingsDialogState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _profileScopeBanner(), _sectionTitle(l10n.d('Logo')), Row( children: [ diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index d7342b9..c770337 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -2086,6 +2086,16 @@ class _CodePreview extends StatelessWidget { required this.profile, }); + /// Natural (unwrapped) size of [text] in [style]: width is the longest line, + /// height the full block. Used to scale code to the available space. + static Size _measureMono(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text.isEmpty ? ' ' : text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + return painter.size; + } + @override Widget build(BuildContext context) { _ensureHighlightLanguages(); @@ -2098,10 +2108,20 @@ class _CodePreview extends StatelessWidget { final codeBg = _hexColor(profile.codeBackgroundColor); final codeFg = _hexColor(profile.codeTextColor); - final mono = TextStyle( - fontFamily: 'monospace', - fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], - fontSize: w * 0.024, + // The chosen monospace family, always backed by a generic monospace fallback + // so an uninstalled face still renders fixed-width. + final fallback = [ + 'Menlo', + 'Consolas', + 'Courier New', + 'monospace', + ]..removeWhere((f) => f == profile.codeFontFamily); + final baseFont = w * 0.024; + final maxFont = w * 0.040; // grow to fill, but never huge + TextStyle monoAt(double size) => TextStyle( + fontFamily: profile.codeFontFamily, + fontFamilyFallback: fallback, + fontSize: size, height: 1.4, color: codeFg, ); @@ -2109,23 +2129,25 @@ class _CodePreview extends StatelessWidget { // HighlightView throws on an unknown language, so fall back to plain (but // monospace) text. When syntax highlighting is off we always render plain // text so the whole block is one colour — needed for a CRT-green screen. - final Widget codeContent = (known && profile.codeHighlightSyntax) + final useHighlight = known && profile.codeHighlightSyntax; + final highlightTheme = { + ...atomOneDarkTheme, + // Keep atom-one-dark's per-token colours but drop its own background so + // our themed [codeBg] shows through unchanged. + 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( + backgroundColor: codeBg, + color: codeFg, + ), + }; + Widget buildCode(TextStyle style) => useHighlight ? HighlightView( code, language: lang, - // Keep atom-one-dark's per-token colours but drop its own - // background so our themed [codeBg] shows through unchanged. - theme: { - ...atomOneDarkTheme, - 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( - backgroundColor: codeBg, - color: codeFg, - ), - }, + theme: highlightTheme, padding: EdgeInsets.zero, - textStyle: mono, + textStyle: style, ) - : Text(code, style: mono); + : Text(code, style: style); return Container( color: _hexColor(profile.slideBackgroundColor), @@ -2136,45 +2158,80 @@ class _CodePreview extends StatelessWidget { pad, pad + safe.bottom, ), - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: codeBg, - borderRadius: BorderRadius.circular(w * 0.012), - border: Border.all(color: codeFg.withValues(alpha: 0.22)), - ), - padding: EdgeInsets.all(w * 0.03), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (slide.title.isNotEmpty) ...[ - _md( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The slide title belongs to the slide, not inside the code window, + // so it sits above the panel like other slide types. + if (slide.title.isNotEmpty) ...[ + 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, + ), + ), + ), + child: _md( context, slide.title, _applyFont( font, TextStyle( - fontSize: w * 0.03, + fontSize: w * 0.032, + height: 1.1, fontWeight: FontWeight.bold, - color: codeFg, + color: _hexColor(profile.titleTextColor), ), ), linkColor: _hexColor(profile.accentColor), ), - SizedBox(height: w * 0.02), - ], - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - // Een onbegrensde breedte laat code-regels op hun natuurlijke - // lengte staan (geen woordafbreking), waarna de FittedBox het - // geheel verkleint tot het past. - child: codeContent, + ), + SizedBox(height: w * 0.018), + ], + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: codeBg, + borderRadius: BorderRadius.circular(w * 0.012), + border: Border.all(color: codeFg.withValues(alpha: 0.22)), + ), + padding: EdgeInsets.all(w * 0.03), + child: LayoutBuilder( + builder: (context, constraints) { + // Size the code to fill the panel: scale up to use spare + // space (capped at [maxFont]) and down so long fragments + // still fit, rather than leaving a small block in a big box. + final measured = useHighlight + ? code.replaceAll('\t', ' ') + : code; + final natural = _measureMono(measured, monoAt(baseFont)); + final availW = math.max(1.0, constraints.maxWidth - 1); + final availH = math.max(1.0, constraints.maxHeight - 1); + var scale = math.min( + availW / natural.width, + availH / natural.height, + ); + if (!scale.isFinite || scale <= 0) scale = 1; + final size = math.min(baseFont * scale, maxFont); + return Align( + alignment: Alignment.topLeft, + child: buildCode(monoAt(size)), + ); + }, ), ), - ], - ), + ), + ], ), ), ); diff --git a/test/code_preview_test.dart b/test/code_preview_test.dart index c3f3eff..6cb91d3 100644 --- a/test/code_preview_test.dart +++ b/test/code_preview_test.dart @@ -80,4 +80,78 @@ void main() { expect(codeText.style?.color, _hex('#33FF33')); expect(tester.takeException(), isNull); }); + + testWidgets('short code is enlarged to use the space; long code shrinks', ( + tester, + ) async { + const profile = ThemeProfile(codeHighlightSyntax: false); + + const short = 'x'; + await tester.pumpWidget( + _host( + Slide.create(SlideType.code).copyWith(customMarkdown: short), + profile, + ), + ); + await tester.pump(); + final shortSize = tester.widget(find.text(short)).style!.fontSize!; + + final long = List.generate( + 40, + (i) => 'final someRatherLongVariableName$i = compute($i);', + ).join('\n'); + await tester.pumpWidget( + _host( + Slide.create(SlideType.code).copyWith(customMarkdown: long), + profile, + ), + ); + await tester.pump(); + final longSize = tester.widget(find.text(long)).style!.fontSize!; + + // A tiny snippet is scaled up to fill; a big one is scaled down to fit. + expect(longSize, lessThan(shortSize)); + expect(tester.takeException(), isNull); + }); + + testWidgets('the slide title sits above the code panel, not inside it', ( + tester, + ) async { + final slide = Slide.create( + SlideType.code, + ).copyWith(title: 'Voorbeeld', customMarkdown: 'print("hi")'); + const profile = ThemeProfile( + titleTextColor: '#FFFFFF', + titleBackgroundColor: '#1C2B47', + codeBackgroundColor: '#000000', + codeTextColor: '#33FF33', + codeHighlightSyntax: false, + ); + + await tester.pumpWidget(_host(slide, profile)); + await tester.pump(); + + // The title is rendered above the code panel rather than inside it. + final titleBottom = tester.getBottomLeft(find.text('Voorbeeld')).dy; + final codeTop = tester.getTopLeft(find.text('print("hi")')).dy; + expect(titleBottom, lessThanOrEqualTo(codeTop)); + expect(tester.takeException(), isNull); + }); + + testWidgets('code uses the chosen monospace font family', (tester) async { + final slide = Slide.create( + SlideType.code, + ).copyWith(customMarkdown: 'void main() {}'); + const profile = ThemeProfile( + codeFontFamily: 'Courier New', + codeHighlightSyntax: false, + ); + + await tester.pumpWidget(_host(slide, profile)); + await tester.pump(); + + final codeText = tester.widget(find.text('void main() {}')); + expect(codeText.style?.fontFamily, 'Courier New'); + expect(tester.takeException(), isNull); + }); } diff --git a/test/marp_html_service_test.dart b/test/marp_html_service_test.dart index 8afd516..319b8c4 100644 --- a/test/marp_html_service_test.dart +++ b/test/marp_html_service_test.dart @@ -106,11 +106,14 @@ void main() {} const theme = ThemeProfile( codeBackgroundColor: '#000000', codeTextColor: '#33FF33', + codeFontFamily: 'Courier New', ); final html = await service.build('```dart\nvoid main() {}\n```', theme: theme); expect(html, contains('.slide pre{background:#000000;color:#33FF33')); expect(html, contains('.slide pre code{color:#33FF33')); + // The chosen code font is used (with a monospace fallback chain). + expect(html, contains("font-family:'Courier New',")); }); test('EB Garamond theme embeds the font for offline rendering', () async { diff --git a/test/settings_provider_test.dart b/test/settings_provider_test.dart index a0249b3..88a4037 100644 --- a/test/settings_provider_test.dart +++ b/test/settings_provider_test.dart @@ -15,24 +15,27 @@ Future _loadedNotifier() async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('ThemeProfile round-trips the code colours through JSON', () { + test('ThemeProfile round-trips the code styling through JSON', () { const profile = ThemeProfile( codeBackgroundColor: '#000000', codeTextColor: '#33FF33', codeHighlightSyntax: false, + codeFontFamily: 'Courier New', ); final back = ThemeProfile.fromJson(profile.toJson()); expect(back.codeBackgroundColor, '#000000'); expect(back.codeTextColor, '#33FF33'); expect(back.codeHighlightSyntax, isFalse); + expect(back.codeFontFamily, 'Courier New'); }); - test('ThemeProfile code colours default to the atom-one-dark look', () { + test('ThemeProfile code styling defaults to the atom-one-dark look', () { // Older decks without the fields fall back to the dark editor defaults. final back = ThemeProfile.fromJson(const {'name': 'Legacy'}); expect(back.codeBackgroundColor, '#282C34'); expect(back.codeTextColor, '#ABB2BF'); expect(back.codeHighlightSyntax, isTrue); + expect(back.codeFontFamily, 'monospace'); }); test('starts with a single default profile', () async {