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));