Improve presentation editing and playback

This commit is contained in:
Brenno de Winter 2026-06-09 13:28:23 +02:00
parent 196cd8adb1
commit 2fd5054603
24 changed files with 2341 additions and 353 deletions

View file

@ -2327,6 +2327,26 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = { const _dutchSourceStringAdditions = {
'en': { 'en': {
'Annuleren': 'Cancel', 'Annuleren': 'Cancel',
'Checklist': 'Task checklist',
'Voortgangsgrafiek tonen': 'Show progress chart',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Shows checked and unchecked items as percentages.',
'Afgevinkt': 'Checked',
'Niet afgevinkt': 'Unchecked',
'Afgevinkte tekst doorhalen': 'Strike through checked text',
'Toont een streep door voltooide checklistitems.':
'Shows completed checklist items with a strike-through.',
'Na media automatisch doorgaan': 'Advance automatically after media',
'Opsomming': 'Bullets',
'Nummering': 'Numbering',
'Varianten': 'Variants',
'Grafiekvarianten maken': 'Create chart variants',
'Slides toevoegen': 'Add slides',
'Omhoog': 'Move up',
'Omlaag': 'Move down',
'Niet toevoegen': 'Do not add',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'These slides use the same data, colors, and title. Use the arrows to choose their order after the current slide.',
'Afbeelding': 'Image', 'Afbeelding': 'Image',
'Broncode': 'Source code', 'Broncode': 'Source code',
'Bullet': 'Bullet', 'Bullet': 'Bullet',
@ -2406,6 +2426,27 @@ const _dutchSourceStringAdditions = {
}, },
'it': { 'it': {
'Annuleren': 'Annulla', 'Annuleren': 'Annulla',
'Checklist': 'Lista di controllo',
'Voortgangsgrafiek tonen': 'Mostra grafico di avanzamento',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Mostra gli elementi selezionati e non selezionati in percentuale.',
'Afgevinkt': 'Selezionati',
'Niet afgevinkt': 'Non selezionati',
'Afgevinkte tekst doorhalen': 'Barra il testo selezionato',
'Toont een streep door voltooide checklistitems.':
'Mostra gli elementi completati con il testo barrato.',
'Na media automatisch doorgaan':
'Avanza automaticamente dopo i contenuti multimediali',
'Opsomming': 'Elenco puntato',
'Nummering': 'Numerazione',
'Varianten': 'Varianti',
'Grafiekvarianten maken': 'Crea varianti del grafico',
'Slides toevoegen': 'Aggiungi diapositive',
'Omhoog': 'Sposta su',
'Omlaag': 'Sposta giù',
'Niet toevoegen': 'Non aggiungere',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'Queste diapositive usano gli stessi dati, colori e titolo. Usa le frecce per scegliere lordine dopo la diapositiva corrente.',
'Spider': 'Radar', 'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un grafico radar richiede almeno tre etichette (assi); ogni serie forma una superficie.', 'Un grafico radar richiede almeno tre etichette (assi); ogni serie forma una superficie.',
@ -2638,6 +2679,26 @@ const _dutchSourceStringAdditions = {
}, },
'de': { 'de': {
'Annuleren': 'Abbrechen', 'Annuleren': 'Abbrechen',
'Checklist': 'Checkliste',
'Voortgangsgrafiek tonen': 'Fortschrittsdiagramm anzeigen',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Zeigt erledigte und offene Einträge als Prozentwerte.',
'Afgevinkt': 'Erledigt',
'Niet afgevinkt': 'Offen',
'Afgevinkte tekst doorhalen': 'Erledigten Text durchstreichen',
'Toont een streep door voltooide checklistitems.':
'Zeigt erledigte Checklistenpunkte durchgestrichen an.',
'Na media automatisch doorgaan': 'Nach Medienwiedergabe automatisch weiter',
'Opsomming': 'Aufzählung',
'Nummering': 'Nummerierung',
'Varianten': 'Varianten',
'Grafiekvarianten maken': 'Diagrammvarianten erstellen',
'Slides toevoegen': 'Folien hinzufügen',
'Omhoog': 'Nach oben',
'Omlaag': 'Nach unten',
'Niet toevoegen': 'Nicht hinzufügen',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'Diese Folien verwenden dieselben Daten, Farben und denselben Titel. Lege mit den Pfeilen die Reihenfolge nach der aktuellen Folie fest.',
'Spider': 'Netz', 'Spider': 'Netz',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Ein Netzdiagramm braucht mindestens drei Beschriftungen (Achsen); jede Reihe bildet eine Fläche.', 'Ein Netzdiagramm braucht mindestens drei Beschriftungen (Achsen); jede Reihe bildet eine Fläche.',
@ -2871,6 +2932,26 @@ const _dutchSourceStringAdditions = {
}, },
'fr': { 'fr': {
'Annuleren': 'Annuler', 'Annuleren': 'Annuler',
'Checklist': 'Liste de contrôle',
'Voortgangsgrafiek tonen': 'Afficher le graphique de progression',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Affiche les éléments cochés et non cochés en pourcentage.',
'Afgevinkt': 'Coché',
'Niet afgevinkt': 'Non coché',
'Afgevinkte tekst doorhalen': 'Barrer le texte coché',
'Toont een streep door voltooide checklistitems.':
'Affiche les éléments terminés avec un texte barré.',
'Na media automatisch doorgaan': 'Avancer automatiquement après le média',
'Opsomming': 'Liste à puces',
'Nummering': 'Numérotation',
'Varianten': 'Variantes',
'Grafiekvarianten maken': 'Créer des variantes du graphique',
'Slides toevoegen': 'Ajouter les diapositives',
'Omhoog': 'Monter',
'Omlaag': 'Descendre',
'Niet toevoegen': 'Ne pas ajouter',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'Ces diapositives utilisent les mêmes données, couleurs et titre. Utilisez les flèches pour choisir leur ordre après la diapositive actuelle.',
'Spider': 'Radar', 'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un graphique radar nécessite au moins trois étiquettes (axes); chaque série forme une surface.', 'Un graphique radar nécessite au moins trois étiquettes (axes); chaque série forme une surface.',
@ -3104,6 +3185,27 @@ const _dutchSourceStringAdditions = {
}, },
'es': { 'es': {
'Annuleren': 'Cancelar', 'Annuleren': 'Cancelar',
'Checklist': 'Lista de verificación',
'Voortgangsgrafiek tonen': 'Mostrar gráfico de progreso',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Muestra los elementos marcados y sin marcar como porcentajes.',
'Afgevinkt': 'Marcado',
'Niet afgevinkt': 'Sin marcar',
'Afgevinkte tekst doorhalen': 'Tachar el texto marcado',
'Toont een streep door voltooide checklistitems.':
'Muestra tachados los elementos completados.',
'Na media automatisch doorgaan':
'Avanzar automáticamente tras el contenido multimedia',
'Opsomming': 'Viñetas',
'Nummering': 'Numeración',
'Varianten': 'Variantes',
'Grafiekvarianten maken': 'Crear variantes del gráfico',
'Slides toevoegen': 'Añadir diapositivas',
'Omhoog': 'Subir',
'Omlaag': 'Bajar',
'Niet toevoegen': 'No añadir',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'Estas diapositivas usan los mismos datos, colores y título. Usa las flechas para elegir su orden después de la diapositiva actual.',
'Spider': 'Radar', 'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un gráfico radar necesita al menos tres etiquetas (ejes); cada serie forma una superficie.', 'Un gráfico radar necesita al menos tres etiquetas (ejes); cada serie forma una superficie.',
@ -3337,6 +3439,26 @@ const _dutchSourceStringAdditions = {
}, },
'fy': { 'fy': {
'Annuleren': 'Annulearje', 'Annuleren': 'Annulearje',
'Checklist': 'Kontrôlelist',
'Voortgangsgrafiek tonen': 'Fuortgongsgrafyk toane',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Toant ôffinkte en net ôffinkte items as persintaazjes.',
'Afgevinkt': 'Ôffinkt',
'Niet afgevinkt': 'Net ôffinkt',
'Afgevinkte tekst doorhalen': 'Ôffinkte tekst trochstreekje',
'Toont een streep door voltooide checklistitems.':
'Toant foltôge kontrôlelistitems mei in streek der troch.',
'Na media automatisch doorgaan': 'Nei media automatysk trochgean',
'Opsomming': 'Puntelist',
'Nummering': 'Nûmering',
'Varianten': 'Farianten',
'Grafiekvarianten maken': 'Grafykfarianten meitsje',
'Slides toevoegen': 'Dias tafoegje',
'Omhoog': 'Omheech',
'Omlaag': 'Omleech',
'Niet toevoegen': 'Net tafoegje',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'Dizze dias brûke deselde gegevens, kleuren en titel. Kies mei de pylken de folchoarder nei de aktive dia.',
'Spider': 'Spider', 'Spider': 'Spider',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'In spiderdiagram hat op syn minst trije labels (assen) nedich; eltse rige foarmet in flak.', 'In spiderdiagram hat op syn minst trije labels (assen) nedich; eltse rige foarmet in flak.',
@ -3567,6 +3689,26 @@ const _dutchSourceStringAdditions = {
}, },
'pap': { 'pap': {
'Annuleren': 'Kanselá', 'Annuleren': 'Kanselá',
'Checklist': 'Lista di kontrol',
'Voortgangsgrafiek tonen': 'Mustra gráfiko di progreso',
'Toont afgevinkt en niet afgevinkt als percentages.':
'Ta mustra elementonan marká i no marká komo porsentahe.',
'Afgevinkt': 'Marká',
'Niet afgevinkt': 'No marká',
'Afgevinkte tekst doorhalen': 'Raya teksto marká',
'Toont een streep door voltooide checklistitems.':
'Ta mustra elementonan kompletá ku un raya den e teksto.',
'Na media automatisch doorgaan': 'Sigui outomátiko despues di multimedia',
'Opsomming': 'Lista ku punto',
'Nummering': 'Numerashon',
'Varianten': 'Variantenan',
'Grafiekvarianten maken': 'Krea variantenan di gráfiko',
'Slides toevoegen': 'Agregá diapositivanan',
'Omhoog': 'Move ariba',
'Omlaag': 'Move abou',
'Niet toevoegen': 'No agregá',
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.':
'E diapositivanan aki ta usa e mesun datonan, kolónan i título. Usa e flechanan pa skohe nan órden despues di e diapositiva aktual.',
'Spider': 'Radar', 'Spider': 'Radar',
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.': 'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
'Un grafiko radar mester di por lo ménos tres etiketa (ehe); kada serie ta forma un superfisie.', 'Un grafiko radar mester di por lo ménos tres etiketa (ehe); kada serie ta forma un superfisie.',

View file

@ -3,6 +3,9 @@ class ThemeProfile {
final String slideBackgroundColor; final String slideBackgroundColor;
final String textColor; final String textColor;
final String accentColor; final String accentColor;
final String checklistCheckedColor;
final String checklistUncheckedColor;
final bool checklistStrikeThrough;
final String tableTextColor; final String tableTextColor;
final String tableHeaderTextColor; final String tableHeaderTextColor;
final String titleBackgroundColor; final String titleBackgroundColor;
@ -50,6 +53,9 @@ class ThemeProfile {
this.slideBackgroundColor = '#FFFFFF', this.slideBackgroundColor = '#FFFFFF',
this.textColor = '#222222', this.textColor = '#222222',
this.accentColor = '#2E7D64', this.accentColor = '#2E7D64',
this.checklistCheckedColor = '#2E7D64',
this.checklistUncheckedColor = '#CBD5E1',
this.checklistStrikeThrough = true,
String? tableTextColor, String? tableTextColor,
this.tableHeaderTextColor = '#FFFFFF', this.tableHeaderTextColor = '#FFFFFF',
this.titleBackgroundColor = '#1C2B47', this.titleBackgroundColor = '#1C2B47',
@ -84,6 +90,9 @@ class ThemeProfile {
String? slideBackgroundColor, String? slideBackgroundColor,
String? textColor, String? textColor,
String? accentColor, String? accentColor,
String? checklistCheckedColor,
String? checklistUncheckedColor,
bool? checklistStrikeThrough,
String? tableTextColor, String? tableTextColor,
String? tableHeaderTextColor, String? tableHeaderTextColor,
String? titleBackgroundColor, String? titleBackgroundColor,
@ -109,6 +118,12 @@ class ThemeProfile {
slideBackgroundColor: slideBackgroundColor ?? this.slideBackgroundColor, slideBackgroundColor: slideBackgroundColor ?? this.slideBackgroundColor,
textColor: textColor ?? this.textColor, textColor: textColor ?? this.textColor,
accentColor: accentColor ?? this.accentColor, accentColor: accentColor ?? this.accentColor,
checklistCheckedColor:
checklistCheckedColor ?? this.checklistCheckedColor,
checklistUncheckedColor:
checklistUncheckedColor ?? this.checklistUncheckedColor,
checklistStrikeThrough:
checklistStrikeThrough ?? this.checklistStrikeThrough,
tableTextColor: tableTextColor ?? this.tableTextColor, tableTextColor: tableTextColor ?? this.tableTextColor,
tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor, tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor,
titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor, titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor,
@ -138,6 +153,9 @@ class ThemeProfile {
'name': name, 'name': name,
'textColor': textColor, 'textColor': textColor,
'accentColor': accentColor, 'accentColor': accentColor,
'checklistCheckedColor': checklistCheckedColor,
'checklistUncheckedColor': checklistUncheckedColor,
'checklistStrikeThrough': checklistStrikeThrough,
'tableTextColor': tableTextColor, 'tableTextColor': tableTextColor,
'tableHeaderTextColor': tableHeaderTextColor, 'tableHeaderTextColor': tableHeaderTextColor,
'titleBackgroundColor': titleBackgroundColor, 'titleBackgroundColor': titleBackgroundColor,
@ -166,6 +184,13 @@ class ThemeProfile {
name: json['name'] as String? ?? 'Standaard', name: json['name'] as String? ?? 'Standaard',
textColor: json['textColor'] as String? ?? '#222222', textColor: json['textColor'] as String? ?? '#222222',
accentColor: json['accentColor'] as String? ?? '#2E7D64', accentColor: json['accentColor'] as String? ?? '#2E7D64',
checklistCheckedColor:
json['checklistCheckedColor'] as String? ??
json['accentColor'] as String? ??
'#2E7D64',
checklistUncheckedColor:
json['checklistUncheckedColor'] as String? ?? '#CBD5E1',
checklistStrikeThrough: json['checklistStrikeThrough'] as bool? ?? true,
tableTextColor: tableTextColor:
json['tableTextColor'] as String? ?? json['tableTextColor'] as String? ??
json['textColor'] as String? ?? json['textColor'] as String? ??
@ -177,8 +202,7 @@ class ThemeProfile {
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF', titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
sectionBackgroundColor: sectionBackgroundColor:
json['sectionBackgroundColor'] as String? ?? '#2E7D64', json['sectionBackgroundColor'] as String? ?? '#2E7D64',
codeBackgroundColor: codeBackgroundColor: json['codeBackgroundColor'] as String? ?? '#282C34',
json['codeBackgroundColor'] as String? ?? '#282C34',
codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF', codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF',
codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true, codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true,
codeFontFamily: json['codeFontFamily'] as String? ?? 'monospace', codeFontFamily: json['codeFontFamily'] as String? ?? 'monospace',

View file

@ -19,6 +19,30 @@ enum SlideType {
chart, chart,
} }
enum ListStyle { bullets, numbered, checklist }
int bulletLevel(String value) {
var level = 0;
while (level < value.length && value[level] == '\t') {
level++;
}
return level;
}
String bulletText(String value) => value.substring(bulletLevel(value));
bool checklistItemChecked(String value) =>
RegExp(r'^\[[xX]\]\s*').hasMatch(bulletText(value));
String checklistItemText(String value) =>
bulletText(value).replaceFirst(RegExp(r'^\[[ xX]\]\s*'), '');
String checklistBullet({
required int level,
required String text,
required bool checked,
}) => '${'\t' * level}[${checked ? 'x' : ' '}] $text';
extension SlideTypeExtension on SlideType { extension SlideTypeExtension on SlideType {
String get label { String get label {
switch (this) { switch (this) {
@ -90,6 +114,8 @@ class Slide {
final String subtitle; final String subtitle;
final List<String> bullets; final List<String> bullets;
final List<String> bullets2; final List<String> bullets2;
final ListStyle listStyle;
final bool showChecklistProgress;
/// Optional headings above the two bullet columns (twoBullets only). Empty = /// Optional headings above the two bullet columns (twoBullets only). Empty =
/// no heading for that column. /// no heading for that column.
@ -128,6 +154,8 @@ class Slide {
this.subtitle = '', this.subtitle = '',
this.bullets = const [], this.bullets = const [],
this.bullets2 = const [], this.bullets2 = const [],
this.listStyle = ListStyle.bullets,
this.showChecklistProgress = false,
this.columnTitle1 = '', this.columnTitle1 = '',
this.columnTitle2 = '', this.columnTitle2 = '',
this.imagePath = '', this.imagePath = '',
@ -183,6 +211,8 @@ class Slide {
subtitle: src.subtitle, subtitle: src.subtitle,
bullets: List<String>.from(src.bullets), bullets: List<String>.from(src.bullets),
bullets2: List<String>.from(src.bullets2), bullets2: List<String>.from(src.bullets2),
listStyle: src.listStyle,
showChecklistProgress: src.showChecklistProgress,
columnTitle1: src.columnTitle1, columnTitle1: src.columnTitle1,
columnTitle2: src.columnTitle2, columnTitle2: src.columnTitle2,
imagePath: src.imagePath, imagePath: src.imagePath,
@ -215,6 +245,8 @@ class Slide {
String? subtitle, String? subtitle,
List<String>? bullets, List<String>? bullets,
List<String>? bullets2, List<String>? bullets2,
ListStyle? listStyle,
bool? showChecklistProgress,
String? columnTitle1, String? columnTitle1,
String? columnTitle2, String? columnTitle2,
String? imagePath, String? imagePath,
@ -246,6 +278,9 @@ class Slide {
subtitle: subtitle ?? this.subtitle, subtitle: subtitle ?? this.subtitle,
bullets: bullets ?? this.bullets, bullets: bullets ?? this.bullets,
bullets2: bullets2 ?? this.bullets2, bullets2: bullets2 ?? this.bullets2,
listStyle: listStyle ?? this.listStyle,
showChecklistProgress:
showChecklistProgress ?? this.showChecklistProgress,
columnTitle1: columnTitle1 ?? this.columnTitle1, columnTitle1: columnTitle1 ?? this.columnTitle1,
columnTitle2: columnTitle2 ?? this.columnTitle2, columnTitle2: columnTitle2 ?? this.columnTitle2,
imagePath: imagePath ?? this.imagePath, imagePath: imagePath ?? this.imagePath,

View file

@ -216,10 +216,12 @@ class MarkdownService {
case SlideType.bullets: case SlideType.bullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}'); if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}');
buf.writeln(); if (slide.listStyle != ListStyle.bullets) {
for (final b in slide.bullets) { buf.writeln('<!-- ocideck_list_style: ${slide.listStyle.name} -->');
_writeBullet(buf, b);
} }
_writeChecklistProgress(buf, slide);
buf.writeln();
_writeList(buf, slide.bullets, slide.listStyle);
case SlideType.twoBullets: case SlideType.twoBullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
@ -230,6 +232,9 @@ class MarkdownService {
slide.bullets2, slide.bullets2,
slide.columnTitle1, slide.columnTitle1,
slide.columnTitle2, slide.columnTitle2,
slide.listStyle,
slide.showChecklistProgress,
themeProfile ?? const ThemeProfile(),
); );
case SlideType.bulletsImage: case SlideType.bulletsImage:
@ -248,10 +253,12 @@ class MarkdownService {
); );
buf.writeln(); buf.writeln();
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
buf.writeln(); if (slide.listStyle != ListStyle.bullets) {
for (final b in slide.bullets) { buf.writeln('<!-- ocideck_list_style: ${slide.listStyle.name} -->');
_writeBullet(buf, b);
} }
_writeChecklistProgress(buf, slide);
buf.writeln();
_writeList(buf, slide.bullets, slide.listStyle);
buf.writeln(); buf.writeln();
buf.writeln('</div>'); buf.writeln('</div>');
buf.writeln(); buf.writeln();
@ -263,10 +270,12 @@ class MarkdownService {
buf.writeln('</div>'); buf.writeln('</div>');
} else { } else {
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
buf.writeln(); if (slide.listStyle != ListStyle.bullets) {
for (final b in slide.bullets) { buf.writeln('<!-- ocideck_list_style: ${slide.listStyle.name} -->');
_writeBullet(buf, b);
} }
_writeChecklistProgress(buf, slide);
buf.writeln();
_writeList(buf, slide.bullets, slide.listStyle);
} }
case SlideType.twoImages: case SlideType.twoImages:
@ -401,14 +410,35 @@ class MarkdownService {
return buf.toString(); return buf.toString();
} }
static void _writeBullet(StringBuffer buf, String bullet) { static void _writeList(
int level = 0; StringBuffer buf,
while (level < bullet.length && bullet[level] == '\t') { List<String> items,
level++; ListStyle style,
} ) {
final text = bullet.substring(level); final counters = <int>[];
if (text.isNotEmpty) { for (final item in items) {
buf.writeln('${' ' * level}- $text'); int level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
final text = item.substring(level);
if (text.isEmpty) continue;
while (counters.length <= level) {
counters.add(0);
}
counters[level]++;
if (counters.length > level + 1) {
counters.removeRange(level + 1, counters.length);
}
final marker = switch (style) {
ListStyle.numbered => '${counters[level]}.',
ListStyle.bullets || ListStyle.checklist => '-',
};
final body = style == ListStyle.checklist
? '[${checklistItemChecked(item) ? 'x' : ' '}] '
'${checklistItemText(item)}'
: text;
buf.writeln('${' ' * level}$marker $body');
} }
} }
@ -418,9 +448,18 @@ class MarkdownService {
List<String> right, List<String> right,
String leftTitle, String leftTitle,
String rightTitle, String rightTitle,
ListStyle listStyle,
bool showChecklistProgress,
ThemeProfile themeProfile,
) { ) {
buf.writeln('<!-- ocideck_two_bullets_left: ${_encodeBullets(left)} -->'); buf.writeln('<!-- ocideck_two_bullets_left: ${_encodeBullets(left)} -->');
buf.writeln('<!-- ocideck_two_bullets_right: ${_encodeBullets(right)} -->'); buf.writeln('<!-- ocideck_two_bullets_right: ${_encodeBullets(right)} -->');
if (listStyle != ListStyle.bullets) {
buf.writeln('<!-- ocideck_list_style: ${listStyle.name} -->');
}
if (showChecklistProgress) {
buf.writeln('<!-- ocideck_checklist_progress: true -->');
}
if (leftTitle.isNotEmpty) { if (leftTitle.isNotEmpty) {
buf.writeln( buf.writeln(
'<!-- ocideck_two_bullets_left_title: ${_encodeText(leftTitle)} -->', '<!-- ocideck_two_bullets_left_title: ${_encodeText(leftTitle)} -->',
@ -434,15 +473,23 @@ class MarkdownService {
buf.writeln( buf.writeln(
'<div class="ocideck-two-bullets" style="display:grid; grid-template-columns:1fr 1fr; gap:3rem; align-items:start;">', '<div class="ocideck-two-bullets" style="display:grid; grid-template-columns:1fr 1fr; gap:3rem; align-items:start;">',
); );
_writeBulletColumn(buf, left, leftTitle); _writeBulletColumn(buf, left, leftTitle, listStyle, themeProfile);
_writeBulletColumn(buf, right, rightTitle); _writeBulletColumn(buf, right, rightTitle, listStyle, themeProfile);
buf.writeln('</div>'); buf.writeln('</div>');
} }
static void _writeChecklistProgress(StringBuffer buf, Slide slide) {
if (slide.showChecklistProgress) {
buf.writeln('<!-- ocideck_checklist_progress: true -->');
}
}
static void _writeBulletColumn( static void _writeBulletColumn(
StringBuffer buf, StringBuffer buf,
List<String> bullets, List<String> bullets,
String columnTitle, String columnTitle,
ListStyle listStyle,
ThemeProfile themeProfile,
) { ) {
buf.writeln('<div>'); buf.writeln('<div>');
if (columnTitle.isNotEmpty) { if (columnTitle.isNotEmpty) {
@ -450,9 +497,10 @@ class MarkdownService {
'<h3 style="margin:0 0 .5rem;">${_escapeHtml(columnTitle)}</h3>', '<h3 style="margin:0 0 .5rem;">${_escapeHtml(columnTitle)}</h3>',
); );
} }
buf.writeln('<ul style="margin:0; padding-left:1.3em;">'); final tag = listStyle == ListStyle.numbered ? 'ol' : 'ul';
_writeHtmlBulletItems(buf, bullets); buf.writeln('<$tag style="margin:0; padding-left:1.3em;">');
buf.writeln('</ul>'); _writeHtmlBulletItems(buf, bullets, listStyle, themeProfile);
buf.writeln('</$tag>');
buf.writeln('</div>'); buf.writeln('</div>');
} }
@ -480,16 +528,41 @@ class MarkdownService {
return const []; return const [];
} }
static void _writeHtmlBulletItems(StringBuffer buf, List<String> bullets) { static void _writeHtmlBulletItems(
StringBuffer buf,
List<String> bullets,
ListStyle listStyle,
ThemeProfile themeProfile,
) {
final counters = <int>[];
for (final b in bullets) { for (final b in bullets) {
int level = 0; int level = 0;
while (level < b.length && b[level] == '\t') { while (level < b.length && b[level] == '\t') {
level++; level++;
} }
final text = b.substring(level).trim(); final text = listStyle == ListStyle.checklist
? checklistItemText(b).trim()
: b.substring(level).trim();
if (text.isEmpty) continue; if (text.isEmpty) continue;
while (counters.length <= level) {
counters.add(0);
}
counters[level]++;
if (counters.length > level + 1) {
counters.removeRange(level + 1, counters.length);
}
final style = level == 0 ? '' : ' style="margin-left:${level * 1.4}em;"'; final style = level == 0 ? '' : ' style="margin-left:${level * 1.4}em;"';
buf.writeln('<li$style>${_escapeHtml(text)}</li>'); final value = listStyle == ListStyle.numbered
? ' value="${counters[level]}"'
: '';
final checkbox = listStyle == ListStyle.checklist
? '${checklistItemChecked(b) ? '' : ''} '
: '';
final decoration = listStyle == ListStyle.checklist
? ' style="${level == 0 ? '' : 'margin-left:${level * 1.4}em;'}'
'${checklistItemChecked(b) && themeProfile.checklistStrikeThrough ? 'text-decoration:line-through;opacity:.7;' : ''}"'
: style;
buf.writeln('<li$value$decoration>${_escapeHtml(checkbox + text)}</li>');
} }
} }
@ -676,6 +749,8 @@ class MarkdownService {
TlpLevel slideTlp = TlpLevel.none; TlpLevel slideTlp = TlpLevel.none;
final bullets = <String>[]; final bullets = <String>[];
var bullets2 = <String>[]; var bullets2 = <String>[];
var listStyle = ListStyle.bullets;
var showChecklistProgress = false;
var columnTitle1 = ''; var columnTitle1 = '';
var columnTitle2 = ''; var columnTitle2 = '';
// bulletsImage slides store their panel width in `<!-- _style: // bulletsImage slides store their panel width in `<!-- _style:
@ -704,6 +779,16 @@ class MarkdownService {
columnTitle2 = _decodeText(content.substring(32)); columnTitle2 = _decodeText(content.substring(32));
} else if (content.startsWith('ocideck_two_bullets_right:')) { } else if (content.startsWith('ocideck_two_bullets_right:')) {
bullets2 = _decodeBullets(content.substring(26)); bullets2 = _decodeBullets(content.substring(26));
} else if (content.startsWith('ocideck_list_style:')) {
final name = content.substring(19).trim();
listStyle = ListStyle.values.firstWhere(
(style) => style.name == name,
orElse: () => ListStyle.bullets,
);
} else if (content.startsWith('ocideck_checklist_progress:')) {
showChecklistProgress =
content.substring('ocideck_checklist_progress:'.length).trim() ==
'true';
} else if (!content.startsWith('_')) { } else if (!content.startsWith('_')) {
notesBuffer.write(notesBuffer.isEmpty ? content : '\n$content'); notesBuffer.write(notesBuffer.isEmpty ? content : '\n$content');
} }
@ -773,7 +858,23 @@ class MarkdownService {
} }
} }
final level = spaces ~/ 2; final level = spaces ~/ 2;
bullets.add('\t' * level + t.substring(2)); final body = t.substring(2);
bullets.add('\t' * level + body);
if (RegExp(r'^\[[ xX]\]\s*').hasMatch(body)) {
listStyle = ListStyle.checklist;
}
} else if (RegExp(r'^\d+\.\s+').hasMatch(t)) {
int spaces = 0;
for (final ch in line.characters) {
if (ch == ' ') {
spaces++;
} else {
break;
}
}
final level = spaces ~/ 2;
bullets.add('\t' * level + t.replaceFirst(RegExp(r'^\d+\.\s+'), ''));
listStyle = ListStyle.numbered;
} else if (t.startsWith('> ')) { } else if (t.startsWith('> ')) {
quote = t.substring(2); quote = t.substring(2);
} else if (t.startsWith('')) { } else if (t.startsWith('')) {
@ -900,6 +1001,8 @@ class MarkdownService {
subtitle: type == SlideType.section ? paragraph : h2, subtitle: type == SlideType.section ? paragraph : h2,
bullets: bullets, bullets: bullets,
bullets2: bullets2, bullets2: bullets2,
listStyle: listStyle,
showChecklistProgress: showChecklistProgress,
columnTitle1: columnTitle1, columnTitle1: columnTitle1,
columnTitle2: columnTitle2, columnTitle2: columnTitle2,
imagePath: imagePath, imagePath: imagePath,

View file

@ -979,6 +979,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
tlp: deck.tlp, tlp: deck.tlp,
annotations: deck.annotations, annotations: deck.annotations,
onAnnotationsChanged: deckNotifier.setAnnotations, onAnnotationsChanged: deckNotifier.setAnnotations,
onSlideChanged: (updated) {
final index = deckNotifier.currentState.deck?.slides.indexWhere(
(slide) => slide.id == updated.id,
);
if (index != null && index >= 0) {
deckNotifier.updateSlide(index, updated);
}
},
); );
} }

View file

@ -942,6 +942,43 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_themeProfile.accentColor, _themeProfile.accentColor,
(v) => _themeProfile = _themeProfile.copyWith(accentColor: v), (v) => _themeProfile = _themeProfile.copyWith(accentColor: v),
), ),
const SizedBox(height: 24),
_sectionTitle(l10n.d('Checklist')),
_colorSetting(
l10n.d('Afgevinkt'),
_themeProfile.checklistCheckedColor,
(v) =>
_themeProfile = _themeProfile.copyWith(checklistCheckedColor: v),
),
const SizedBox(height: 12),
_colorSetting(
l10n.d('Niet afgevinkt'),
_themeProfile.checklistUncheckedColor,
(v) => _themeProfile = _themeProfile.copyWith(
checklistUncheckedColor: v,
),
),
const SizedBox(height: 6),
SwitchListTile(
value: _themeProfile.checklistStrikeThrough,
onChanged: (value) => setState(() {
_themeProfile = _themeProfile.copyWith(
checklistStrikeThrough: value,
);
_profileTouched = true;
}),
title: Text(
l10n.d('Afgevinkte tekst doorhalen'),
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
l10n.d('Toont een streep door voltooide checklistitems.'),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
),
const SizedBox(height: 12), const SizedBox(height: 12),
_colorSetting( _colorSetting(
l10n.d('Tabeltekst'), l10n.d('Tabeltekst'),
@ -980,8 +1017,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_colorSetting( _colorSetting(
l10n.d('Broncode achtergrond'), l10n.d('Broncode achtergrond'),
_themeProfile.codeBackgroundColor, _themeProfile.codeBackgroundColor,
(v) => (v) => _themeProfile = _themeProfile.copyWith(codeBackgroundColor: v),
_themeProfile = _themeProfile.copyWith(codeBackgroundColor: v),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_colorSetting( _colorSetting(
@ -1012,7 +1048,8 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: AppSettings.codeFonts.contains(_themeProfile.codeFontFamily) initialValue:
AppSettings.codeFonts.contains(_themeProfile.codeFontFamily)
? _themeProfile.codeFontFamily ? _themeProfile.codeFontFamily
: 'monospace', : 'monospace',
decoration: InputDecoration( decoration: InputDecoration(
@ -1282,11 +1319,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFCBD5E1)), border: Border.all(color: const Color(0xFFCBD5E1)),
), ),
child: const Icon( child: const Icon(Icons.tune, size: 18, color: Color(0xFF64748B)),
Icons.tune,
size: 18,
color: Color(0xFF64748B),
),
), ),
), ),
); );
@ -1363,7 +1396,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
l10n.d('Bijvoorbeeld #33FF33 voor een CRT-groen scherm.'), l10n.d('Bijvoorbeeld #33FF33 voor een CRT-groen scherm.'),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), style: const TextStyle(
fontSize: 11,
color: Color(0xFF94A3B8),
),
), ),
], ],
), ),

View file

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '_editor_field.dart'; import '_editor_field.dart';
import 'list_style_selector.dart';
class BulletsEditor extends StatefulWidget { class BulletsEditor extends StatefulWidget {
final Slide slide; final Slide slide;
@ -19,7 +20,10 @@ class _BulletsEditorState extends State<BulletsEditor> {
late final TextEditingController _subtitle; late final TextEditingController _subtitle;
late List<TextEditingController> _bullets; late List<TextEditingController> _bullets;
late List<int> _levels; late List<int> _levels;
late List<bool> _checked;
late List<FocusNode> _focusNodes; late List<FocusNode> _focusNodes;
late ListStyle _listStyle;
late bool _showChecklistProgress;
static const _maxLevel = 4; static const _maxLevel = 4;
@ -30,13 +34,16 @@ class _BulletsEditorState extends State<BulletsEditor> {
_title.addListener(_emit); _title.addListener(_emit);
_subtitle = TextEditingController(text: widget.slide.subtitle); _subtitle = TextEditingController(text: widget.slide.subtitle);
_subtitle.addListener(_emit); _subtitle.addListener(_emit);
_listStyle = widget.slide.listStyle;
_showChecklistProgress = widget.slide.showChecklistProgress;
_initBullets(widget.slide.bullets); _initBullets(widget.slide.bullets);
} }
void _initBullets(List<String> raw) { void _initBullets(List<String> raw) {
final list = raw.isEmpty ? [''] : raw; final list = raw.isEmpty ? [''] : raw;
_levels = list.map(_levelOf).toList(); _levels = list.map(_levelOf).toList();
_bullets = list.map((b) => _makeCtrl(b.trimLeft())).toList(); _checked = list.map(checklistItemChecked).toList();
_bullets = list.map((b) => _makeCtrl(checklistItemText(b))).toList();
_focusNodes = List.generate(_bullets.length, (_) => FocusNode()); _focusNodes = List.generate(_bullets.length, (_) => FocusNode());
} }
@ -59,9 +66,17 @@ class _BulletsEditorState extends State<BulletsEditor> {
widget.slide.copyWith( widget.slide.copyWith(
title: _title.text, title: _title.text,
subtitle: _subtitle.text, subtitle: _subtitle.text,
listStyle: _listStyle,
showChecklistProgress: _showChecklistProgress,
bullets: List.generate( bullets: List.generate(
_bullets.length, _bullets.length,
(i) => '\t' * _levels[i] + _bullets[i].text, (i) => _listStyle == ListStyle.checklist
? checklistBullet(
level: _levels[i],
text: _bullets[i].text,
checked: _checked[i],
)
: '\t' * _levels[i] + _bullets[i].text,
), ),
), ),
); );
@ -75,9 +90,11 @@ class _BulletsEditorState extends State<BulletsEditor> {
setState(() { setState(() {
final ctrl = _bullets.removeAt(oldIndex); final ctrl = _bullets.removeAt(oldIndex);
final level = _levels.removeAt(oldIndex); final level = _levels.removeAt(oldIndex);
final checked = _checked.removeAt(oldIndex);
final focus = _focusNodes.removeAt(oldIndex); final focus = _focusNodes.removeAt(oldIndex);
_bullets.insert(newIndex, ctrl); _bullets.insert(newIndex, ctrl);
_levels.insert(newIndex, level); _levels.insert(newIndex, level);
_checked.insert(newIndex, checked);
_focusNodes.insert(newIndex, focus); _focusNodes.insert(newIndex, focus);
}); });
_emit(); _emit();
@ -88,6 +105,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
setState(() { setState(() {
_bullets.insert(i + 1, _makeCtrl('')); _bullets.insert(i + 1, _makeCtrl(''));
_levels.insert(i + 1, newLevel); _levels.insert(i + 1, newLevel);
_checked.insert(i + 1, false);
_focusNodes.insert(i + 1, FocusNode()); _focusNodes.insert(i + 1, FocusNode());
}); });
_emit(); _emit();
@ -97,13 +115,25 @@ class _BulletsEditorState extends State<BulletsEditor> {
} }
void _removeBulletAndFocus(int i) { void _removeBulletAndFocus(int i) {
if (_bullets.length <= 1) return; if (_bullets.length == 1) {
setState(() {
_bullets[i].removeListener(_emit);
_bullets[i].clear();
_bullets[i].addListener(_emit);
_levels[i] = 0;
_checked[i] = false;
});
_emit();
_focusNodes[i].requestFocus();
return;
}
final target = (i - 1).clamp(0, _bullets.length - 2); final target = (i - 1).clamp(0, _bullets.length - 2);
setState(() { setState(() {
_bullets[i].removeListener(_emit); _bullets[i].removeListener(_emit);
_bullets[i].dispose(); _bullets[i].dispose();
_bullets.removeAt(i); _bullets.removeAt(i);
_levels.removeAt(i); _levels.removeAt(i);
_checked.removeAt(i);
_focusNodes[i].dispose(); _focusNodes[i].dispose();
_focusNodes.removeAt(i); _focusNodes.removeAt(i);
}); });
@ -142,6 +172,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
for (int j = 1; j < lines.length; j++) { for (int j = 1; j < lines.length; j++) {
_bullets.insert(i + j, _makeCtrl(lines[j])); _bullets.insert(i + j, _makeCtrl(lines[j]));
_levels.insert(i + j, _levels[i]); _levels.insert(i + j, _levels[i]);
_checked.insert(i + j, false);
_focusNodes.insert(i + j, FocusNode()); _focusNodes.insert(i + j, FocusNode());
} }
}); });
@ -179,6 +210,27 @@ class _BulletsEditorState extends State<BulletsEditor> {
hint: l10n.d('Subkop'), hint: l10n.d('Subkop'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ListStyleSelector(
value: _listStyle,
onChanged: (value) {
setState(() => _listStyle = value);
_emit();
},
),
if (_listStyle == ListStyle.checklist)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.d('Voortgangsgrafiek tonen')),
subtitle: Text(
l10n.d('Toont afgevinkt en niet afgevinkt als percentages.'),
),
value: _showChecklistProgress,
onChanged: (value) {
setState(() => _showChecklistProgress = value);
_emit();
},
),
const SizedBox(height: 16),
const SectionLabel('Bullets'), const SectionLabel('Bullets'),
ReorderableListView( ReorderableListView(
shrinkWrap: true, shrinkWrap: true,
@ -220,10 +272,25 @@ class _BulletsEditorState extends State<BulletsEditor> {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( if (_listStyle == ListStyle.checklist)
_markerForLevel(level), SizedBox(
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), width: 24,
), height: 24,
child: Checkbox(
key: ValueKey('checklist-item-$i'),
value: _checked[i],
onChanged: (value) {
setState(() => _checked[i] = value ?? false);
_emit();
},
visualDensity: VisualDensity.compact,
),
)
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Focus( child: Focus(
@ -271,14 +338,13 @@ class _BulletsEditorState extends State<BulletsEditor> {
), ),
), ),
IconButton( IconButton(
key: ValueKey('remove-bullet-$i'),
icon: const Icon( icon: const Icon(
Icons.remove_circle_outline, Icons.remove_circle_outline,
size: 18, size: 18,
color: Color(0xFF94A3B8), color: Color(0xFF94A3B8),
), ),
onPressed: _bullets.length > 1 onPressed: () => _removeBulletAndFocus(i),
? () => _removeBulletAndFocus(i)
: null,
tooltip: l10n.d('Verwijder'), tooltip: l10n.d('Verwijder'),
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: const BoxConstraints(minWidth: 28), constraints: const BoxConstraints(minWidth: 28),
@ -292,4 +358,18 @@ class _BulletsEditorState extends State<BulletsEditor> {
const markers = ['', '', '', '', '']; const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)]; return markers[level.clamp(0, markers.length - 1)];
} }
String _markerForItem(int index) {
if (_listStyle == ListStyle.bullets) {
return _markerForLevel(_levels[index]);
}
if (_listStyle == ListStyle.checklist) return '';
final level = _levels[index];
var number = 0;
for (var i = 0; i <= index; i++) {
if (_levels[i] == level) number++;
if (_levels[i] < level) number = 0;
}
return '$number.';
}
} }

View file

@ -4,6 +4,7 @@ import '../../models/slide.dart';
import '../../services/image_service.dart'; import '../../services/image_service.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '_editor_field.dart'; import '_editor_field.dart';
import 'list_style_selector.dart';
class BulletsImageEditor extends StatefulWidget { class BulletsImageEditor extends StatefulWidget {
final Slide slide; final Slide slide;
@ -29,7 +30,10 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
late final TextEditingController _title; late final TextEditingController _title;
late List<TextEditingController> _bullets; late List<TextEditingController> _bullets;
late List<int> _levels; late List<int> _levels;
late List<bool> _checked;
late List<FocusNode> _focusNodes; late List<FocusNode> _focusNodes;
late ListStyle _listStyle;
late bool _showChecklistProgress;
static const _maxLevel = 4; static const _maxLevel = 4;
@ -38,9 +42,12 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
super.initState(); super.initState();
_title = TextEditingController(text: widget.slide.title); _title = TextEditingController(text: widget.slide.title);
_title.addListener(_emit); _title.addListener(_emit);
_listStyle = widget.slide.listStyle;
_showChecklistProgress = widget.slide.showChecklistProgress;
final list = widget.slide.bullets.isEmpty ? [''] : widget.slide.bullets; final list = widget.slide.bullets.isEmpty ? [''] : widget.slide.bullets;
_levels = list.map(_levelOf).toList(); _levels = list.map(_levelOf).toList();
_bullets = list.map((b) => _makeCtrl(b.trimLeft())).toList(); _checked = list.map(checklistItemChecked).toList();
_bullets = list.map((b) => _makeCtrl(checklistItemText(b))).toList();
_focusNodes = List.generate(_bullets.length, (_) => FocusNode()); _focusNodes = List.generate(_bullets.length, (_) => FocusNode());
} }
@ -62,9 +69,17 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
widget.onUpdate( widget.onUpdate(
widget.slide.copyWith( widget.slide.copyWith(
title: _title.text, title: _title.text,
listStyle: _listStyle,
showChecklistProgress: _showChecklistProgress,
bullets: List.generate( bullets: List.generate(
_bullets.length, _bullets.length,
(i) => '\t' * _levels[i] + _bullets[i].text, (i) => _listStyle == ListStyle.checklist
? checklistBullet(
level: _levels[i],
text: _bullets[i].text,
checked: _checked[i],
)
: '\t' * _levels[i] + _bullets[i].text,
), ),
), ),
); );
@ -74,9 +89,11 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
setState(() { setState(() {
final ctrl = _bullets.removeAt(oldIndex); final ctrl = _bullets.removeAt(oldIndex);
final level = _levels.removeAt(oldIndex); final level = _levels.removeAt(oldIndex);
final checked = _checked.removeAt(oldIndex);
final focus = _focusNodes.removeAt(oldIndex); final focus = _focusNodes.removeAt(oldIndex);
_bullets.insert(newIndex, ctrl); _bullets.insert(newIndex, ctrl);
_levels.insert(newIndex, level); _levels.insert(newIndex, level);
_checked.insert(newIndex, checked);
_focusNodes.insert(newIndex, focus); _focusNodes.insert(newIndex, focus);
}); });
_emit(); _emit();
@ -86,6 +103,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
setState(() { setState(() {
_bullets.insert(i + 1, _makeCtrl('')); _bullets.insert(i + 1, _makeCtrl(''));
_levels.insert(i + 1, _levels[i]); _levels.insert(i + 1, _levels[i]);
_checked.insert(i + 1, false);
_focusNodes.insert(i + 1, FocusNode()); _focusNodes.insert(i + 1, FocusNode());
}); });
_emit(); _emit();
@ -95,13 +113,25 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
} }
void _removeBulletAndFocus(int i) { void _removeBulletAndFocus(int i) {
if (_bullets.length <= 1) return; if (_bullets.length == 1) {
setState(() {
_bullets[i].removeListener(_emit);
_bullets[i].clear();
_bullets[i].addListener(_emit);
_levels[i] = 0;
_checked[i] = false;
});
_emit();
_focusNodes[i].requestFocus();
return;
}
final target = (i - 1).clamp(0, _bullets.length - 2); final target = (i - 1).clamp(0, _bullets.length - 2);
setState(() { setState(() {
_bullets[i].removeListener(_emit); _bullets[i].removeListener(_emit);
_bullets[i].dispose(); _bullets[i].dispose();
_bullets.removeAt(i); _bullets.removeAt(i);
_levels.removeAt(i); _levels.removeAt(i);
_checked.removeAt(i);
_focusNodes[i].dispose(); _focusNodes[i].dispose();
_focusNodes.removeAt(i); _focusNodes.removeAt(i);
}); });
@ -138,6 +168,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
for (int j = 1; j < lines.length; j++) { for (int j = 1; j < lines.length; j++) {
_bullets.insert(i + j, _makeCtrl(lines[j])); _bullets.insert(i + j, _makeCtrl(lines[j]));
_levels.insert(i + j, _levels[i]); _levels.insert(i + j, _levels[i]);
_checked.insert(i + j, false);
_focusNodes.insert(i + j, FocusNode()); _focusNodes.insert(i + j, FocusNode());
} }
}); });
@ -184,6 +215,27 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
children: [ children: [
EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'),
const SizedBox(height: 16), const SizedBox(height: 16),
ListStyleSelector(
value: _listStyle,
onChanged: (value) {
setState(() => _listStyle = value);
_emit();
},
),
if (_listStyle == ListStyle.checklist)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.d('Voortgangsgrafiek tonen')),
subtitle: Text(
l10n.d('Toont afgevinkt en niet afgevinkt als percentages.'),
),
value: _showChecklistProgress,
onChanged: (value) {
setState(() => _showChecklistProgress = value);
_emit();
},
),
const SizedBox(height: 16),
const SectionLabel('Bullets (links)'), const SectionLabel('Bullets (links)'),
ReorderableListView( ReorderableListView(
shrinkWrap: true, shrinkWrap: true,
@ -254,10 +306,25 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( if (_listStyle == ListStyle.checklist)
_markerForLevel(level), SizedBox(
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), width: 24,
), height: 24,
child: Checkbox(
key: ValueKey('checklist-item-$i'),
value: _checked[i],
onChanged: (value) {
setState(() => _checked[i] = value ?? false);
_emit();
},
visualDensity: VisualDensity.compact,
),
)
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Focus( child: Focus(
@ -301,14 +368,13 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
), ),
), ),
IconButton( IconButton(
key: ValueKey('remove-bullet-$i'),
icon: const Icon( icon: const Icon(
Icons.remove_circle_outline, Icons.remove_circle_outline,
size: 18, size: 18,
color: Color(0xFF94A3B8), color: Color(0xFF94A3B8),
), ),
onPressed: _bullets.length > 1 onPressed: () => _removeBulletAndFocus(i),
? () => _removeBulletAndFocus(i)
: null,
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: const BoxConstraints(minWidth: 28), constraints: const BoxConstraints(minWidth: 28),
), ),
@ -321,4 +387,18 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
const markers = ['', '', '', '', '']; const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)]; return markers[level.clamp(0, markers.length - 1)];
} }
String _markerForItem(int index) {
if (_listStyle == ListStyle.bullets) {
return _markerForLevel(_levels[index]);
}
if (_listStyle == ListStyle.checklist) return '';
final level = _levels[index];
var number = 0;
for (var i = 0; i <= index; i++) {
if (_levels[i] == level) number++;
if (_levels[i] < level) number = 0;
}
return '$number.';
}
} }

View file

@ -17,12 +17,14 @@ import '_editor_field.dart';
class ChartEditor extends StatefulWidget { class ChartEditor extends StatefulWidget {
final Slide slide; final Slide slide;
final ValueChanged<Slide> onUpdate; final ValueChanged<Slide> onUpdate;
final ValueChanged<List<Slide>>? onAddVariants;
final String? projectPath; final String? projectPath;
const ChartEditor({ const ChartEditor({
super.key, super.key,
required this.slide, required this.slide,
required this.onUpdate, required this.onUpdate,
this.onAddVariants,
this.projectPath, this.projectPath,
}); });
@ -30,6 +32,114 @@ class ChartEditor extends StatefulWidget {
State<ChartEditor> createState() => _ChartEditorState(); State<ChartEditor> createState() => _ChartEditorState();
} }
class _ChartVariantsDialog extends StatefulWidget {
final ChartType currentType;
const _ChartVariantsDialog({required this.currentType});
@override
State<_ChartVariantsDialog> createState() => _ChartVariantsDialogState();
}
class _ChartVariantsDialogState extends State<_ChartVariantsDialog> {
late final List<ChartType> _types = [
for (final type in ChartType.values)
if (type != widget.currentType) type,
];
String _label(BuildContext context, ChartType type) {
final l10n = context.l10n;
return switch (type) {
ChartType.bar => l10n.d('Staaf'),
ChartType.line => l10n.d('Lijn'),
ChartType.pie => l10n.d('Cirkel'),
ChartType.radar => l10n.d('Spider'),
};
}
void _move(int index, int delta) {
final target = index + delta;
if (target < 0 || target >= _types.length) return;
setState(() {
final type = _types.removeAt(index);
_types.insert(target, type);
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(l10n.d('Grafiekvarianten maken')),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'Deze slides gebruiken dezelfde data, kleuren en titel. Kies met de pijlen de volgorde na de huidige slide.',
),
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
for (var i = 0; i < _types.length; i++)
ListTile(
key: ValueKey('chart-variant-${_types[i].name}'),
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(switch (_types[i]) {
ChartType.bar => Icons.bar_chart,
ChartType.line => Icons.show_chart,
ChartType.pie => Icons.pie_chart_outline,
ChartType.radar => Icons.radar,
}),
title: Text(_label(context, _types[i])),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: ValueKey('chart-variant-up-$i'),
onPressed: i == 0 ? null : () => _move(i, -1),
icon: const Icon(Icons.arrow_upward, size: 18),
tooltip: l10n.d('Omhoog'),
),
IconButton(
key: ValueKey('chart-variant-down-$i'),
onPressed: i == _types.length - 1
? null
: () => _move(i, 1),
icon: const Icon(Icons.arrow_downward, size: 18),
tooltip: l10n.d('Omlaag'),
),
IconButton(
onPressed: () => setState(() => _types.removeAt(i)),
icon: const Icon(Icons.close, size: 18),
tooltip: l10n.d('Niet toevoegen'),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.d('Annuleren')),
),
FilledButton(
onPressed: _types.isEmpty
? null
: () => Navigator.pop(context, List<ChartType>.from(_types)),
child: Text(l10n.d('Slides toevoegen')),
),
],
);
}
}
class _ChartEditorState extends State<ChartEditor> { class _ChartEditorState extends State<ChartEditor> {
late final TextEditingController _title; late final TextEditingController _title;
late final TextEditingController _minBound; late final TextEditingController _minBound;
@ -111,7 +221,7 @@ class _ChartEditorState extends State<ChartEditor> {
super.dispose(); super.dispose();
} }
void _emit() { ChartSpec _currentSpec() {
final series = <ChartSeries>[ final series = <ChartSeries>[
for (var c = 0; c < _seriesNames.length; c++) for (var c = 0; c < _seriesNames.length; c++)
ChartSeries( ChartSeries(
@ -128,7 +238,7 @@ class _ChartEditorState extends State<ChartEditor> {
], ],
), ),
]; ];
final spec = ChartSpec( return ChartSpec(
type: _type, type: _type,
title: _title.text, title: _title.text,
source: _source, source: _source,
@ -138,7 +248,33 @@ class _ChartEditorState extends State<ChartEditor> {
minBound: _supportsBounds ? _parseBound(_minBound.text) : null, minBound: _supportsBounds ? _parseBound(_minBound.text) : null,
maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null, maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null,
); );
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock())); }
void _emit() {
widget.onUpdate(
widget.slide.copyWith(customMarkdown: _currentSpec().toBlock()),
);
}
Future<void> _createVariants() async {
final selected = await showDialog<List<ChartType>>(
context: context,
builder: (context) => _ChartVariantsDialog(currentType: _type),
);
if (selected == null || selected.isEmpty) return;
final base = _currentSpec();
widget.onAddVariants?.call([
for (final type in selected)
widget.slide.copyWith(
customMarkdown: base
.copyWith(
type: type,
clearMinBound: type == ChartType.pie,
clearMaxBound: type == ChartType.pie,
)
.toBlock(),
),
]);
} }
void _bump() => setState(() => _rev++); void _bump() => setState(() => _rev++);
@ -481,6 +617,15 @@ class _ChartEditorState extends State<ChartEditor> {
}, },
), ),
const Spacer(), const Spacer(),
if (widget.onAddVariants != null) ...[
TextButton.icon(
key: const ValueKey('chart-create-variants'),
onPressed: _createVariants,
icon: const Icon(Icons.auto_awesome_motion, size: 16),
label: Text(l10n.d('Varianten')),
),
const SizedBox(width: 4),
],
TextButton.icon( TextButton.icon(
onPressed: _importCsv, onPressed: _importCsv,
icon: const Icon(Icons.upload_file, size: 16), icon: const Icon(Icons.upload_file, size: 16),
@ -787,9 +932,7 @@ class _ChartEditorState extends State<ChartEditor> {
decimal: true, decimal: true,
signed: true, signed: true,
), ),
inputFormatters: [ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]'))],
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]')),
],
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,

View file

@ -35,17 +35,28 @@ class _ImageSlideEditorState extends State<ImageSlideEditor> {
void _emit() => widget.onUpdate(widget.slide.copyWith(title: _title.text)); void _emit() => widget.onUpdate(widget.slide.copyWith(title: _title.text));
void _setImage(String path, {String caption = ''}) {
widget.onUpdate(
widget.slide.copyWith(
imagePath: path,
imageCaption: caption,
// A full-slide image should start at the largest uncropped size.
imageSize: 100,
),
);
}
Future<void> _pasteImage() async { Future<void> _pasteImage() async {
final path = await widget.imageService.pasteImage(); final path = await widget.imageService.pasteImage();
if (path != null) { if (path != null) {
widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); _setImage(path);
} }
} }
Future<void> _pickImage() async { Future<void> _pickImage() async {
final path = await widget.imageService.pickImage(); final path = await widget.imageService.pickImage();
if (path != null) { if (path != null) {
widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); _setImage(path);
} }
} }
@ -66,9 +77,7 @@ class _ImageSlideEditorState extends State<ImageSlideEditor> {
imageCaption: widget.slide.imageCaption, imageCaption: widget.slide.imageCaption,
searchPaths: widget.searchPaths, searchPaths: widget.searchPaths,
captionBasePath: widget.captionBasePath, captionBasePath: widget.captionBasePath,
onPicked: (path, caption) => widget.onUpdate( onPicked: (path, caption) => _setImage(path, caption: caption),
widget.slide.copyWith(imagePath: path, imageCaption: caption),
),
onBrowse: _pickImage, onBrowse: _pickImage,
onPaste: _pasteImage, onPaste: _pasteImage,
onClear: widget.slide.imagePath.isNotEmpty onClear: widget.slide.imagePath.isNotEmpty

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/slide.dart';
class ListStyleSelector extends StatelessWidget {
final ListStyle value;
final ValueChanged<ListStyle> onChanged;
const ListStyleSelector({
super.key,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SegmentedButton<ListStyle>(
segments: [
ButtonSegment(
value: ListStyle.bullets,
icon: const Icon(Icons.format_list_bulleted, size: 18),
label: Text(l10n.d('Opsomming')),
),
ButtonSegment(
value: ListStyle.numbered,
icon: const Icon(Icons.format_list_numbered, size: 18),
label: Text(l10n.d('Nummering')),
),
ButtonSegment(
value: ListStyle.checklist,
icon: const Icon(Icons.checklist, size: 18),
label: Text(l10n.d('Checklist')),
),
],
selected: {value},
showSelectedIcon: false,
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '_editor_field.dart'; import '_editor_field.dart';
import 'list_style_selector.dart';
typedef _Mutate = void Function(VoidCallback fn); typedef _Mutate = void Function(VoidCallback fn);
@ -26,6 +27,8 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
late final TextEditingController _heading2; late final TextEditingController _heading2;
late _BulletSet _left; late _BulletSet _left;
late _BulletSet _right; late _BulletSet _right;
late ListStyle _listStyle;
late bool _showChecklistProgress;
@override @override
void initState() { void initState() {
@ -36,6 +39,8 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
_heading2 = TextEditingController(text: widget.slide.columnTitle2); _heading2 = TextEditingController(text: widget.slide.columnTitle2);
_heading1.addListener(_emit); _heading1.addListener(_emit);
_heading2.addListener(_emit); _heading2.addListener(_emit);
_listStyle = widget.slide.listStyle;
_showChecklistProgress = widget.slide.showChecklistProgress;
_left = _BulletSet(widget.slide.bullets, _emit); _left = _BulletSet(widget.slide.bullets, _emit);
_right = _BulletSet(widget.slide.bullets2, _emit); _right = _BulletSet(widget.slide.bullets2, _emit);
} }
@ -46,8 +51,10 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
title: _title.text, title: _title.text,
columnTitle1: _heading1.text, columnTitle1: _heading1.text,
columnTitle2: _heading2.text, columnTitle2: _heading2.text,
bullets: _left.values, listStyle: _listStyle,
bullets2: _right.values, showChecklistProgress: _showChecklistProgress,
bullets: _left.values(_listStyle),
bullets2: _right.values(_listStyle),
), ),
); );
} }
@ -69,6 +76,29 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
children: [ children: [
EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'),
const SizedBox(height: 16), const SizedBox(height: 16),
ListStyleSelector(
value: _listStyle,
onChanged: (value) {
setState(() => _listStyle = value);
_emit();
},
),
if (_listStyle == ListStyle.checklist)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.d('Voortgangsgrafiek tonen')),
subtitle: Text(
context.l10n.d(
'Toont afgevinkt en niet afgevinkt als percentages.',
),
),
value: _showChecklistProgress,
onChanged: (value) {
setState(() => _showChecklistProgress = value);
_emit();
},
),
const SizedBox(height: 16),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final narrow = constraints.maxWidth < 560; final narrow = constraints.maxWidth < 560;
@ -78,12 +108,14 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
set: _left, set: _left,
emit: _emit, emit: _emit,
headingController: _heading1, headingController: _heading1,
listStyle: _listStyle,
), ),
_BulletColumn( _BulletColumn(
label: 'Bullets rechts', label: 'Bullets rechts',
set: _right, set: _right,
emit: _emit, emit: _emit,
headingController: _heading2, headingController: _heading2,
listStyle: _listStyle,
), ),
]; ];
if (narrow) { if (narrow) {
@ -112,18 +144,26 @@ class _BulletSet {
final VoidCallback emit; final VoidCallback emit;
late List<TextEditingController> controllers; late List<TextEditingController> controllers;
late List<int> levels; late List<int> levels;
late List<bool> checked;
late List<FocusNode> focusNodes; late List<FocusNode> focusNodes;
_BulletSet(List<String> raw, this.emit) { _BulletSet(List<String> raw, this.emit) {
final list = raw.isEmpty ? [''] : raw; final list = raw.isEmpty ? [''] : raw;
levels = list.map(_levelOf).toList(); levels = list.map(_levelOf).toList();
controllers = list.map((b) => _makeCtrl(b.trimLeft())).toList(); checked = list.map(checklistItemChecked).toList();
controllers = list.map((b) => _makeCtrl(checklistItemText(b))).toList();
focusNodes = List.generate(controllers.length, (_) => FocusNode()); focusNodes = List.generate(controllers.length, (_) => FocusNode());
} }
List<String> get values => List.generate( List<String> values(ListStyle listStyle) => List.generate(
controllers.length, controllers.length,
(i) => '\t' * levels[i] + controllers[i].text, (i) => listStyle == ListStyle.checklist
? checklistBullet(
level: levels[i],
text: controllers[i].text,
checked: checked[i],
)
: '\t' * levels[i] + controllers[i].text,
); );
static int _levelOf(String b) { static int _levelOf(String b) {
@ -144,6 +184,7 @@ class _BulletSet {
mutate(() { mutate(() {
controllers.insert(i + 1, _makeCtrl('')); controllers.insert(i + 1, _makeCtrl(''));
levels.insert(i + 1, levels[i]); levels.insert(i + 1, levels[i]);
checked.insert(i + 1, false);
focusNodes.insert(i + 1, FocusNode()); focusNodes.insert(i + 1, FocusNode());
}); });
emit(); emit();
@ -153,13 +194,25 @@ class _BulletSet {
} }
void removeAndFocus(_Mutate mutate, int i) { void removeAndFocus(_Mutate mutate, int i) {
if (controllers.length <= 1) return; if (controllers.length == 1) {
mutate(() {
controllers[i].removeListener(emit);
controllers[i].clear();
controllers[i].addListener(emit);
levels[i] = 0;
checked[i] = false;
});
emit();
focusNodes[i].requestFocus();
return;
}
final target = (i - 1).clamp(0, controllers.length - 2); final target = (i - 1).clamp(0, controllers.length - 2);
mutate(() { mutate(() {
controllers[i].removeListener(emit); controllers[i].removeListener(emit);
controllers[i].dispose(); controllers[i].dispose();
controllers.removeAt(i); controllers.removeAt(i);
levels.removeAt(i); levels.removeAt(i);
checked.removeAt(i);
focusNodes[i].dispose(); focusNodes[i].dispose();
focusNodes.removeAt(i); focusNodes.removeAt(i);
}); });
@ -198,6 +251,7 @@ class _BulletSet {
for (int j = 1; j < lines.length; j++) { for (int j = 1; j < lines.length; j++) {
controllers.insert(i + j, _makeCtrl(lines[j])); controllers.insert(i + j, _makeCtrl(lines[j]));
levels.insert(i + j, levels[i]); levels.insert(i + j, levels[i]);
checked.insert(i + j, false);
focusNodes.insert(i + j, FocusNode()); focusNodes.insert(i + j, FocusNode());
} }
}); });
@ -223,12 +277,14 @@ class _BulletColumn extends StatefulWidget {
final _BulletSet set; final _BulletSet set;
final VoidCallback emit; final VoidCallback emit;
final TextEditingController headingController; final TextEditingController headingController;
final ListStyle listStyle;
const _BulletColumn({ const _BulletColumn({
required this.label, required this.label,
required this.set, required this.set,
required this.emit, required this.emit,
required this.headingController, required this.headingController,
required this.listStyle,
}); });
@override @override
@ -275,10 +331,25 @@ class _BulletColumnState extends State<_BulletColumn> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( if (widget.listStyle == ListStyle.checklist)
_markerForLevel(level), SizedBox(
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), width: 24,
), height: 24,
child: Checkbox(
key: ValueKey('checklist-item-${widget.label}-$i'),
value: set.checked[i],
onChanged: (value) {
setState(() => set.checked[i] = value ?? false);
widget.emit();
},
visualDensity: VisualDensity.compact,
),
)
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Focus( child: Focus(
@ -324,14 +395,13 @@ class _BulletColumnState extends State<_BulletColumn> {
), ),
), ),
IconButton( IconButton(
key: ValueKey('remove-bullet-${widget.label}-$i'),
icon: const Icon( icon: const Icon(
Icons.remove_circle_outline, Icons.remove_circle_outline,
size: 18, size: 18,
color: Color(0xFF94A3B8), color: Color(0xFF94A3B8),
), ),
onPressed: set.controllers.length > 1 onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
? () => set.removeAndFocus((fn) => setState(fn), i)
: null,
tooltip: l10n.d('Verwijder'), tooltip: l10n.d('Verwijder'),
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: const BoxConstraints(minWidth: 28), constraints: const BoxConstraints(minWidth: 28),
@ -345,4 +415,18 @@ class _BulletColumnState extends State<_BulletColumn> {
const markers = ['', '', '', '', '']; const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)]; return markers[level.clamp(0, markers.length - 1)];
} }
String _markerForItem(int index) {
if (widget.listStyle == ListStyle.bullets) {
return _markerForLevel(set.levels[index]);
}
if (widget.listStyle == ListStyle.checklist) return '';
final level = set.levels[index];
var number = 0;
for (var i = 0; i <= index; i++) {
if (set.levels[i] == level) number++;
if (set.levels[i] < level) number = 0;
}
return '$number.';
}
} }

View file

@ -102,6 +102,13 @@ class EditorPanel extends ConsumerWidget {
imgService, imgService,
searchPaths, searchPaths,
deck.projectPath, deck.projectPath,
(variants) {
final first = deckNotifier.insertSlides(
variants,
afterIndex: idx,
);
if (first >= 0) editorNotifier.select(first);
},
), ),
), ),
if (slide.type != SlideType.video) ...[ if (slide.type != SlideType.video) ...[
@ -160,6 +167,8 @@ class EditorPanel extends ConsumerWidget {
bullets2: newType == SlideType.twoBullets bullets2: newType == SlideType.twoBullets
? (slide.bullets2.isNotEmpty ? slide.bullets2 : ['']) ? (slide.bullets2.isNotEmpty ? slide.bullets2 : [''])
: const [], : const [],
listStyle: slide.listStyle,
showChecklistProgress: slide.showChecklistProgress,
imagePath: keepsImage ? slide.imagePath : '', imagePath: keepsImage ? slide.imagePath : '',
imagePath2: newType == SlideType.twoImages ? slide.imagePath2 : '', imagePath2: newType == SlideType.twoImages ? slide.imagePath2 : '',
imageCaption: keepsImage ? slide.imageCaption : '', imageCaption: keepsImage ? slide.imageCaption : '',
@ -197,6 +206,7 @@ class EditorPanel extends ConsumerWidget {
ImageService imgService, ImageService imgService,
List<String> searchPaths, List<String> searchPaths,
String? captionBasePath, String? captionBasePath,
ValueChanged<List<Slide>> onAddChartVariants,
) { ) {
switch (slide.type) { switch (slide.type) {
case SlideType.title: case SlideType.title:
@ -289,6 +299,7 @@ class EditorPanel extends ConsumerWidget {
key: ValueKey(slide.id), key: ValueKey(slide.id),
slide: slide, slide: slide,
onUpdate: onUpdate, onUpdate: onUpdate,
onAddVariants: onAddChartVariants,
projectPath: captionBasePath, projectPath: captionBasePath,
); );
} }

View file

@ -103,6 +103,16 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
? null ? null
: Offset((pt[0] as num).toDouble(), (pt[1] as num).toDouble()); : Offset((pt[0] as num).toDouble(), (pt[1] as num).toDouble());
}); });
case 'checklistUpdate':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['slideIndex'] as num?)?.toInt();
if (i == null || i < 0 || i >= _slides.length || !mounted) return null;
setState(() {
_slides[i] = _slides[i].copyWith(
bullets: List<String>.from(m['bullets'] as List? ?? const []),
bullets2: List<String>.from(m['bullets2'] as List? ?? const []),
);
});
case 'close': case 'close':
try { try {
final self = await WindowController.fromCurrentEngine(); final self = await WindowController.fromCurrentEngine();
@ -112,9 +122,9 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
return null; return null;
} }
void _send(String method) { void _send(String method, [Object? arguments]) {
// Best-effort: the presenter may already be gone. // Best-effort: the presenter may already be gone.
presenterChannel.invokeMethod(method).catchError((_) => null); presenterChannel.invokeMethod(method, arguments).catchError((_) => null);
} }
@override @override
@ -169,11 +179,23 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
slideCount: _slides.length, slideCount: _slides.length,
tlp: _tlp, tlp: _tlp,
presentationMode: true, presentationMode: true,
onChecklistItemToggle: (column, itemIndex) =>
_send('checklistToggle', {
'slideIndex': _index,
'column': column,
'itemIndex': itemIndex,
}),
enableMedia: true, enableMedia: true,
autoplayMedia: true, autoplayMedia: true,
// Audio finishing on the beamer drives the presenter's // Media finishing on the beamer drives auto-advance.
// auto-advance. onAudioComplete: () => _send('mediaComplete', {
onAudioComplete: () => _send('audioComplete'), 'index': _index,
'kind': 'audio',
}),
onVideoComplete: () => _send('mediaComplete', {
'index': _index,
'kind': 'video',
}),
), ),
AnnotationLayer( AnnotationLayer(
strokes: _ink[_index] ?? const [], strokes: _ink[_index] ?? const [],

View file

@ -37,6 +37,7 @@ class FullscreenPresenter extends StatefulWidget {
/// made while presenting back to the deck. /// made while presenting back to the deck.
final Map<String, List<InkStroke>> initialAnnotations; final Map<String, List<InkStroke>> initialAnnotations;
final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged; final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged;
final ValueChanged<Slide>? onSlideChanged;
const FullscreenPresenter({ const FullscreenPresenter({
super.key, super.key,
@ -48,6 +49,7 @@ class FullscreenPresenter extends StatefulWidget {
this.audienceWindow, this.audienceWindow,
this.initialAnnotations = const {}, this.initialAnnotations = const {},
this.onAnnotationsChanged, this.onAnnotationsChanged,
this.onSlideChanged,
}); });
/// Entry point used by the app: pick dual-screen mode when a second display is /// Entry point used by the app: pick dual-screen mode when a second display is
@ -62,6 +64,7 @@ class FullscreenPresenter extends StatefulWidget {
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
Map<String, List<InkStroke>> annotations = const {}, Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged, void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged,
}) async { }) async {
var displayCount = 0; var displayCount = 0;
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
@ -89,6 +92,7 @@ class FullscreenPresenter extends StatefulWidget {
tlp: tlp, tlp: tlp,
annotations: annotations, annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
); );
} else { } else {
await show( await show(
@ -100,6 +104,7 @@ class FullscreenPresenter extends StatefulWidget {
tlp: tlp, tlp: tlp,
annotations: annotations, annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
); );
} }
} }
@ -113,6 +118,7 @@ class FullscreenPresenter extends StatefulWidget {
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
Map<String, List<InkStroke>> annotations = const {}, Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged, void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged,
}) async { }) async {
final hadWakeLock = await _wakeLockEnabled(); final hadWakeLock = await _wakeLockEnabled();
await _enableWakeLock(); await _enableWakeLock();
@ -131,6 +137,7 @@ class FullscreenPresenter extends StatefulWidget {
tlp: tlp, tlp: tlp,
initialAnnotations: annotations, initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
), ),
transitionsBuilder: (context, animation, secondary, child) => transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
@ -156,6 +163,7 @@ class FullscreenPresenter extends StatefulWidget {
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
Map<String, List<InkStroke>> annotations = const {}, Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged, void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
ValueChanged<Slide>? onSlideChanged,
}) async { }) async {
// A self-contained markdown deck is the payload for the audience window; it // A self-contained markdown deck is the payload for the audience window; it
// carries the slides, the style profile and the TLP level in one string. // carries the slides, the style profile and the TLP level in one string.
@ -205,6 +213,7 @@ class FullscreenPresenter extends StatefulWidget {
tlp: tlp, tlp: tlp,
annotations: annotations, annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
); );
} }
return; return;
@ -227,6 +236,7 @@ class FullscreenPresenter extends StatefulWidget {
audienceWindow: audience, audienceWindow: audience,
initialAnnotations: annotations, initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged, onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
), ),
transitionsBuilder: (context, animation, secondary, child) => transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
@ -255,6 +265,16 @@ bool shouldUseDualScreen({
return (isMacOS || isWindows || isLinux) && displayCount >= 2; return (isMacOS || isWindows || isLinux) && displayCount >= 2;
} }
@visibleForTesting
bool autoAdvanceWaitsForMedia(Slide slide) {
final autoplayVideo =
slide.type == SlideType.video &&
slide.videoPath.isNotEmpty &&
slide.videoAutoplay;
final autoplayAudio = slide.audioPath.isNotEmpty && slide.audioAutoplay;
return autoplayVideo || autoplayAudio;
}
Future<bool> _wakeLockEnabled() async { Future<bool> _wakeLockEnabled() async {
try { try {
return await WakelockPlus.enabled; return await WakelockPlus.enabled;
@ -325,9 +345,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
/// laatste slide staan). Met L te wisselen. /// laatste slide staan). Met L te wisselen.
bool _loop = false; bool _loop = false;
/// Wissel het afspelen van de audio op de slide i.p.v. op de tijdwissel. /// Wissel het afspelen van autoplay-media i.p.v. op de tijdwissel.
/// Met M te wisselen. /// Met M te wisselen.
bool _advanceOnAudioEnd = true; bool _advanceOnMediaEnd = true;
/// Known displays for moving the fullscreen presentation window. This is not /// Known displays for moving the fullscreen presentation window. This is not
/// a second presenter window; it keeps the current output movable between /// a second presenter window; it keeps the current output movable between
@ -385,7 +405,20 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
case 'exit': case 'exit':
_exit(); _exit();
case 'audioComplete': case 'audioComplete':
_onAudioCompleted(); _onMediaCompleted(kind: 'audio');
case 'mediaComplete':
final args = Map<String, dynamic>.from(call.arguments as Map);
_onMediaCompleted(
index: (args['index'] as num?)?.toInt(),
kind: args['kind']?.toString(),
);
case 'checklistToggle':
final args = Map<String, dynamic>.from(call.arguments as Map);
_toggleChecklistItem(
slideIndex: (args['slideIndex'] as num?)?.toInt() ?? _index,
column: (args['column'] as num?)?.toInt() ?? 0,
itemIndex: (args['itemIndex'] as num?)?.toInt() ?? 0,
);
} }
return null; return null;
}); });
@ -431,6 +464,38 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
if (indexChanged) _pushInk(); if (indexChanged) _pushInk();
} }
void _toggleChecklistItem({
required int slideIndex,
required int column,
required int itemIndex,
}) {
if (slideIndex < 0 || slideIndex >= widget.slides.length) return;
final slide = widget.slides[slideIndex];
final source = column == 1 ? slide.bullets2 : slide.bullets;
if (itemIndex < 0 || itemIndex >= source.length) return;
final updatedItems = List<String>.from(source);
final item = updatedItems[itemIndex];
updatedItems[itemIndex] = checklistBullet(
level: bulletLevel(item),
text: checklistItemText(item),
checked: !checklistItemChecked(item),
);
final updated = column == 1
? slide.copyWith(bullets2: updatedItems)
: slide.copyWith(bullets: updatedItems);
setState(() => widget.slides[slideIndex] = updated);
widget.onSlideChanged?.call(updated);
if (_dual) {
audienceChannel
.invokeMethod('checklistUpdate', {
'slideIndex': slideIndex,
'bullets': updated.bullets,
'bullets2': updated.bullets2,
})
.catchError((_) => null);
}
}
// Annotatielaag // Annotatielaag
/// Send the current slide's strokes to the beamer (keyed by index there). /// Send the current slide's strokes to the beamer (keyed by index there).
@ -528,12 +593,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
final slide = widget.slides[_index.clamp(0, widget.slides.length - 1)]; final slide = widget.slides[_index.clamp(0, widget.slides.length - 1)];
// Audio-gestuurd: heeft deze slide audio die vanzelf speelt én is de keuze if (_advanceOnMediaEnd && autoAdvanceWaitsForMedia(slide)) return;
// 'na audio doorgaan' actief? Dan wachten we op het audio-einde (de
// _AudioPlayback meldt zich via onAudioComplete) en zetten we geen timer.
final audioDriven =
_advanceOnAudioEnd && slide.audioPath.isNotEmpty && slide.audioAutoplay;
if (audioDriven) return;
final dur = slide.advanceDuration; final dur = slide.advanceDuration;
if (dur <= 0) return; if (dur <= 0) return;
@ -559,7 +619,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
}); });
} }
/// Automatisch doorschakelen (tijd of audio-einde): naar de volgende slide, /// Automatisch doorschakelen (tijd of media-einde): naar de volgende slide,
/// of bij herhaling vanaf de laatste terug naar de eerste. Zonder herhaling /// of bij herhaling vanaf de laatste terug naar de eerste. Zonder herhaling
/// blijft de laatste slide gewoon staan. /// blijft de laatste slide gewoon staan.
void _autoAdvance() { void _autoAdvance() {
@ -573,10 +633,20 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
} }
} }
/// Aangeroepen door de audiospeler zodra de audio op de huidige slide klaar void _onMediaCompleted({int? index, String? kind}) {
/// is. In automatische modus met 'na audio doorgaan' schakelen we dan door. if (index != null && index != _index) return;
void _onAudioCompleted() { final slide = widget.slides[_index.clamp(0, widget.slides.length - 1)];
if (_autoPlay && _advanceOnAudioEnd) _autoAdvance(); // A video is primary on a video slide. Ignore an attached audio track that
// happens to finish earlier.
if (kind == 'audio' &&
slide.type == SlideType.video &&
slide.videoPath.isNotEmpty &&
slide.videoAutoplay) {
return;
}
if (_autoPlay && _advanceOnMediaEnd && autoAdvanceWaitsForMedia(slide)) {
_autoAdvance();
}
} }
void _toggleAutoPlay() { void _toggleAutoPlay() {
@ -589,8 +659,8 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_scheduleAdvance(); _scheduleAdvance();
} }
void _toggleAudioAdvance() { void _toggleMediaAdvance() {
setState(() => _advanceOnAudioEnd = !_advanceOnAudioEnd); setState(() => _advanceOnMediaEnd = !_advanceOnMediaEnd);
_scheduleAdvance(); _scheduleAdvance();
} }
@ -892,7 +962,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_toggleLoop(); _toggleLoop();
return KeyEventResult.handled; return KeyEventResult.handled;
case LogicalKeyboardKey.keyM: case LogicalKeyboardKey.keyM:
_toggleAudioAdvance(); _toggleMediaAdvance();
return KeyEventResult.handled; return KeyEventResult.handled;
case LogicalKeyboardKey.keyS: case LogicalKeyboardKey.keyS:
_cycleDisplay(); _cycleDisplay();
@ -1155,7 +1225,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
('R', l10n.d('Verstreken tijd resetten')), ('R', l10n.d('Verstreken tijd resetten')),
('A', l10n.d('Automatische modus aan/uit')), ('A', l10n.d('Automatische modus aan/uit')),
('L', l10n.d('Herhalen (loop) aan/uit')), ('L', l10n.d('Herhalen (loop) aan/uit')),
('M', l10n.d('Na audio automatisch doorgaan')), ('M', l10n.d('Na media automatisch doorgaan')),
('H', l10n.d('Deze legenda')), ('H', l10n.d('Deze legenda')),
('Esc', l10n.d('Terug / afsluiten')), ('Esc', l10n.d('Terug / afsluiten')),
]; ];
@ -1289,13 +1359,20 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
slideCount: widget.slides.length, slideCount: widget.slides.length,
tlp: widget.tlp, tlp: widget.tlp,
presentationMode: true, presentationMode: true,
onChecklistItemToggle: (column, itemIndex) =>
_toggleChecklistItem(
slideIndex: _index,
column: column,
itemIndex: itemIndex,
),
// Tijdens het presenteren speelt media en starten audio/video // Tijdens het presenteren speelt media en starten audio/video
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual- // vanzelf; het media-einde stuurt auto-advance aan. In dual-
// schermmodus speelt de media op het beamervenster, niet hier, // schermmodus speelt de media op het beamervenster, niet hier,
// anders zou het geluid dubbel klinken. // anders zou het geluid dubbel klinken.
enableMedia: !_dual, enableMedia: !_dual,
autoplayMedia: !_dual, autoplayMedia: !_dual,
onAudioComplete: _onAudioCompleted, onAudioComplete: () => _onMediaCompleted(kind: 'audio'),
onVideoComplete: () => _onMediaCompleted(kind: 'video'),
), ),
// Annotatielaag bovenop de dia. Laat klikken door wanneer er // Annotatielaag bovenop de dia. Laat klikken door wanneer er
// geen gereedschap actief is (zodat tikken blijft doorbladeren). // geen gereedschap actief is (zodat tikken blijft doorbladeren).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,286 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/editors/bullets_editor.dart';
import 'package:ocideck/widgets/slides/inline_markdown.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
void main() {
testWidgets('checklist items can be marked as checked', (tester) async {
var updated = Slide.create(SlideType.bullets).copyWith(bullets: ['Taak']);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BulletsEditor(
slide: updated,
onUpdate: (slide) => updated = slide,
),
),
),
);
await tester.tap(find.text('Checklist'));
await tester.pump();
expect(updated.listStyle, ListStyle.checklist);
expect(updated.bullets, ['[ ] Taak']);
await tester.tap(find.byKey(const ValueKey('checklist-item-0')));
await tester.pump();
expect(updated.bullets, ['[x] Taak']);
});
testWidgets('removing the final bullet leaves an empty bullet', (
tester,
) async {
var updated = Slide.create(
SlideType.bullets,
).copyWith(bullets: ['Enige bullet']);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BulletsEditor(
slide: updated,
onUpdate: (slide) => updated = slide,
),
),
),
);
await tester.tap(find.byKey(const ValueKey('remove-bullet-0')));
await tester.pump();
expect(updated.bullets, ['']);
expect(find.byType(TextField), findsNWidgets(3));
});
testWidgets('removing the final checklist item also resets its state', (
tester,
) async {
var updated = Slide.create(
SlideType.bullets,
).copyWith(bullets: ['\t[x] Afgerond'], listStyle: ListStyle.checklist);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BulletsEditor(
slide: updated,
onUpdate: (slide) => updated = slide,
),
),
),
);
await tester.tap(find.byKey(const ValueKey('remove-bullet-0')));
await tester.pump();
expect(updated.bullets, ['[ ] ']);
expect(
tester
.widget<Checkbox>(find.byKey(const ValueKey('checklist-item-0')))
.value,
isFalse,
);
});
testWidgets('checked items render checked and struck through', (
tester,
) async {
final slide = Slide.create(SlideType.bullets).copyWith(
bullets: ['[x] Klaar', '[ ] Open'],
listStyle: ListStyle.checklist,
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(slide: slide),
),
),
),
);
expect(find.text(''), findsOneWidget);
expect(find.text(''), findsOneWidget);
final checked = tester.widget<InlineMarkdownText>(
find.byWidgetPredicate(
(widget) => widget is InlineMarkdownText && widget.text == 'Klaar',
),
);
expect(checked.style.decoration, TextDecoration.lineThrough);
});
testWidgets('checked item strike-through follows the style profile', (
tester,
) async {
final slide = Slide.create(
SlideType.bullets,
).copyWith(bullets: ['[x] Klaar'], listStyle: ListStyle.checklist);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: slide,
themeProfile: const ThemeProfile(checklistStrikeThrough: false),
),
),
),
),
);
final checked = tester.widget<InlineMarkdownText>(
find.byWidgetPredicate(
(widget) => widget is InlineMarkdownText && widget.text == 'Klaar',
),
);
expect(checked.style.decoration, isNull);
});
testWidgets('checklist progress chart can be enabled', (tester) async {
var updated = Slide.create(SlideType.bullets).copyWith(
bullets: ['[x] Klaar', '[ ] Open'],
listStyle: ListStyle.checklist,
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BulletsEditor(
slide: updated,
onUpdate: (slide) => updated = slide,
),
),
),
);
expect(find.text('Voortgangsgrafiek tonen'), findsOneWidget);
await tester.tap(find.byType(Switch));
await tester.pump();
expect(updated.showChecklistProgress, isTrue);
});
testWidgets('checklist progress chart shows checked percentages', (
tester,
) async {
final slide = Slide.create(SlideType.bullets).copyWith(
bullets: ['[x] Klaar', '[ ] Open'],
listStyle: ListStyle.checklist,
showChecklistProgress: true,
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: slide,
themeProfile: const ThemeProfile(
checklistCheckedColor: '#00AA00',
checklistUncheckedColor: '#CC0000',
),
),
),
),
),
);
expect(find.text('Afgevinkt 50%'), findsOneWidget);
expect(find.text('Niet afgevinkt 50%'), findsOneWidget);
expect(
find.byKey(const ValueKey('checklist-progress-pie')),
findsOneWidget,
);
expect(
tester
.getSize(find.byKey(const ValueKey('checklist-progress-pie')))
.width,
greaterThan(200),
);
final pie = tester.widget<PieChart>(
find.byKey(const ValueKey('checklist-progress-pie')),
);
expect(pie.data.sections[0].color, const Color(0xFF00AA00));
expect(pie.data.sections[1].color, const Color(0xFFCC0000));
});
testWidgets('presented checklist items can be toggled', (tester) async {
Slide? updated;
final slide = Slide.create(
SlideType.bullets,
).copyWith(bullets: ['[ ] Open'], listStyle: ListStyle.checklist);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: slide,
presentationMode: true,
onChecklistItemToggle: (column, itemIndex) {
updated = slide.copyWith(bullets: ['[x] Open']);
},
),
),
),
),
);
await tester.tap(
find.byKey(const ValueKey('checklist-preview-toggle-0-0')),
);
expect(updated?.bullets, ['[x] Open']);
});
testWidgets('hovering progress highlights matching checklist items', (
tester,
) async {
final slide = Slide.create(SlideType.bullets).copyWith(
bullets: ['[x] Klaar', '[ ] Open'],
listStyle: ListStyle.checklist,
showChecklistProgress: true,
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: slide,
presentationMode: true,
onChecklistItemToggle: (_, _) {},
),
),
),
),
);
final checkedRow = find.byKey(const ValueKey('checklist-preview-item-0-0'));
var container = tester.widget<AnimatedContainer>(checkedRow);
expect((container.decoration as BoxDecoration).color, Colors.transparent);
final checkedSegment = tester.widget<MouseRegion>(
find.byKey(const ValueKey('checklist-progress-checked')),
);
checkedSegment.onEnter!(const PointerEnterEvent());
await tester.pumpAndSettle();
container = tester.widget<AnimatedContainer>(checkedRow);
expect(
(container.decoration as BoxDecoration).color,
isNot(Colors.transparent),
);
});
}

View file

@ -4,13 +4,21 @@ import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/slide.dart'; import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/editors/chart_editor.dart'; import 'package:ocideck/widgets/editors/chart_editor.dart';
Widget _host(Slide slide, ValueChanged<Slide> onUpdate) { Widget _host(
Slide slide,
ValueChanged<Slide> onUpdate, {
ValueChanged<List<Slide>>? onAddVariants,
}) {
return MaterialApp( return MaterialApp(
home: Scaffold( home: Scaffold(
body: SizedBox( body: SizedBox(
width: 900, width: 900,
height: 650, height: 650,
child: ChartEditor(slide: slide, onUpdate: onUpdate), child: ChartEditor(
slide: slide,
onUpdate: onUpdate,
onAddVariants: onAddVariants,
),
), ),
), ),
); );
@ -136,10 +144,7 @@ void main() {
expect(find.byKey(const ValueKey('chart-min-bound')), findsOneWidget); expect(find.byKey(const ValueKey('chart-min-bound')), findsOneWidget);
expect(find.byKey(const ValueKey('chart-max-bound')), findsOneWidget); expect(find.byKey(const ValueKey('chart-max-bound')), findsOneWidget);
await tester.enterText( await tester.enterText(find.byKey(const ValueKey('chart-max-bound')), '20');
find.byKey(const ValueKey('chart-max-bound')),
'20',
);
await tester.pump(); await tester.pump();
expect(ChartSpec.parse(updated.customMarkdown).maxBound, 20); expect(ChartSpec.parse(updated.customMarkdown).maxBound, 20);
@ -163,4 +168,39 @@ void main() {
expect(find.byKey(const ValueKey('chart-min-bound')), findsNothing); expect(find.byKey(const ValueKey('chart-min-bound')), findsNothing);
expect(find.byKey(const ValueKey('chart-max-bound')), findsNothing); expect(find.byKey(const ValueKey('chart-max-bound')), findsNothing);
}); });
testWidgets('chart variants reuse data in the chosen order', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet',
x: ['A', 'B'],
series: [
ChartSeries(name: 'Waarde', data: [10, 20], color: '#003399'),
],
);
final slide = Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock());
List<Slide>? variants;
await tester.pumpWidget(
_host(slide, (_) {}, onAddVariants: (value) => variants = value),
);
await tester.tap(find.byKey(const ValueKey('chart-create-variants')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('chart-variant-down-0')));
await tester.pump();
await tester.tap(find.text('Slides toevoegen'));
await tester.pump();
final specs = variants!.map((s) => ChartSpec.parse(s.customMarkdown));
expect(specs.map((s) => s.type), [
ChartType.pie,
ChartType.line,
ChartType.radar,
]);
expect(specs.first.x, ['A', 'B']);
expect(specs.first.series.single.data, [10, 20]);
expect(specs.first.series.single.color, '#003399');
});
} }

View file

@ -291,7 +291,11 @@ void main() {
final radar = tester.widget<RadarChart>(find.byType(RadarChart)); final radar = tester.widget<RadarChart>(find.byType(RadarChart));
// Two visible series plus one invisible scale anchor. // Two visible series plus one invisible scale anchor.
expect(radar.data.dataSets.length, 3); expect(radar.data.dataSets.length, 3);
expect(radar.data.dataSets.first.dataEntries.map((e) => e.value), [3, 4, 5]); expect(radar.data.dataSets.first.dataEntries.map((e) => e.value), [
3,
4,
5,
]);
expect(radar.data.dataSets.last.fillColor, Colors.transparent); expect(radar.data.dataSets.last.fillColor, Colors.transparent);
// The spoke labels are supplied through getTitle (canvas-painted). // The spoke labels are supplied through getTitle (canvas-painted).
expect(radar.data.getTitle!(0, 0).text, 'Snelheid'); expect(radar.data.getTitle!(0, 0).text, 'Snelheid');
@ -302,6 +306,43 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('long radar labels stay outside the diagram and each other', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: [
'Strategische wendbaarheid',
'Operationele betrouwbaarheid',
'Klantgerichte innovatie',
'Duurzame inzetbaarheid',
'Digitale volwassenheid',
'Financiële weerbaarheid',
],
series: [
ChartSeries(name: 'Score', data: [3, 4, 5, 2, 4, 3]),
],
);
await tester.pumpWidget(_host(spec, presentationMode: true));
await tester.pump();
final radarRect = tester.getRect(find.byType(RadarChart));
final labelRects = [
for (var i = 0; i < spec.x.length; i++)
tester.getRect(find.byKey(ValueKey('radar-axis-label-$i'))),
];
for (final rect in labelRects) {
expect(rect.overlaps(radarRect), isFalse);
}
for (var i = 0; i < labelRects.length; i++) {
for (var j = i + 1; j < labelRects.length; j++) {
expect(labelRects[i].overlaps(labelRects[j]), isFalse);
}
}
expect(tester.takeException(), isNull);
});
testWidgets('radar honours an explicit min/max scale with even ticks', ( testWidgets('radar honours an explicit min/max scale with even ticks', (
tester, tester,
) async { ) async {

View file

@ -75,6 +75,91 @@ void main() {
); );
}); });
test('autoplay audio or video takes precedence over slide timing', () {
expect(
autoAdvanceWaitsForMedia(
Slide.create(SlideType.bullets).copyWith(
advanceDuration: 3,
audioPath: 'sound.mp3',
audioAutoplay: true,
),
),
isTrue,
);
expect(
autoAdvanceWaitsForMedia(
Slide.create(SlideType.video).copyWith(
advanceDuration: 3,
videoPath: 'movie.mp4',
videoAutoplay: true,
),
),
isTrue,
);
expect(
autoAdvanceWaitsForMedia(
Slide.create(SlideType.video).copyWith(
advanceDuration: 3,
videoPath: 'movie.mp4',
videoAutoplay: false,
),
),
isFalse,
);
});
testWidgets('slide timer does not interrupt autoplay media', (tester) async {
final mediaSlides = [
Slide.create(SlideType.video).copyWith(
title: 'Video blijft staan',
videoPath: '/tmp/does-not-exist.mp4',
videoAutoplay: true,
advanceDuration: 3,
),
Slide.create(SlideType.bullets).copyWith(title: 'Na video'),
];
await tester.pumpWidget(_host(mediaSlides));
await tester.pump(const Duration(seconds: 4));
expect(find.text('Video blijft staan'), findsOneWidget);
expect(find.text('Na video'), findsNothing);
await tester.pumpWidget(const SizedBox());
});
testWidgets('checklist changes during presenting are persisted', (
tester,
) async {
Slide? updated;
final checklistSlides = [
Slide.create(SlideType.bullets).copyWith(
title: 'Taken',
bullets: ['[ ] Live afvinken'],
listStyle: ListStyle.checklist,
),
];
await tester.pumpWidget(
MaterialApp(
home: FullscreenPresenter(
slides: checklistSlides,
projectPath: null,
themeProfile: const ThemeProfile(),
initialIndex: 0,
onSlideChanged: (slide) => updated = slide,
),
),
);
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('checklist-preview-toggle-0-0')),
);
await tester.pump();
expect(updated?.bullets, ['[x] Live afvinken']);
expect(find.text(''), findsOneWidget);
});
testWidgets('starts in audience view without presenter chrome', ( testWidgets('starts in audience view without presenter chrome', (
tester, tester,
) async { ) async {

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/image_service.dart';
import 'package:ocideck/widgets/editors/_editor_field.dart';
import 'package:ocideck/widgets/editors/image_slide_editor.dart';
class _FakeImageService extends ImageService {
final String? pastedPath;
_FakeImageService({this.pastedPath});
@override
Future<String?> pasteImage() async => pastedPath;
}
Widget _host(
Slide slide,
ImageService imageService,
ValueChanged<Slide> onUpdate,
) {
return ProviderScope(
child: MaterialApp(
home: Scaffold(
body: ImageSlideEditor(
slide: slide,
onUpdate: onUpdate,
imageService: imageService,
),
),
),
);
}
void main() {
testWidgets('pasted full-slide image starts fully visible', (tester) async {
var updated = Slide.create(SlideType.image);
await tester.pumpWidget(
_host(
updated,
_FakeImageService(pastedPath: '/tmp/pasted.png'),
(slide) => updated = slide,
),
);
await tester.tap(find.byIcon(Icons.content_paste));
await tester.pump();
expect(updated.imagePath, '/tmp/pasted.png');
expect(updated.imageSize, 100);
});
testWidgets('carousel selection resets an old crop to fully visible', (
tester,
) async {
var updated = Slide.create(
SlideType.image,
).copyWith(imagePath: 'old.png', imageSize: 160);
await tester.pumpWidget(
_host(updated, _FakeImageService(), (slide) => updated = slide),
);
final picker = tester.widget<ImagePickerBar>(find.byType(ImagePickerBar));
picker.onPicked('new.png', 'Bijschrift');
expect(updated.imagePath, 'new.png');
expect(updated.imageCaption, 'Bijschrift');
expect(updated.imageSize, 100);
});
}

View file

@ -90,6 +90,51 @@ void main() {
expect(out.bullets, ['Punt een', 'Punt twee']); expect(out.bullets, ['Punt een', 'Punt twee']);
}); });
test('numbered list style round-trips', () {
final service = MarkdownService();
final markdown = service.generateDeck(
Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.bullets).copyWith(
title: 'Stappen',
bullets: ['Eerst', '\tDetail', 'Daarna'],
listStyle: ListStyle.numbered,
),
],
),
);
final out = service.parseDeck(markdown)!.slides.single;
expect(out.listStyle, ListStyle.numbered);
expect(out.bullets, ['Eerst', '\tDetail', 'Daarna']);
expect(markdown, contains('1. Eerst'));
expect(markdown, contains('2. Daarna'));
});
test('checklist style and checked items round-trip', () {
final service = MarkdownService();
final markdown = service.generateDeck(
Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.bullets).copyWith(
title: 'Taken',
bullets: ['[x] Gedaan', '[ ] Nog doen', '\t[x] Subtaak'],
listStyle: ListStyle.checklist,
showChecklistProgress: true,
),
],
),
);
final out = service.parseDeck(markdown)!.slides.single;
expect(out.listStyle, ListStyle.checklist);
expect(out.showChecklistProgress, isTrue);
expect(out.bullets, ['[x] Gedaan', '[ ] Nog doen', '\t[x] Subtaak']);
expect(markdown, contains('- [x] Gedaan'));
expect(markdown, contains('- [ ] Nog doen'));
expect(markdown, contains('ocideck_checklist_progress: true'));
});
test('twoBullets slide keeps both bullet columns', () { test('twoBullets slide keeps both bullet columns', () {
final out = _roundTrip( final out = _roundTrip(
Slide.create(SlideType.twoBullets).copyWith( Slide.create(SlideType.twoBullets).copyWith(
@ -127,7 +172,9 @@ void main() {
Deck( Deck(
title: 'Demo', title: 'Demo',
slides: [ slides: [
Slide.create(SlideType.twoBullets).copyWith(bullets: ['A'], bullets2: ['B']), Slide.create(
SlideType.twoBullets,
).copyWith(bullets: ['A'], bullets2: ['B']),
], ],
), ),
); );
@ -137,6 +184,24 @@ void main() {
expect(out.columnTitle2, ''); expect(out.columnTitle2, '');
}); });
test('two-column checklist export respects disabled strike-through', () {
final service = MarkdownService();
final markdown = service.generateDeck(
Deck(
title: 'Demo',
themeProfile: const ThemeProfile(checklistStrikeThrough: false),
slides: [
Slide.create(SlideType.twoBullets).copyWith(
bullets: ['[x] Klaar'],
bullets2: ['[ ] Open'],
listStyle: ListStyle.checklist,
),
],
),
);
expect(markdown, isNot(contains('text-decoration:line-through')));
});
test('bulletsImage slide keeps bullets, image, size and caption', () { test('bulletsImage slide keeps bullets, image, size and caption', () {
final out = _roundTrip( final out = _roundTrip(
Slide.create(SlideType.bulletsImage).copyWith( Slide.create(SlideType.bulletsImage).copyWith(

View file

@ -29,6 +29,18 @@ void main() {
expect(back.codeFontFamily, 'Courier New'); expect(back.codeFontFamily, 'Courier New');
}); });
test('ThemeProfile round-trips checklist styling through JSON', () {
const profile = ThemeProfile(
checklistCheckedColor: '#00AA00',
checklistUncheckedColor: '#CC0000',
checklistStrikeThrough: false,
);
final back = ThemeProfile.fromJson(profile.toJson());
expect(back.checklistCheckedColor, '#00AA00');
expect(back.checklistUncheckedColor, '#CC0000');
expect(back.checklistStrikeThrough, isFalse);
});
test('ThemeProfile code styling defaults to the atom-one-dark look', () { test('ThemeProfile code styling defaults to the atom-one-dark look', () {
// Older decks without the fields fall back to the dark editor defaults. // Older decks without the fields fall back to the dark editor defaults.
final back = ThemeProfile.fromJson(const {'name': 'Legacy'}); final back = ThemeProfile.fromJson(const {'name': 'Legacy'});

View file

@ -59,9 +59,7 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('two-bullet columns scale independently (sparse stays large)', ( testWidgets('two-bullet columns use the same font size', (tester) async {
tester,
) async {
final slide = Slide.create(SlideType.twoBullets).copyWith( final slide = Slide.create(SlideType.twoBullets).copyWith(
bullets: const ['Solo'], bullets: const ['Solo'],
bullets2: List.generate(14, (i) => 'Item $i'), bullets2: List.generate(14, (i) => 'Item $i'),
@ -79,8 +77,7 @@ void main() {
.style .style
.fontSize!; .fontSize!;
// The single-bullet column is not shrunk to the 14-bullet column's size. expect(sizeOf('Solo'), sizeOf('Item 0'));
expect(sizeOf('Solo'), greaterThan(sizeOf('Item 0') * 1.3));
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });