diff --git a/CHANGELOG.md b/CHANGELOG.md
index 985a604..6a7dd11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,11 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
the EUPL-1.2 licence text.
### Changed
+- **Bullet slides** can now carry an optional **subheading** under the title; the
+ **two bullet columns** type can have an optional **heading above each column**,
+ separate from the slide title.
+- Slide text auto-sizing now measures with the deck's own font, so text grows to
+ use the available space more accurately instead of staying smaller than needed.
- Slide transitions in the presenter no longer flash a black frame (neighbour
images are precached and `gaplessPlayback` is enabled) — important for
recording.
diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md
index 9731e78..1f8eb01 100644
--- a/docs/FILE_FORMAT.md
+++ b/docs/FILE_FORMAT.md
@@ -216,10 +216,11 @@ afbeelding geschreven.
Optionele toelichtende paragraaf
```
-**Bullets** (geen class) — inspringen met tabs in het model → 2 spaties per
-niveau in Markdown:
+**Bullets** (geen class) — optioneel een **subkop** (`## …`, komt in `subtitle`),
+en inspringen met tabs in het model → 2 spaties per niveau in Markdown:
```markdown
# Kop
+## Subkop (optioneel)
- Eerste punt
- Subpunt
@@ -227,13 +228,17 @@ niveau in Markdown:
**Twee bulletkolommen** (`two-bullets`) — naast de zichtbare HTML-grid worden de
twee kolommen ook **canoniek opgeslagen** in commentaar (base64url van een JSON-
-array), zodat ze verliesvrij teruggelezen worden:
+array), zodat ze verliesvrij teruggelezen worden. Elke kolom kan optioneel een
+**kop** krijgen (`*_title`, base64url van platte tekst); die wordt alleen
+geschreven als hij gevuld is en verschijnt als `
` boven de kolom:
```markdown
+ (optioneel)
+ (optioneel)
```
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 7adae2d..80dc866 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -2384,6 +2384,9 @@ const _dutchSourceStringAdditions = {
'For example #33FF33 for a CRT-green screen.',
'Onderdeel van stijlprofiel ': 'Part of style profile ',
'Broncode lettertype': 'Code font',
+ 'Kop (optioneel)': 'Heading (optional)',
+ 'Subkop (optioneel)': 'Subheading (optional)',
+ 'Subkop': 'Subheading',
'Systeem (monospace)': 'System (monospace)',
'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)',
@@ -2423,6 +2426,9 @@ const _dutchSourceStringAdditions = {
'Ad esempio #33FF33 per uno schermo verde CRT.',
'Onderdeel van stijlprofiel ': 'Parte del profilo di stile ',
'Broncode lettertype': 'Font del codice',
+ 'Kop (optioneel)': 'Intestazione (facoltativa)',
+ 'Subkop (optioneel)': 'Sottotitolo (facoltativo)',
+ 'Subkop': 'Sottotitolo',
'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Colore della serie',
'Kleur van rij': 'Colore della riga',
@@ -2652,6 +2658,9 @@ const _dutchSourceStringAdditions = {
'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.',
'Onderdeel van stijlprofiel ': 'Teil des Stilprofils ',
'Broncode lettertype': 'Code-Schriftart',
+ 'Kop (optioneel)': 'Überschrift (optional)',
+ 'Subkop (optioneel)': 'Unterüberschrift (optional)',
+ 'Subkop': 'Unterüberschrift',
'Systeem (monospace)': 'System (monospace)',
'Kleur van reeks': 'Reihenfarbe',
'Kleur van rij': 'Zeilenfarbe',
@@ -2882,6 +2891,9 @@ const _dutchSourceStringAdditions = {
'Par exemple #33FF33 pour un écran vert CRT.',
'Onderdeel van stijlprofiel ': 'Fait partie du profil de style ',
'Broncode lettertype': 'Police du code',
+ 'Kop (optioneel)': 'En-tête (facultatif)',
+ 'Subkop (optioneel)': 'Sous-titre (facultatif)',
+ 'Subkop': 'Sous-titre',
'Systeem (monospace)': 'Système (monospace)',
'Kleur van reeks': 'Couleur de la série',
'Kleur van rij': 'Couleur de la ligne',
@@ -3112,6 +3124,9 @@ const _dutchSourceStringAdditions = {
'Por ejemplo #33FF33 para una pantalla verde CRT.',
'Onderdeel van stijlprofiel ': 'Parte del perfil de estilo ',
'Broncode lettertype': 'Fuente del código',
+ 'Kop (optioneel)': 'Encabezado (opcional)',
+ 'Subkop (optioneel)': 'Subtítulo (opcional)',
+ 'Subkop': 'Subtítulo',
'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Color de la serie',
'Kleur van rij': 'Color de la fila',
@@ -3342,6 +3357,9 @@ const _dutchSourceStringAdditions = {
'Bygelyks #33FF33 foar in CRT-grien skerm.',
'Onderdeel van stijlprofiel ': 'Underdiel fan stylprofyl ',
'Broncode lettertype': 'Boarnekoade lettertype',
+ 'Kop (optioneel)': 'Kop (opsjoneel)',
+ 'Subkop (optioneel)': 'Subkop (opsjoneel)',
+ 'Subkop': 'Subkop',
'Systeem (monospace)': 'Systeem (monospace)',
'Kleur van reeks': 'Rigekleur',
'Kleur van rij': 'Rijekleur',
@@ -3569,6 +3587,9 @@ const _dutchSourceStringAdditions = {
'Por ehèmpel #33FF33 pa un pantaya berde CRT.',
'Onderdeel van stijlprofiel ': 'Parti di e perfil di estilo ',
'Broncode lettertype': 'Tipo di lèter di kódigo',
+ 'Kop (optioneel)': 'Enkabesado (opshonal)',
+ 'Subkop (optioneel)': 'Subtítulo (opshonal)',
+ 'Subkop': 'Subtítulo',
'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Koló di serie',
'Kleur van rij': 'Koló di liña',
diff --git a/lib/models/slide.dart b/lib/models/slide.dart
index 2a84970..748a710 100644
--- a/lib/models/slide.dart
+++ b/lib/models/slide.dart
@@ -90,6 +90,11 @@ class Slide {
final String subtitle;
final List bullets;
final List bullets2;
+
+ /// Optional headings above the two bullet columns (twoBullets only). Empty =
+ /// no heading for that column.
+ final String columnTitle1;
+ final String columnTitle2;
final String imagePath;
final String imagePath2;
final String imageCaption;
@@ -123,6 +128,8 @@ class Slide {
this.subtitle = '',
this.bullets = const [],
this.bullets2 = const [],
+ this.columnTitle1 = '',
+ this.columnTitle2 = '',
this.imagePath = '',
this.imagePath2 = '',
this.imageCaption = '',
@@ -176,6 +183,8 @@ class Slide {
subtitle: src.subtitle,
bullets: List.from(src.bullets),
bullets2: List.from(src.bullets2),
+ columnTitle1: src.columnTitle1,
+ columnTitle2: src.columnTitle2,
imagePath: src.imagePath,
imagePath2: src.imagePath2,
imageCaption: src.imageCaption,
@@ -206,6 +215,8 @@ class Slide {
String? subtitle,
List? bullets,
List? bullets2,
+ String? columnTitle1,
+ String? columnTitle2,
String? imagePath,
String? imagePath2,
String? imageCaption,
@@ -235,6 +246,8 @@ class Slide {
subtitle: subtitle ?? this.subtitle,
bullets: bullets ?? this.bullets,
bullets2: bullets2 ?? this.bullets2,
+ columnTitle1: columnTitle1 ?? this.columnTitle1,
+ columnTitle2: columnTitle2 ?? this.columnTitle2,
imagePath: imagePath ?? this.imagePath,
imagePath2: imagePath2 ?? this.imagePath2,
imageCaption: imageCaption ?? this.imageCaption,
diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart
index e384949..2c2b206 100644
--- a/lib/services/markdown_service.dart
+++ b/lib/services/markdown_service.dart
@@ -18,6 +18,9 @@ class MarkdownService {
buf.writeln('theme: ${deck.theme}');
if (deck.paginate) buf.writeln('paginate: true');
// General presentation metadata (also picked up by Marp where applicable).
+ if (deck.title.isNotEmpty) {
+ buf.writeln('title: ${_yamlScalar(deck.title)}');
+ }
if (deck.author.isNotEmpty) {
buf.writeln('author: ${_yamlScalar(deck.author)}');
}
@@ -212,6 +215,7 @@ class MarkdownService {
case SlideType.bullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
+ if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}');
buf.writeln();
for (final b in slide.bullets) {
_writeBullet(buf, b);
@@ -220,7 +224,13 @@ class MarkdownService {
case SlideType.twoBullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
buf.writeln();
- _writeTwoBulletColumns(buf, slide.bullets, slide.bullets2);
+ _writeTwoBulletColumns(
+ buf,
+ slide.bullets,
+ slide.bullets2,
+ slide.columnTitle1,
+ slide.columnTitle2,
+ );
case SlideType.bulletsImage:
if (slide.imagePath.isNotEmpty) {
@@ -406,17 +416,42 @@ class MarkdownService {
StringBuffer buf,
List left,
List right,
+ String leftTitle,
+ String rightTitle,
) {
buf.writeln('');
buf.writeln('');
+ if (leftTitle.isNotEmpty) {
+ buf.writeln(
+ '',
+ );
+ }
+ if (rightTitle.isNotEmpty) {
+ buf.writeln(
+ '',
+ );
+ }
buf.writeln(
'',
);
+ _writeBulletColumn(buf, left, leftTitle);
+ _writeBulletColumn(buf, right, rightTitle);
+ buf.writeln('
');
+ }
+
+ static void _writeBulletColumn(
+ StringBuffer buf,
+ List bullets,
+ String columnTitle,
+ ) {
+ buf.writeln('');
+ if (columnTitle.isNotEmpty) {
+ buf.writeln(
+ '
${_escapeHtml(columnTitle)}
',
+ );
+ }
buf.writeln('
');
- _writeHtmlBulletItems(buf, left);
- buf.writeln('
');
- buf.writeln('
');
- _writeHtmlBulletItems(buf, right);
+ _writeHtmlBulletItems(buf, bullets);
buf.writeln('
');
buf.writeln('
');
}
@@ -425,6 +460,17 @@ class MarkdownService {
return base64Url.encode(utf8.encode(jsonEncode(bullets)));
}
+ static String _encodeText(String value) =>
+ base64Url.encode(utf8.encode(value));
+
+ static String _decodeText(String encoded) {
+ try {
+ return utf8.decode(base64Url.decode(encoded.trim()));
+ } catch (_) {
+ return '';
+ }
+ }
+
static List _decodeBullets(String encoded) {
try {
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
@@ -523,6 +569,7 @@ class MarkdownService {
String theme = 'ocideck';
bool paginate = true;
ThemeProfile themeProfile = const ThemeProfile();
+ String? presentationTitle;
String author = '';
String organization = '';
String version = '';
@@ -541,6 +588,8 @@ class MarkdownService {
theme = line.substring(6).trim();
} else if (line.startsWith('paginate:')) {
paginate = line.substring(9).trim() == 'true';
+ } else if (line.startsWith('title:')) {
+ presentationTitle = _parseScalar(line.substring(6));
} else if (line.startsWith('author:')) {
author = _parseScalar(line.substring(7));
} else if (line.startsWith('organization:')) {
@@ -574,10 +623,11 @@ class MarkdownService {
if (slide != null) slides.add(slide);
}
- String title = 'Presentatie';
- if (slides.isNotEmpty && slides.first.title.isNotEmpty) {
- title = slides.first.title;
- }
+ final title =
+ presentationTitle ??
+ (slides.isNotEmpty && slides.first.title.isNotEmpty
+ ? slides.first.title
+ : 'Presentatie');
String? projectPath;
if (filePath != null) {
@@ -626,6 +676,8 @@ class MarkdownService {
TlpLevel slideTlp = TlpLevel.none;
final bullets = [];
var bullets2 = [];
+ var columnTitle1 = '';
+ var columnTitle2 = '';
// bulletsImage slides store their panel width in ``; capture it before the comment is stripped.
int styleImageWidth = 0;
@@ -646,6 +698,10 @@ class MarkdownService {
bullets
..clear()
..addAll(_decodeBullets(content.substring(25)));
+ } else if (content.startsWith('ocideck_two_bullets_left_title:')) {
+ columnTitle1 = _decodeText(content.substring(31));
+ } else if (content.startsWith('ocideck_two_bullets_right_title:')) {
+ columnTitle2 = _decodeText(content.substring(32));
} else if (content.startsWith('ocideck_two_bullets_right:')) {
bullets2 = _decodeBullets(content.substring(26));
} else if (!content.startsWith('_')) {
@@ -844,6 +900,8 @@ class MarkdownService {
subtitle: type == SlideType.section ? paragraph : h2,
bullets: bullets,
bullets2: bullets2,
+ columnTitle1: columnTitle1,
+ columnTitle2: columnTitle2,
imagePath: imagePath,
imagePath2: imagePath2,
imageCaption: imageCaption,
diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart
index c9611b3..48ffbc2 100644
--- a/lib/widgets/app_shell.dart
+++ b/lib/widgets/app_shell.dart
@@ -550,7 +550,8 @@ class _AppTabBar extends StatelessWidget {
_TabChip(
tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex,
- showClose: tabsState.tabs.length > 1,
+ showClose:
+ tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i),
diff --git a/lib/widgets/dialogs/presentation_info_dialog.dart b/lib/widgets/dialogs/presentation_info_dialog.dart
index 2535395..431e136 100644
--- a/lib/widgets/dialogs/presentation_info_dialog.dart
+++ b/lib/widgets/dialogs/presentation_info_dialog.dart
@@ -91,6 +91,13 @@ class _PresentationInfoDialogState extends State {
);
}
+ void _setCurrentDate() {
+ final now = DateTime.now();
+ String twoDigits(int value) => value.toString().padLeft(2, '0');
+ _date.text = '${now.year}-${twoDigits(now.month)}-${twoDigits(now.day)}';
+ _date.selection = TextSelection.collapsed(offset: _date.text.length);
+ }
+
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -143,7 +150,12 @@ class _PresentationInfoDialogState extends State {
const SizedBox(width: 12),
SizedBox(
width: 120,
- child: _field(_date, 'Datum', 'Bijv. 2026-05-30'),
+ child: _field(
+ _date,
+ 'Datum',
+ 'Bijv. 2026-05-30',
+ onDoubleTap: _setCurrentDate,
+ ),
),
],
),
@@ -190,9 +202,10 @@ class _PresentationInfoDialogState extends State {
String label,
String hint, {
int maxLines = 1,
+ VoidCallback? onDoubleTap,
}) {
final l10n = context.l10n;
- return TextField(
+ final field = TextField(
controller: controller,
maxLines: maxLines,
decoration: InputDecoration(
@@ -202,5 +215,11 @@ class _PresentationInfoDialogState extends State {
border: const OutlineInputBorder(),
),
);
+ if (onDoubleTap == null) return field;
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onDoubleTap: onDoubleTap,
+ child: field,
+ );
}
}
diff --git a/lib/widgets/editors/bullets_editor.dart b/lib/widgets/editors/bullets_editor.dart
index 777a469..d40bbb5 100644
--- a/lib/widgets/editors/bullets_editor.dart
+++ b/lib/widgets/editors/bullets_editor.dart
@@ -16,6 +16,7 @@ class BulletsEditor extends StatefulWidget {
class _BulletsEditorState extends State {
late final TextEditingController _title;
+ late final TextEditingController _subtitle;
late List _bullets;
late List _levels;
late List _focusNodes;
@@ -27,6 +28,8 @@ class _BulletsEditorState extends State {
super.initState();
_title = TextEditingController(text: widget.slide.title);
_title.addListener(_emit);
+ _subtitle = TextEditingController(text: widget.slide.subtitle);
+ _subtitle.addListener(_emit);
_initBullets(widget.slide.bullets);
}
@@ -55,6 +58,7 @@ class _BulletsEditorState extends State {
widget.onUpdate(
widget.slide.copyWith(
title: _title.text,
+ subtitle: _subtitle.text,
bullets: List.generate(
_bullets.length,
(i) => '\t' * _levels[i] + _bullets[i].text,
@@ -151,6 +155,7 @@ class _BulletsEditorState extends State {
@override
void dispose() {
_title.dispose();
+ _subtitle.dispose();
for (final c in _bullets) {
c.dispose();
}
@@ -167,6 +172,12 @@ class _BulletsEditorState extends State {
padding: const EdgeInsets.all(16),
children: [
EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'),
+ const SizedBox(height: 12),
+ EditorField(
+ label: l10n.d('Subkop (optioneel)'),
+ controller: _subtitle,
+ hint: l10n.d('Subkop'),
+ ),
const SizedBox(height: 16),
const SectionLabel('Bullets'),
ReorderableListView(
diff --git a/lib/widgets/editors/two_bullets_editor.dart b/lib/widgets/editors/two_bullets_editor.dart
index af4fdf3..7833553 100644
--- a/lib/widgets/editors/two_bullets_editor.dart
+++ b/lib/widgets/editors/two_bullets_editor.dart
@@ -22,6 +22,8 @@ class TwoBulletsEditor extends StatefulWidget {
class _TwoBulletsEditorState extends State {
late final TextEditingController _title;
+ late final TextEditingController _heading1;
+ late final TextEditingController _heading2;
late _BulletSet _left;
late _BulletSet _right;
@@ -30,6 +32,10 @@ class _TwoBulletsEditorState extends State {
super.initState();
_title = TextEditingController(text: widget.slide.title);
_title.addListener(_emit);
+ _heading1 = TextEditingController(text: widget.slide.columnTitle1);
+ _heading2 = TextEditingController(text: widget.slide.columnTitle2);
+ _heading1.addListener(_emit);
+ _heading2.addListener(_emit);
_left = _BulletSet(widget.slide.bullets, _emit);
_right = _BulletSet(widget.slide.bullets2, _emit);
}
@@ -38,6 +44,8 @@ class _TwoBulletsEditorState extends State {
widget.onUpdate(
widget.slide.copyWith(
title: _title.text,
+ columnTitle1: _heading1.text,
+ columnTitle2: _heading2.text,
bullets: _left.values,
bullets2: _right.values,
),
@@ -47,6 +55,8 @@ class _TwoBulletsEditorState extends State {
@override
void dispose() {
_title.dispose();
+ _heading1.dispose();
+ _heading2.dispose();
_left.dispose();
_right.dispose();
super.dispose();
@@ -63,8 +73,18 @@ class _TwoBulletsEditorState extends State {
builder: (context, constraints) {
final narrow = constraints.maxWidth < 560;
final columns = [
- _BulletColumn(label: 'Bullets links', set: _left, emit: _emit),
- _BulletColumn(label: 'Bullets rechts', set: _right, emit: _emit),
+ _BulletColumn(
+ label: 'Bullets links',
+ set: _left,
+ emit: _emit,
+ headingController: _heading1,
+ ),
+ _BulletColumn(
+ label: 'Bullets rechts',
+ set: _right,
+ emit: _emit,
+ headingController: _heading2,
+ ),
];
if (narrow) {
return Column(
@@ -202,11 +222,13 @@ class _BulletColumn extends StatefulWidget {
final String label;
final _BulletSet set;
final VoidCallback emit;
+ final TextEditingController headingController;
const _BulletColumn({
required this.label,
required this.set,
required this.emit,
+ required this.headingController,
});
@override
@@ -222,6 +244,14 @@ class _BulletColumnState extends State<_BulletColumn> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+ TextField(
+ controller: widget.headingController,
+ decoration: InputDecoration(
+ labelText: l10n.d('Kop (optioneel)'),
+ isDense: true,
+ ),
+ ),
+ const SizedBox(height: 12),
SectionLabel(widget.label),
const SizedBox(height: 6),
for (int i = 0; i < set.controllers.length; i++) _buildRow(i),
diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart
index c770337..343356d 100644
--- a/lib/widgets/slides/slide_preview.dart
+++ b/lib/widgets/slides/slide_preview.dart
@@ -646,6 +646,7 @@ class _BulletsPreview extends StatelessWidget {
final pad = w * 0.07;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final titleSize = w * 0.042;
+ final subtitleSize = w * 0.030;
final bulletSize = w * 0.026;
final spacing = pad * 0.5;
final bulletGap = w * 0.006;
@@ -653,6 +654,8 @@ class _BulletsPreview extends StatelessWidget {
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
+ final subtitle = slide.subtitle;
+ final hasSubtitle = subtitle.isNotEmpty;
final slideHeight = w * 9 / 16;
final availW = (w - pad * 2).clamp(w * 0.12, w);
@@ -669,6 +672,9 @@ class _BulletsPreview extends StatelessWidget {
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
+ font: font,
+ subtitle: subtitle,
+ subtitleSize: subtitleSize,
maxScale: _kSplitBulletsMaxScale,
);
@@ -705,7 +711,23 @@ class _BulletsPreview extends StatelessWidget {
),
linkColor: _hexColor(profile.accentColor),
),
- if (hasTitle && bullets.isNotEmpty)
+ if (hasSubtitle) ...[
+ SizedBox(height: spacing * scale * 0.4),
+ _md(
+ context,
+ subtitle,
+ _applyFont(
+ font,
+ TextStyle(
+ fontSize: subtitleSize * scale,
+ fontWeight: FontWeight.w600,
+ color: _hexColor(profile.accentColor),
+ ),
+ ),
+ linkColor: _hexColor(profile.accentColor),
+ ),
+ ],
+ if ((hasTitle || hasSubtitle) && bullets.isNotEmpty)
SizedBox(height: spacing * scale),
...bullets.map((b) {
int level = 0;
@@ -900,6 +922,62 @@ class _TwoBulletsPreview extends StatelessWidget {
required this.profile,
});
+ /// One bullet column with an optional heading above it. When any column has a
+ /// heading, an equal-height slot is reserved in both so the bullet lists line
+ /// up.
+ Widget _bulletColumn(
+ BuildContext context, {
+ required String title,
+ required List bullets,
+ required double columnW,
+ required double headingSize,
+ required double headingSlotH,
+ required double headingGap,
+ required double bulletSize,
+ required double bulletGap,
+ required double scale,
+ }) {
+ return SizedBox(
+ width: columnW,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (headingSlotH > 0) ...[
+ SizedBox(
+ width: double.infinity,
+ height: headingSlotH,
+ child: title.isEmpty
+ ? null
+ : _md(
+ context,
+ title,
+ _applyFont(
+ font,
+ TextStyle(
+ fontSize: headingSize,
+ fontWeight: FontWeight.bold,
+ color: _hexColor(profile.accentColor),
+ ),
+ ),
+ linkColor: _hexColor(profile.accentColor),
+ ),
+ ),
+ SizedBox(height: headingGap),
+ ],
+ _BulletListColumn(
+ bullets: bullets,
+ font: font,
+ profile: profile,
+ bulletSize: bulletSize,
+ bulletGap: bulletGap,
+ scale: scale,
+ ),
+ ],
+ ),
+ );
+ }
+
@override
Widget build(BuildContext context) {
final pad = w * 0.065;
@@ -917,6 +995,12 @@ class _TwoBulletsPreview extends StatelessWidget {
.toList();
final hasTitle = slide.title.isNotEmpty;
+ final col1Title = slide.columnTitle1.trim();
+ final col2Title = slide.columnTitle2.trim();
+ final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty;
+ final headingSize = w * 0.03;
+ final headingGap = w * 0.012;
+
final slideHeight = w * 9 / 16;
final contentW = (w - pad * 2).clamp(w * 0.12, w);
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
@@ -927,9 +1011,19 @@ class _TwoBulletsPreview extends StatelessWidget {
titleSize,
contentW,
bold: true,
+ fontFamily: font,
);
availH -= spacing;
}
+ // Reserve room for the (optional) column headings so the bullets still fit.
+ double headingHeight(String t) => t.isEmpty
+ ? 0
+ : _measureTextHeight(t, headingSize, columnW, bold: true, fontFamily: font);
+ final maxHeadingH = math.max(
+ headingHeight(col1Title),
+ headingHeight(col2Title),
+ );
+ if (hasColumnTitles) availH -= maxHeadingH + headingGap;
final leftScale = _bulletsFitScale(
availW: columnW,
availH: availH,
@@ -940,6 +1034,7 @@ class _TwoBulletsPreview extends StatelessWidget {
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
+ font: font,
maxScale: _kBulletsMaxScale,
);
final rightScale = _bulletsFitScale(
@@ -952,6 +1047,7 @@ class _TwoBulletsPreview extends StatelessWidget {
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
+ font: font,
maxScale: _kBulletsMaxScale,
);
final scale = leftScale < rightScale ? leftScale : rightScale;
@@ -993,28 +1089,30 @@ class _TwoBulletsPreview extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- SizedBox(
- width: columnW,
- child: _BulletListColumn(
- bullets: leftBullets,
- font: font,
- profile: profile,
- bulletSize: bulletSize,
- bulletGap: bulletGap,
- scale: scale,
- ),
+ _bulletColumn(
+ context,
+ title: col1Title,
+ bullets: leftBullets,
+ columnW: columnW,
+ headingSize: headingSize,
+ headingSlotH: hasColumnTitles ? maxHeadingH : 0,
+ headingGap: headingGap,
+ bulletSize: bulletSize,
+ bulletGap: bulletGap,
+ scale: scale,
),
SizedBox(width: columnGap),
- SizedBox(
- width: columnW,
- child: _BulletListColumn(
- bullets: rightBullets,
- font: font,
- profile: profile,
- bulletSize: bulletSize,
- bulletGap: bulletGap,
- scale: scale,
- ),
+ _bulletColumn(
+ context,
+ title: col2Title,
+ bullets: rightBullets,
+ columnW: columnW,
+ headingSize: headingSize,
+ headingSlotH: hasColumnTitles ? maxHeadingH : 0,
+ headingGap: headingGap,
+ bulletSize: bulletSize,
+ bulletGap: bulletGap,
+ scale: scale,
),
],
),
@@ -1087,6 +1185,7 @@ class _BulletsImagePreview extends StatelessWidget {
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
+ font: font,
maxScale: _kBulletsMaxScale,
);
@@ -1329,6 +1428,9 @@ double _bulletsFitScale({
required double bulletSize,
required double spacing,
required double bulletGap,
+ required String font,
+ String subtitle = '',
+ double subtitleSize = 0,
double minScale = 0.2,
double maxScale = 1.0,
}) {
@@ -1345,6 +1447,9 @@ double _bulletsFitScale({
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
+ font: font,
+ subtitle: subtitle,
+ subtitleSize: subtitleSize,
);
// Everything already fits at the largest allowed size → use it.
@@ -1381,12 +1486,33 @@ double _bulletsBlockHeight({
required double bulletSize,
required double spacing,
required double bulletGap,
+ required String font,
+ String subtitle = '',
+ double subtitleSize = 0,
}) {
var height = 0.0;
if (hasTitle) {
- height += _measureTextHeight(title, titleSize * scale, availW, bold: true);
+ height += _measureTextHeight(
+ title,
+ titleSize * scale,
+ availW,
+ bold: true,
+ fontFamily: font,
+ );
+ }
+ if (subtitle.isNotEmpty) {
+ height += spacing * scale * 0.4;
+ height += _measureTextHeight(
+ subtitle,
+ subtitleSize * scale,
+ availW,
+ bold: true,
+ fontFamily: font,
+ );
+ }
+ if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
+ height += spacing * scale;
}
- if (hasTitle && bullets.isNotEmpty) height += spacing * scale;
for (final b in bullets) {
int level = 0;
while (level < b.length && b[level] == '\t') {
@@ -1396,15 +1522,21 @@ double _bulletsBlockHeight({
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${_bulletMarkerForLevel(level)} ';
- final markerW = _measureTextWidth(marker, fontSize, bold: true);
+ final markerW = _measureTextWidth(marker, fontSize, bold: true, fontFamily: font);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = _measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: _kBulletLineHeight,
+ fontFamily: font,
+ );
+ final markerH = _measureTextHeight(
+ marker,
+ fontSize,
+ double.infinity,
+ fontFamily: font,
);
- final markerH = _measureTextHeight(marker, fontSize, double.infinity);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
@@ -1416,11 +1548,13 @@ double _measureTextHeight(
double maxWidth, {
double? lineHeight,
bool bold = false,
+ String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
+ fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
@@ -1431,11 +1565,17 @@ double _measureTextHeight(
return painter.height;
}
-double _measureTextWidth(String text, double fontSize, {bool bold = false}) {
+double _measureTextWidth(
+ String text,
+ double fontSize, {
+ bool bold = false,
+ String? fontFamily,
+}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
+ fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
diff --git a/test/markdown_round_trip_test.dart b/test/markdown_round_trip_test.dart
index add1356..3a32abf 100644
--- a/test/markdown_round_trip_test.dart
+++ b/test/markdown_round_trip_test.dart
@@ -76,6 +76,20 @@ void main() {
]);
});
+ test('bullets slide keeps an optional subheading', () {
+ final out = _roundTrip(
+ Slide.create(SlideType.bullets).copyWith(
+ title: 'Agenda',
+ subtitle: 'Vandaag',
+ bullets: ['Punt een', 'Punt twee'],
+ ),
+ );
+ expect(out.type, SlideType.bullets);
+ expect(out.title, 'Agenda');
+ expect(out.subtitle, 'Vandaag');
+ expect(out.bullets, ['Punt een', 'Punt twee']);
+ });
+
test('twoBullets slide keeps both bullet columns', () {
final out = _roundTrip(
Slide.create(SlideType.twoBullets).copyWith(
@@ -90,6 +104,39 @@ void main() {
expect(out.bullets2, ['Rechts punt', '\t\tRechts diep']);
});
+ test('twoBullets slide keeps optional column headings', () {
+ final out = _roundTrip(
+ Slide.create(SlideType.twoBullets).copyWith(
+ title: 'Vergelijking',
+ columnTitle1: 'Voordelen',
+ columnTitle2: 'Nadelen',
+ bullets: ['Snel'],
+ bullets2: ['Duur'],
+ ),
+ );
+ expect(out.type, SlideType.twoBullets);
+ expect(out.columnTitle1, 'Voordelen');
+ expect(out.columnTitle2, 'Nadelen');
+ expect(out.bullets, ['Snel']);
+ expect(out.bullets2, ['Duur']);
+ });
+
+ test('twoBullets without headings stays empty (no spurious comments)', () {
+ final service = MarkdownService();
+ final md = service.generateDeck(
+ Deck(
+ title: 'Demo',
+ slides: [
+ Slide.create(SlideType.twoBullets).copyWith(bullets: ['A'], bullets2: ['B']),
+ ],
+ ),
+ );
+ expect(md, isNot(contains('ocideck_two_bullets_left_title')));
+ final out = service.parseDeck(md)!.slides.single;
+ expect(out.columnTitle1, '');
+ expect(out.columnTitle2, '');
+ });
+
test('bulletsImage slide keeps bullets, image, size and caption', () {
final out = _roundTrip(
Slide.create(SlideType.bulletsImage).copyWith(
@@ -386,7 +433,8 @@ void main() {
);
final deck = service.parseDeck(markdown);
expect(deck, isNotNull);
- expect(deck!.author, 'Jan Jansen');
+ expect(deck!.title, 'Demo');
+ expect(deck.author, 'Jan Jansen');
expect(deck.organization, 'Vigilis');
expect(deck.version, '1.2');
expect(deck.date, '2026-05-30');
diff --git a/test/presentation_info_dialog_test.dart b/test/presentation_info_dialog_test.dart
new file mode 100644
index 0000000..0b78317
--- /dev/null
+++ b/test/presentation_info_dialog_test.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ocideck/models/deck.dart';
+import 'package:ocideck/widgets/dialogs/presentation_info_dialog.dart';
+
+void main() {
+ testWidgets('double-clicking date fills in the current date', (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Scaffold(
+ body: PresentationInfoDialog(deck: Deck(title: 'Test')),
+ ),
+ ),
+ );
+
+ final dateField = find.byWidgetPredicate(
+ (widget) =>
+ widget is TextField && widget.decoration?.labelText == 'Datum',
+ );
+ final now = DateTime.now();
+ String twoDigits(int value) => value.toString().padLeft(2, '0');
+ final expected =
+ '${now.year}-${twoDigits(now.month)}-${twoDigits(now.day)}';
+
+ await tester.tap(dateField);
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.tap(dateField);
+ await tester.pump(const Duration(milliseconds: 100));
+
+ final field = tester.widget(dateField);
+ expect(field.controller!.text, expected);
+ });
+}
diff --git a/test/two_bullets_preview_test.dart b/test/two_bullets_preview_test.dart
new file mode 100644
index 0000000..ea45c21
--- /dev/null
+++ b/test/two_bullets_preview_test.dart
@@ -0,0 +1,82 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ocideck/models/slide.dart';
+import 'package:ocideck/widgets/slides/slide_preview.dart';
+
+Widget _host(Slide slide) {
+ return MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: SizedBox(
+ width: 800,
+ height: 450,
+ child: SlidePreviewWidget(slide: slide),
+ ),
+ ),
+ ),
+ );
+}
+
+void main() {
+ testWidgets('two-bullet columns render their optional headings', (
+ tester,
+ ) async {
+ final slide = Slide.create(SlideType.twoBullets).copyWith(
+ title: 'Vergelijking',
+ columnTitle1: 'Voordelen',
+ columnTitle2: 'Nadelen',
+ bullets: const ['Snel', 'Goedkoop'],
+ bullets2: const ['Complex'],
+ );
+
+ await tester.pumpWidget(_host(slide));
+ await tester.pump();
+
+ expect(find.text('Voordelen'), findsOneWidget);
+ expect(find.text('Nadelen'), findsOneWidget);
+ // Heading sits above the bullets of its column.
+ final headingTop = tester.getTopLeft(find.text('Voordelen')).dy;
+ final bulletTop = tester.getTopLeft(find.text('Snel')).dy;
+ expect(headingTop, lessThan(bulletTop));
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('two-bullet columns without headings render no heading text', (
+ tester,
+ ) async {
+ final slide = Slide.create(SlideType.twoBullets).copyWith(
+ title: 'Geen koppen',
+ bullets: const ['Links'],
+ bullets2: const ['Rechts'],
+ );
+
+ await tester.pumpWidget(_host(slide));
+ await tester.pump();
+
+ expect(find.text('Links'), findsOneWidget);
+ expect(find.text('Rechts'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('bullets slide renders an optional subheading below the title', (
+ tester,
+ ) async {
+ final slide = Slide.create(SlideType.bullets).copyWith(
+ title: 'Agenda',
+ subtitle: 'Vandaag',
+ bullets: const ['Punt een', 'Punt twee'],
+ );
+
+ await tester.pumpWidget(_host(slide));
+ await tester.pump();
+
+ expect(find.text('Agenda'), findsOneWidget);
+ expect(find.text('Vandaag'), findsOneWidget);
+ final titleTop = tester.getTopLeft(find.text('Agenda')).dy;
+ final subTop = tester.getTopLeft(find.text('Vandaag')).dy;
+ final bulletTop = tester.getTopLeft(find.text('Punt een')).dy;
+ expect(titleTop, lessThan(subTop));
+ expect(subTop, lessThan(bulletTop));
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/test/widget_test.dart b/test/widget_test.dart
index 52890ac..de17730 100644
--- a/test/widget_test.dart
+++ b/test/widget_test.dart
@@ -2,6 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/app.dart';
+import 'package:ocideck/models/deck.dart';
+import 'package:ocideck/models/slide.dart';
+import 'package:ocideck/state/tabs_provider.dart';
+import 'package:ocideck/widgets/app_shell.dart';
void main() {
testWidgets('Welcome screen shows startup logo', (WidgetTester tester) async {
@@ -16,4 +20,27 @@ void main() {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
});
+
+ testWidgets('the only open presentation can be closed', (tester) async {
+ await tester.binding.setSurfaceSize(const Size(1600, 1000));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+ await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
+ final container = ProviderScope.containerOf(
+ tester.element(find.byType(AppShell)),
+ );
+ final tab = container.read(tabsProvider).current!;
+ tab.deckNotifier.loadDeck(
+ Deck(
+ title: 'Test',
+ slides: [Slide.create(SlideType.title).copyWith(title: 'Test')],
+ ),
+ );
+ await tester.pump();
+
+ expect(find.byIcon(Icons.close), findsOneWidget);
+ await tester.tap(find.byIcon(Icons.close));
+ await tester.pump();
+
+ expect(container.read(tabsProvider).current!.isOpen, isFalse);
+ });
}