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

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

View file

@ -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',

View file

@ -18,7 +18,7 @@ const List<String> 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,

View file

@ -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,

View file

@ -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(
'<polygon points="$pts" fill="none" stroke="#e2e8f0" '
'stroke-width="1"/>',
);
}
// 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(
'<text x="${cx + 6}" y="${y - 2}" font-size="11" '
'fill="#94a3b8">${_num(value)}</text>',
);
}
// 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(
'<line x1="$cx" y1="$cy" x2="${cx + radius * c}" '
'y2="${cy + radius * s}" stroke="#e2e8f0" stroke-width="1"/>',
);
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(
'<text x="${cx + (radius + 18) * c}" y="${cy + (radius + 18) * s + 4}" '
'text-anchor="$anchor" font-size="13" fill="$textColor">'
'${_esc(label)}</text>',
);
}
// 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(
'<polygon points="$pts" fill="$color" fill-opacity="0.16" '
'stroke="$color" stroke-width="3" stroke-linejoin="round"/>',
);
}
}
/// 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%}'

View file

@ -939,6 +939,41 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
(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<SettingsDialog> {
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<String> 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<void> _editCustomColor(
String initial,
ValueChanged<String> onChanged,
) async {
final picked = await _pickHexColor(initial);
if (picked == null || !mounted) return;
setState(() {
onChanged(picked);
_profileTouched = true;
});
}
Future<String?> _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<String>(
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,

View file

@ -65,6 +65,8 @@ class _ChartEditorState extends State<ChartEditor> {
_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<ChartEditor> {
x: List<String>.from(_xLabels),
rowColors: List<String?>.from(_rowColors),
series: series,
minBound: _type == ChartType.pie ? null : _parseBound(_minBound.text),
maxBound: _type == ChartType.pie ? null : _parseBound(_maxBound.text),
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<ChartEditor> {
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<ChartEditor> {
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<ChartEditor> {
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<ChartEditor> {
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)'),
),
),
],

View file

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

View file

@ -50,6 +50,7 @@ void main() {
'SLIDES',
'Slide',
'slide',
'Spider',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')

View file

@ -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<LineChart>(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<LineTooltipItem>().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<RadarChart>(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<RadarChart>(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,

View file

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

View file

@ -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<Container>(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<Text>(find.text('void main() {}'));
expect(codeText.style?.color, _hex('#33FF33'));
expect(tester.takeException(), isNull);
});
}

View file

@ -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('<polygon'));
expect(html, contains('Snelheid'));
expect(html, contains('Kracht'));
expect(html, contains('Uithouding'));
// Both series are drawn with their colours.
expect(html, contains('fill="#2563EB"'));
expect(html, contains('fill="#EF4444"'));
// The series legend is shown (not a pie legend).
expect(html, contains('A'));
expect(html, contains('B'));
});
}

View file

@ -15,6 +15,26 @@ Future<SettingsNotifier> _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));