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)',
|
'Stoppen (Esc)': 'Interrompi (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma',
|
'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma',
|
||||||
'Laser · annotaties wissen': 'Laser · cancella annotazioni',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Incolla o digita qui il tuo codice sorgente...',
|
'Incolla o digita qui il tuo codice sorgente...',
|
||||||
'Overgeslagen': 'Saltata',
|
'Overgeslagen': 'Saltata',
|
||||||
|
|
@ -1355,6 +1368,19 @@ const _dutchSourceStrings = {
|
||||||
'Stoppen (Esc)': 'Beenden (Esc)',
|
'Stoppen (Esc)': 'Beenden (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Stift · Marker · Radierer',
|
'Pen · markeerstift · gum': 'Stift · Marker · Radierer',
|
||||||
'Laser · annotaties wissen': 'Laser · Anmerkungen löschen',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Quellcode hier einfügen oder eingeben...',
|
'Quellcode hier einfügen oder eingeben...',
|
||||||
'Overgeslagen': 'Übersprungen',
|
'Overgeslagen': 'Übersprungen',
|
||||||
|
|
@ -1532,6 +1558,19 @@ const _dutchSourceStrings = {
|
||||||
'Stoppen (Esc)': 'Arrêter (Esc)',
|
'Stoppen (Esc)': 'Arrêter (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Stylo · surligneur · gomme',
|
'Pen · markeerstift · gum': 'Stylo · surligneur · gomme',
|
||||||
'Laser · annotaties wissen': 'Laser · effacer les annotations',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Collez ou tapez votre code source ici...',
|
'Collez ou tapez votre code source ici...',
|
||||||
'Overgeslagen': 'Ignorée',
|
'Overgeslagen': 'Ignorée',
|
||||||
|
|
@ -1708,6 +1747,19 @@ const _dutchSourceStrings = {
|
||||||
'Stoppen (Esc)': 'Detener (Esc)',
|
'Stoppen (Esc)': 'Detener (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Lápiz · marcador · goma',
|
'Pen · markeerstift · gum': 'Lápiz · marcador · goma',
|
||||||
'Laser · annotaties wissen': 'Láser · borrar anotaciones',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Pega o escribe aquí tu código fuente...',
|
'Pega o escribe aquí tu código fuente...',
|
||||||
'Overgeslagen': 'Omitida',
|
'Overgeslagen': 'Omitida',
|
||||||
|
|
@ -1885,6 +1937,19 @@ const _dutchSourceStrings = {
|
||||||
'Stoppen (Esc)': 'Stopje (Esc)',
|
'Stoppen (Esc)': 'Stopje (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Pen · markearstift · gom',
|
'Pen · markeerstift · gum': 'Pen · markearstift · gom',
|
||||||
'Laser · annotaties wissen': 'Laser · annotaasjes wiskje',
|
'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...',
|
'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...',
|
||||||
'Overgeslagen': 'Oerslein',
|
'Overgeslagen': 'Oerslein',
|
||||||
'Kopiëren': 'Kopiearje',
|
'Kopiëren': 'Kopiearje',
|
||||||
|
|
@ -2062,6 +2127,19 @@ const _dutchSourceStrings = {
|
||||||
'Stoppen (Esc)': 'Stòp (Esc)',
|
'Stoppen (Esc)': 'Stòp (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Pèn · marker · gòm',
|
'Pen · markeerstift · gum': 'Pèn · marker · gòm',
|
||||||
'Laser · annotaties wissen': 'Laser · kita anotashonnan',
|
'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...',
|
'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...',
|
||||||
'Overgeslagen': 'Saltá',
|
'Overgeslagen': 'Saltá',
|
||||||
'Kopiëren': 'Kopia',
|
'Kopiëren': 'Kopia',
|
||||||
|
|
@ -2228,6 +2306,19 @@ const _dutchSourceStringAdditions = {
|
||||||
'Stoppen (Esc)': 'Stop (Esc)',
|
'Stoppen (Esc)': 'Stop (Esc)',
|
||||||
'Pen · markeerstift · gum': 'Pen · highlighter · eraser',
|
'Pen · markeerstift · gum': 'Pen · highlighter · eraser',
|
||||||
'Laser · annotaties wissen': 'Laser · clear annotations',
|
'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',
|
'Platte tekst': 'Plain text',
|
||||||
'Titel (optioneel)': 'Title (optional)',
|
'Titel (optioneel)': 'Title (optional)',
|
||||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
'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,
|
table,
|
||||||
freeMarkdown,
|
freeMarkdown,
|
||||||
code,
|
code,
|
||||||
|
chart,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SlideTypeExtension on SlideType {
|
extension SlideTypeExtension on SlideType {
|
||||||
|
|
@ -45,6 +46,8 @@ extension SlideTypeExtension on SlideType {
|
||||||
return 'Vrije Markdown';
|
return 'Vrije Markdown';
|
||||||
case SlideType.code:
|
case SlideType.code:
|
||||||
return 'Broncode';
|
return 'Broncode';
|
||||||
|
case SlideType.chart:
|
||||||
|
return 'Grafiek';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +77,8 @@ extension SlideTypeExtension on SlideType {
|
||||||
return '';
|
return '';
|
||||||
case SlideType.code:
|
case SlideType.code:
|
||||||
return 'code';
|
return 'code';
|
||||||
|
case SlideType.chart:
|
||||||
|
return 'chart';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:flutter/services.dart' show rootBundle;
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
import 'annotation_codec.dart';
|
import 'annotation_codec.dart';
|
||||||
import 'caption_service.dart';
|
import 'caption_service.dart';
|
||||||
|
|
@ -146,7 +147,7 @@ class FileService {
|
||||||
}
|
}
|
||||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||||
if (deck == null) return null;
|
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.
|
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
final sidecar = File(_sidecarPath(filePath));
|
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 {
|
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
||||||
final safeName = deck.title
|
final safeName = deck.title
|
||||||
.replaceAll(RegExp(r'[^\w\s-]'), '')
|
.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 logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
|
||||||
final profile = logoRel != null
|
final profile = logoRel != null
|
||||||
? deck.themeProfile.copyWith(logoPath: logoRel)
|
? deck.themeProfile.copyWith(logoPath: logoRel)
|
||||||
: deck.themeProfile;
|
: deck.themeProfile;
|
||||||
|
|
||||||
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
|
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
|
||||||
|
|
||||||
// Markdown.
|
// Markdown.
|
||||||
final markdown = _md.generateDeck(packDeck);
|
final markdown = _md.generateDeck(packDeck);
|
||||||
|
|
@ -453,6 +530,9 @@ class FileService {
|
||||||
logoAsset.cssUrl,
|
logoAsset.cssUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bring linked chart CSVs along when saving to a new location.
|
||||||
|
await _copyChartData(deck, dir);
|
||||||
|
|
||||||
final markdown = _md.generateDeck(updatedDeck);
|
final markdown = _md.generateDeck(updatedDeck);
|
||||||
await File(filePath).writeAsString(markdown);
|
await File(filePath).writeAsString(markdown);
|
||||||
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:characters/characters.dart';
|
import 'package:characters/characters.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
|
@ -10,7 +11,7 @@ const _uuid = Uuid();
|
||||||
class MarkdownService {
|
class MarkdownService {
|
||||||
// ── Generation ──────────────────────────────────────────────────────────────
|
// ── Generation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String generateDeck(Deck deck) {
|
String generateDeck(Deck deck, {bool inlineChartData = false}) {
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
buf.writeln('marp: true');
|
buf.writeln('marp: true');
|
||||||
|
|
@ -49,7 +50,13 @@ class MarkdownService {
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
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();
|
return buf.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +165,11 @@ class MarkdownService {
|
||||||
return out.toString().replaceAll('<br>', '\n');
|
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 buf = StringBuffer();
|
||||||
final cssClass = slide.cssClass.isNotEmpty
|
final cssClass = slide.cssClass.isNotEmpty
|
||||||
? slide.cssClass
|
? slide.cssClass
|
||||||
|
|
@ -330,6 +341,14 @@ class MarkdownService {
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
}
|
}
|
||||||
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) {
|
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');
|
final lines = remaining.split('\n');
|
||||||
String h1 = '';
|
String h1 = '';
|
||||||
String h2 = '';
|
String h2 = '';
|
||||||
|
|
@ -908,4 +939,66 @@ class MarkdownService {
|
||||||
tlp: tlp,
|
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:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
|
||||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
/// 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)) {
|
for (final slide in marpSlides(deckMarkdown)) {
|
||||||
sections
|
sections
|
||||||
..write('<section class="slide"><script type="text/markdown">')
|
..write('<section class="slide"><script type="text/markdown">')
|
||||||
..write(_guard(slide))
|
..write(_guard(renderChartBlocks(slide)))
|
||||||
..write('</script></section>');
|
..write('</script></section>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +103,206 @@ class MarpHtmlService {
|
||||||
.replaceAll('</script', r'<\/script')
|
.replaceAll('</script', r'<\/script')
|
||||||
.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
|
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
|
||||||
/// accent colours, table colours and font. The EB Garamond font is embedded
|
/// accent colours, table colours and font. The EB Garamond font is embedded
|
||||||
/// (base64) so it renders offline; other fonts resolve to system families.
|
/// (base64) so it renders offline; other fonts resolve to system families.
|
||||||
|
|
|
||||||
|
|
@ -1002,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
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
|
markdown: ref
|
||||||
.read(markdownServiceProvider)
|
.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.video, Icons.movie_outlined, 'Video'),
|
||||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
||||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
||||||
|
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
||||||
(SlideType.code, Icons.terminal, 'Broncode'),
|
(SlideType.code, Icons.terminal, 'Broncode'),
|
||||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
(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_editor.dart';
|
||||||
import '../editors/bullets_image_editor.dart';
|
import '../editors/bullets_image_editor.dart';
|
||||||
import '../editors/audio_attachment_editor.dart';
|
import '../editors/audio_attachment_editor.dart';
|
||||||
|
import '../editors/chart_editor.dart';
|
||||||
import '../editors/code_editor.dart';
|
import '../editors/code_editor.dart';
|
||||||
import '../editors/free_markdown_editor.dart';
|
import '../editors/free_markdown_editor.dart';
|
||||||
import '../editors/image_slide_editor.dart';
|
import '../editors/image_slide_editor.dart';
|
||||||
|
|
@ -283,6 +284,13 @@ class EditorPanel extends ConsumerWidget {
|
||||||
slide: slide,
|
slide: slide,
|
||||||
onUpdate: onUpdate,
|
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;
|
return Icons.code;
|
||||||
case SlideType.code:
|
case SlideType.code:
|
||||||
return Icons.terminal;
|
return Icons.terminal;
|
||||||
|
case SlideType.chart:
|
||||||
|
return Icons.bar_chart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||||
import 'package:flutter_highlight/themes/github.dart';
|
import 'package:flutter_highlight/themes/github.dart';
|
||||||
import 'package:flutter_highlight/themes/atom-one-dark.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:highlight/languages/all.dart' show allLanguages;
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/chart.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
|
@ -329,6 +331,13 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
font: fontFamily,
|
font: fontFamily,
|
||||||
profile: themeProfile,
|
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
|
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||||
/// colour any common language without throwing.
|
/// colour any common language without throwing.
|
||||||
bool _highlightReady = false;
|
bool _highlightReady = false;
|
||||||
|
|
@ -2377,6 +2727,8 @@ double _contentLeftInset(Slide slide, double w) {
|
||||||
return w * 0.07;
|
return w * 0.07;
|
||||||
case SlideType.code:
|
case SlideType.code:
|
||||||
return w * 0.05;
|
return w * 0.05;
|
||||||
|
case SlideType.chart:
|
||||||
|
return w * 0.06;
|
||||||
case SlideType.twoBullets:
|
case SlideType.twoBullets:
|
||||||
return w * 0.065;
|
return w * 0.065;
|
||||||
case SlideType.table:
|
case SlideType.table:
|
||||||
|
|
|
||||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -176,6 +176,14 @@ packages:
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.3.0"
|
version: "0.3.0"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -216,6 +224,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ dependencies:
|
||||||
# the published 0.3.0 dropped, needed for the dual-screen presenter mode.
|
# the published 0.3.0 dropped, needed for the dual-screen presenter mode.
|
||||||
desktop_multi_window:
|
desktop_multi_window:
|
||||||
path: third_party/desktop_multi_window
|
path: third_party/desktop_multi_window
|
||||||
|
fl_chart: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/chart.dart';
|
||||||
import 'package:ocideck/models/deck.dart';
|
import 'package:ocideck/models/deck.dart';
|
||||||
import 'package:ocideck/models/settings.dart';
|
import 'package:ocideck/models/settings.dart';
|
||||||
import 'package:ocideck/models/slide.dart';
|
import 'package:ocideck/models/slide.dart';
|
||||||
|
|
@ -262,6 +263,38 @@ void main() {
|
||||||
expect(out.customMarkdown, code);
|
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', () {
|
test('code slide without a language stays plain code', () {
|
||||||
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
|
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
|
||||||
final out = _roundTrip(
|
final out = _roundTrip(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue