App-thema’s, meerschermen, annotaties en grafiekslides #1
15 changed files with 1379 additions and 7 deletions
|
|
@ -1179,6 +1179,19 @@ const _dutchSourceStrings = {
|
|||
'Stoppen (Esc)': 'Interrompi (Esc)',
|
||||
'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma',
|
||||
'Laser · annotaties wissen': 'Laser · cancella annotazioni',
|
||||
'Grafiek': 'Grafico',
|
||||
'Type grafiek': 'Tipo di grafico',
|
||||
'Staaf': 'Barre',
|
||||
'Lijn': 'Linee',
|
||||
'Cirkel': 'Torta',
|
||||
'CSV importeren': 'Importa CSV',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Dati (CSV: prima riga = nomi serie, prima colonna = etichette)',
|
||||
'Gekoppeld aan': 'Collegato a',
|
||||
'Ontkoppelen': 'Scollega',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Salvare i dati nella slide o tenerli come file CSV separato accanto alla presentazione?',
|
||||
'In de slide': 'Nella slide',
|
||||
'Als CSV-bestand': 'Come file CSV',
|
||||
'Geen grafiekgegevens': 'Nessun dato del grafico',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Incolla o digita qui il tuo codice sorgente...',
|
||||
'Overgeslagen': 'Saltata',
|
||||
|
|
@ -1355,6 +1368,19 @@ const _dutchSourceStrings = {
|
|||
'Stoppen (Esc)': 'Beenden (Esc)',
|
||||
'Pen · markeerstift · gum': 'Stift · Marker · Radierer',
|
||||
'Laser · annotaties wissen': 'Laser · Anmerkungen löschen',
|
||||
'Grafiek': 'Diagramm',
|
||||
'Type grafiek': 'Diagrammtyp',
|
||||
'Staaf': 'Balken',
|
||||
'Lijn': 'Linie',
|
||||
'Cirkel': 'Kreis',
|
||||
'CSV importeren': 'CSV importieren',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Daten (CSV: erste Zeile = Reihennamen, erste Spalte = Beschriftungen)',
|
||||
'Gekoppeld aan': 'Verknüpft mit',
|
||||
'Ontkoppelen': 'Trennen',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Daten in der Folie speichern oder als separate CSV-Datei neben der Präsentation behalten?',
|
||||
'In de slide': 'In der Folie',
|
||||
'Als CSV-bestand': 'Als CSV-Datei',
|
||||
'Geen grafiekgegevens': 'Keine Diagrammdaten',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Quellcode hier einfügen oder eingeben...',
|
||||
'Overgeslagen': 'Übersprungen',
|
||||
|
|
@ -1532,6 +1558,19 @@ const _dutchSourceStrings = {
|
|||
'Stoppen (Esc)': 'Arrêter (Esc)',
|
||||
'Pen · markeerstift · gum': 'Stylo · surligneur · gomme',
|
||||
'Laser · annotaties wissen': 'Laser · effacer les annotations',
|
||||
'Grafiek': 'Graphique',
|
||||
'Type grafiek': 'Type de graphique',
|
||||
'Staaf': 'Barres',
|
||||
'Lijn': 'Lignes',
|
||||
'Cirkel': 'Secteurs',
|
||||
'CSV importeren': 'Importer un CSV',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Données (CSV : 1re ligne = noms de séries, 1re colonne = libellés)',
|
||||
'Gekoppeld aan': 'Lié à',
|
||||
'Ontkoppelen': 'Dissocier',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Enregistrer les données dans la diapositive, ou les conserver dans un fichier CSV séparé à côté de la présentation ?',
|
||||
'In de slide': 'Dans la diapositive',
|
||||
'Als CSV-bestand': 'Comme fichier CSV',
|
||||
'Geen grafiekgegevens': 'Aucune donnée de graphique',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Collez ou tapez votre code source ici...',
|
||||
'Overgeslagen': 'Ignorée',
|
||||
|
|
@ -1708,6 +1747,19 @@ const _dutchSourceStrings = {
|
|||
'Stoppen (Esc)': 'Detener (Esc)',
|
||||
'Pen · markeerstift · gum': 'Lápiz · marcador · goma',
|
||||
'Laser · annotaties wissen': 'Láser · borrar anotaciones',
|
||||
'Grafiek': 'Gráfico',
|
||||
'Type grafiek': 'Tipo de gráfico',
|
||||
'Staaf': 'Barras',
|
||||
'Lijn': 'Líneas',
|
||||
'Cirkel': 'Circular',
|
||||
'CSV importeren': 'Importar CSV',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Datos (CSV: primera fila = nombres de series, primera columna = etiquetas)',
|
||||
'Gekoppeld aan': 'Vinculado a',
|
||||
'Ontkoppelen': 'Desvincular',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': '¿Guardar los datos en la diapositiva o mantenerlos como archivo CSV separado junto a la presentación?',
|
||||
'In de slide': 'En la diapositiva',
|
||||
'Als CSV-bestand': 'Como archivo CSV',
|
||||
'Geen grafiekgegevens': 'Sin datos de gráfico',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Pega o escribe aquí tu código fuente...',
|
||||
'Overgeslagen': 'Omitida',
|
||||
|
|
@ -1885,6 +1937,19 @@ const _dutchSourceStrings = {
|
|||
'Stoppen (Esc)': 'Stopje (Esc)',
|
||||
'Pen · markeerstift · gum': 'Pen · markearstift · gom',
|
||||
'Laser · annotaties wissen': 'Laser · annotaasjes wiskje',
|
||||
'Grafiek': 'Grafyk',
|
||||
'Type grafiek': 'Grafyktype',
|
||||
'Staaf': 'Steaf',
|
||||
'Lijn': 'Line',
|
||||
'Cirkel': 'Sirkel',
|
||||
'CSV importeren': 'CSV ymportearje',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: earste rige = rige-nammen, earste kolom = labels)',
|
||||
'Gekoppeld aan': 'Keppele oan',
|
||||
'Ontkoppelen': 'Ûntkeppelje',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Data yn de slide bewarje, of as los CSV-bestân neist de presintaasje hâlde?',
|
||||
'In de slide': 'Yn de slide',
|
||||
'Als CSV-bestand': 'As CSV-bestân',
|
||||
'Geen grafiekgegevens': 'Gjin grafykgegevens',
|
||||
'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...',
|
||||
'Overgeslagen': 'Oerslein',
|
||||
'Kopiëren': 'Kopiearje',
|
||||
|
|
@ -2062,6 +2127,19 @@ const _dutchSourceStrings = {
|
|||
'Stoppen (Esc)': 'Stòp (Esc)',
|
||||
'Pen · markeerstift · gum': 'Pèn · marker · gòm',
|
||||
'Laser · annotaties wissen': 'Laser · kita anotashonnan',
|
||||
'Grafiek': 'Gráfiko',
|
||||
'Type grafiek': 'Tipo di gráfiko',
|
||||
'Staaf': 'Bara',
|
||||
'Lijn': 'Liña',
|
||||
'Cirkel': 'Sirkel',
|
||||
'CSV importeren': 'Importá CSV',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Dato (CSV: promé fila = nòmber di serie, promé kolom = etiketnan)',
|
||||
'Gekoppeld aan': 'Konektá na',
|
||||
'Ontkoppelen': 'Deskonektá',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Warda e dato den e slide, òf keda komo un archivo CSV separá banda di e presentashon?',
|
||||
'In de slide': 'Den e slide',
|
||||
'Als CSV-bestand': 'Komo archivo CSV',
|
||||
'Geen grafiekgegevens': 'Sin dato di gráfiko',
|
||||
'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...',
|
||||
'Overgeslagen': 'Saltá',
|
||||
'Kopiëren': 'Kopia',
|
||||
|
|
@ -2228,6 +2306,19 @@ const _dutchSourceStringAdditions = {
|
|||
'Stoppen (Esc)': 'Stop (Esc)',
|
||||
'Pen · markeerstift · gum': 'Pen · highlighter · eraser',
|
||||
'Laser · annotaties wissen': 'Laser · clear annotations',
|
||||
'Grafiek': 'Chart',
|
||||
'Type grafiek': 'Chart type',
|
||||
'Staaf': 'Bar',
|
||||
'Lijn': 'Line',
|
||||
'Cirkel': 'Pie',
|
||||
'CSV importeren': 'Import CSV',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: first row = series names, first column = labels)',
|
||||
'Gekoppeld aan': 'Linked to',
|
||||
'Ontkoppelen': 'Unlink',
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Store the data in the slide, or keep it as a separate CSV file next to the presentation?',
|
||||
'In de slide': 'In the slide',
|
||||
'Als CSV-bestand': 'As a CSV file',
|
||||
'Geen grafiekgegevens': 'No chart data',
|
||||
'Platte tekst': 'Plain text',
|
||||
'Titel (optioneel)': 'Title (optional)',
|
||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
||||
|
|
|
|||
147
lib/models/chart.dart
Normal file
147
lib/models/chart.dart
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import 'dart:convert';
|
||||
|
||||
/// Supported chart kinds for a chart slide.
|
||||
enum ChartType { bar, line, pie }
|
||||
|
||||
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
|
||||
(t) => t.name == name,
|
||||
orElse: () => ChartType.bar,
|
||||
);
|
||||
|
||||
/// One named data series (a row of values aligned to the x labels).
|
||||
class ChartSeries {
|
||||
final String name;
|
||||
final List<double> data;
|
||||
const ChartSeries({required this.name, required this.data});
|
||||
|
||||
Map<String, dynamic> toJson() => {'name': name, 'data': data};
|
||||
|
||||
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
|
||||
name: (json['name'] ?? '').toString(),
|
||||
data: [
|
||||
for (final v in (json['data'] as List? ?? const []))
|
||||
(v as num?)?.toDouble() ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// external CSV via [source] (kept as the living source of truth and packaged
|
||||
/// alongside the deck like images). When a [source] is set the inline data is
|
||||
/// stripped from the markdown on save and re-hydrated from the CSV on load.
|
||||
class ChartSpec {
|
||||
final ChartType type;
|
||||
final String title;
|
||||
final String? source;
|
||||
final List<String> x;
|
||||
final List<ChartSeries> series;
|
||||
|
||||
const ChartSpec({
|
||||
this.type = ChartType.bar,
|
||||
this.title = '',
|
||||
this.source,
|
||||
this.x = const [],
|
||||
this.series = const [],
|
||||
});
|
||||
|
||||
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
|
||||
|
||||
ChartSpec copyWith({
|
||||
ChartType? type,
|
||||
String? title,
|
||||
String? source,
|
||||
bool clearSource = false,
|
||||
List<String>? x,
|
||||
List<ChartSeries>? series,
|
||||
}) => ChartSpec(
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
source: clearSource ? null : (source ?? this.source),
|
||||
x: x ?? this.x,
|
||||
series: series ?? this.series,
|
||||
);
|
||||
|
||||
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
|
||||
/// spec on any error so a malformed block never crashes rendering.
|
||||
factory ChartSpec.parse(String raw) {
|
||||
try {
|
||||
final data = jsonDecode(raw.trim());
|
||||
if (data is! Map) return const ChartSpec();
|
||||
final src = (data['source'] as String?)?.trim();
|
||||
return ChartSpec(
|
||||
type: _chartTypeFromName(data['type'] as String?),
|
||||
title: (data['title'] ?? '').toString(),
|
||||
source: (src == null || src.isEmpty) ? null : src,
|
||||
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
|
||||
series: [
|
||||
for (final s in (data['series'] as List? ?? const []))
|
||||
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
return const ChartSpec();
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize back to the pretty JSON that lives in the markdown block.
|
||||
/// When [forStorage] is true and a [source] is set, the (re-hydratable)
|
||||
/// inline data is omitted so the .md stays lean and the CSV stays the source.
|
||||
String toBlock({bool forStorage = false}) {
|
||||
final map = <String, dynamic>{'type': type.name};
|
||||
if (title.isNotEmpty) map['title'] = title;
|
||||
if (source != null) map['source'] = source;
|
||||
final dropData = forStorage && source != null;
|
||||
if (!dropData) {
|
||||
if (x.isNotEmpty) map['x'] = x;
|
||||
if (series.isNotEmpty) {
|
||||
map['series'] = [for (final s in series) s.toJson()];
|
||||
}
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(map);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse CSV text into (x labels, series). The first row is a header whose
|
||||
/// first cell is ignored (the label column) and whose remaining cells are the
|
||||
/// series names; each later row is `label, v1, v2, …`.
|
||||
(List<String>, List<ChartSeries>) parseCsv(String csv) {
|
||||
final lines = csv
|
||||
.replaceAll('\r\n', '\n')
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
if (lines.isEmpty) return (const [], const []);
|
||||
|
||||
List<String> cells(String line) => line.split(',').map((c) => c.trim()).toList();
|
||||
|
||||
final header = cells(lines.first);
|
||||
final seriesNames = header.length > 1 ? header.sublist(1) : <String>[];
|
||||
final x = <String>[];
|
||||
final seriesData = [for (final _ in seriesNames) <double>[]];
|
||||
|
||||
for (final line in lines.skip(1)) {
|
||||
final row = cells(line);
|
||||
if (row.isEmpty) continue;
|
||||
x.add(row.first);
|
||||
for (var i = 0; i < seriesNames.length; i++) {
|
||||
final raw = (i + 1) < row.length ? row[i + 1] : '';
|
||||
seriesData[i].add(double.tryParse(raw) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
x,
|
||||
[
|
||||
for (var i = 0; i < seriesNames.length; i++)
|
||||
ChartSeries(name: seriesNames[i], data: seriesData[i]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ enum SlideType {
|
|||
table,
|
||||
freeMarkdown,
|
||||
code,
|
||||
chart,
|
||||
}
|
||||
|
||||
extension SlideTypeExtension on SlideType {
|
||||
|
|
@ -45,6 +46,8 @@ extension SlideTypeExtension on SlideType {
|
|||
return 'Vrije Markdown';
|
||||
case SlideType.code:
|
||||
return 'Broncode';
|
||||
case SlideType.chart:
|
||||
return 'Grafiek';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +77,8 @@ extension SlideTypeExtension on SlideType {
|
|||
return '';
|
||||
case SlideType.code:
|
||||
return 'code';
|
||||
case SlideType.chart:
|
||||
return 'chart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:flutter/services.dart' show rootBundle;
|
|||
import '../models/deck.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/chart.dart';
|
||||
import '../models/slide.dart';
|
||||
import 'annotation_codec.dart';
|
||||
import 'caption_service.dart';
|
||||
|
|
@ -146,7 +147,7 @@ class FileService {
|
|||
}
|
||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||
if (deck == null) return null;
|
||||
final hydrated = await _hydrateImageCaptions(deck);
|
||||
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
|
||||
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||
if (content == null) {
|
||||
final sidecar = File(_sidecarPath(filePath));
|
||||
|
|
@ -179,6 +180,75 @@ class FileService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Load the external CSV of any chart slide that links one, inlining the data
|
||||
/// into the in-memory spec so the renderer has it. The markdown on disk keeps
|
||||
/// only the `source` reference (data is stripped again on save).
|
||||
Future<Deck> _hydrateCharts(Deck deck) async {
|
||||
if (deck.projectPath == null) return deck;
|
||||
var changed = false;
|
||||
final slides = <Slide>[];
|
||||
for (final s in deck.slides) {
|
||||
if (s.type != SlideType.chart) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
final spec = ChartSpec.parse(s.customMarkdown);
|
||||
if (spec.source == null || spec.hasInlineData) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
final abs = p.isAbsolute(spec.source!)
|
||||
? spec.source!
|
||||
: p.join(deck.projectPath!, spec.source!);
|
||||
final file = File(abs);
|
||||
if (!await file.exists()) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final csv = await file.readAsString();
|
||||
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
||||
changed = true;
|
||||
} catch (_) {
|
||||
slides.add(s);
|
||||
}
|
||||
}
|
||||
return changed ? deck.copyWith(slides: slides) : deck;
|
||||
}
|
||||
|
||||
/// For packaging: add a chart's linked CSV under data/ and rewrite its source
|
||||
/// path; if the CSV is missing, fall back to keeping the data inline.
|
||||
Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) {
|
||||
final spec = ChartSpec.parse(s.customMarkdown);
|
||||
final src = spec.source;
|
||||
if (src == null) return s;
|
||||
final rel = addAsset(src, 'data');
|
||||
if (rel == null) {
|
||||
return s.copyWith(
|
||||
customMarkdown: spec.copyWith(clearSource: true).toBlock(),
|
||||
);
|
||||
}
|
||||
return s.copyWith(
|
||||
customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new
|
||||
/// location). A normal save is a no-op because source and dest coincide.
|
||||
Future<void> _copyChartData(Deck deck, String destDir) async {
|
||||
for (final s in deck.slides) {
|
||||
if (s.type != SlideType.chart) continue;
|
||||
final src = ChartSpec.parse(s.customMarkdown).source;
|
||||
if (src == null || p.isAbsolute(src) || deck.projectPath == null) continue;
|
||||
final from = File(p.join(deck.projectPath!, src));
|
||||
final toPath = p.join(destDir, src);
|
||||
if (from.path == toPath || !from.existsSync()) continue;
|
||||
final out = File(toPath);
|
||||
await out.parent.create(recursive: true);
|
||||
await out.writeAsBytes(await from.readAsBytes(), flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
||||
final safeName = deck.title
|
||||
.replaceAll(RegExp(r'[^\w\s-]'), '')
|
||||
|
|
@ -245,12 +315,19 @@ class FileService {
|
|||
),
|
||||
];
|
||||
|
||||
// Chart slides link their data via a CSV path inside the JSON block; bring
|
||||
// the file along under data/ and rewrite the path to match.
|
||||
final packedSlides = [
|
||||
for (final s in slides)
|
||||
if (s.type == SlideType.chart) _packChartSlide(s, addAsset) else s,
|
||||
];
|
||||
|
||||
final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
|
||||
final profile = logoRel != null
|
||||
? deck.themeProfile.copyWith(logoPath: logoRel)
|
||||
: deck.themeProfile;
|
||||
|
||||
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
|
||||
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
|
||||
|
||||
// Markdown.
|
||||
final markdown = _md.generateDeck(packDeck);
|
||||
|
|
@ -453,6 +530,9 @@ class FileService {
|
|||
logoAsset.cssUrl,
|
||||
);
|
||||
|
||||
// Bring linked chart CSVs along when saving to a new location.
|
||||
await _copyChartData(deck, dir);
|
||||
|
||||
final markdown = _md.generateDeck(updatedDeck);
|
||||
await File(filePath).writeAsString(markdown);
|
||||
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'package:characters/characters.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/chart.dart';
|
||||
import '../models/deck.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
|
|
@ -10,7 +11,7 @@ const _uuid = Uuid();
|
|||
class MarkdownService {
|
||||
// ── Generation ──────────────────────────────────────────────────────────────
|
||||
|
||||
String generateDeck(Deck deck) {
|
||||
String generateDeck(Deck deck, {bool inlineChartData = false}) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('---');
|
||||
buf.writeln('marp: true');
|
||||
|
|
@ -49,7 +50,13 @@ class MarkdownService {
|
|||
buf.writeln('---');
|
||||
buf.writeln();
|
||||
}
|
||||
buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile));
|
||||
buf.write(
|
||||
generateSlide(
|
||||
deck.slides[i],
|
||||
themeProfile: deck.themeProfile,
|
||||
inlineChartData: inlineChartData,
|
||||
),
|
||||
);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
|
@ -158,7 +165,11 @@ class MarkdownService {
|
|||
return out.toString().replaceAll('<br>', '\n');
|
||||
}
|
||||
|
||||
String generateSlide(Slide slide, {ThemeProfile? themeProfile}) {
|
||||
String generateSlide(
|
||||
Slide slide, {
|
||||
ThemeProfile? themeProfile,
|
||||
bool inlineChartData = false,
|
||||
}) {
|
||||
final buf = StringBuffer();
|
||||
final cssClass = slide.cssClass.isNotEmpty
|
||||
? slide.cssClass
|
||||
|
|
@ -330,6 +341,14 @@ class MarkdownService {
|
|||
buf.writeln();
|
||||
}
|
||||
buf.writeln('```');
|
||||
|
||||
case SlideType.chart:
|
||||
// Re-serialize so inline data is dropped when the chart links a CSV
|
||||
// (the .md keeps only the spec + source; the CSV stays the source).
|
||||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||
buf.writeln('```chart');
|
||||
buf.writeln(spec.toBlock(forStorage: !inlineChartData));
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
if (slide.audioPath.isNotEmpty) {
|
||||
|
|
@ -650,6 +669,18 @@ class MarkdownService {
|
|||
);
|
||||
}
|
||||
|
||||
// Chart slides carry a fenced ```chart JSON block; handle up front too.
|
||||
if (cssClass.split(RegExp(r'\s+')).contains('chart')) {
|
||||
return _parseChartBlock(
|
||||
remaining: remaining,
|
||||
cssClass: cssClass,
|
||||
notes: notes,
|
||||
advanceDuration: advanceDuration,
|
||||
skipped: skipped,
|
||||
tlp: slideTlp,
|
||||
);
|
||||
}
|
||||
|
||||
final lines = remaining.split('\n');
|
||||
String h1 = '';
|
||||
String h2 = '';
|
||||
|
|
@ -908,4 +939,66 @@ class MarkdownService {
|
|||
tlp: tlp,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a `<!-- _class: chart -->` slide: the fenced ```chart JSON block and
|
||||
/// an optional `<audio>`. The JSON is kept verbatim in [Slide.customMarkdown].
|
||||
Slide _parseChartBlock({
|
||||
required String remaining,
|
||||
required String cssClass,
|
||||
required String notes,
|
||||
required double advanceDuration,
|
||||
required bool skipped,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
}) {
|
||||
final lines = remaining.split('\n');
|
||||
final json = <String>[];
|
||||
String audioPath = '';
|
||||
bool audioAutoplay = false;
|
||||
bool inFence = false;
|
||||
|
||||
for (final line in lines) {
|
||||
final fence = RegExp(r'^\s*```').hasMatch(line);
|
||||
if (fence) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
json.add(line);
|
||||
continue;
|
||||
}
|
||||
final t = line.trim();
|
||||
if (t.startsWith('<audio')) {
|
||||
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||
if (m != null) audioPath = m.group(1) ?? '';
|
||||
audioAutoplay = t.contains('autoplay');
|
||||
}
|
||||
}
|
||||
|
||||
final classTokens = cssClass.split(RegExp(r'\s+'));
|
||||
final effectiveClass = classTokens
|
||||
.where(
|
||||
(c) =>
|
||||
c.isNotEmpty &&
|
||||
c != 'chart' &&
|
||||
c != 'logo-safe' &&
|
||||
c != 'no-logo' &&
|
||||
c != 'no-footer',
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return Slide(
|
||||
id: _uuid.v4(),
|
||||
type: SlideType.chart,
|
||||
customMarkdown: json.join('\n').trim(),
|
||||
audioPath: audioPath,
|
||||
audioAutoplay: audioAutoplay,
|
||||
cssClass: effectiveClass,
|
||||
notes: notes,
|
||||
advanceDuration: advanceDuration,
|
||||
showLogo: !classTokens.contains('no-logo'),
|
||||
showFooter: !classTokens.contains('no-footer'),
|
||||
skipped: skipped,
|
||||
tlp: tlp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import '../models/chart.dart';
|
||||
import '../models/settings.dart';
|
||||
|
||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
||||
|
|
@ -46,7 +48,7 @@ class MarpHtmlService {
|
|||
for (final slide in marpSlides(deckMarkdown)) {
|
||||
sections
|
||||
..write('<section class="slide"><script type="text/markdown">')
|
||||
..write(_guard(slide))
|
||||
..write(_guard(renderChartBlocks(slide)))
|
||||
..write('</script></section>');
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +103,206 @@ class MarpHtmlService {
|
|||
.replaceAll('</script', r'<\/script')
|
||||
.replaceAll('</SCRIPT', r'<\/SCRIPT');
|
||||
|
||||
// ── Charts → inline SVG ────────────────────────────────────────────────────
|
||||
|
||||
static final RegExp _chartFence = RegExp(
|
||||
r'```chart[ \t]*\n([\s\S]*?)\n```',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
static const List<String> _chartPalette = [
|
||||
'#2563EB',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#EF4444',
|
||||
'#8B5CF6',
|
||||
'#06B6D4',
|
||||
'#EC4899',
|
||||
'#84CC16',
|
||||
];
|
||||
|
||||
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
|
||||
/// exported HTML renders charts without any JS chart library.
|
||||
static String renderChartBlocks(String slideMarkdown) {
|
||||
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
|
||||
final spec = ChartSpec.parse(m.group(1)!);
|
||||
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
|
||||
});
|
||||
}
|
||||
|
||||
static String _esc(String s) => s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
static String _color(int i) =>
|
||||
_chartPalette[i % _chartPalette.length];
|
||||
|
||||
static String _chartSvg(ChartSpec spec) {
|
||||
if (!spec.hasInlineData) {
|
||||
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
|
||||
}
|
||||
final b = StringBuffer()
|
||||
..write(
|
||||
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
|
||||
'font-family="inherit" width="100%">',
|
||||
);
|
||||
if (spec.title.isNotEmpty) {
|
||||
b.write(
|
||||
'<text x="400" y="34" text-anchor="middle" font-size="26" '
|
||||
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
|
||||
);
|
||||
}
|
||||
// Legend (multi-series, non-pie).
|
||||
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
|
||||
var plotTop = top;
|
||||
if (spec.type != ChartType.pie && spec.series.length > 1) {
|
||||
var lx = 60.0;
|
||||
for (var i = 0; i < spec.series.length; i++) {
|
||||
b
|
||||
..write(
|
||||
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</text>',
|
||||
);
|
||||
lx += 30 + spec.series[i].name.length * 9 + 24;
|
||||
}
|
||||
plotTop = top + 28;
|
||||
}
|
||||
switch (spec.type) {
|
||||
case ChartType.bar:
|
||||
_barSvg(b, spec, plotTop);
|
||||
case ChartType.line:
|
||||
_lineSvg(b, spec, plotTop);
|
||||
case ChartType.pie:
|
||||
_pieSvg(b, spec, plotTop);
|
||||
}
|
||||
b.write('</svg>');
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
static double _maxY(ChartSpec spec) {
|
||||
var m = 0.0;
|
||||
for (final s in spec.series) {
|
||||
for (final v in s.data) {
|
||||
if (v > m) m = v;
|
||||
}
|
||||
}
|
||||
return m <= 0 ? 1 : m * 1.15;
|
||||
}
|
||||
|
||||
static String _num(double v) =>
|
||||
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
|
||||
|
||||
static void _axes(
|
||||
StringBuffer b,
|
||||
ChartSpec spec,
|
||||
double left,
|
||||
double top,
|
||||
double right,
|
||||
double bottom,
|
||||
double maxY,
|
||||
) {
|
||||
// Horizontal gridlines + y labels.
|
||||
for (var i = 0; i <= 4; i++) {
|
||||
final y = bottom - (bottom - top) * i / 4;
|
||||
final val = maxY * i / 4;
|
||||
b
|
||||
..write(
|
||||
'<line x1="$left" y1="$y" x2="$right" y2="$y" stroke="#e2e8f0" stroke-width="1"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="${left - 8}" y="${y + 5}" text-anchor="end" font-size="14" fill="#64748b">${_num(val)}</text>',
|
||||
);
|
||||
}
|
||||
// X labels.
|
||||
final n = spec.x.length;
|
||||
for (var i = 0; i < n; i++) {
|
||||
final x = left + (right - left) * (i + 0.5) / n;
|
||||
b.write(
|
||||
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||
const left = 60.0, right = 770.0, bottom = 400.0;
|
||||
final maxY = _maxY(spec);
|
||||
_axes(b, spec, left, top, right, bottom, maxY);
|
||||
final n = spec.x.length;
|
||||
final groupW = (right - left) / n;
|
||||
final sCount = spec.series.length;
|
||||
final barW = (groupW * 0.7) / sCount;
|
||||
for (var xi = 0; xi < n; xi++) {
|
||||
final gx = left + groupW * xi + groupW * 0.15;
|
||||
for (var si = 0; si < sCount; si++) {
|
||||
if (xi >= spec.series[si].data.length) continue;
|
||||
final v = spec.series[si].data[xi];
|
||||
final h = (bottom - top) * (v / maxY);
|
||||
final x = gx + barW * si;
|
||||
b.write(
|
||||
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||
const left = 60.0, right = 770.0, bottom = 400.0;
|
||||
final maxY = _maxY(spec);
|
||||
_axes(b, spec, left, top, right, bottom, maxY);
|
||||
final n = spec.x.length;
|
||||
double px(int i) => left + (right - left) * (i + 0.5) / n;
|
||||
double py(double v) => bottom - (bottom - top) * (v / maxY);
|
||||
for (var si = 0; si < spec.series.length; si++) {
|
||||
final data = spec.series[si].data;
|
||||
final pts = [
|
||||
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
|
||||
].join(' ');
|
||||
b.write(
|
||||
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
|
||||
);
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
b.write(
|
||||
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||
final series = spec.series.first;
|
||||
final total = series.data.fold<double>(0, (a, v) => a + v);
|
||||
const cx = 250.0, cy = 240.0, r = 150.0;
|
||||
var angle = -90.0; // start at top
|
||||
for (var i = 0; i < series.data.length; i++) {
|
||||
final frac = total > 0 ? series.data[i] / total : 0;
|
||||
final sweep = frac * 360;
|
||||
final a0 = angle * math.pi / 180;
|
||||
final a1 = (angle + sweep) * math.pi / 180;
|
||||
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0);
|
||||
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1);
|
||||
final large = sweep > 180 ? 1 : 0;
|
||||
b.write(
|
||||
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>',
|
||||
);
|
||||
angle += sweep;
|
||||
}
|
||||
// Legend on the right.
|
||||
var ly = 120.0;
|
||||
for (var i = 0; i < spec.x.length && i < series.data.length; i++) {
|
||||
b
|
||||
..write(
|
||||
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>',
|
||||
);
|
||||
ly += 28;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -1002,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
exportService: widget.exportService,
|
||||
tlp: deck.tlp,
|
||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||
// Inline chart data so the HTML export can render charts standalone,
|
||||
// even when a chart links an external CSV.
|
||||
markdown: ref
|
||||
.read(markdownServiceProvider)
|
||||
.generateDeck(deck.copyWith(slides: slides)),
|
||||
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class AddSlideDialog extends StatelessWidget {
|
|||
(SlideType.video, Icons.movie_outlined, 'Video'),
|
||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
||||
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
||||
(SlideType.code, Icons.terminal, 'Broncode'),
|
||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
||||
];
|
||||
|
|
|
|||
265
lib/widgets/editors/chart_editor.dart
Normal file
265
lib/widgets/editors/chart_editor.dart
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/chart.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '_editor_field.dart';
|
||||
|
||||
/// Editor for a chart slide: type, title, and the data as a CSV-style table.
|
||||
/// Data can be typed/pasted, imported from a CSV (inline), or linked to a
|
||||
/// CSV file kept next to the deck (the living source).
|
||||
class ChartEditor extends StatefulWidget {
|
||||
final Slide slide;
|
||||
final ValueChanged<Slide> onUpdate;
|
||||
final String? projectPath;
|
||||
|
||||
const ChartEditor({
|
||||
super.key,
|
||||
required this.slide,
|
||||
required this.onUpdate,
|
||||
this.projectPath,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChartEditor> createState() => _ChartEditorState();
|
||||
}
|
||||
|
||||
class _ChartEditorState extends State<ChartEditor> {
|
||||
late final TextEditingController _title;
|
||||
late final TextEditingController _csv;
|
||||
late ChartType _type;
|
||||
String? _source;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final spec = ChartSpec.parse(widget.slide.customMarkdown);
|
||||
_type = spec.type;
|
||||
_source = spec.source;
|
||||
_title = TextEditingController(text: spec.title);
|
||||
_title.addListener(_emit);
|
||||
_csv = TextEditingController(text: _specToCsv(spec));
|
||||
_csv.addListener(_emit);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_csv.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Render the spec's inline data back to the CSV-style table text.
|
||||
static String _specToCsv(ChartSpec spec) {
|
||||
if (!spec.hasInlineData) return '';
|
||||
final header = ['', ...spec.series.map((s) => s.name)].join(', ');
|
||||
final rows = <String>[header];
|
||||
for (var i = 0; i < spec.x.length; i++) {
|
||||
rows.add(
|
||||
[
|
||||
spec.x[i],
|
||||
...spec.series.map(
|
||||
(s) => i < s.data.length ? _fmt(s.data[i]) : '',
|
||||
),
|
||||
].join(', '),
|
||||
);
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
static String _fmt(double v) =>
|
||||
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
|
||||
|
||||
void _emit() {
|
||||
final parsed = parseCsv(_csv.text);
|
||||
final spec = ChartSpec(
|
||||
type: _type,
|
||||
title: _title.text,
|
||||
source: _source,
|
||||
x: parsed.$1,
|
||||
series: parsed.$2,
|
||||
);
|
||||
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
|
||||
}
|
||||
|
||||
Future<void> _importCsv() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
final text = file.bytes != null
|
||||
? utf8.decode(file.bytes!)
|
||||
: (file.path != null ? await File(file.path!).readAsString() : null);
|
||||
if (text == null) return;
|
||||
|
||||
// Offer to keep the CSV as an external, living source when the deck is
|
||||
// saved (so it can be re-edited in a spreadsheet); otherwise inline it.
|
||||
var asFile = false;
|
||||
if (widget.projectPath != null && mounted) {
|
||||
asFile =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.d('CSV importeren')),
|
||||
content: Text(
|
||||
ctx.l10n.d(
|
||||
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.d('In de slide')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.d('Als CSV-bestand')),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
String? source;
|
||||
if (asFile && widget.projectPath != null) {
|
||||
final name = p.basename(file.name);
|
||||
final dir = Directory(p.join(widget.projectPath!, 'data'));
|
||||
await dir.create(recursive: true);
|
||||
await File(p.join(dir.path, name)).writeAsString(text, flush: true);
|
||||
source = 'data/$name';
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_source = source;
|
||||
_csv.text = text;
|
||||
});
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _unlink() {
|
||||
setState(() => _source = null);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final linked = _source != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
EditorField(label: 'Titel (optioneel)', controller: _title),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.d('Type grafiek'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
DropdownButton<ChartType>(
|
||||
value: _type,
|
||||
isDense: true,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ChartType.bar,
|
||||
child: Text(l10n.d('Staaf')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ChartType.line,
|
||||
child: Text(l10n.d('Lijn')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ChartType.pie,
|
||||
child: Text(l10n.d('Cirkel')),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => _type = v);
|
||||
_emit();
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _importCsv,
|
||||
icon: const Icon(Icons.upload_file, size: 16),
|
||||
label: Text(l10n.d('CSV importeren')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.d('Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (linked)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.link, size: 14, color: Color(0xFF0369A1)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${l10n.d('Gekoppeld aan')} $_source',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF0369A1),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _unlink,
|
||||
child: Text(l10n.d('Ontkoppelen')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _csv,
|
||||
readOnly: linked,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: ', 2025, 2026\nQ1, 10, 12\nQ2, 14, 9',
|
||||
alignLabelWithHint: true,
|
||||
filled: linked,
|
||||
fillColor: linked ? const Color(0xFFF1F5F9) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import '../../l10n/app_localizations.dart';
|
|||
import '../editors/bullets_editor.dart';
|
||||
import '../editors/bullets_image_editor.dart';
|
||||
import '../editors/audio_attachment_editor.dart';
|
||||
import '../editors/chart_editor.dart';
|
||||
import '../editors/code_editor.dart';
|
||||
import '../editors/free_markdown_editor.dart';
|
||||
import '../editors/image_slide_editor.dart';
|
||||
|
|
@ -283,6 +284,13 @@ class EditorPanel extends ConsumerWidget {
|
|||
slide: slide,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
case SlideType.chart:
|
||||
return ChartEditor(
|
||||
key: ValueKey(slide.id),
|
||||
slide: slide,
|
||||
onUpdate: onUpdate,
|
||||
projectPath: captionBasePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +323,8 @@ IconData _slideTypeIcon(SlideType type) {
|
|||
return Icons.code;
|
||||
case SlideType.code:
|
||||
return Icons.terminal;
|
||||
case SlideType.chart:
|
||||
return Icons.bar_chart;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||
import 'package:flutter_highlight/themes/github.dart';
|
||||
import 'package:flutter_highlight/themes/atom-one-dark.dart';
|
||||
|
|
@ -8,6 +9,7 @@ import 'package:highlight/highlight.dart' show highlight;
|
|||
import 'package:highlight/languages/all.dart' show allLanguages;
|
||||
import 'package:video_player/video_player.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/chart.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
|
|
@ -329,6 +331,13 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
font: fontFamily,
|
||||
profile: themeProfile,
|
||||
);
|
||||
case SlideType.chart:
|
||||
return _ChartPreview(
|
||||
slide: slide,
|
||||
w: w,
|
||||
font: fontFamily,
|
||||
profile: themeProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2153,6 +2162,347 @@ class _CodePreview extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
|
||||
class _ChartPreview extends StatelessWidget {
|
||||
final Slide slide;
|
||||
final double w;
|
||||
final String font;
|
||||
final ThemeProfile profile;
|
||||
|
||||
const _ChartPreview({
|
||||
required this.slide,
|
||||
required this.w,
|
||||
required this.font,
|
||||
required this.profile,
|
||||
});
|
||||
|
||||
static const _palette = <int>[
|
||||
0xFF2563EB,
|
||||
0xFFF59E0B,
|
||||
0xFF10B981,
|
||||
0xFFEF4444,
|
||||
0xFF8B5CF6,
|
||||
0xFF06B6D4,
|
||||
0xFFEC4899,
|
||||
0xFF84CC16,
|
||||
];
|
||||
|
||||
Color _seriesColor(int i) =>
|
||||
i == 0 ? _hexColor(profile.accentColor) : Color(_palette[i % _palette.length]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||
final pad = w * 0.06;
|
||||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||
final textColor = _hexColor(profile.textColor);
|
||||
|
||||
return Container(
|
||||
color: _hexColor(profile.slideBackgroundColor),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
pad,
|
||||
pad + safe.top,
|
||||
pad,
|
||||
pad + safe.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (spec.title.isNotEmpty) ...[
|
||||
_md(
|
||||
context,
|
||||
spec.title,
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.04,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
linkColor: _hexColor(profile.accentColor),
|
||||
),
|
||||
SizedBox(height: w * 0.02),
|
||||
],
|
||||
if (spec.series.length > 1 && spec.type != ChartType.pie)
|
||||
_legend(spec, textColor),
|
||||
Expanded(
|
||||
child: spec.hasInlineData
|
||||
? _chart(spec, textColor)
|
||||
: _placeholder(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
SizedBox(width: w * 0.008),
|
||||
Text(
|
||||
spec.series[i].name,
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.02, color: textColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chart(ChartSpec spec, Color textColor) {
|
||||
switch (spec.type) {
|
||||
case ChartType.bar:
|
||||
return _barChart(spec, textColor);
|
||||
case ChartType.line:
|
||||
return _lineChart(spec, textColor);
|
||||
case ChartType.pie:
|
||||
return _pieChart(spec, textColor);
|
||||
}
|
||||
}
|
||||
|
||||
double _maxY(ChartSpec spec) {
|
||||
var m = 0.0;
|
||||
for (final s in spec.series) {
|
||||
for (final v in s.data) {
|
||||
if (v > m) m = v;
|
||||
}
|
||||
}
|
||||
return m <= 0 ? 1 : m * 1.15;
|
||||
}
|
||||
|
||||
FlTitlesData _titles(ChartSpec spec, Color textColor) {
|
||||
final style = _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.018, color: textColor.withValues(alpha: 0.8)),
|
||||
);
|
||||
return FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: w * 0.06,
|
||||
getTitlesWidget: (value, meta) =>
|
||||
Text(_fmtNum(value), style: style.copyWith(fontSize: w * 0.016)),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: w * 0.05,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= spec.x.length) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: w * 0.008),
|
||||
child: Text(spec.x[i], style: style),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmtNum(double v) {
|
||||
if (v == v.roundToDouble()) return v.toInt().toString();
|
||||
return v.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
FlGridData _grid(Color textColor) => FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (v) =>
|
||||
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
|
||||
);
|
||||
|
||||
Widget _barChart(ChartSpec spec, Color textColor) {
|
||||
final groups = <BarChartGroupData>[];
|
||||
for (var xi = 0; xi < spec.x.length; xi++) {
|
||||
groups.add(
|
||||
BarChartGroupData(
|
||||
x: xi,
|
||||
barRods: [
|
||||
for (var si = 0; si < spec.series.length; si++)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: _maxY(spec),
|
||||
barGroups: groups,
|
||||
titlesData: _titles(spec, textColor),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
barTouchData: BarTouchData(enabled: false),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _lineChart(ChartSpec spec, Color textColor) {
|
||||
final bars = <LineChartBarData>[];
|
||||
for (var si = 0; si < spec.series.length; si++) {
|
||||
bars.add(
|
||||
LineChartBarData(
|
||||
spots: [
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
minY: 0,
|
||||
maxY: _maxY(spec),
|
||||
lineBarsData: bars,
|
||||
titlesData: _titles(spec, textColor),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
),
|
||||
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<double>(0, (a, b) => a + b);
|
||||
final sections = <PieChartSectionData>[];
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: sections,
|
||||
sectionsSpace: 1,
|
||||
centerSpaceRadius: w * 0.05,
|
||||
pieTouchData: PieTouchData(enabled: false),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context) =>
|
||||
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
||||
|
||||
Widget _placeholderText(String text) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bar_chart_outlined,
|
||||
size: w * 0.08,
|
||||
color: const Color(0xFF94A3B8),
|
||||
),
|
||||
SizedBox(height: w * 0.01),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||
/// colour any common language without throwing.
|
||||
bool _highlightReady = false;
|
||||
|
|
@ -2377,6 +2727,8 @@ double _contentLeftInset(Slide slide, double w) {
|
|||
return w * 0.07;
|
||||
case SlideType.code:
|
||||
return w * 0.05;
|
||||
case SlideType.chart:
|
||||
return w * 0.06;
|
||||
case SlideType.twoBullets:
|
||||
return w * 0.065;
|
||||
case SlideType.table:
|
||||
|
|
|
|||
16
pubspec.lock
16
pubspec.lock
|
|
@ -176,6 +176,14 @@ packages:
|
|||
relative: true
|
||||
source: path
|
||||
version: "0.3.0"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -216,6 +224,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ dependencies:
|
|||
# the published 0.3.0 dropped, needed for the dual-screen presenter mode.
|
||||
desktop_multi_window:
|
||||
path: third_party/desktop_multi_window
|
||||
fl_chart: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
74
test/chart_test.dart
Normal file
74
test/chart_test.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/chart.dart';
|
||||
|
||||
void main() {
|
||||
group('parseCsv', () {
|
||||
test('reads header series names and labelled rows', () {
|
||||
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
|
||||
expect(x, ['Q1', 'Q2']);
|
||||
expect(series.map((s) => s.name), ['2025', '2026']);
|
||||
expect(series[0].data, [10, 14]);
|
||||
expect(series[1].data, [12, 9]);
|
||||
});
|
||||
|
||||
test('non-numeric cells become 0', () {
|
||||
final (x, series) = parseCsv(',A\nQ1,oops');
|
||||
expect(x, ['Q1']);
|
||||
expect(series.single.data, [0]);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChartSpec', () {
|
||||
test('round-trips inline data through the block JSON', () {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.line,
|
||||
title: 'Omzet',
|
||||
x: ['Q1', 'Q2'],
|
||||
series: [
|
||||
ChartSeries(name: '2025', data: [10, 14]),
|
||||
],
|
||||
);
|
||||
final back = ChartSpec.parse(spec.toBlock());
|
||||
expect(back.type, ChartType.line);
|
||||
expect(back.title, 'Omzet');
|
||||
expect(back.x, ['Q1', 'Q2']);
|
||||
expect(back.series.single.name, '2025');
|
||||
expect(back.series.single.data, [10, 14]);
|
||||
expect(back.hasInlineData, isTrue);
|
||||
});
|
||||
|
||||
test('storage form drops inline data when a source is linked', () {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
title: 'Omzet',
|
||||
source: 'data/omzet.csv',
|
||||
x: ['Q1', 'Q2'],
|
||||
series: [
|
||||
ChartSeries(name: '2025', data: [10, 14]),
|
||||
],
|
||||
);
|
||||
final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
|
||||
expect(stored.source, 'data/omzet.csv');
|
||||
expect(stored.hasInlineData, isFalse);
|
||||
|
||||
// The in-app/full form keeps the data.
|
||||
final full = ChartSpec.parse(spec.toBlock());
|
||||
expect(full.hasInlineData, isTrue);
|
||||
});
|
||||
|
||||
test('withCsv fills x/series and keeps the source', () {
|
||||
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv');
|
||||
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]);
|
||||
});
|
||||
|
||||
test('parse is tolerant of malformed JSON', () {
|
||||
final spec = ChartSpec.parse('{ not json');
|
||||
expect(spec.type, ChartType.bar);
|
||||
expect(spec.hasInlineData, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/chart.dart';
|
||||
import 'package:ocideck/models/deck.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
|
|
@ -262,6 +263,38 @@ void main() {
|
|||
expect(out.customMarkdown, code);
|
||||
});
|
||||
|
||||
test('chart slide keeps its inline spec', () {
|
||||
const block =
|
||||
'{\n "type": "bar",\n "title": "Omzet",\n "x": ["Q1","Q2"],\n'
|
||||
' "series": [\n {"name":"2025","data":[10,14]}\n ]\n}';
|
||||
final out = _roundTrip(
|
||||
Slide.create(SlideType.chart).copyWith(customMarkdown: block),
|
||||
);
|
||||
expect(out.type, SlideType.chart);
|
||||
final spec = ChartSpec.parse(out.customMarkdown);
|
||||
expect(spec.type, ChartType.bar);
|
||||
expect(spec.x, ['Q1', 'Q2']);
|
||||
expect(spec.series.single.data, [10, 14]);
|
||||
});
|
||||
|
||||
test('chart slide with a source keeps only the reference in markdown', () {
|
||||
const block = '{"type":"line","source":"data/omzet.csv",'
|
||||
'"x":["Q1"],"series":[{"name":"2025","data":[10]}]}';
|
||||
final service = MarkdownService();
|
||||
final md = service.generateDeck(
|
||||
Deck(
|
||||
title: 'Demo',
|
||||
slides: [Slide.create(SlideType.chart).copyWith(customMarkdown: block)],
|
||||
),
|
||||
);
|
||||
// The stored markdown references the CSV but does not inline the data.
|
||||
expect(md.contains('data/omzet.csv'), isTrue);
|
||||
final out = service.parseDeck(md)!.slides.single;
|
||||
final spec = ChartSpec.parse(out.customMarkdown);
|
||||
expect(spec.source, 'data/omzet.csv');
|
||||
expect(spec.hasInlineData, isFalse);
|
||||
});
|
||||
|
||||
test('code slide without a language stays plain code', () {
|
||||
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
|
||||
final out = _roundTrip(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue