diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 6ea141f..36723fb 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -30,173 +30,13 @@ import 'presentation/fullscreen_presenter.dart'; // ── Shared helpers ────────────────────────────────────────────────────────── -/// Open the search-based presentation picker and load the chosen file -/// (optionally jumping to a matched slide). -Future _openWithSearch( - BuildContext context, - WidgetRef ref, - String? initialDirectory, -) async { - final settings = ref.read(settingsProvider); - final result = await OpenPresentationDialog.show( - context, - fileService: ref.read(fileServiceProvider), - initialDirectory: initialDirectory ?? settings.homeDirectory, - ); - if (result == null) return; - await ref - .read(tabsProvider.notifier) - .openFileByPath(result.path, selectIndex: result.slideIndex); -} - -/// Vraag een URL op om een presentatie (pakket of markdown) op te halen. -Future _showUrlDialog(BuildContext context) { - final l10n = context.l10n; - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l10n.d('Importeren via URL')), - content: SizedBox( - width: 460, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.d( - 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', - ), - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), - ), - const SizedBox(height: 12), - TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.url, - decoration: const InputDecoration( - hintText: 'https://…', - prefixIcon: Icon(Icons.link, size: 18), - isDense: true, - border: OutlineInputBorder(), - ), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(l10n.t('cancel')), - ), - ElevatedButton.icon( - onPressed: () => Navigator.pop(ctx, controller.text), - icon: const Icon(Icons.download, size: 16), - label: Text(l10n.d('Ophalen')), - ), - ], - ), - ); -} - -List _imageSearchPaths(String? projectPath, String? homeDirectory) { - final projectImagesPath = projectPath == null - ? null - : p.join(projectPath, 'images'); - return [?projectImagesPath, ?projectPath, ?homeDirectory]; -} - -String? _resolveImagePath(String path, String? projectPath) { - if (path.isEmpty) return null; - if (p.isAbsolute(path) || projectPath == null) return path; - return p.join(projectPath, path); -} - -List _imageUsages(WidgetRef ref, String absolutePath) { - final target = p.normalize(absolutePath); - final usages = []; - for (final tab in ref.read(tabsProvider).tabs) { - final deck = tab.deckNotifier.currentState.deck; - if (deck == null) continue; - for (var i = 0; i < deck.slides.length; i++) { - final slide = deck.slides[i]; - for (final candidate in [slide.imagePath, slide.imagePath2]) { - if (candidate.isEmpty) continue; - final resolved = p.normalize( - p.isAbsolute(candidate) - ? candidate - : p.join(deck.projectPath ?? '', candidate), - ); - if (resolved == target) { - usages.add('${tab.label} · slide ${i + 1}'); - break; - } - } - } - } - return usages; -} - -/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar -/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5- -/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen. -Future _replaceImageUsages( - WidgetRef ref, - String fromAbsolute, - String toAbsolute, -) async { - final target = p.normalize(fromAbsolute); - for (final tab in ref.read(tabsProvider).tabs) { - final notifier = tab.deckNotifier; - final deck = notifier.currentState.deck; - if (deck == null) continue; - final projectPath = deck.projectPath ?? ''; - - String resolve(String candidate) => p.normalize( - p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate), - ); - // Blijf relatief opslaan als de slide dat al deed en het nieuwe pad - // binnen het project ligt; anders absoluut. - String replacement(String candidate) { - if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute; - return p.isWithin(projectPath, toAbsolute) - ? p.relative(toAbsolute, from: projectPath) - : toAbsolute; - } - - for (var i = 0; i < deck.slides.length; i++) { - final slide = deck.slides[i]; - var updated = slide; - if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) { - updated = updated.copyWith(imagePath: replacement(slide.imagePath)); - } - if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) { - updated = updated.copyWith(imagePath2: replacement(slide.imagePath2)); - } - if (!identical(updated, slide)) notifier.updateSlide(i, updated); - } - } -} - -List _slidesForPresentationOrExport(Deck deck) { - // Drop skipped slides and slides whose TLP classification is stricter than - // the level chosen for this presentation/export. - final slides = deck.slides - .where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp)) - .toList(); - final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim(); - if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) { - slides.add( - Slide.create( - SlideType.freeMarkdown, - ).copyWith(customMarkdown: closingMarkdown), - ); - } - return slides; -} - -// ── App shell ───────────────────────────────────────────────────────────────── +// Shell sub-widgets and helpers, split into part files for navigability. +// These parts share this library's imports and private scope. +part 'shell/shell_actions.dart'; +part 'shell/tab_bar.dart'; +part 'shell/welcome_screen.dart'; +part 'shell/status_bar.dart'; +part 'shell/shell_overlays.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @@ -504,387 +344,6 @@ class _AppShellState extends ConsumerState with WindowListener { } } -/// Visuele hint terwijl bestanden boven het venster zweven. -class _DropOverlay extends StatelessWidget { - const _DropOverlay(); - - @override - Widget build(BuildContext context) { - return Positioned.fill( - child: IgnorePointer( - child: Container( - color: const Color(0xFF1C2B47).withValues(alpha: 0.55), - alignment: Alignment.center, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF60A5FA), width: 2), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.file_download_outlined, - size: 40, - color: Color(0xFF2563EB), - ), - const SizedBox(height: 10), - Text( - context.l10n.d('Laat los om toe te voegen'), - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.d( - 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen', - ), - style: const TextStyle( - fontSize: 12, - color: Color(0xFF64748B), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -// ── Tab bar ─────────────────────────────────────────────────────────────────── - -class _AppTabBar extends StatelessWidget { - final TabsState tabsState; - final ValueChanged onSelect; - final ValueChanged onClose; - final VoidCallback onAdd; - - const _AppTabBar({ - required this.tabsState, - required this.onSelect, - required this.onClose, - required this.onAdd, - }); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final palette = Theme.of(context).extension()!; - return Container( - height: 36, - color: palette.panel, - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (int i = 0; i < tabsState.tabs.length; i++) - _TabChip( - tab: tabsState.tabs[i], - isActive: i == tabsState.clampedIndex, - showClose: - tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen, - panelText: palette.panelText, - accent: Theme.of(context).colorScheme.secondary, - onTap: () => onSelect(i), - onClose: () => onClose(i), - ), - ], - ), - ), - ), - Tooltip( - message: l10n.t('newTab'), - child: InkWell( - onTap: onAdd, - child: SizedBox( - width: 36, - height: 36, - child: Icon( - Icons.add, - size: 16, - color: palette.panelText.withValues(alpha: 0.55), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _TabChip extends StatelessWidget { - final TabInfo tab; - final bool isActive; - final bool showClose; - final VoidCallback onTap; - final VoidCallback onClose; - final Color panelText; - final Color accent; - - const _TabChip({ - required this.tab, - required this.isActive, - required this.showClose, - required this.onTap, - required this.onClose, - required this.panelText, - required this.accent, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - constraints: const BoxConstraints(minWidth: 80, maxWidth: 200), - height: 36, - decoration: BoxDecoration( - color: isActive - ? panelText.withValues(alpha: 0.12) - : Colors.transparent, - border: Border( - bottom: BorderSide( - color: isActive ? accent : Colors.transparent, - width: 2, - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (tab.isDirty) - Container( - width: 6, - height: 6, - margin: const EdgeInsets.only(right: 5), - decoration: const BoxDecoration( - color: Colors.orangeAccent, - shape: BoxShape.circle, - ), - ), - Flexible( - child: Text( - tab.label, - style: TextStyle( - fontSize: 12, - color: isActive - ? panelText - : panelText.withValues(alpha: 0.72), - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (showClose) ...[ - const SizedBox(width: 4), - InkWell( - onTap: onClose, - borderRadius: BorderRadius.circular(3), - child: Padding( - padding: const EdgeInsets.all(2), - child: Icon( - Icons.close, - size: 12, - color: panelText.withValues(alpha: 0.55), - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -// ── Per-tab content ─────────────────────────────────────────────────────────── - -class _TabContent extends ConsumerWidget { - const _TabContent(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isOpen = ref.watch(deckProvider.select((s) => s.isOpen)); - if (!isOpen) return const _WelcomeScreen(); - return _MainLayout(exportService: ExportService()); - } -} - -// ── Welcome screen ──────────────────────────────────────────────────────────── - -class _WelcomeScreen extends ConsumerWidget { - const _WelcomeScreen(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = context.l10n; - final theme = Theme.of(context); - final palette = theme.extension()!; - final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); - final recentFiles = ref.watch( - settingsProvider.select((s) => s.recentFiles), - ); - - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - body: Row( - children: [ - // ── Midden: logo + knoppen ───────────────────────────────────── - Expanded( - child: Align( - alignment: const Alignment(-0.15, 0.12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - label: 'De Winter Information Solutions', - image: true, - child: Image.asset( - 'assets/images/de-winter-wittegeheel.png', - width: 320, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), - ), - const SizedBox(height: 36), - SizedBox( - width: 220, - child: ElevatedButton.icon( - onPressed: () => _newDeck(context, ref), - icon: const Icon(Icons.add, size: 18), - label: Text(l10n.t('newPresentation')), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: 220, - child: OutlinedButton.icon( - onPressed: () => _openWithSearch(context, ref, homeDir), - icon: const Icon(Icons.folder_open_outlined, size: 18), - label: Text(l10n.t('open')), - ), - ), - const SizedBox(height: 8), - TextButton.icon( - onPressed: () => SettingsDialog.show(context), - icon: const Icon(Icons.settings_outlined, size: 17), - label: Text(l10n.t('settings')), - ), - ], - ), - ), - ), - // ── Rechts: recente bestanden ────────────────────────────────── - if (recentFiles.isNotEmpty) - Container( - width: 280, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - left: BorderSide(color: theme.colorScheme.outlineVariant), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), - child: Text( - l10n.t('recentPresentations'), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: palette.mutedText, - letterSpacing: 0.8, - ), - ), - ), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.only(bottom: 16), - itemCount: recentFiles.length, - itemBuilder: (_, i) { - final path = recentFiles[i]; - final name = path.split('/').last.replaceAll('.md', ''); - return InkWell( - onTap: () => ref - .read(tabsProvider.notifier) - .openFileByPath(path), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Row( - children: [ - Icon( - Icons.slideshow_outlined, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: theme.colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - Text( - path, - style: TextStyle( - fontSize: 10, - color: palette.mutedText, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Future _newDeck(BuildContext context, WidgetRef ref) async { - final title = await NewDeckDialog.show(context); - if (title != null) { - ref.read(tabsProvider.notifier).newDeckInCurrentTab(title); - } - } -} - -// ── Main 2-panel layout ─────────────────────────────────────────────────────── - class _MainLayout extends ConsumerStatefulWidget { final ExportService exportService; @@ -1535,396 +994,3 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { } // ── AppBar helpers ──────────────────────────────────────────────────────────── - -class _DeckStatusBar extends StatelessWidget { - final Deck deck; - final DeckState deckState; - final String? exportDirectory; - final Future Function() onSave; - final VoidCallback? onExport; - final String exportTooltip; - - const _DeckStatusBar({ - required this.deck, - required this.deckState, - required this.exportDirectory, - required this.onSave, - required this.onExport, - required this.exportTooltip, - }); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final skipped = deck.slides.where((s) => s.skipped).length; - final fileLabel = deckState.filePath == null - ? l10n.t('notSavedYet') - : p.basename(deckState.filePath!); - final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved'); - final exportLabel = exportDirectory == null - ? l10n.t('exportNextToDeck') - : '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}'; - - final theme = Theme.of(context); - return Material( - color: theme.colorScheme.surface, - child: Container( - height: 30, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.colorScheme.outlineVariant), - ), - ), - child: Row( - children: [ - _StatusAction( - icon: deckState.isDirty - ? Icons.radio_button_checked - : Icons.check_circle_outline, - label: saveLabel, - tooltip: deckState.isDirty - ? l10n.t('unsavedChanges') - : l10n.t('noUnsavedChanges'), - color: deckState.isDirty - ? const Color(0xFFD97706) - : const Color(0xFF15803D), - onTap: () => onSave(), - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.description_outlined, - label: fileLabel, - tooltip: deckState.filePath ?? l10n.t('noFileYet'), - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.slideshow_outlined, - label: skipped == 0 - ? '${deck.slides.length} ${l10n.t('slides')}' - : '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}', - tooltip: skipped == 0 - ? l10n.t('allSlidesIncluded') - : '$skipped ${l10n.t('skippedSlidesExcluded')}', - color: skipped == 0 ? null : const Color(0xFF8A6D3B), - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.palette_outlined, - label: deck.themeProfile.name, - tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}', - ), - if (deck.tlp != TlpLevel.none) ...[ - const _StatusDivider(), - _StatusItem( - icon: Icons.shield_outlined, - label: deck.tlp.label, - tooltip: '${l10n.t('classification')}: ${deck.tlp.label}', - color: Color(deck.tlp.foreground), - ), - ], - const Spacer(), - _StatusItem( - icon: Icons.folder_outlined, - label: exportLabel, - tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'), - ), - const SizedBox(width: 6), - _StatusAction( - icon: Icons.upload_file_outlined, - label: l10n.t('export'), - tooltip: exportTooltip, - onTap: onExport, - ), - ], - ), - ), - ); - } -} - -class _StatusItem extends StatelessWidget { - final IconData icon; - final String label; - final String tooltip; - final Color? color; - - const _StatusItem({ - required this.icon, - required this.label, - required this.tooltip, - this.color, - }); - - @override - Widget build(BuildContext context) { - final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant; - return Tooltip( - message: tooltip, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: fg), - const SizedBox(width: 4), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 210), - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: fg, - fontWeight: color == null ? FontWeight.normal : FontWeight.w600, - ), - ), - ), - ], - ), - ); - } -} - -class _StatusAction extends StatelessWidget { - final IconData icon; - final String label; - final String tooltip; - final Color? color; - final VoidCallback? onTap; - - const _StatusAction({ - required this.icon, - required this.label, - required this.tooltip, - this.color, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final enabled = onTap != null; - final fg = enabled - ? (color ?? Theme.of(context).colorScheme.secondary) - : Theme.of(context).disabledColor; - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: fg), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: 11, - color: fg, - fontWeight: enabled ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _StatusDivider extends StatelessWidget { - const _StatusDivider(); - - @override - Widget build(BuildContext context) { - return Container( - width: 1, - height: 14, - margin: const EdgeInsets.symmetric(horizontal: 8), - color: Theme.of(context).colorScheme.outlineVariant, - ); - } -} - -/// Dunne verticale scheiding tussen groepen AppBar-knoppen. -class _ActionsDivider extends StatelessWidget { - const _ActionsDivider(); - - @override - Widget build(BuildContext context) { - return Container( - width: 1, - height: 20, - margin: const EdgeInsets.symmetric(horizontal: 6), - color: Colors.white24, - ); - } -} - -class _ResizableDivider extends StatefulWidget { - final ValueChanged onDrag; - - const _ResizableDivider({required this.onDrag}); - - @override - State<_ResizableDivider> createState() => _ResizableDividerState(); -} - -class _ResizableDividerState extends State<_ResizableDivider> { - static const double _keyboardStep = 24; - - bool _hovered = false; - bool _dragging = false; - bool _focused = false; - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyUpEvent) return KeyEventResult.ignored; - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - widget.onDrag(-_keyboardStep); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - widget.onDrag(_keyboardStep); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final active = _hovered || _dragging || _focused; - // Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys - // move it, and focus is shown with the same highlight as hovering - // (WCAG 2.4.7). Screen readers see it as an adjustable element. - return Focus( - onKeyEvent: _onKeyEvent, - onFocusChange: (focused) => setState(() => _focused = focused), - child: Semantics( - slider: true, - label: l10n.d('Breedte van het slidepaneel'), - hint: l10n.d('Pijltjestoetsen passen de breedte aan'), - onIncrease: () => widget.onDrag(_keyboardStep), - onDecrease: () => widget.onDrag(-_keyboardStep), - child: MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => setState(() => _dragging = true), - onHorizontalDragEnd: (_) => setState(() => _dragging = false), - onHorizontalDragCancel: () => setState(() => _dragging = false), - onHorizontalDragUpdate: (details) => - widget.onDrag(details.delta.dx), - child: Tooltip( - message: l10n.d( - 'Sleep om de slide-preview breder of smaller te maken', - ), - child: SizedBox( - width: 9, - child: Center( - child: AnimatedContainer( - duration: const Duration(milliseconds: 90), - width: active ? 3 : 1, - color: active - ? Theme.of(context).colorScheme.secondary - : Theme.of(context).colorScheme.outlineVariant, - ), - ), - ), - ), - ), - ), - ), - ); - } -} - -/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de -/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent -/// bij klikken een keuzelijst met alle niveaus (incl. "Geen"). -class _TlpChip extends StatelessWidget { - final TlpLevel tlp; - final ValueChanged onSelected; - - const _TlpChip({required this.tlp, required this.onSelected}); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final isSet = tlp != TlpLevel.none; - final fg = Color(tlp.foreground); - - final child = Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), - decoration: BoxDecoration( - color: isSet ? Colors.black : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isSet) - const Icon(Icons.shield_outlined, size: 14, color: Colors.white70), - if (!isSet) const SizedBox(width: 5), - Text( - isSet ? tlp.label : 'TLP', - style: TextStyle( - color: isSet ? fg : Colors.white70, - fontSize: 11.5, - fontWeight: FontWeight.w700, - fontFamily: 'monospace', - fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], - letterSpacing: 0.3, - ), - ), - Icon( - Icons.arrow_drop_down, - size: 16, - color: isSet ? fg : Colors.white54, - ), - ], - ), - ); - - return PopupMenuButton( - tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'), - position: PopupMenuPosition.under, - onSelected: onSelected, - itemBuilder: (_) => [ - for (final level in TlpLevel.values) - PopupMenuItem( - value: level, - child: Row( - children: [ - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: level == TlpLevel.none - ? Colors.transparent - : Color(level.foreground), - border: Border.all(color: const Color(0xFF94A3B8)), - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 10), - Text(level == TlpLevel.none ? l10n.d('Geen') : level.label), - if (level == tlp) ...[ - const SizedBox(width: 12), - const Spacer(), - const Icon(Icons.check, size: 16, color: Color(0xFF475569)), - ], - ], - ), - ), - ], - child: child, - ); - } -} diff --git a/lib/widgets/shell/shell_actions.dart b/lib/widgets/shell/shell_actions.dart new file mode 100644 index 0000000..18e11d5 --- /dev/null +++ b/lib/widgets/shell/shell_actions.dart @@ -0,0 +1,171 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +/// Open the search-based presentation picker and load the chosen file +/// (optionally jumping to a matched slide). +Future _openWithSearch( + BuildContext context, + WidgetRef ref, + String? initialDirectory, +) async { + final settings = ref.read(settingsProvider); + final result = await OpenPresentationDialog.show( + context, + fileService: ref.read(fileServiceProvider), + initialDirectory: initialDirectory ?? settings.homeDirectory, + ); + if (result == null) return; + await ref + .read(tabsProvider.notifier) + .openFileByPath(result.path, selectIndex: result.slideIndex); +} + +/// Vraag een URL op om een presentatie (pakket of markdown) op te halen. +Future _showUrlDialog(BuildContext context) { + final l10n = context.l10n; + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.d('Importeren via URL')), + content: SizedBox( + width: 460, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.d( + 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', + ), + style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + hintText: 'https://…', + prefixIcon: Icon(Icons.link, size: 18), + isDense: true, + border: OutlineInputBorder(), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.t('cancel')), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx, controller.text), + icon: const Icon(Icons.download, size: 16), + label: Text(l10n.d('Ophalen')), + ), + ], + ), + ); +} + +List _imageSearchPaths(String? projectPath, String? homeDirectory) { + final projectImagesPath = projectPath == null + ? null + : p.join(projectPath, 'images'); + return [?projectImagesPath, ?projectPath, ?homeDirectory]; +} + +String? _resolveImagePath(String path, String? projectPath) { + if (path.isEmpty) return null; + if (p.isAbsolute(path) || projectPath == null) return path; + return p.join(projectPath, path); +} + +List _imageUsages(WidgetRef ref, String absolutePath) { + final target = p.normalize(absolutePath); + final usages = []; + for (final tab in ref.read(tabsProvider).tabs) { + final deck = tab.deckNotifier.currentState.deck; + if (deck == null) continue; + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + for (final candidate in [slide.imagePath, slide.imagePath2]) { + if (candidate.isEmpty) continue; + final resolved = p.normalize( + p.isAbsolute(candidate) + ? candidate + : p.join(deck.projectPath ?? '', candidate), + ); + if (resolved == target) { + usages.add('${tab.label} · slide ${i + 1}'); + break; + } + } + } + } + return usages; +} + +/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar +/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5- +/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen. +Future _replaceImageUsages( + WidgetRef ref, + String fromAbsolute, + String toAbsolute, +) async { + final target = p.normalize(fromAbsolute); + for (final tab in ref.read(tabsProvider).tabs) { + final notifier = tab.deckNotifier; + final deck = notifier.currentState.deck; + if (deck == null) continue; + final projectPath = deck.projectPath ?? ''; + + String resolve(String candidate) => p.normalize( + p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate), + ); + // Blijf relatief opslaan als de slide dat al deed en het nieuwe pad + // binnen het project ligt; anders absoluut. + String replacement(String candidate) { + if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute; + return p.isWithin(projectPath, toAbsolute) + ? p.relative(toAbsolute, from: projectPath) + : toAbsolute; + } + + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + var updated = slide; + if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) { + updated = updated.copyWith(imagePath: replacement(slide.imagePath)); + } + if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) { + updated = updated.copyWith(imagePath2: replacement(slide.imagePath2)); + } + if (!identical(updated, slide)) notifier.updateSlide(i, updated); + } + } +} + +List _slidesForPresentationOrExport(Deck deck) { + // Drop skipped slides and slides whose TLP classification is stricter than + // the level chosen for this presentation/export. + final slides = deck.slides + .where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp)) + .toList(); + final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim(); + if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) { + slides.add( + Slide.create( + SlideType.freeMarkdown, + ).copyWith(customMarkdown: closingMarkdown), + ); + } + return slides; +} + +// ── App shell ───────────────────────────────────────────────────────────────── diff --git a/lib/widgets/shell/shell_overlays.dart b/lib/widgets/shell/shell_overlays.dart new file mode 100644 index 0000000..16d4477 --- /dev/null +++ b/lib/widgets/shell/shell_overlays.dart @@ -0,0 +1,139 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +/// Visuele hint terwijl bestanden boven het venster zweven. +class _DropOverlay extends StatelessWidget { + const _DropOverlay(); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: Container( + color: const Color(0xFF1C2B47).withValues(alpha: 0.55), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF60A5FA), width: 2), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.file_download_outlined, + size: 40, + color: Color(0xFF2563EB), + ), + const SizedBox(height: 10), + Text( + context.l10n.d('Laat los om toe te voegen'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + context.l10n.d( + 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen', + ), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Tab bar ─────────────────────────────────────────────────────────────────── + +class _ResizableDivider extends StatefulWidget { + final ValueChanged onDrag; + + const _ResizableDivider({required this.onDrag}); + + @override + State<_ResizableDivider> createState() => _ResizableDividerState(); +} + +class _ResizableDividerState extends State<_ResizableDivider> { + static const double _keyboardStep = 24; + + bool _hovered = false; + bool _dragging = false; + bool _focused = false; + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + widget.onDrag(-_keyboardStep); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + widget.onDrag(_keyboardStep); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final active = _hovered || _dragging || _focused; + // Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys + // move it, and focus is shown with the same highlight as hovering + // (WCAG 2.4.7). Screen readers see it as an adjustable element. + return Focus( + onKeyEvent: _onKeyEvent, + onFocusChange: (focused) => setState(() => _focused = focused), + child: Semantics( + slider: true, + label: l10n.d('Breedte van het slidepaneel'), + hint: l10n.d('Pijltjestoetsen passen de breedte aan'), + onIncrease: () => widget.onDrag(_keyboardStep), + onDecrease: () => widget.onDrag(-_keyboardStep), + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragEnd: (_) => setState(() => _dragging = false), + onHorizontalDragCancel: () => setState(() => _dragging = false), + onHorizontalDragUpdate: (details) => + widget.onDrag(details.delta.dx), + child: Tooltip( + message: l10n.d( + 'Sleep om de slide-preview breder of smaller te maken', + ), + child: SizedBox( + width: 9, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 90), + width: active ? 3 : 1, + color: active + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/shell/status_bar.dart b/lib/widgets/shell/status_bar.dart new file mode 100644 index 0000000..17ca208 --- /dev/null +++ b/lib/widgets/shell/status_bar.dart @@ -0,0 +1,316 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +class _DeckStatusBar extends StatelessWidget { + final Deck deck; + final DeckState deckState; + final String? exportDirectory; + final Future Function() onSave; + final VoidCallback? onExport; + final String exportTooltip; + + const _DeckStatusBar({ + required this.deck, + required this.deckState, + required this.exportDirectory, + required this.onSave, + required this.onExport, + required this.exportTooltip, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final skipped = deck.slides.where((s) => s.skipped).length; + final fileLabel = deckState.filePath == null + ? l10n.t('notSavedYet') + : p.basename(deckState.filePath!); + final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved'); + final exportLabel = exportDirectory == null + ? l10n.t('exportNextToDeck') + : '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}'; + + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surface, + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + _StatusAction( + icon: deckState.isDirty + ? Icons.radio_button_checked + : Icons.check_circle_outline, + label: saveLabel, + tooltip: deckState.isDirty + ? l10n.t('unsavedChanges') + : l10n.t('noUnsavedChanges'), + color: deckState.isDirty + ? const Color(0xFFD97706) + : const Color(0xFF15803D), + onTap: () => onSave(), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.description_outlined, + label: fileLabel, + tooltip: deckState.filePath ?? l10n.t('noFileYet'), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.slideshow_outlined, + label: skipped == 0 + ? '${deck.slides.length} ${l10n.t('slides')}' + : '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}', + tooltip: skipped == 0 + ? l10n.t('allSlidesIncluded') + : '$skipped ${l10n.t('skippedSlidesExcluded')}', + color: skipped == 0 ? null : const Color(0xFF8A6D3B), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.palette_outlined, + label: deck.themeProfile.name, + tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}', + ), + if (deck.tlp != TlpLevel.none) ...[ + const _StatusDivider(), + _StatusItem( + icon: Icons.shield_outlined, + label: deck.tlp.label, + tooltip: '${l10n.t('classification')}: ${deck.tlp.label}', + color: Color(deck.tlp.foreground), + ), + ], + const Spacer(), + _StatusItem( + icon: Icons.folder_outlined, + label: exportLabel, + tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'), + ), + const SizedBox(width: 6), + _StatusAction( + icon: Icons.upload_file_outlined, + label: l10n.t('export'), + tooltip: exportTooltip, + onTap: onExport, + ), + ], + ), + ), + ); + } +} + +class _StatusItem extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final Color? color; + + const _StatusItem({ + required this.icon, + required this.label, + required this.tooltip, + this.color, + }); + + @override + Widget build(BuildContext context) { + final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + return Tooltip( + message: tooltip, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 210), + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: color == null ? FontWeight.normal : FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _StatusAction extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final Color? color; + final VoidCallback? onTap; + + const _StatusAction({ + required this.icon, + required this.label, + required this.tooltip, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final fg = enabled + ? (color ?? Theme.of(context).colorScheme.secondary) + : Theme.of(context).disabledColor; + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: enabled ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StatusDivider extends StatelessWidget { + const _StatusDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 14, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: Theme.of(context).colorScheme.outlineVariant, + ); + } +} + +/// Dunne verticale scheiding tussen groepen AppBar-knoppen. +class _ActionsDivider extends StatelessWidget { + const _ActionsDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Colors.white24, + ); + } +} + +/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de +/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent +/// bij klikken een keuzelijst met alle niveaus (incl. "Geen"). +class _TlpChip extends StatelessWidget { + final TlpLevel tlp; + final ValueChanged onSelected; + + const _TlpChip({required this.tlp, required this.onSelected}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final isSet = tlp != TlpLevel.none; + final fg = Color(tlp.foreground); + + final child = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: isSet ? Colors.black : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isSet) + const Icon(Icons.shield_outlined, size: 14, color: Colors.white70), + if (!isSet) const SizedBox(width: 5), + Text( + isSet ? tlp.label : 'TLP', + style: TextStyle( + color: isSet ? fg : Colors.white70, + fontSize: 11.5, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + letterSpacing: 0.3, + ), + ), + Icon( + Icons.arrow_drop_down, + size: 16, + color: isSet ? fg : Colors.white54, + ), + ], + ), + ); + + return PopupMenuButton( + tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'), + position: PopupMenuPosition.under, + onSelected: onSelected, + itemBuilder: (_) => [ + for (final level in TlpLevel.values) + PopupMenuItem( + value: level, + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: level == TlpLevel.none + ? Colors.transparent + : Color(level.foreground), + border: Border.all(color: const Color(0xFF94A3B8)), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 10), + Text(level == TlpLevel.none ? l10n.d('Geen') : level.label), + if (level == tlp) ...[ + const SizedBox(width: 12), + const Spacer(), + const Icon(Icons.check, size: 16, color: Color(0xFF475569)), + ], + ], + ), + ), + ], + child: child, + ); + } +} diff --git a/lib/widgets/shell/tab_bar.dart b/lib/widgets/shell/tab_bar.dart new file mode 100644 index 0000000..d864fc8 --- /dev/null +++ b/lib/widgets/shell/tab_bar.dart @@ -0,0 +1,167 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +class _AppTabBar extends StatelessWidget { + final TabsState tabsState; + final ValueChanged onSelect; + final ValueChanged onClose; + final VoidCallback onAdd; + + const _AppTabBar({ + required this.tabsState, + required this.onSelect, + required this.onClose, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final palette = Theme.of(context).extension()!; + return Container( + height: 36, + color: palette.panel, + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < tabsState.tabs.length; i++) + _TabChip( + tab: tabsState.tabs[i], + isActive: i == tabsState.clampedIndex, + showClose: + tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen, + panelText: palette.panelText, + accent: Theme.of(context).colorScheme.secondary, + onTap: () => onSelect(i), + onClose: () => onClose(i), + ), + ], + ), + ), + ), + Tooltip( + message: l10n.t('newTab'), + child: InkWell( + onTap: onAdd, + child: SizedBox( + width: 36, + height: 36, + child: Icon( + Icons.add, + size: 16, + color: palette.panelText.withValues(alpha: 0.55), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _TabChip extends StatelessWidget { + final TabInfo tab; + final bool isActive; + final bool showClose; + final VoidCallback onTap; + final VoidCallback onClose; + final Color panelText; + final Color accent; + + const _TabChip({ + required this.tab, + required this.isActive, + required this.showClose, + required this.onTap, + required this.onClose, + required this.panelText, + required this.accent, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minWidth: 80, maxWidth: 200), + height: 36, + decoration: BoxDecoration( + color: isActive + ? panelText.withValues(alpha: 0.12) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isActive ? accent : Colors.transparent, + width: 2, + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (tab.isDirty) + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 5), + decoration: const BoxDecoration( + color: Colors.orangeAccent, + shape: BoxShape.circle, + ), + ), + Flexible( + child: Text( + tab.label, + style: TextStyle( + fontSize: 12, + color: isActive + ? panelText + : panelText.withValues(alpha: 0.72), + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (showClose) ...[ + const SizedBox(width: 4), + InkWell( + onTap: onClose, + borderRadius: BorderRadius.circular(3), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close, + size: 12, + color: panelText.withValues(alpha: 0.55), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ── Per-tab content ─────────────────────────────────────────────────────────── + +class _TabContent extends ConsumerWidget { + const _TabContent(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOpen = ref.watch(deckProvider.select((s) => s.isOpen)); + if (!isOpen) return const _WelcomeScreen(); + return _MainLayout(exportService: ExportService()); + } +} + +// ── Welcome screen ──────────────────────────────────────────────────────────── diff --git a/lib/widgets/shell/welcome_screen.dart b/lib/widgets/shell/welcome_screen.dart new file mode 100644 index 0000000..5697975 --- /dev/null +++ b/lib/widgets/shell/welcome_screen.dart @@ -0,0 +1,164 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +class _WelcomeScreen extends ConsumerWidget { + const _WelcomeScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final theme = Theme.of(context); + final palette = theme.extension()!; + final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); + final recentFiles = ref.watch( + settingsProvider.select((s) => s.recentFiles), + ); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: Row( + children: [ + // ── Midden: logo + knoppen ───────────────────────────────────── + Expanded( + child: Align( + alignment: const Alignment(-0.15, 0.12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: 'De Winter Information Solutions', + image: true, + child: Image.asset( + 'assets/images/de-winter-wittegeheel.png', + width: 320, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + const SizedBox(height: 36), + SizedBox( + width: 220, + child: ElevatedButton.icon( + onPressed: () => _newDeck(context, ref), + icon: const Icon(Icons.add, size: 18), + label: Text(l10n.t('newPresentation')), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: 220, + child: OutlinedButton.icon( + onPressed: () => _openWithSearch(context, ref, homeDir), + icon: const Icon(Icons.folder_open_outlined, size: 18), + label: Text(l10n.t('open')), + ), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => SettingsDialog.show(context), + icon: const Icon(Icons.settings_outlined, size: 17), + label: Text(l10n.t('settings')), + ), + ], + ), + ), + ), + // ── Rechts: recente bestanden ────────────────────────────────── + if (recentFiles.isNotEmpty) + Container( + width: 280, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + left: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), + child: Text( + l10n.t('recentPresentations'), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: palette.mutedText, + letterSpacing: 0.8, + ), + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 16), + itemCount: recentFiles.length, + itemBuilder: (_, i) { + final path = recentFiles[i]; + final name = path.split('/').last.replaceAll('.md', ''); + return InkWell( + onTap: () => ref + .read(tabsProvider.notifier) + .openFileByPath(path), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.slideshow_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + path, + style: TextStyle( + fontSize: 10, + color: palette.mutedText, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _newDeck(BuildContext context, WidgetRef ref) async { + final title = await NewDeckDialog.show(context); + if (title != null) { + ref.read(tabsProvider.notifier).newDeckInCurrentTab(title); + } + } +} + +// ── Main 2-panel layout ─────────────────────────────────────────────────────── diff --git a/lib/widgets/slides/previews/bullets_previews.dart b/lib/widgets/slides/previews/bullets_previews.dart new file mode 100644 index 0000000..7217b0c --- /dev/null +++ b/lib/widgets/slides/previews/bullets_previews.dart @@ -0,0 +1,916 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _BulletsPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _BulletsPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.07; + // Slightly tighter top/bottom margin than the side margin so short + // checklists can grow into more of the slide height instead of leaving a + // wide empty band below the text. + final vPad = w * 0.05; + 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; + final bullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + final subtitle = slide.subtitle; + final hasSubtitle = subtitle.isNotEmpty; + final showProgress = + slide.listStyle == ListStyle.checklist && + slide.showChecklistProgress && + bullets.isNotEmpty; + + final slideHeight = w * 9 / 16; + final availW = (w - pad * 2).clamp(w * 0.12, w); + // The progress chart only needs a modest, fixed slot; give all remaining + // width to the bullets so the text can grow as large (and readable) as + // possible, especially on slides with many checklist items. + final progressGap = w * 0.025; + final progressW = w * 0.34; + final textAvailW = showProgress + ? (availW - progressGap - progressW).clamp(w * 0.12, availW) + : availW; + final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); + // Grow (or, when needed, shrink) the text so it uses the full vertical + // space instead of leaving a large empty area below a few short bullets. + final scale = _bulletsFitScale( + availW: textAvailW, + availH: availH, + hasTitle: hasTitle, + title: slide.title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + subtitle: subtitle, + subtitleSize: subtitleSize, + maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), + listStyle: slide.listStyle, + ); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + vPad + safe.top, + pad, + vPad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize * scale, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + 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), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _BulletListColumn( + bullets: bullets, + listStyle: slide.listStyle, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + column: 0, + ), + ), + if (showProgress) ...[ + SizedBox(width: progressGap), + SizedBox( + width: progressW, + child: Center( + child: _ChecklistProgress( + bullets: bullets, + w: w, + font: font, + profile: profile, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TwoBulletsPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _TwoBulletsPreview({ + required this.slide, + required this.w, + required this.font, + 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, + required int column, + }) { + 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, + listStyle: slide.listStyle, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + column: column, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final pad = w * 0.065; + // Tighter top/bottom margin than the side margin so dense columns (e.g. a + // 19-item list) can use more of the slide height and stay readable. + final vPad = w * 0.045; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final leftBullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final rightBullets = slide.bullets2 + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + // On dense slides (a long column drives the shared text size down) spend + // less of the height on the title, headings and inter-item gaps so the + // list items themselves can render larger and stay readable. + final dense = math.max(leftBullets.length, rightBullets.length) > 12; + final titleSize = w * (dense ? 0.034 : 0.04); + final bulletSize = w * 0.024; + final spacing = pad * (dense ? 0.28 : 0.38); + final bulletGap = w * (dense ? 0.0036 : 0.0055); + final columnGap = w * 0.055; + + final col1Title = slide.columnTitle1.trim(); + final col2Title = slide.columnTitle2.trim(); + final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty; + final headingSize = w * (dense ? 0.023 : 0.03); + final headingGap = w * (dense ? 0.007 : 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); + var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); + if (hasTitle) { + availH -= _measureTextHeight( + slide.title, + 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, + hasTitle: false, + title: '', + bullets: leftBullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + listStyle: slide.listStyle, + ); + final rightScale = _bulletsFitScale( + availW: columnW, + availH: availH, + hasTitle: false, + title: '', + bullets: rightBullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + listStyle: slide.listStyle, + ); + // Treat both columns as one composition: the busiest column determines + // the shared text size, so left and right never look typographically + // unrelated. + final columnScale = math.min(leftScale, rightScale); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand( + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + vPad + safe.top, + pad, + vPad + safe.bottom, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: contentW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle) SizedBox(height: spacing), + if (slide.listStyle == ListStyle.checklist && + slide.showChecklistProgress && + (leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[ + Align( + alignment: Alignment.center, + child: SizedBox( + width: contentW * 0.5, + child: _ChecklistProgress( + bullets: [...leftBullets, ...rightBullets], + w: w, + font: font, + profile: profile, + ), + ), + ), + SizedBox(height: spacing), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _bulletColumn( + context, + title: col1Title, + bullets: leftBullets, + columnW: columnW, + headingSize: headingSize, + headingSlotH: hasColumnTitles ? maxHeadingH : 0, + headingGap: headingGap, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: columnScale, + column: 0, + ), + SizedBox(width: columnGap), + _bulletColumn( + context, + title: col2Title, + bullets: rightBullets, + columnW: columnW, + headingSize: headingSize, + headingSlotH: hasColumnTitles ? maxHeadingH : 0, + headingGap: headingGap, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: columnScale, + column: 1, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BulletsImagePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _BulletsImagePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final leftPad = w * 0.038; + final verticalPad = w * 0.042; + // Keep the gap between the text column and the image equal to the slide's + // left margin so the layout stays symmetric. + final gap = leftPad; + final safe = slide.showLogo + ? _splitTextLogoSafeInsets(w, profile) + : EdgeInsets.zero; + final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40) + .clamp(0.1, 0.70); + final imgWidth = w * imgFraction; + final bulletSize = w * 0.031; + final titleSize = w * 0.042; + final spacing = verticalPad * 0.32; + final bulletGap = w * 0.005; + final bullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + // The slide is always rendered 16:9, so the available area for the text + // column is fully determined by the width. Computing it directly (instead + // of via a LayoutBuilder) keeps the widget tree identical to the image + // side and avoids any layout-timing surprises. + final slideHeight = w * 9 / 16; + final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w); + final availH = + slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom); + // Pick the largest font scale (capped at the design size) whose content + // still fits the available height at the full column width. This keeps the + // text as large as possible and lets it span the full width toward the + // image, instead of uniformly shrinking and leaving a wide gap. + final scale = _bulletsFitScale( + availW: availW, + availH: availH, + hasTitle: hasTitle, + title: slide.title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + listStyle: slide.listStyle, + ); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Stack( + children: [ + Positioned( + top: 0, + right: 0, + bottom: 0, + width: imgWidth, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(context, slide.imagePath, projectPath), + _captionOverlay(context, slide.imageCaption, w), + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: imgWidth + gap, + bottom: 0, + child: Padding( + padding: EdgeInsets.fromLTRB( + leftPad, + verticalPad + safe.top, + 0, + verticalPad + safe.bottom, + ), + // FittedBox stays as a safety net for measurement rounding; with + // an accurate scale it renders at scale 1 (full width). + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: availW, + child: _contentColumn( + context: context, + scale: scale, + bullets: bullets, + hasTitle: hasTitle, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _contentColumn({ + required BuildContext context, + required double scale, + required List bullets, + required bool hasTitle, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize * scale, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale), + if (slide.listStyle == ListStyle.checklist && + slide.showChecklistProgress && + bullets.isNotEmpty) ...[ + _ChecklistProgress( + bullets: bullets, + w: w, + font: font, + profile: profile, + ), + SizedBox(height: spacing * scale), + ], + ...bullets.asMap().entries.map((entry) { + final b = entry.value; + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = slide.listStyle == ListStyle.checklist + ? checklistItemText(b) + : b.substring(level); + final checked = + slide.listStyle == ListStyle.checklist && checklistItemChecked(b); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + return _ChecklistBulletRow( + bullets: bullets, + itemIndex: entry.key, + column: 0, + listStyle: slide.listStyle, + checked: checked, + text: text, + level: level, + fontSize: fontSize, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + font: font, + profile: profile, + ); + }), + ], + ); + } +} + +class _BulletListColumn extends StatelessWidget { + final List bullets; + final ListStyle listStyle; + final String font; + final ThemeProfile profile; + final double bulletSize; + final double bulletGap; + final double scale; + final int column; + + const _BulletListColumn({ + required this.bullets, + required this.listStyle, + required this.font, + required this.profile, + required this.bulletSize, + required this.bulletGap, + required this.scale, + this.column = 0, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ...bullets.asMap().entries.map((entry) { + final b = entry.value; + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = listStyle == ListStyle.checklist + ? checklistItemText(b) + : b.substring(level); + final checked = + listStyle == ListStyle.checklist && checklistItemChecked(b); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + return _ChecklistBulletRow( + bullets: bullets, + itemIndex: entry.key, + column: column, + listStyle: listStyle, + checked: checked, + text: text, + level: level, + fontSize: fontSize, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + font: font, + profile: profile, + ); + }), + ], + ); + } +} + +/// Upper bound for growing bullet text to fill otherwise empty vertical space. +const double _kBulletsMaxScale = 3.2; + +/// Split slides have a much narrower column, so short bullet lists can stay +/// visually timid unless they are allowed to grow a little further. +const double _kSplitBulletsMaxScale = 4.35; + +/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a +/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's +/// 960pt-wide canvas). Presentation-design guidance consistently puts body +/// text at 24–32pt — beyond that it stops aiding readability and starts +/// competing with the title. The fit scale multiplies title and bullets +/// alike, so capping the bullet size also keeps the hierarchy intact. +const double _kBulletMaxFontFraction = 0.0335; + +/// The largest auto-fit scale that keeps bullets at or under +/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound. +double _bulletScaleCap(double w, double bulletSize, double layoutMax) => + math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize); + +/// Line height used for bullet body text, shared by rendering and measuring. +const double _kBulletLineHeight = 1.16; + +String _bulletMarkerForLevel(int level) { + const markers = ['•', '◦', '▪', '▫', '–']; + return markers[level.clamp(0, markers.length - 1)]; +} + +String _listMarker(List items, int index, ListStyle style) { + int levelOf(String item) { + var level = 0; + while (level < item.length && item[level] == '\t') { + level++; + } + return level; + } + + final level = levelOf(items[index]); + if (style == ListStyle.bullets) return _bulletMarkerForLevel(level); + if (style == ListStyle.checklist) { + return checklistItemChecked(items[index]) ? '☑' : '☐'; + } + var number = 0; + for (var i = 0; i <= index; i++) { + final itemLevel = levelOf(items[i]); + if (itemLevel == level) number++; + if (itemLevel < level) number = 0; + } + return '$number.'; +} + +double _bulletLevelScale(int level) { + if (level <= 0) return 1.0; + if (level == 1) return 0.86; + if (level == 2) return 0.80; + return 0.76; +} + +/// Largest scale in [minScale, maxScale] for which the bullet block fits +/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this +/// also grows the text *above* its design size when there is spare vertical +/// room, so short slides use the full height instead of clustering at the top. +double _bulletsFitScale({ + required double availW, + required double availH, + required bool hasTitle, + required String title, + required List bullets, + required double titleSize, + 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, + ListStyle listStyle = ListStyle.bullets, +}) { + if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0; + // 2% safety margin so minor measurement differences never overflow. + final budget = availH * 0.98; + double measure(double scale) => _bulletsBlockHeight( + scale: scale, + availW: availW, + listStyle: listStyle, + hasTitle: hasTitle, + title: title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + subtitle: subtitle, + subtitleSize: subtitleSize, + ); + + // Everything already fits at the largest allowed size → use it. + if (measure(maxScale) <= budget) return maxScale; + + // Otherwise binary-search the largest scale that fits. Search upward from the + // design size when it fits, downward when even the design size overflows. + double lo, hi; + if (maxScale > 1.0 && measure(1.0) <= budget) { + lo = 1.0; + hi = maxScale; + } else { + lo = minScale; + hi = maxScale > 1.0 ? 1.0 : maxScale; + } + for (var i = 0; i < 24; i++) { + final mid = (lo + hi) / 2; + if (measure(mid) <= budget) { + lo = mid; + } else { + hi = mid; + } + } + return lo; +} + +double _bulletsBlockHeight({ + required double scale, + required double availW, + required bool hasTitle, + required String title, + required List bullets, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + required String font, + String subtitle = '', + double subtitleSize = 0, + ListStyle listStyle = ListStyle.bullets, +}) { + var height = 0.0; + if (hasTitle) { + 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; + } + for (var i = 0; i < bullets.length; i++) { + final b = bullets[i]; + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + // Measure exactly what gets rendered: checklists strip the `[x] ` prefix + // and use a checkbox marker, numbered lists use `N.`. Measuring the raw + // string with a bullet marker over-counts the height and would shrink the + // text below the space it actually needs. + final text = listStyle == ListStyle.checklist + ? checklistItemText(b) + : b.substring(level); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + final indent = level * bulletSize * 1.05 * scale; + final marker = '${_listMarker(bullets, i, listStyle)} '; + 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, + ); + height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH); + } + return height; +} + +double _measureTextHeight( + String text, + double fontSize, + 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, + ), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity); + return painter.height; +} + +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, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + return painter.width; +} diff --git a/lib/widgets/slides/previews/chart_preview.dart b/lib/widgets/slides/previews/chart_preview.dart new file mode 100644 index 0000000..3f2b8b4 --- /dev/null +++ b/lib/widgets/slides/previews/chart_preview.dart @@ -0,0 +1,1478 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec. +class _ChartPreview extends StatefulWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + final bool presentationMode; + + const _ChartPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + required this.presentationMode, + }); + + @override + State<_ChartPreview> createState() => _ChartPreviewState(); +} + +class _ChartPreviewState extends State<_ChartPreview> { + Slide get slide => widget.slide; + double get w => widget.w; + String get font => widget.font; + ThemeProfile get profile => widget.profile; + bool get presentationMode => widget.presentationMode; + + /// Legend entry the pointer is over: a series index for bar/line charts, or a + /// slice (category) index for pie charts. Null when nothing is hovered. + int? _hovered; + + /// The radar vertex under the pointer, used to draw its tooltip. Null when not + /// hovering a point. + ({int series, int entry, double value, Offset offset})? _radarTouch; + + void _setHover(int? index) { + if (_hovered != index) setState(() => _hovered = index); + } + + /// True when another legend entry is hovered, so [index] should fade back. + bool _dimmed(int index) => _hovered != null && _hovered != index; + + /// Series colour with legend-hover feedback: non-hovered series fade out so + /// the hovered one stands out in the plot. + Color _seriesDisplayColor(ChartSeries series, int i) { + final base = _seriesColor(series, i); + return _dimmed(i) ? base.withValues(alpha: 0.2) : base; + } + + double get _labelScale => presentationMode ? 1.12 : 1; + + Color _seriesColor(ChartSeries series, int i) { + if (series.color == null && i == 0) { + return _hexColor(profile.accentColor); + } + return _hexColor(chartSeriesColor(series, i)); + } + + /// Text alternative for the chart (WCAG 1.1.1): chart type, title and the + /// underlying values per series, so a screen reader conveys the same + /// information the visual encodes. + String _semanticsLabel(BuildContext context, ChartSpec spec) { + final l10n = context.l10n; + final typeName = switch (spec.type) { + ChartType.bar => l10n.d('Staaf'), + ChartType.line => l10n.d('Lijn'), + ChartType.pie => l10n.d('Cirkel'), + ChartType.radar => l10n.d('Spider'), + }; + final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)'); + if (spec.title.isNotEmpty) { + buffer.write(': ${stripInlineMarkdown(spec.title)}'); + } + if (!spec.hasInlineData) return buffer.toString(); + for (var si = 0; si < spec.series.length; si++) { + final series = spec.series[si]; + final name = series.name.isEmpty + ? '${l10n.d('Reeks')} ${si + 1}' + : series.name; + final values = [ + for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++) + '${spec.x[xi]} ${_fmtNum(series.data[xi])}', + ]; + buffer.write('. $name: ${values.join(', ')}'); + } + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + final spec = ChartSpec.parse(slide.customMarkdown); + final horizontalPad = w * 0.05; + final verticalPad = w * 0.018; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final textColor = _hexColor(profile.textColor); + + return Semantics( + image: true, + label: _semanticsLabel(context, spec), + // The visual chart (axis labels, legend chips, tooltips) would read as + // disconnected fragments; the label above carries the full story. + child: ExcludeSemantics( + child: _chartBody( + context, + spec, + horizontalPad, + verticalPad, + safe, + textColor, + ), + ), + ); + } + + Widget _chartBody( + BuildContext context, + ChartSpec spec, + double horizontalPad, + double verticalPad, + EdgeInsets safe, + Color textColor, + ) { + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Padding( + padding: EdgeInsets.fromLTRB( + horizontalPad, + verticalPad + safe.top, + horizontalPad, + verticalPad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (spec.title.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: w * 0.025, + vertical: w * 0.01, + ), + decoration: BoxDecoration( + color: _hexColor(profile.titleBackgroundColor), + borderRadius: BorderRadius.circular(w * 0.012), + border: Border( + left: BorderSide( + color: _hexColor(profile.accentColor), + width: w * 0.006, + ), + ), + ), + child: _md( + context, + spec.title, + _applyFont( + font, + TextStyle( + fontSize: w * 0.032, + height: 1.1, + fontWeight: FontWeight.bold, + color: _hexColor(profile.titleTextColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + SizedBox(height: w * 0.012), + ], + Expanded( + child: Container( + key: const ValueKey('chart-surface'), + padding: EdgeInsets.fromLTRB( + w * 0.02, + w * 0.01, + w * 0.025, + w * 0.01, + ), + decoration: BoxDecoration( + color: textColor.withValues(alpha: 0.035), + borderRadius: BorderRadius.circular(w * 0.014), + border: Border.all(color: textColor.withValues(alpha: 0.09)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: spec.hasInlineData + ? _chart(spec, textColor) + : _placeholder(context), + ), + if (spec.hasInlineData && spec.series.isNotEmpty) ...[ + SizedBox(height: w * 0.006), + spec.type == ChartType.pie + ? _pieLegend(spec, textColor) + : _legend(spec, textColor), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _legend(ChartSpec spec, Color textColor) { + return SizedBox( + height: w * 0.03, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < spec.series.length; i++) ...[ + if (i > 0) SizedBox(width: w * 0.01), + MouseRegion( + onEnter: (_) => _setHover(i), + onExit: (_) => _setHover(null), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _dimmed(i) ? 0.4 : 1, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.01, + vertical: w * 0.004, + ), + decoration: BoxDecoration( + color: _hovered == i + ? _seriesColor( + spec.series[i], + i, + ).withValues(alpha: 0.18) + : textColor.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(w), + border: Border.all( + color: _hovered == i + ? _seriesColor(spec.series[i], i) + : Colors.transparent, + width: w * 0.0015, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: w * 0.012, + height: w * 0.012, + decoration: BoxDecoration( + color: _seriesColor(spec.series[i], i), + shape: BoxShape.circle, + ), + ), + SizedBox(width: w * 0.006), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: w * 0.16), + child: Text( + spec.series[i].name.isEmpty + ? 'Reeks ${i + 1}' + : spec.series[i].name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.013, + fontWeight: FontWeight.w600, + color: textColor.withValues(alpha: 0.82), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _pieLegend(ChartSpec spec, Color textColor) { + final itemCount = math.min(spec.x.length, 18); + final columns = math.min(itemCount, presentationMode ? 4 : 6); + final rows = (itemCount / columns).ceil(); + return LayoutBuilder( + builder: (context, constraints) { + final gap = w * 0.006; + final itemWidth = + (constraints.maxWidth - gap * (columns - 1)) / columns; + return SizedBox( + height: rows * w * 0.03 * _labelScale + (rows - 1) * gap, + child: Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (var i = 0; i < itemCount; i++) + MouseRegion( + onEnter: (_) => _setHover(i), + onExit: (_) => _setHover(null), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _dimmed(i) ? 0.4 : 1, + child: Container( + width: itemWidth, + height: w * 0.03 * _labelScale, + padding: EdgeInsets.symmetric(horizontal: w * 0.008), + decoration: BoxDecoration( + color: _hovered == i + ? _hexColor( + chartRowColor(spec, i), + ).withValues(alpha: 0.18) + : textColor.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(w), + border: Border.all( + color: _hovered == i + ? _hexColor(chartRowColor(spec, i)) + : Colors.transparent, + width: w * 0.0015, + ), + ), + child: Row( + children: [ + Container( + width: w * 0.012, + height: w * 0.012, + decoration: BoxDecoration( + color: _hexColor(chartRowColor(spec, i)), + shape: BoxShape.circle, + ), + ), + SizedBox(width: w * 0.006), + Expanded( + child: Text( + spec.x[i], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.013 * _labelScale, + fontWeight: FontWeight.w600, + color: textColor.withValues(alpha: 0.82), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _chart(ChartSpec spec, Color textColor) { + switch (spec.type) { + case ChartType.bar: + return _barChart(spec, textColor); + case ChartType.line: + return _lineChart(spec, textColor); + case ChartType.pie: + return _pieChart(spec, textColor); + case ChartType.radar: + return _radarChart(spec, textColor); + } + } + + double _maxY(ChartSpec spec) { + var m = 0.0; + for (final s in spec.series) { + for (final v in s.data) { + if (v > m) m = v; + } + } + // Keep any bound line comfortably inside the plot so its label is visible. + if (spec.supportsBounds) { + for (final b in [spec.minBound, spec.maxBound]) { + if (b != null && b > m) m = b; + } + } + return m <= 0 ? 1 : m * 1.15; + } + + double _minY(ChartSpec spec) { + var m = 0.0; + for (final s in spec.series) { + for (final v in s.data) { + if (v < m) m = v; + } + } + if (spec.supportsBounds) { + for (final b in [spec.minBound, spec.maxBound]) { + if (b != null && b < m) m = b; + } + } + return m >= 0 ? 0 : m * 1.15; + } + + /// Optional min/max threshold lines drawn across the plot (bar/line only). + ExtraLinesData _boundLines(ChartSpec spec) { + if (!spec.supportsBoundLines) return const ExtraLinesData(); + final dash = [ + (w * 0.018).round().clamp(4, 14), + (w * 0.01).round().clamp(3, 9), + ]; + HorizontalLine line(double value, Color color, String prefix) => + HorizontalLine( + y: value, + color: color, + strokeWidth: w * 0.0035, + dashArray: dash, + label: HorizontalLineLabel( + show: true, + alignment: Alignment.topRight, + padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002), + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.0115 * _labelScale, + color: color, + fontWeight: FontWeight.w700, + ), + ), + labelResolver: (_) => '$prefix ${_fmtNum(value)}', + ), + ); + return ExtraLinesData( + horizontalLines: [ + if (spec.minBound != null) + line(spec.minBound!, const Color(0xFFF59E0B), 'min'), + if (spec.maxBound != null) + line(spec.maxBound!, const Color(0xFFEF4444), 'max'), + ], + ); + } + + FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) { + final style = _applyFont( + font, + TextStyle( + fontSize: w * 0.0115 * _labelScale, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal, + ), + ); + return FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: w * 0.05 * _labelScale, + getTitlesWidget: (value, meta) => Text( + _fmtNum(value), + style: style.copyWith(fontSize: w * 0.0105 * _labelScale), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + reservedSize: w * 0.044 * _labelScale, + getTitlesWidget: (value, meta) { + final i = value.round(); + final n = spec.x.length; + if (i < 0 || i >= n) return const SizedBox.shrink(); + // Show as many labels as fit without colliding: keep at least + // [minSlot] of horizontal room per label, then thin them out + // evenly based on the actual pixel spacing between points. Line + // charts spread n points over n-1 intervals; bar groups are laid + // out spaceEvenly, which puts their centres (axis + groupWidth) / + // (n + 1) apart. + final spacing = bars + ? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1) + : (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize); + final minSlot = w * 0.085 * _labelScale; + final step = math.max(1, (minSlot / spacing).ceil()); + final lastMultiple = ((n - 1) ~/ step) * step; + final lastGap = n - 1 - lastMultiple; + final showLast = i == n - 1 && lastGap > step / 2; + if (i % step != 0 && !showLast) return const SizedBox.shrink(); + // The extra end label can sit closer than a full step to its + // neighbour; shrink both of their slots to the real gap so they + // never run through each other. + var slotSteps = step.toDouble(); + if (showLast || (i == lastMultiple && lastGap > step / 2)) { + slotSteps = math.min(slotSteps, lastGap.toDouble()); + } + final slot = (slotSteps * spacing - w * 0.012).clamp( + w * 0.04, + w * 0.16, + ); + return Padding( + padding: EdgeInsets.only(top: w * 0.008), + child: SizedBox( + width: slot, + child: Text( + spec.x[i], + style: style, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + ), + ); + } + + String _fmtNum(double v) { + if (v == v.roundToDouble()) return v.toInt().toString(); + return v.toStringAsFixed(1); + } + + FlGridData _grid(Color textColor) => FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (v) => + FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1), + ); + + /// Width of one bar rod, shared by the chart and the axis-label spacing. + double _barRodWidth(ChartSpec spec) => + (w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022); + + /// Total width of one bar group: its rods plus fl_chart's default 2px + /// spacing between rods within a group. + double _barGroupWidth(ChartSpec spec) { + final rods = math.max(1, spec.series.length); + return rods * _barRodWidth(spec) + (rods - 1) * 2; + } + + Widget _barChart(ChartSpec spec, Color textColor) { + final groups = []; + for (var xi = 0; xi < spec.x.length; xi++) { + groups.add( + BarChartGroupData( + x: xi, + barRods: [ + for (var si = 0; si < spec.series.length; si++) + if (xi < spec.series[si].data.length) + BarChartRodData( + toY: spec.series[si].data[xi], + color: _seriesDisplayColor(spec.series[si], si), + width: _barRodWidth(spec), + borderRadius: BorderRadius.vertical( + top: Radius.circular(w * 0.006), + ), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: _maxY(spec), + color: textColor.withValues(alpha: 0.025), + ), + ), + ], + ), + ); + } + return BarChart( + BarChartData( + minY: _minY(spec), + maxY: _maxY(spec), + // The axis-label spacing in _titles assumes this layout; keep it + // explicit rather than relying on fl_chart's default. + alignment: BarChartAlignment.spaceEvenly, + barGroups: groups, + titlesData: _titles(spec, textColor, bars: true), + gridData: _grid(textColor), + borderData: FlBorderData(show: false), + extraLinesData: _boundLines(spec), + barTouchData: BarTouchData( + enabled: true, + mouseCursorResolver: (event, response) => response?.spot == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchTooltipData: BarTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => const Color(0xFF0F172A), + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final label = group.x >= 0 && group.x < spec.x.length + ? spec.x[group.x] + : ''; + final series = rodIndex < spec.series.length + ? spec.series[rodIndex].name + : ''; + return BarTooltipItem( + '$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}', + _tooltipStyle(), + ); + }, + ), + ), + ), + duration: Duration.zero, + ); + } + + Widget _lineChart(ChartSpec spec, Color textColor) { + final bars = []; + for (var si = 0; si < spec.series.length; si++) { + bars.add( + LineChartBarData( + spots: [ + for (var xi = 0; xi < spec.series[si].data.length; xi++) + FlSpot(xi.toDouble(), spec.series[si].data[xi]), + ], + color: _seriesDisplayColor(spec.series[si], si), + barWidth: w * (_hovered == si ? 0.0065 : 0.0045), + isCurved: true, + curveSmoothness: 0.22, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( + radius: w * 0.005, + color: _seriesDisplayColor(spec.series[si], si), + strokeWidth: w * 0.0025, + strokeColor: _hexColor(profile.slideBackgroundColor), + ), + ), + belowBarData: BarAreaData( + show: true, + color: _seriesDisplayColor( + spec.series[si], + si, + ).withValues(alpha: spec.series.length == 1 ? 0.14 : 0.05), + ), + ), + ); + } + return LineChart( + LineChartData( + minY: _minY(spec), + maxY: _maxY(spec), + lineBarsData: bars, + titlesData: _titles(spec, textColor), + gridData: _grid(textColor), + borderData: FlBorderData(show: false), + extraLinesData: _boundLines(spec), + lineTouchData: LineTouchData( + enabled: true, + // Measure proximity to the actual dot (x *and* y), not just the + // column, so the tooltip belongs to the point under the cursor. + distanceCalculator: (touch, spot) => (touch - spot).distance, + touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(), + mouseCursorResolver: (event, response) => + response?.lineBarSpots?.isEmpty ?? true + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => const Color(0xFF0F172A), + // Show every dot near the cursor. When several dots sit on (almost) + // the same spot they all appear; the font shrinks to keep them + // readable when stacked. + getTooltipItems: (spots) { + final style = _lineTooltipStyle(spots.length); + return [ + for (final spot in spots) + LineTooltipItem( + '${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n' + '${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}', + style, + ), + ]; + }, + ), + ), + ), + duration: Duration.zero, + ); + } + + Widget _pieChart(ChartSpec spec, Color textColor) { + if (spec.series.isEmpty || spec.x.isEmpty) { + return _placeholderText('—'); + } + return LayoutBuilder( + builder: (context, constraints) { + final visibleSeries = math.min(spec.series.length, 2); + final columns = visibleSeries; + const rows = 1; + final tileHeight = constraints.maxHeight / rows; + final tileWidth = constraints.maxWidth / columns; + return GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + childAspectRatio: tileWidth / tileHeight, + crossAxisSpacing: w * 0.012, + mainAxisSpacing: w * 0.008, + ), + itemCount: visibleSeries, + itemBuilder: (context, si) { + final series = spec.series[si]; + final values = [ + for (var xi = 0; xi < spec.x.length; xi++) + xi < series.data.length && series.data[xi] > 0 + ? series.data[xi] + : 0.0, + ]; + final total = values.fold(0, (a, b) => a + b); + return Row( + children: [ + Expanded( + flex: 4, + child: total <= 0 + ? Center( + child: Text( + '0', + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.025, + color: textColor.withValues(alpha: 0.5), + ), + ), + ), + ) + : LayoutBuilder( + builder: (context, pieConstraints) { + final available = + pieConstraints.biggest.shortestSide; + final radius = (available * 0.42).clamp( + w * 0.018, + w * 0.075, + ); + return ClipRect( + child: _HoverPieChart( + externalHover: _hovered, + values: values, + labels: spec.x, + colors: [ + for (var xi = 0; xi < values.length; xi++) + _hexColor(chartRowColor(spec, xi)), + ], + radius: radius, + centerSpaceRadius: radius * 0.42, + sectionSpace: w * 0.002, + titleStyle: _applyFont( + font, + TextStyle( + fontSize: (radius * 0.18).clamp( + w * 0.009, + w * 0.013, + ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + tooltipStyle: _tooltipStyle(), + ), + ); + }, + ), + ), + SizedBox(width: w * 0.008), + Expanded( + flex: 2, + child: Text( + series.name.isEmpty ? 'Reeks ${si + 1}' : series.name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.015, + height: 1.1, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _radarChart(ChartSpec spec, Color textColor) { + if (spec.x.length < 3 || spec.series.isEmpty) { + return _placeholderText( + context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'), + ); + } + final grid = textColor.withValues(alpha: 0.18); + final scale = radarScale(spec); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012), + child: LayoutBuilder( + builder: (context, constraints) { + // Reserve a slim column on the right for the scale legend; the rest + // of the area is shared between the spider and its axis labels. + final legendWidth = w * 0.075; + final boxW = math.max( + 0.0, + constraints.maxWidth - legendWidth - w * 0.02, + ); + final boxH = constraints.maxHeight; + if (boxW <= 0 || !boxH.isFinite || boxH <= 0) { + return const SizedBox.shrink(); + } + // Measure every axis label and grow the spider until the labels just + // fit between the polygon and the edges of the available area, so + // the diagram uses the space the old fixed label bands wasted. + final layout = _radarLabelLayout(spec, boxW, boxH, textColor); + final chartSide = layout.chartSide; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: SizedBox( + width: boxW, + height: boxH, + child: Stack( + children: [ + for (var i = 0; i < spec.x.length; i++) + _radarAxisLabel( + label: spec.x[i], + index: i, + count: spec.x.length, + layout: layout, + textColor: textColor, + ), + Positioned( + left: (boxW - chartSide) / 2, + top: (boxH - chartSide) / 2, + width: chartSide, + height: chartSide, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: RadarChart( + RadarChartData( + dataSets: [ + for ( + var si = 0; + si < spec.series.length; + si++ + ) + RadarDataSet( + dataEntries: [ + for ( + var xi = 0; + xi < spec.x.length; + xi++ + ) + RadarEntry( + value: + xi < + spec + .series[si] + .data + .length + ? spec.series[si].data[xi] + : 0, + ), + ], + fillColor: + _seriesDisplayColor( + spec.series[si], + si, + ).withValues( + alpha: _dimmed(si) + ? 0.04 + : 0.16, + ), + borderColor: _seriesDisplayColor( + spec.series[si], + si, + ), + borderWidth: + w * + (_hovered == si + ? 0.0055 + : 0.0035), + entryRadius: + w * + (_hovered == si ? 0.006 : 0.004), + ), + // Invisible anchor pinning the scale to [lo, hi] + // so the rings represent a fixed scale. + RadarDataSet( + dataEntries: [ + for ( + var xi = 0; + xi < spec.x.length; + xi++ + ) + RadarEntry( + value: xi == 0 + ? scale.hi + : scale.lo, + ), + ], + fillColor: Colors.transparent, + borderColor: Colors.transparent, + borderWidth: 0, + entryRadius: 0, + ), + ], + radarShape: RadarShape.polygon, + radarBackgroundColor: Colors.transparent, + radarBorderData: BorderSide( + color: grid, + width: 1, + ), + gridBorderData: BorderSide( + color: grid, + width: 1, + ), + tickBorderData: BorderSide( + color: grid, + width: 1, + ), + tickCount: scale.ticks, + isMinValueAtCenter: true, + // The scale now lives in a side legend, so hide + // fl_chart's in-chart ring numbers. + ticksTextStyle: const TextStyle( + color: Colors.transparent, + fontSize: 0.001, + ), + titlePositionPercentageOffset: 0, + getTitle: (index, angle) => RadarChartTitle( + text: index < spec.x.length + ? spec.x[index] + : '', + ), + // Labels are rendered as constrained widgets + // around the chart so long text can wrap. + titleTextStyle: const TextStyle( + color: Colors.transparent, + fontSize: 0.001, + ), + radarTouchData: RadarTouchData( + enabled: true, + touchSpotThreshold: (w * 0.02) + .clamp(8.0, 24.0) + .toDouble(), + mouseCursorResolver: (event, response) => + _radarSpotFrom(response, spec) == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchCallback: (event, response) { + final next = + event.isInterestedForInteractions + ? _radarSpotFrom(response, spec) + : null; + if (next != _radarTouch) { + setState(() => _radarTouch = next); + } + }, + ), + ), + duration: Duration.zero, + ), + ), + if (_radarTouch != null) + _radarTooltip(spec, chartSide, _radarTouch!), + ], + ), + ), + ], + ), + ), + ), + ), + SizedBox( + width: legendWidth, + child: _radarScaleLegend(scale, textColor), + ), + ], + ); + }, + ), + ); + } + + TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont( + font, + TextStyle( + fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, + height: 1.05, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, + ), + ); + + /// True when the vertex in [direction] gets its label placed beside the + /// polygon (left/right) rather than above/below it. + static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35; + + /// Sizes the spider and places every axis label around it. + /// + /// Each label is measured at its real text size, then the polygon radius is + /// grown until the tightest label exactly fits between the polygon and the + /// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the + /// side of its (square) widget, which is what ties [chartSide] to the + /// resulting radius. + ({double chartSide, List rects, List aligns, int maxLines}) + _radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) { + const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8 + final n = spec.x.length; + final style = _radarLabelStyle(n, textColor); + final gap = w * 0.008; + final maxLines = n <= 6 ? 3 : 2; + final sideCap = math.min(boxW * 0.28, w * 0.2); + final topCap = math.min(boxW * 0.5, w * 0.3); + + Size measure(String text, double maxWidth) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + maxLines: maxLines, + ellipsis: '…', + )..layout(maxWidth: math.max(0.0, maxWidth)); + final size = Size(painter.width, painter.height); + painter.dispose(); + return size; + } + + final directions = []; + final sizes = []; + for (var i = 0; i < n; i++) { + final angle = (2 * math.pi * i / n) - math.pi / 2; + final dir = Offset(math.cos(angle), math.sin(angle)); + directions.add(dir); + sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap)); + } + + // The largest polygon radius every label still fits next to. + var radius = radiusFactor * math.min(boxW, boxH); + for (var i = 0; i < n; i++) { + final dx = directions[i].dx.abs(); + final dy = directions[i].dy.abs(); + if (_radarLabelBeside(directions[i])) { + radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx); + if (dy > 0.01) { + radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy); + } + } else { + radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy); + if (dx > 0.01) { + radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx); + } + } + } + // Never let extreme labels crush the spider entirely; below this floor the + // labels get clamped (and ellipsized) instead. + final floor = 0.18 * math.min(boxW, boxH); + radius = radius.clamp( + math.min(floor, radiusFactor * math.min(boxW, boxH)), + radiusFactor * math.min(boxW, boxH), + ); + final chartSide = radius / radiusFactor; + + final center = Offset(boxW / 2, boxH / 2); + final rects = []; + final aligns = []; + for (var i = 0; i < n; i++) { + final dir = directions[i]; + final anchor = center + dir * (radius + gap); + var size = sizes[i]; + double left; + double top; + if (_radarLabelBeside(dir)) { + // Re-measure against the room actually left beside the polygon, so a + // clamped radius still produces a label that wraps inside the box. + final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx; + if (size.width > room) size = measure(spec.x[i], room); + left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width; + top = anchor.dy - size.height / 2; + aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right); + } else { + left = anchor.dx - size.width / 2; + top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy; + aligns.add(TextAlign.center); + } + rects.add( + Rect.fromLTWH( + left.clamp(0.0, math.max(0.0, boxW - size.width)), + top.clamp(0.0, math.max(0.0, boxH - size.height)), + size.width, + size.height, + ), + ); + } + return ( + chartSide: chartSide, + rects: rects, + aligns: aligns, + maxLines: maxLines, + ); + } + + Widget _radarAxisLabel({ + required String label, + required int index, + required int count, + required ({ + double chartSide, + List rects, + List aligns, + int maxLines, + }) + layout, + required Color textColor, + }) { + final rect = layout.rects[index]; + return Positioned( + key: ValueKey('radar-axis-label-$index'), + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: Text( + label, + maxLines: layout.maxLines, + overflow: TextOverflow.ellipsis, + textAlign: layout.aligns[index], + style: _radarLabelStyle(count, textColor), + ), + ); + } + + /// Extract the touched real-series vertex from a radar touch response, + /// ignoring the invisible scale anchor dataset. + ({int series, int entry, double value, Offset offset})? _radarSpotFrom( + RadarTouchResponse? response, + ChartSpec spec, + ) { + final spot = response?.touchedSpot; + if (spot == null) return null; + if (spot.touchedDataSetIndex < 0 || + spot.touchedDataSetIndex >= spec.series.length) { + return null; // the anchor dataset, or out of range + } + return ( + series: spot.touchedDataSetIndex, + entry: spot.touchedRadarEntryIndex, + value: spot.touchedRadarEntry.value, + offset: spot.offset, + ); + } + + /// A small floating tooltip for the hovered radar vertex, like the other + /// charts: the axis label, the series name and the value. + Widget _radarTooltip( + ChartSpec spec, + double side, + ({int series, int entry, double value, Offset offset}) touch, + ) { + final axis = touch.entry >= 0 && touch.entry < spec.x.length + ? spec.x[touch.entry] + : ''; + final series = touch.series < spec.series.length + ? spec.series[touch.series].name + : ''; + final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series; + final onLeftHalf = touch.offset.dx <= side / 2; + return Positioned( + left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null, + right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012), + top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)), + child: IgnorePointer( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: side * 0.6), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.012, + vertical: w * 0.006, + ), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(w * 0.008), + boxShadow: const [ + BoxShadow(color: Color(0x33000000), blurRadius: 6), + ], + ), + child: Text( + '${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: _tooltipStyle(), + ), + ), + ), + ), + ); + } + + /// Vertical scale legend shown to the right of a radar chart: the tick values + /// from the outer ring (top) down to the centre (bottom), in a small font. + Widget _radarScaleLegend( + ({double lo, double hi, int ticks}) scale, + Color textColor, + ) { + final style = _applyFont( + font, + TextStyle( + fontSize: w * 0.012 * _labelScale, + color: textColor.withValues(alpha: 0.62), + fontWeight: FontWeight.w600, + ), + ); + final tickColor = textColor.withValues(alpha: 0.3); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var k = scale.ticks; k >= 0; k--) ...[ + if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: w * 0.012, height: 1, color: tickColor), + SizedBox(width: w * 0.006), + Flexible( + child: Text( + _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ); + } + + /// Resolves the radar scale: a low/high pair plus an even tick count. Honours + /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data + /// range to a tidy scale so the rings read as round numbers. + ({double lo, double hi, int ticks}) radarScale(ChartSpec spec) { + var dataMin = 0.0; + var dataMax = 0.0; + var seen = false; + for (final s in spec.series) { + for (final v in s.data) { + if (!seen) { + dataMin = v; + dataMax = v; + seen = true; + } else { + if (v < dataMin) dataMin = v; + if (v > dataMax) dataMax = v; + } + } + } + if (!seen) { + dataMin = 0; + dataMax = 1; + } + final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); + final rawHi = spec.maxBound ?? dataMax; + final nice = _niceScale(rawLo, rawHi); + final lo = spec.minBound ?? nice.lo; + var hi = spec.maxBound ?? nice.hi; + if (hi <= lo) hi = lo + nice.step; + final ticks = math.max(2, ((hi - lo) / nice.step).round()); + return (lo: lo, hi: hi, ticks: ticks); + } + + ({double lo, double hi, double step}) _niceScale(double lo, double hi) { + final range = (hi - lo).abs(); + final r = range <= 0 ? 1.0 : range; + final rawStep = r / 4; + final mag = math + .pow(10, (math.log(rawStep) / math.ln10).floor()) + .toDouble(); + final norm = rawStep / mag; + final niceNorm = norm < 1.5 + ? 1.0 + : norm < 3 + ? 2.0 + : norm < 7 + ? 5.0 + : 10.0; + final step = niceNorm * mag; + return ( + lo: (lo / step).floor() * step, + hi: (hi / step).ceil() * step, + step: step, + ); + } + + TextStyle _tooltipStyle() => _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: (w * 0.013 * _labelScale).clamp(11, 18), + height: 1.25, + fontWeight: FontWeight.w700, + ), + ); + + /// Tooltip style for line charts. Each touched dot adds two lines, so when + /// several dots overlap the font shrinks a step to keep the stack readable. + TextStyle _lineTooltipStyle(int count) { + final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0); + final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0); + return _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: (base * shrink).clamp(8.0, 18.0), + height: 1.2, + fontWeight: FontWeight.w700, + ), + ); + } + + Widget _placeholder(BuildContext context) => + _placeholderText(context.l10n.d('Geen grafiekgegevens')); + + Widget _placeholderText(String text) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.bar_chart_outlined, + size: w * 0.08, + color: const Color(0xFF94A3B8), + ), + SizedBox(height: w * 0.01), + Text( + text, + style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02), + ), + ], + ), + ); +} + +class _HoverPieChart extends StatefulWidget { + final List values; + final List labels; + final List colors; + final double radius; + final double centerSpaceRadius; + final double sectionSpace; + final TextStyle titleStyle; + final TextStyle tooltipStyle; + + /// Slice index highlighted from outside (e.g. hovering the legend), combined + /// with this chart's own touch hover. + final int? externalHover; + + const _HoverPieChart({ + required this.values, + required this.labels, + required this.colors, + required this.radius, + required this.centerSpaceRadius, + required this.sectionSpace, + required this.titleStyle, + required this.tooltipStyle, + this.externalHover, + }); + + @override + State<_HoverPieChart> createState() => _HoverPieChartState(); +} + +class _HoverPieChartState extends State<_HoverPieChart> { + int? _hovered; + + @override + Widget build(BuildContext context) { + final total = widget.values.fold(0, (a, b) => a + b); + final external = widget.externalHover; + final hovered = + _hovered ?? + (external != null && external >= 0 && external < widget.values.length + ? external + : null); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: PieChart( + PieChartData( + sections: [ + for (var i = 0; i < widget.values.length; i++) + PieChartSectionData( + value: widget.values[i], + color: widget.colors[i], + title: widget.values[i] / total >= 0.08 + ? '${(widget.values[i] / total * 100).round()}%' + : '', + radius: widget.radius * (hovered == i ? 1.08 : 1), + titleStyle: widget.titleStyle, + ), + ], + sectionsSpace: widget.sectionSpace, + centerSpaceRadius: widget.centerSpaceRadius, + pieTouchData: PieTouchData( + enabled: true, + mouseCursorResolver: (event, response) => + response?.touchedSection == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchCallback: (event, response) { + final next = event.isInterestedForInteractions + ? response?.touchedSection?.touchedSectionIndex + : null; + if (next != _hovered) setState(() => _hovered = next); + }, + ), + ), + duration: Duration.zero, + ), + ), + if (hovered != null && hovered >= 0 && hovered < widget.values.length) + Positioned( + top: 4, + left: 4, + right: 4, + child: IgnorePointer( + child: Center( + child: Container( + key: const ValueKey('pie-hover-tooltip'), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Color(0x33000000), blurRadius: 6), + ], + ), + child: Text( + '${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: widget.tooltipStyle, + ), + ), + ), + ), + ), + ], + ); + } +} + +String _formatChartValue(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); diff --git a/lib/widgets/slides/previews/checklist_previews.dart b/lib/widgets/slides/previews/checklist_previews.dart new file mode 100644 index 0000000..188a175 --- /dev/null +++ b/lib/widgets/slides/previews/checklist_previews.dart @@ -0,0 +1,333 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _ChecklistProgress extends StatelessWidget { + final List bullets; + final double w; + final String font; + final ThemeProfile profile; + + const _ChecklistProgress({ + required this.bullets, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final items = bullets + .where((bullet) => checklistItemText(bullet).trim().isNotEmpty) + .toList(); + final checked = items.where(checklistItemChecked).length; + final total = items.length; + final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round(); + final openPercent = total == 0 ? 0 : 100 - checkedPercent; + final textColor = _hexColor(profile.textColor); + final checkedColor = _hexColor(profile.checklistCheckedColor); + final openColor = _hexColor(profile.checklistUncheckedColor); + final labelStyle = _applyFont( + font, + TextStyle( + fontSize: w * 0.0125, + height: 1.2, + color: textColor, + fontWeight: FontWeight.w600, + ), + ); + + final interaction = _ChecklistInteractionScope.maybeOf(context); + + return LayoutBuilder( + builder: (context, constraints) { + // Grow the pie to fill the width it is handed instead of staying at a + // fixed, tiny size. Every caller gives this widget a bounded column + // width, so the chart now scales with the space that is actually + // available next to (or above) the bullets. + final maxW = constraints.maxWidth.isFinite + ? constraints.maxWidth + : w * 0.4; + // Cap the pie so it stays a balanced companion to the bullet column + // rather than dominating it: a smaller chart keeps the visual split + // closer to 50/50 and, crucially, never forces the surrounding text to + // shrink to fit the chart's height when a slide has many bullets. + final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble(); + final baseRadius = diameter * 0.44; + final hoverRadius = diameter * 0.48; + final pieTitleStyle = _applyFont( + font, + TextStyle( + fontSize: diameter * 0.085, + height: 1.1, + fontWeight: FontWeight.bold, + color: textColor, + ), + ); + + Widget pie(bool? hovered) => PieChart( + key: const ValueKey('checklist-progress-pie'), + PieChartData( + sectionsSpace: w * 0.002, + centerSpaceRadius: 0, + startDegreeOffset: -90, + sections: [ + if (checkedPercent > 0) + PieChartSectionData( + value: checkedPercent.toDouble(), + color: checkedColor, + radius: hovered == true ? hoverRadius : baseRadius, + title: '$checkedPercent%', + titleStyle: pieTitleStyle.copyWith(color: Colors.white), + ), + if (openPercent > 0) + PieChartSectionData( + value: openPercent.toDouble(), + color: openColor, + radius: hovered == false ? hoverRadius : baseRadius, + title: '$openPercent%', + titleStyle: pieTitleStyle, + ), + ], + pieTouchData: PieTouchData( + enabled: interaction?.enabled == true, + touchCallback: (event, response) { + if (interaction?.enabled != true) return; + final index = event.isInterestedForInteractions + ? response?.touchedSection?.touchedSectionIndex + : null; + if (index == null) { + interaction!.hovered.value = null; + } else if (checkedPercent == 0) { + interaction!.hovered.value = false; + } else { + interaction!.hovered.value = index == 0; + } + }, + ), + ), + duration: Duration.zero, + ); + + return Semantics( + label: + '${context.l10n.d('Afgevinkt')} $checkedPercent%, ' + '${context.l10n.d('Niet afgevinkt')} $openPercent%', + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: diameter, + height: diameter, + child: interaction == null + ? pie(null) + : ValueListenableBuilder( + valueListenable: interaction.hovered, + builder: (_, hovered, _) => pie(hovered), + ), + ), + SizedBox(height: w * 0.008), + MouseRegion( + key: const ValueKey('checklist-progress-checked'), + onEnter: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = true, + onExit: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = null, + child: Text( + '${context.l10n.d('Afgevinkt')} $checkedPercent%', + style: labelStyle, + ), + ), + MouseRegion( + key: const ValueKey('checklist-progress-unchecked'), + onEnter: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = false, + onExit: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = null, + child: Text( + '${context.l10n.d('Niet afgevinkt')} $openPercent%', + style: labelStyle.copyWith( + color: textColor.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ChecklistBulletRow extends StatelessWidget { + final List bullets; + final int itemIndex; + final int column; + final ListStyle listStyle; + final bool checked; + final String text; + final int level; + final double fontSize; + final double bulletSize; + final double bulletGap; + final double scale; + final String font; + final ThemeProfile profile; + + const _ChecklistBulletRow({ + required this.bullets, + required this.itemIndex, + required this.column, + required this.listStyle, + required this.checked, + required this.text, + required this.level, + required this.fontSize, + required this.bulletSize, + required this.bulletGap, + required this.scale, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final interaction = _ChecklistInteractionScope.maybeOf(context); + Widget row(bool highlighted) => AnimatedContainer( + key: ValueKey('checklist-preview-item-$column-$itemIndex'), + duration: const Duration(milliseconds: 140), + padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0), + decoration: BoxDecoration( + color: highlighted + ? _hexColor(profile.accentColor).withValues(alpha: 0.16) + : Colors.transparent, + borderRadius: BorderRadius.circular(wScale(5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + key: ValueKey('checklist-preview-toggle-$column-$itemIndex'), + behavior: HitTestBehavior.opaque, + onTap: + listStyle == ListStyle.checklist && interaction?.enabled == true + ? () => interaction!.onToggle?.call(column, itemIndex) + : null, + child: MouseRegion( + cursor: + listStyle == ListStyle.checklist && + interaction?.enabled == true + ? SystemMouseCursors.click + : MouseCursor.defer, + child: Text( + '${_listMarker(bullets, itemIndex, listStyle)} ', + style: TextStyle( + fontSize: fontSize, + color: _hexColor(profile.accentColor), + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + child: _md( + context, + text, + _applyFont( + font, + TextStyle( + fontSize: fontSize, + height: _kBulletLineHeight, + color: _hexColor(profile.textColor), + decoration: checked && profile.checklistStrikeThrough + ? TextDecoration.lineThrough + : null, + decorationColor: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + ], + ), + ); + + final padded = Padding( + padding: EdgeInsets.only( + left: level * bulletSize * 1.05 * scale, + top: bulletGap * scale, + bottom: bulletGap * scale, + ), + child: interaction == null || listStyle != ListStyle.checklist + ? row(false) + : ValueListenableBuilder( + valueListenable: interaction.hovered, + builder: (_, hovered, _) => row(hovered == checked), + ), + ); + return padded; + } + + double wScale(double value) => value * scale; +} + +class _ChecklistInteractionHost extends StatefulWidget { + final bool enabled; + final void Function(int column, int itemIndex)? onToggle; + final Widget child; + + const _ChecklistInteractionHost({ + required this.enabled, + required this.onToggle, + required this.child, + }); + + @override + State<_ChecklistInteractionHost> createState() => + _ChecklistInteractionHostState(); +} + +class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> { + final ValueNotifier hovered = ValueNotifier(null); + + @override + void dispose() { + hovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ChecklistInteractionScope( + enabled: widget.enabled, + hovered: hovered, + onToggle: widget.onToggle, + child: widget.child, + ); + } +} + +class _ChecklistInteractionScope extends InheritedWidget { + final bool enabled; + final ValueNotifier hovered; + final void Function(int column, int itemIndex)? onToggle; + + const _ChecklistInteractionScope({ + required this.enabled, + required this.hovered, + required this.onToggle, + required super.child, + }); + + static _ChecklistInteractionScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>(); + + @override + bool updateShouldNotify(_ChecklistInteractionScope oldWidget) => + enabled != oldWidget.enabled || onToggle != oldWidget.onToggle; +} diff --git a/lib/widgets/slides/previews/code_preview.dart b/lib/widgets/slides/previews/code_preview.dart new file mode 100644 index 0000000..e0f00a8 --- /dev/null +++ b/lib/widgets/slides/previews/code_preview.dart @@ -0,0 +1,180 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +/// Een 'broncode-sheet': de code op een donker editor-vlak, met +/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar +/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de +/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen. +class _CodePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _CodePreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + /// Natural (unwrapped) size of [text] in [style]: width is the longest line, + /// height the full block. Used to scale code to the available space. + static Size _measureMono(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text.isEmpty ? ' ' : text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + return painter.size; + } + + @override + Widget build(BuildContext context) { + _ensureHighlightLanguages(); + final pad = w * 0.05; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final code = slide.customMarkdown; + final lang = slide.codeLanguage.trim(); + final known = lang.isNotEmpty && allLanguages.containsKey(lang); + + final codeBg = _hexColor(profile.codeBackgroundColor); + final codeFg = _hexColor(profile.codeTextColor); + + // The chosen monospace family, always backed by a generic monospace fallback + // so an uninstalled face still renders fixed-width. + final fallback = ['Menlo', 'Consolas', 'Courier New', 'monospace'] + ..removeWhere((f) => f == profile.codeFontFamily); + final baseFont = w * 0.024; + final maxFont = w * 0.040; // grow to fill, but never huge + TextStyle monoAt(double size) => TextStyle( + fontFamily: profile.codeFontFamily, + fontFamilyFallback: fallback, + fontSize: size, + height: 1.4, + color: codeFg, + ); + + // HighlightView throws on an unknown language, so fall back to plain (but + // monospace) text. When syntax highlighting is off we always render plain + // text so the whole block is one colour — needed for a CRT-green screen. + final useHighlight = known && profile.codeHighlightSyntax; + final highlightTheme = { + ...atomOneDarkTheme, + // Keep atom-one-dark's per-token colours but drop its own background so + // our themed [codeBg] shows through unchanged. + 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( + backgroundColor: codeBg, + color: codeFg, + ), + }; + Widget buildCode(TextStyle style) => useHighlight + ? HighlightView( + code, + language: lang, + theme: highlightTheme, + padding: EdgeInsets.zero, + textStyle: style, + ) + : Text(code, style: style); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The slide title belongs to the slide, not inside the code window, + // so it sits above the panel like other slide types. + if (slide.title.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: w * 0.025, + vertical: w * 0.01, + ), + decoration: BoxDecoration( + color: _hexColor(profile.titleBackgroundColor), + borderRadius: BorderRadius.circular(w * 0.012), + border: Border( + left: BorderSide( + color: _hexColor(profile.accentColor), + width: w * 0.006, + ), + ), + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: w * 0.032, + height: 1.1, + fontWeight: FontWeight.bold, + color: _hexColor(profile.titleTextColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + SizedBox(height: w * 0.018), + ], + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: codeBg, + borderRadius: BorderRadius.circular(w * 0.012), + border: Border.all(color: codeFg.withValues(alpha: 0.22)), + ), + padding: EdgeInsets.all(w * 0.03), + child: LayoutBuilder( + builder: (context, constraints) { + // Size the code to fill the panel: scale up to use spare + // space (capped at [maxFont]) and down so long fragments + // still fit, rather than leaving a small block in a big box. + final measured = useHighlight + ? code.replaceAll('\t', ' ') + : code; + final natural = _measureMono(measured, monoAt(baseFont)); + final availW = math.max(1.0, constraints.maxWidth - 1); + final availH = math.max(1.0, constraints.maxHeight - 1); + var scale = math.min( + availW / natural.width, + availH / natural.height, + ); + if (!scale.isFinite || scale <= 0) scale = 1; + final size = math.min(baseFont * scale, maxFont); + return Align( + alignment: Alignment.topLeft, + child: buildCode(monoAt(size)), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Register highlight.js language definitions once, so [HighlightView] can +/// colour any common language without throwing. +bool _highlightReady = false; + +void _ensureHighlightLanguages() { + if (_highlightReady) return; + allLanguages.forEach(highlight.registerLanguage); + _highlightReady = true; +} + +// ── Shared helper ───────────────────────────────────────────────────────────── diff --git a/lib/widgets/slides/previews/media_previews.dart b/lib/widgets/slides/previews/media_previews.dart new file mode 100644 index 0000000..3df31fc --- /dev/null +++ b/lib/widgets/slides/previews/media_previews.dart @@ -0,0 +1,601 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _AudioPlayback extends StatefulWidget { + final String audioPath; + final String? projectPath; + final bool autoplay; + final double w; + final VoidCallback? onComplete; + + const _AudioPlayback({ + required this.audioPath, + required this.projectPath, + required this.autoplay, + required this.w, + this.onComplete, + }); + + @override + State<_AudioPlayback> createState() => _AudioPlaybackState(); +} + +class _AudioPlaybackState extends State<_AudioPlayback> { + VideoPlayerController? _controller; + bool _completed = false; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(_AudioPlayback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.audioPath != widget.audioPath || + oldWidget.autoplay != widget.autoplay) { + _init(); + } + } + + Future _init() async { + _controller?.removeListener(_onTick); + await _controller?.dispose(); + _completed = false; + final path = _resolvePath(widget.audioPath, widget.projectPath); + if (path == null) return; + final controller = VideoPlayerController.file(File(path)); + _controller = controller; + try { + await controller.initialize(); + controller.addListener(_onTick); + if (widget.autoplay) await controller.play(); + } catch (e) { + logWarning('_AudioPlaybackState._init: audio controller init failed', e); + } + if (mounted) setState(() {}); + } + + /// Detecteer het einde van de audio en meld dat één keer (voor auto-advance). + void _onTick() { + final c = _controller; + if (c == null || !c.value.isInitialized || _completed) return; + final pos = c.value.position; + final dur = c.value.duration; + if (dur > Duration.zero && + pos.inMilliseconds >= dur.inMilliseconds - 200 && + !c.value.isPlaying) { + _completed = true; + widget.onComplete?.call(); + } + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Positioned( + right: widget.w * 0.035, + bottom: widget.w * 0.035, + child: IconButton( + tooltip: 'Audio', + onPressed: controller == null || !controller.value.isInitialized + ? null + : () { + setState(() { + controller.value.isPlaying + ? controller.pause() + : controller.play(); + }); + }, + icon: Icon( + controller?.value.isPlaying == true + ? Icons.volume_up + : Icons.volume_up_outlined, + ), + iconSize: widget.w * 0.032, + ), + ); + } +} + +// ── Individual slide-type renderers ────────────────────────────────────────── + +class _TwoImagesPreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _TwoImagesPreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5) + .clamp(0.1, 0.9); + final leftW = w * splitFraction; + final rightW = w * (1 - splitFraction); + final titleSize = w * 0.032; + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Stack( + fit: StackFit.expand, + children: [ + // Twee afbeeldingen naast elkaar + Row( + children: [ + SizedBox( + width: leftW, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(context, slide.imagePath, projectPath), + _captionOverlay(context, slide.imageCaption, w), + ], + ), + ), + SizedBox( + width: rightW, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(context, slide.imagePath2, projectPath), + _captionOverlay(context, slide.imageCaption2, w), + ], + ), + ), + ], + ), + // Optionele ondertitel + if (slide.title.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: w * 0.04, + child: Container( + color: Colors.black54, + padding: EdgeInsets.symmetric( + horizontal: w * 0.04, + vertical: w * 0.015, + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: titleSize, + fontWeight: FontWeight.w500, + ), + ), + linkColor: const Color(0xFF8BB8FF), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _ImagePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final hasTitle = slide.title.isNotEmpty; + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + context, + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.slideBackgroundColor), + // When zoomed out, anchor the image to the top so the bottom title + // banner sits in the freed-up space instead of over the picture. + alignment: hasTitle ? Alignment.topCenter : Alignment.center, + ), + if (slide.title.isNotEmpty) + Positioned( + left: w * 0.06, + right: w * 0.06, + bottom: w * 0.06, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.04, + vertical: w * 0.02, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: w * 0.038, + fontWeight: FontWeight.bold, + ), + ), + linkColor: const Color(0xFF8BB8FF), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _VideoPreview extends StatefulWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + final bool autoplay; + final VoidCallback? onComplete; + + const _VideoPreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + this.autoplay = false, + this.onComplete, + }); + + @override + State<_VideoPreview> createState() => _VideoPreviewState(); +} + +class _VideoPreviewState extends State<_VideoPreview> { + VideoPlayerController? _controller; + String? _path; + bool _completed = false; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(_VideoPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.slide.videoPath != widget.slide.videoPath || + oldWidget.autoplay != widget.autoplay) { + _init(); + } + } + + Future _init() async { + _controller?.removeListener(_onTick); + await _controller?.dispose(); + _controller = null; + _completed = false; + _path = _resolvePath(widget.slide.videoPath, widget.projectPath); + if (_path == null) { + if (mounted) setState(() {}); + return; + } + final controller = VideoPlayerController.file(File(_path!)); + _controller = controller; + try { + await controller.initialize(); + controller.addListener(_onTick); + await controller.setLooping(false); + if (widget.autoplay) await controller.play(); + } catch (e) { + logWarning('_VideoPreviewState._init: video controller init failed', e); + // Keep the placeholder visible when the platform cannot open the file. + } + if (mounted) setState(() {}); + } + + void _onTick() { + final controller = _controller; + if (controller == null || + !controller.value.isInitialized || + _completed || + !widget.autoplay) { + return; + } + final duration = controller.value.duration; + final position = controller.value.position; + if (duration > Duration.zero && + position.inMilliseconds >= duration.inMilliseconds - 200 && + !controller.value.isPlaying) { + _completed = true; + widget.onComplete?.call(); + } + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Container( + color: _hexColor(widget.profile.slideBackgroundColor), + child: Stack( + fit: StackFit.expand, + children: [ + if (controller != null && controller.value.isInitialized) + Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ) + else + _mediaPlaceholder(Icons.movie_outlined, 'Video'), + if (widget.slide.title.isNotEmpty) + Positioned( + left: widget.w * 0.06, + right: widget.w * 0.06, + top: widget.w * 0.04, + child: _md( + context, + widget.slide.title, + _applyFont( + widget.font, + TextStyle( + color: _hexColor(widget.profile.textColor), + fontSize: widget.w * 0.038, + fontWeight: FontWeight.bold, + ), + ), + linkColor: _hexColor(widget.profile.accentColor), + ), + ), + Positioned( + left: widget.w * 0.04, + bottom: widget.w * 0.035, + child: IconButton( + onPressed: controller == null || !controller.value.isInitialized + ? null + : () { + setState(() { + controller.value.isPlaying + ? controller.pause() + : controller.play(); + }); + }, + icon: Icon( + controller?.value.isPlaying == true + ? Icons.pause_circle + : Icons.play_circle, + ), + iconSize: widget.w * 0.045, + ), + ), + ], + ), + ); + } +} + +/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain. +/// imageSize = 0 → cover (Marp-standaard, vult frame, snijdt bij) +/// imageSize = 100 → volledige afbeelding zichtbaar (contain, evt. randen) +/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect +/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain +Widget _zoomedImage( + BuildContext context, + String imagePath, + String? projectPath, + int imageSize, { + Color bgColor = Colors.black, + Alignment alignment = Alignment.center, +}) { + if (imageSize == 0) { + return _resolvedImage( + context, + imagePath, + projectPath, + ); // BoxFit.cover standaard + } + final scale = imageSize / 100.0; + // Size the image box to `scale` × the available area and let BoxFit.contain + // fit the picture inside it. This produces the same visual result as a + // Transform.scale but without a transform layer, which `RepaintBoundary + // .toImage` (used for exports) captures far more reliably — a scaled + // transform layer would frequently render blank in the exported PNG. + return ClipRect( + child: ColoredBox( + color: bgColor, + child: LayoutBuilder( + builder: (context, constraints) { + final boxW = constraints.maxWidth * scale; + final boxH = constraints.maxHeight * scale; + return Align( + alignment: alignment, + child: SizedBox( + width: boxW, + height: boxH, + // BoxFit.contain: toont de volledige afbeelding zonder bijsnijden + child: _resolvedImage( + context, + imagePath, + projectPath, + fit: BoxFit.contain, + ), + ), + ); + }, + ), + ), + ); +} + +Widget _resolvedImage( + BuildContext context, + String imagePath, + String? projectPath, { + BoxFit fit = BoxFit.cover, +}) { + if (imagePath.isEmpty) return _imagePlaceholder(context); + + final String resolved; + if (imagePath.startsWith('/') || imagePath.contains(':\\')) { + resolved = imagePath; + } else if (projectPath != null) { + resolved = '$projectPath/$imagePath'; + } else { + resolved = imagePath; + } + + return Image.file( + File(resolved), + fit: fit, + width: double.infinity, + height: double.infinity, + // Keep showing the previous frame while the next image decodes. Without + // this the widget paints nothing for a frame on a source change, which + // shows up as a black flash between slides — fatal when recording video. + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context), + ); +} + +Widget _captionOverlay( + BuildContext context, + String caption, + double w, { + double? right, + double? bottom, +}) { + final text = caption.trim(); + if (text.isEmpty) return const SizedBox.shrink(); + // Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering + // staat, schuift het bijschrift erboven zodat het niet wordt overschreven. + final lift = _SlideLinkScope.hasBottomTlpOf(context) + ? _tlpVerticalReserve(w) + : 0.0; + return Positioned( + right: right ?? w * _kTlpEdge, + bottom: (bottom ?? _tlpBottomInset(w)) + lift, + child: Container( + constraints: BoxConstraints(maxWidth: w * 0.5), + padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + text, + textAlign: TextAlign.right, + style: TextStyle( + color: Colors.white, + fontSize: w * 0.011, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); +} + +Widget _mediaPlaceholder(IconData icon, String label) { + return Container( + color: const Color(0xFFE2E8F0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF94A3B8), size: 32), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + ], + ), + ), + ); +} + +Widget _imagePlaceholder(BuildContext context) { + return ColoredBox( + color: const Color(0xFFE2E8F0), + child: LayoutBuilder( + builder: (context, constraints) { + final shortestSide = constraints.biggest.shortestSide; + if (shortestSide < 48) { + return Center( + child: Icon( + Icons.image_outlined, + color: const Color(0xFF94A3B8), + size: shortestSide * 0.65, + ), + ); + } + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.image_outlined, + color: Color(0xFF94A3B8), + size: 24, + ), + const SizedBox(height: 4), + Text( + context.l10n.d('Afbeelding'), + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/widgets/slides/previews/overlays.dart b/lib/widgets/slides/previews/overlays.dart new file mode 100644 index 0000000..8f9394b --- /dev/null +++ b/lib/widgets/slides/previews/overlays.dart @@ -0,0 +1,246 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _LogoOverlay extends StatelessWidget { + final String logoPath; + final String? projectPath; + final String position; + final double size; + + const _LogoOverlay({ + required this.logoPath, + required this.projectPath, + required this.position, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final horizontalInset = size * 0.28; + final topInset = size * 0.42; + final bottomInset = size * 0.12; + return Positioned( + top: position.startsWith('top') ? topInset : null, + bottom: position.startsWith('bottom') ? bottomInset : null, + left: position.endsWith('left') ? horizontalInset : null, + right: position.endsWith('right') ? horizontalInset : null, + child: SizedBox( + width: size, + height: size, + child: _resolvedImage( + context, + logoPath, + projectPath, + fit: BoxFit.contain, + ), + ), + ); + } +} + +// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ────── +const double _kTlpFont = 0.018; // × slidebreedte + +const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte) + +const double _kTlpHPad = 0.011; + +const double _kTlpVPad = 0.005; + +double _tlpBottomInset(double w) => w * 0.022; + +/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken. +double _tlpBadgeWidth(double w, TlpLevel tlp) => + tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad); + +/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften). +double _tlpVerticalReserve(double w) => + w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w); + +/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak, +/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. +class _TlpOverlay extends StatelessWidget { + final TlpLevel tlp; + final double w; + final ThemeProfile profile; + final bool hasLogo; + + const _TlpOverlay({ + required this.tlp, + required this.w, + required this.profile, + required this.hasLogo, + }); + + @override + Widget build(BuildContext context) { + final toLeft = hasLogo && profile.logoPosition == 'bottom-right'; + return Positioned( + bottom: _tlpBottomInset(w), + left: toLeft ? w * _kTlpEdge : null, + right: toLeft ? null : w * _kTlpEdge, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * _kTlpHPad, + vertical: w * _kTlpVPad, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(w * 0.005), + ), + child: Text( + tlp.label, + style: TextStyle( + color: Color(tlp.foreground), + fontSize: w * _kTlpFont, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + height: 1.0, + ), + ), + ), + ); + } +} + +class _FooterOverlay extends StatelessWidget { + final Slide slide; + final double w; + final ThemeProfile profile; + final int? slideNumber; + final int? slideCount; + final TlpLevel tlp; + + const _FooterOverlay({ + required this.slide, + required this.w, + required this.profile, + this.slideNumber, + this.slideCount, + this.tlp = TlpLevel.none, + }); + + String _applyTokens(String s) { + final now = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final date = '${two(now.day)}-${two(now.month)}-${now.year}'; + return s + .replaceAll('{page}', slideNumber?.toString() ?? '') + .replaceAll('{total}', slideCount?.toString() ?? '') + .replaceAll('{date}', date) + .replaceAll('{title}', slide.title); + } + + @override + Widget build(BuildContext context) { + if (!slide.showFooter) return const SizedBox.shrink(); + if (slide.type == SlideType.title || slide.type == SlideType.section) { + return const SizedBox.shrink(); + } + + final footerText = _applyTokens(profile.footerText).trim(); + final showPages = profile.footerShowPageNumbers && slideNumber != null; + if (footerText.isEmpty && !showPages) return const SizedBox.shrink(); + + // Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt. + final fontSize = w * 0.0145; + final style = TextStyle( + color: _hexColor(profile.textColor).withValues(alpha: 0.7), + fontSize: fontSize, + // Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft. + shadows: [ + Shadow( + color: Colors.white.withValues(alpha: 0.5), + blurRadius: w * 0.003, + ), + ], + ); + + // Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en + // de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen. + double mx(double a, double b) => a > b ? a : b; + final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo; + final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom'); + final logoOnLeft = profile.logoPosition.endsWith('left'); + final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012; + final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28; + final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right'); + final tlpSpan = tlp == TlpLevel.none + ? 0.0 + : w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012; + final footerLeftAligned = profile.footerPosition == 'left'; + + // Links uitgelijnd begint de footer waar het logo of de bullets beginnen, + // voor een consistente linkermarge. Anders de standaardmarge. + var left = footerLeftAligned + ? (logoBottom && logoOnLeft + ? logoLeftEdge + : _contentLeftInset(slide, w)) + : w * 0.04; + var right = w * 0.04; + if (logoBottom) { + if (logoOnLeft) { + // Een links-uitgelijnde footer mag bewust met de logo-linkerkant + // uitlijnen; anders schuift 'ie rechts van het logo om overlap te + // voorkomen. + if (!footerLeftAligned) left = mx(left, logoSpan); + } else { + right = mx(right, logoSpan); + } + } + if (tlp != TlpLevel.none) { + if (tlpOnRight) { + right = mx(right, tlpSpan); + } else { + left = mx(left, tlpSpan); + } + } + + final alignment = switch (profile.footerPosition) { + 'left' => Alignment.centerLeft, + 'center' => Alignment.center, + _ => Alignment.centerRight, + }; + final textAlign = switch (profile.footerPosition) { + 'left' => TextAlign.left, + 'center' => TextAlign.center, + _ => TextAlign.right, + }; + + return Positioned( + left: left, + right: right, + bottom: w * 0.02, + child: Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: w - left - right), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (footerText.isNotEmpty) + Flexible( + child: Text( + footerText, + style: style, + textAlign: textAlign, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02), + if (showPages) + Text( + '$slideNumber / ${slideCount ?? slideNumber}', + style: style, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/previews/table_preview.dart b/lib/widgets/slides/previews/table_preview.dart new file mode 100644 index 0000000..6611473 --- /dev/null +++ b/lib/widgets/slides/previews/table_preview.dart @@ -0,0 +1,129 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _TablePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _TablePreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.06; + final safe = slide.showLogo + ? _splitTextLogoSafeInsets(w, profile) + : EdgeInsets.zero; + final titleSize = w * 0.038; + final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); + final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); + + // Scale cell text down as the table grows so it keeps fitting nicely. + final density = (rows.length + colCount).clamp(2, 24); + final cellSize = (w * 0.025 * (10 / (density + 6))).clamp( + w * 0.010, + w * 0.021, + ); + + final accent = _hexColor(profile.accentColor); + final textColor = _hexColor(profile.tableTextColor); + final headerTextColor = _hexColor(profile.tableHeaderTextColor); + final borderColor = accent.withValues(alpha: 0.35); + + Widget cell(String value, {required bool header}) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: cellSize * 0.55, + vertical: cellSize * 0.36, + ), + child: _md( + context, + value, + _applyFont( + font, + TextStyle( + fontSize: cellSize, + color: header ? headerTextColor : textColor, + fontWeight: header ? FontWeight.bold : FontWeight.normal, + ), + ), + linkColor: header ? headerTextColor : accent, + ), + ); + } + + TableRow buildRow(List row, {required bool header}) { + return TableRow( + decoration: BoxDecoration(color: header ? accent : null), + children: List.generate(colCount, (c) { + final value = c < row.length ? row[c] : ''; + return TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: cell(value, header: header), + ); + }), + ); + } + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (slide.title.isNotEmpty) ...[ + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + SizedBox(height: pad * 0.35), + ], + if (rows.isNotEmpty && colCount > 0) + Table( + border: TableBorder.all( + color: borderColor, + width: w * 0.0012, + ), + defaultColumnWidth: const FlexColumnWidth(), + children: [ + buildRow(rows.first, header: true), + for (var i = 1; i < rows.length; i++) + buildRow(rows[i], header: false), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/previews/text_previews.dart b/lib/widgets/slides/previews/text_previews.dart new file mode 100644 index 0000000..21ebe9d --- /dev/null +++ b/lib/widgets/slides/previews/text_previews.dart @@ -0,0 +1,478 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _TitlePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _TitlePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + Widget _content(BuildContext context) { + final pad = w * 0.08; + final link = _hexColor(profile.accentColor); + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slide.title.isNotEmpty) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: _hexColor(profile.titleTextColor), + fontSize: w * 0.055, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + linkColor: link, + ), + if (slide.subtitle.isNotEmpty) ...[ + SizedBox(height: w * 0.02), + _md( + context, + slide.subtitle, + _applyFont( + font, + TextStyle( + color: _hexColor( + profile.titleTextColor, + ).withValues(alpha: 0.72), + fontSize: w * 0.03, + height: 1.3, + ), + ), + linkColor: link, + ), + ], + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final hasBg = slide.imagePath.isNotEmpty; + + if (!hasBg) { + return Container( + color: _hexColor(profile.titleBackgroundColor), + child: SizedBox.expand(child: _content(context)), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + context, + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.titleBackgroundColor), + ), + Container( + color: _hexColor( + profile.titleBackgroundColor, + ).withValues(alpha: 0.62), + ), + _content(context), + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _SectionPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _SectionPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.08; + return Container( + color: _hexColor(profile.sectionBackgroundColor), + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slide.title.isNotEmpty) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: _hexColor(profile.titleTextColor), + fontSize: w * 0.05, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (slide.subtitle.isNotEmpty) ...[ + SizedBox(height: w * 0.015), + _md( + context, + slide.subtitle, + _applyFont( + font, + TextStyle( + color: _hexColor( + profile.titleTextColor, + ).withValues(alpha: 0.72), + fontSize: w * 0.025, + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _QuotePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final String? projectPath; + final ThemeProfile profile; + + const _QuotePreview({ + required this.slide, + required this.w, + required this.font, + this.projectPath, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.08; + final hasBg = slide.imagePath.isNotEmpty; + final textColor = hasBg ? Colors.white : _hexColor(profile.textColor); + final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!; + final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor); + + final content = FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: w * 0.008, + height: w * 0.12, + color: accentColor, + margin: EdgeInsets.only(right: pad * 0.4), + ), + Expanded( + child: _md( + context, + slide.quote.isEmpty ? '' : '"${slide.quote}"', + _applyFont( + font, + TextStyle( + fontSize: w * 0.033, + fontStyle: FontStyle.italic, + color: textColor, + height: 1.4, + ), + ), + linkColor: accentColor, + ), + ), + ], + ), + if (slide.quoteAuthor.isNotEmpty) ...[ + SizedBox(height: pad * 0.6), + _md( + context, + '— ${slide.quoteAuthor}', + _applyFont( + font, + TextStyle( + fontSize: w * 0.026, + color: authorColor, + fontWeight: FontWeight.w500, + ), + ), + linkColor: accentColor, + ), + ], + ], + ), + ), + ), + ); + + if (!hasBg) { + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand(child: content), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + context, + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.slideBackgroundColor), + ), + Container(color: Colors.black.withValues(alpha: 0.52)), + content, + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _MarkdownPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _MarkdownPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.07; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + + return Container( + color: Colors.white, + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: _buildBlocks(context), + ), + ), + ), + ), + ), + ); + } + + /// Parse the free Markdown into block widgets: fenced ```code``` (syntax + /// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines. + List _buildBlocks(BuildContext context) { + final link = _hexColor(profile.accentColor); + final lines = slide.customMarkdown.split('\n'); + final widgets = []; + var i = 0; + // Cap rendered blocks so a huge slide can't blow up layout (the preview is a + // thumbnail; FittedBox scales the rest down). + while (i < lines.length && widgets.length < 24) { + final line = lines[i]; + + // Fenced code block: ``` or ```language … ``` + final fence = RegExp(r'^\s*```(.*)$').firstMatch(line); + if (fence != null) { + final language = fence.group(1)!.trim(); + final code = []; + i++; + while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) { + code.add(lines[i]); + i++; + } + if (i < lines.length) i++; // consume the closing fence + widgets.add(_codeBlock(code.join('\n'), language)); + continue; + } + + // Display math fenced by lines containing only `$$`. + if (line.trim() == r'$$') { + final tex = []; + i++; + while (i < lines.length && lines[i].trim() != r'$$') { + tex.add(lines[i]); + i++; + } + if (i < lines.length) i++; // consume the closing $$ + widgets.add(_mathBlock(tex.join('\n'))); + continue; + } + // Single-line display math: $$ … $$ + final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line); + if (oneLine != null) { + widgets.add(_mathBlock(oneLine.group(1)!.trim())); + i++; + continue; + } + + widgets.add(_textLine(context, line, link)); + i++; + } + return widgets; + } + + Widget _textLine(BuildContext context, String line, Color link) { + if (line.startsWith('# ')) { + return _md( + context, + line.substring(2), + _applyFont( + font, + TextStyle( + fontSize: w * 0.04, + fontWeight: FontWeight.bold, + color: AppTheme.navy, + ), + ), + linkColor: link, + ); + } else if (line.startsWith('## ')) { + return _md( + context, + line.substring(3), + _applyFont( + font, + TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600), + ), + linkColor: link, + ); + } else if (line.startsWith('- ')) { + return _md( + context, + '• ${line.substring(2)}', + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } else if (line.isEmpty) { + return SizedBox(height: w * 0.01); + } + return _md( + context, + line, + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } + + Widget _codeBlock(String code, String language) { + _ensureHighlightLanguages(); + final mono = TextStyle( + fontFamily: 'monospace', + fontSize: w * 0.02, + height: 1.3, + color: const Color(0xFF24292E), + ); + // HighlightView throws on an unregistered language, so only use it for ones + // we actually know; otherwise fall back to plain monospace. + final known = language.isNotEmpty && allLanguages.containsKey(language); + final Widget content = known + ? HighlightView( + code, + language: language, + theme: githubTheme, + padding: EdgeInsets.zero, + textStyle: mono, + ) + : Text(code, style: mono); + return Container( + width: double.infinity, + margin: EdgeInsets.symmetric(vertical: w * 0.008), + padding: EdgeInsets.all(w * 0.018), + decoration: BoxDecoration( + color: const Color(0xFFF6F8FA), + borderRadius: BorderRadius.circular(w * 0.008), + border: Border.all(color: const Color(0xFFE1E4E8)), + ), + child: content, + ); + } + + Widget _mathBlock(String tex) { + return Padding( + padding: EdgeInsets.symmetric(vertical: w * 0.012), + child: Math.tex( + tex, + textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)), + onErrorFallback: (err) => Text( + '\$\$$tex\$\$', + style: TextStyle( + fontFamily: 'monospace', + fontSize: w * 0.022, + color: Colors.red, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index 8da485d..16edc1a 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -16,8 +16,20 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../theme/app_theme.dart'; +import '../../utils/log.dart'; import 'inline_markdown.dart'; +// Slide preview widgets, split into part files by slide type for +// navigability. These parts share this library's imports and private scope. +part 'previews/text_previews.dart'; +part 'previews/bullets_previews.dart'; +part 'previews/checklist_previews.dart'; +part 'previews/table_preview.dart'; +part 'previews/media_previews.dart'; +part 'previews/code_preview.dart'; +part 'previews/chart_preview.dart'; +part 'previews/overlays.dart'; + /// Returns a TextStyle with the correct font. 'EB Garamond' is bundled with the /// app (see pubspec.yaml); all other fonts resolve to system families. TextStyle _applyFont(String font, TextStyle base) { @@ -370,4076 +382,6 @@ class SlidePreviewWidget extends StatelessWidget { } } -class _AudioPlayback extends StatefulWidget { - final String audioPath; - final String? projectPath; - final bool autoplay; - final double w; - final VoidCallback? onComplete; - - const _AudioPlayback({ - required this.audioPath, - required this.projectPath, - required this.autoplay, - required this.w, - this.onComplete, - }); - - @override - State<_AudioPlayback> createState() => _AudioPlaybackState(); -} - -class _AudioPlaybackState extends State<_AudioPlayback> { - VideoPlayerController? _controller; - bool _completed = false; - - @override - void initState() { - super.initState(); - _init(); - } - - @override - void didUpdateWidget(_AudioPlayback oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.audioPath != widget.audioPath || - oldWidget.autoplay != widget.autoplay) { - _init(); - } - } - - Future _init() async { - _controller?.removeListener(_onTick); - await _controller?.dispose(); - _completed = false; - final path = _resolvePath(widget.audioPath, widget.projectPath); - if (path == null) return; - final controller = VideoPlayerController.file(File(path)); - _controller = controller; - try { - await controller.initialize(); - controller.addListener(_onTick); - if (widget.autoplay) await controller.play(); - } catch (_) {} - if (mounted) setState(() {}); - } - - /// Detecteer het einde van de audio en meld dat één keer (voor auto-advance). - void _onTick() { - final c = _controller; - if (c == null || !c.value.isInitialized || _completed) return; - final pos = c.value.position; - final dur = c.value.duration; - if (dur > Duration.zero && - pos.inMilliseconds >= dur.inMilliseconds - 200 && - !c.value.isPlaying) { - _completed = true; - widget.onComplete?.call(); - } - } - - @override - void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final controller = _controller; - return Positioned( - right: widget.w * 0.035, - bottom: widget.w * 0.035, - child: IconButton( - tooltip: 'Audio', - onPressed: controller == null || !controller.value.isInitialized - ? null - : () { - setState(() { - controller.value.isPlaying - ? controller.pause() - : controller.play(); - }); - }, - icon: Icon( - controller?.value.isPlaying == true - ? Icons.volume_up - : Icons.volume_up_outlined, - ), - iconSize: widget.w * 0.032, - ), - ); - } -} - -// ── Individual slide-type renderers ────────────────────────────────────────── - -class _TitlePreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _TitlePreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - Widget _content(BuildContext context) { - final pad = w * 0.08; - final link = _hexColor(profile.accentColor); - return FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.all(pad), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (slide.title.isNotEmpty) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: _hexColor(profile.titleTextColor), - fontSize: w * 0.055, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - linkColor: link, - ), - if (slide.subtitle.isNotEmpty) ...[ - SizedBox(height: w * 0.02), - _md( - context, - slide.subtitle, - _applyFont( - font, - TextStyle( - color: _hexColor( - profile.titleTextColor, - ).withValues(alpha: 0.72), - fontSize: w * 0.03, - height: 1.3, - ), - ), - linkColor: link, - ), - ], - ], - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final hasBg = slide.imagePath.isNotEmpty; - - if (!hasBg) { - return Container( - color: _hexColor(profile.titleBackgroundColor), - child: SizedBox.expand(child: _content(context)), - ); - } - - return Stack( - fit: StackFit.expand, - children: [ - _zoomedImage( - context, - slide.imagePath, - projectPath, - slide.imageSize, - bgColor: _hexColor(profile.titleBackgroundColor), - ), - Container( - color: _hexColor( - profile.titleBackgroundColor, - ).withValues(alpha: 0.62), - ), - _content(context), - _captionOverlay(context, slide.imageCaption, w), - ], - ); - } -} - -class _SectionPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _SectionPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.08; - return Container( - color: _hexColor(profile.sectionBackgroundColor), - child: SizedBox.expand( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.all(pad), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (slide.title.isNotEmpty) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: _hexColor(profile.titleTextColor), - fontSize: w * 0.05, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (slide.subtitle.isNotEmpty) ...[ - SizedBox(height: w * 0.015), - _md( - context, - slide.subtitle, - _applyFont( - font, - TextStyle( - color: _hexColor( - profile.titleTextColor, - ).withValues(alpha: 0.72), - fontSize: w * 0.025, - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ], - ], - ), - ), - ), - ), - ), - ); - } -} - -class _BulletsPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _BulletsPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.07; - // Slightly tighter top/bottom margin than the side margin so short - // checklists can grow into more of the slide height instead of leaving a - // wide empty band below the text. - final vPad = w * 0.05; - 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; - final bullets = slide.bullets - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final hasTitle = slide.title.isNotEmpty; - final subtitle = slide.subtitle; - final hasSubtitle = subtitle.isNotEmpty; - final showProgress = - slide.listStyle == ListStyle.checklist && - slide.showChecklistProgress && - bullets.isNotEmpty; - - final slideHeight = w * 9 / 16; - final availW = (w - pad * 2).clamp(w * 0.12, w); - // The progress chart only needs a modest, fixed slot; give all remaining - // width to the bullets so the text can grow as large (and readable) as - // possible, especially on slides with many checklist items. - final progressGap = w * 0.025; - final progressW = w * 0.34; - final textAvailW = showProgress - ? (availW - progressGap - progressW).clamp(w * 0.12, availW) - : availW; - final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); - // Grow (or, when needed, shrink) the text so it uses the full vertical - // space instead of leaving a large empty area below a few short bullets. - final scale = _bulletsFitScale( - availW: textAvailW, - availH: availH, - hasTitle: hasTitle, - title: slide.title, - bullets: bullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - subtitle: subtitle, - subtitleSize: subtitleSize, - maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), - listStyle: slide.listStyle, - ); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: SizedBox.expand( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - vPad + safe.top, - pad, - vPad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasTitle) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize * scale, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - 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), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _BulletListColumn( - bullets: bullets, - listStyle: slide.listStyle, - font: font, - profile: profile, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - column: 0, - ), - ), - if (showProgress) ...[ - SizedBox(width: progressGap), - SizedBox( - width: progressW, - child: Center( - child: _ChecklistProgress( - bullets: bullets, - w: w, - font: font, - profile: profile, - ), - ), - ), - ], - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _TablePreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _TablePreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.06; - final safe = slide.showLogo - ? _splitTextLogoSafeInsets(w, profile) - : EdgeInsets.zero; - final titleSize = w * 0.038; - final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); - final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); - - // Scale cell text down as the table grows so it keeps fitting nicely. - final density = (rows.length + colCount).clamp(2, 24); - final cellSize = (w * 0.025 * (10 / (density + 6))).clamp( - w * 0.010, - w * 0.021, - ); - - final accent = _hexColor(profile.accentColor); - final textColor = _hexColor(profile.tableTextColor); - final headerTextColor = _hexColor(profile.tableHeaderTextColor); - final borderColor = accent.withValues(alpha: 0.35); - - Widget cell(String value, {required bool header}) { - return Padding( - padding: EdgeInsets.symmetric( - horizontal: cellSize * 0.55, - vertical: cellSize * 0.36, - ), - child: _md( - context, - value, - _applyFont( - font, - TextStyle( - fontSize: cellSize, - color: header ? headerTextColor : textColor, - fontWeight: header ? FontWeight.bold : FontWeight.normal, - ), - ), - linkColor: header ? headerTextColor : accent, - ), - ); - } - - TableRow buildRow(List row, {required bool header}) { - return TableRow( - decoration: BoxDecoration(color: header ? accent : null), - children: List.generate(colCount, (c) { - final value = c < row.length ? row[c] : ''; - return TableCell( - verticalAlignment: TableCellVerticalAlignment.middle, - child: cell(value, header: header), - ); - }), - ); - } - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (slide.title.isNotEmpty) ...[ - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - SizedBox(height: pad * 0.35), - ], - if (rows.isNotEmpty && colCount > 0) - Table( - border: TableBorder.all( - color: borderColor, - width: w * 0.0012, - ), - defaultColumnWidth: const FlexColumnWidth(), - children: [ - buildRow(rows.first, header: true), - for (var i = 1; i < rows.length; i++) - buildRow(rows[i], header: false), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _TwoBulletsPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _TwoBulletsPreview({ - required this.slide, - required this.w, - required this.font, - 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, - required int column, - }) { - 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, - listStyle: slide.listStyle, - font: font, - profile: profile, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - column: column, - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final pad = w * 0.065; - // Tighter top/bottom margin than the side margin so dense columns (e.g. a - // 19-item list) can use more of the slide height and stay readable. - final vPad = w * 0.045; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final leftBullets = slide.bullets - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final rightBullets = slide.bullets2 - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final hasTitle = slide.title.isNotEmpty; - - // On dense slides (a long column drives the shared text size down) spend - // less of the height on the title, headings and inter-item gaps so the - // list items themselves can render larger and stay readable. - final dense = math.max(leftBullets.length, rightBullets.length) > 12; - final titleSize = w * (dense ? 0.034 : 0.04); - final bulletSize = w * 0.024; - final spacing = pad * (dense ? 0.28 : 0.38); - final bulletGap = w * (dense ? 0.0036 : 0.0055); - final columnGap = w * 0.055; - - final col1Title = slide.columnTitle1.trim(); - final col2Title = slide.columnTitle2.trim(); - final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty; - final headingSize = w * (dense ? 0.023 : 0.03); - final headingGap = w * (dense ? 0.007 : 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); - var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); - if (hasTitle) { - availH -= _measureTextHeight( - slide.title, - 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, - hasTitle: false, - title: '', - bullets: leftBullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), - listStyle: slide.listStyle, - ); - final rightScale = _bulletsFitScale( - availW: columnW, - availH: availH, - hasTitle: false, - title: '', - bullets: rightBullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), - listStyle: slide.listStyle, - ); - // Treat both columns as one composition: the busiest column determines - // the shared text size, so left and right never look typographically - // unrelated. - final columnScale = math.min(leftScale, rightScale); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: SizedBox.expand( - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - vPad + safe.top, - pad, - vPad + safe.bottom, - ), - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: contentW, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasTitle) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (hasTitle) SizedBox(height: spacing), - if (slide.listStyle == ListStyle.checklist && - slide.showChecklistProgress && - (leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[ - Align( - alignment: Alignment.center, - child: SizedBox( - width: contentW * 0.5, - child: _ChecklistProgress( - bullets: [...leftBullets, ...rightBullets], - w: w, - font: font, - profile: profile, - ), - ), - ), - SizedBox(height: spacing), - ], - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _bulletColumn( - context, - title: col1Title, - bullets: leftBullets, - columnW: columnW, - headingSize: headingSize, - headingSlotH: hasColumnTitles ? maxHeadingH : 0, - headingGap: headingGap, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: columnScale, - column: 0, - ), - SizedBox(width: columnGap), - _bulletColumn( - context, - title: col2Title, - bullets: rightBullets, - columnW: columnW, - headingSize: headingSize, - headingSlotH: hasColumnTitles ? maxHeadingH : 0, - headingGap: headingGap, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: columnScale, - column: 1, - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _BulletsImagePreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _BulletsImagePreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final leftPad = w * 0.038; - final verticalPad = w * 0.042; - // Keep the gap between the text column and the image equal to the slide's - // left margin so the layout stays symmetric. - final gap = leftPad; - final safe = slide.showLogo - ? _splitTextLogoSafeInsets(w, profile) - : EdgeInsets.zero; - final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40) - .clamp(0.1, 0.70); - final imgWidth = w * imgFraction; - final bulletSize = w * 0.031; - final titleSize = w * 0.042; - final spacing = verticalPad * 0.32; - final bulletGap = w * 0.005; - final bullets = slide.bullets - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final hasTitle = slide.title.isNotEmpty; - - // The slide is always rendered 16:9, so the available area for the text - // column is fully determined by the width. Computing it directly (instead - // of via a LayoutBuilder) keeps the widget tree identical to the image - // side and avoids any layout-timing surprises. - final slideHeight = w * 9 / 16; - final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w); - final availH = - slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom); - // Pick the largest font scale (capped at the design size) whose content - // still fits the available height at the full column width. This keeps the - // text as large as possible and lets it span the full width toward the - // image, instead of uniformly shrinking and leaving a wide gap. - final scale = _bulletsFitScale( - availW: availW, - availH: availH, - hasTitle: hasTitle, - title: slide.title, - bullets: bullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), - listStyle: slide.listStyle, - ); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Stack( - children: [ - Positioned( - top: 0, - right: 0, - bottom: 0, - width: imgWidth, - child: Stack( - fit: StackFit.expand, - children: [ - _resolvedImage(context, slide.imagePath, projectPath), - _captionOverlay(context, slide.imageCaption, w), - ], - ), - ), - Positioned( - top: 0, - left: 0, - right: imgWidth + gap, - bottom: 0, - child: Padding( - padding: EdgeInsets.fromLTRB( - leftPad, - verticalPad + safe.top, - 0, - verticalPad + safe.bottom, - ), - // FittedBox stays as a safety net for measurement rounding; with - // an accurate scale it renders at scale 1 (full width). - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: availW, - child: _contentColumn( - context: context, - scale: scale, - bullets: bullets, - hasTitle: hasTitle, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _contentColumn({ - required BuildContext context, - required double scale, - required List bullets, - required bool hasTitle, - required double titleSize, - required double bulletSize, - required double spacing, - required double bulletGap, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasTitle) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize * scale, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale), - if (slide.listStyle == ListStyle.checklist && - slide.showChecklistProgress && - bullets.isNotEmpty) ...[ - _ChecklistProgress( - bullets: bullets, - w: w, - font: font, - profile: profile, - ), - SizedBox(height: spacing * scale), - ], - ...bullets.asMap().entries.map((entry) { - final b = entry.value; - int level = 0; - while (level < b.length && b[level] == '\t') { - level++; - } - final text = slide.listStyle == ListStyle.checklist - ? checklistItemText(b) - : b.substring(level); - final checked = - slide.listStyle == ListStyle.checklist && checklistItemChecked(b); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; - return _ChecklistBulletRow( - bullets: bullets, - itemIndex: entry.key, - column: 0, - listStyle: slide.listStyle, - checked: checked, - text: text, - level: level, - fontSize: fontSize, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - font: font, - profile: profile, - ); - }), - ], - ); - } -} - -class _ChecklistProgress extends StatelessWidget { - final List bullets; - final double w; - final String font; - final ThemeProfile profile; - - const _ChecklistProgress({ - required this.bullets, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final items = bullets - .where((bullet) => checklistItemText(bullet).trim().isNotEmpty) - .toList(); - final checked = items.where(checklistItemChecked).length; - final total = items.length; - final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round(); - final openPercent = total == 0 ? 0 : 100 - checkedPercent; - final textColor = _hexColor(profile.textColor); - final checkedColor = _hexColor(profile.checklistCheckedColor); - final openColor = _hexColor(profile.checklistUncheckedColor); - final labelStyle = _applyFont( - font, - TextStyle( - fontSize: w * 0.0125, - height: 1.2, - color: textColor, - fontWeight: FontWeight.w600, - ), - ); - - final interaction = _ChecklistInteractionScope.maybeOf(context); - - return LayoutBuilder( - builder: (context, constraints) { - // Grow the pie to fill the width it is handed instead of staying at a - // fixed, tiny size. Every caller gives this widget a bounded column - // width, so the chart now scales with the space that is actually - // available next to (or above) the bullets. - final maxW = constraints.maxWidth.isFinite - ? constraints.maxWidth - : w * 0.4; - // Cap the pie so it stays a balanced companion to the bullet column - // rather than dominating it: a smaller chart keeps the visual split - // closer to 50/50 and, crucially, never forces the surrounding text to - // shrink to fit the chart's height when a slide has many bullets. - final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble(); - final baseRadius = diameter * 0.44; - final hoverRadius = diameter * 0.48; - final pieTitleStyle = _applyFont( - font, - TextStyle( - fontSize: diameter * 0.085, - height: 1.1, - fontWeight: FontWeight.bold, - color: textColor, - ), - ); - - Widget pie(bool? hovered) => PieChart( - key: const ValueKey('checklist-progress-pie'), - PieChartData( - sectionsSpace: w * 0.002, - centerSpaceRadius: 0, - startDegreeOffset: -90, - sections: [ - if (checkedPercent > 0) - PieChartSectionData( - value: checkedPercent.toDouble(), - color: checkedColor, - radius: hovered == true ? hoverRadius : baseRadius, - title: '$checkedPercent%', - titleStyle: pieTitleStyle.copyWith(color: Colors.white), - ), - if (openPercent > 0) - PieChartSectionData( - value: openPercent.toDouble(), - color: openColor, - radius: hovered == false ? hoverRadius : baseRadius, - title: '$openPercent%', - titleStyle: pieTitleStyle, - ), - ], - pieTouchData: PieTouchData( - enabled: interaction?.enabled == true, - touchCallback: (event, response) { - if (interaction?.enabled != true) return; - final index = event.isInterestedForInteractions - ? response?.touchedSection?.touchedSectionIndex - : null; - if (index == null) { - interaction!.hovered.value = null; - } else if (checkedPercent == 0) { - interaction!.hovered.value = false; - } else { - interaction!.hovered.value = index == 0; - } - }, - ), - ), - duration: Duration.zero, - ); - - return Semantics( - label: - '${context.l10n.d('Afgevinkt')} $checkedPercent%, ' - '${context.l10n.d('Niet afgevinkt')} $openPercent%', - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: diameter, - height: diameter, - child: interaction == null - ? pie(null) - : ValueListenableBuilder( - valueListenable: interaction.hovered, - builder: (_, hovered, _) => pie(hovered), - ), - ), - SizedBox(height: w * 0.008), - MouseRegion( - key: const ValueKey('checklist-progress-checked'), - onEnter: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = true, - onExit: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = null, - child: Text( - '${context.l10n.d('Afgevinkt')} $checkedPercent%', - style: labelStyle, - ), - ), - MouseRegion( - key: const ValueKey('checklist-progress-unchecked'), - onEnter: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = false, - onExit: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = null, - child: Text( - '${context.l10n.d('Niet afgevinkt')} $openPercent%', - style: labelStyle.copyWith( - color: textColor.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ); - }, - ); - } -} - -class _BulletListColumn extends StatelessWidget { - final List bullets; - final ListStyle listStyle; - final String font; - final ThemeProfile profile; - final double bulletSize; - final double bulletGap; - final double scale; - final int column; - - const _BulletListColumn({ - required this.bullets, - required this.listStyle, - required this.font, - required this.profile, - required this.bulletSize, - required this.bulletGap, - required this.scale, - this.column = 0, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ...bullets.asMap().entries.map((entry) { - final b = entry.value; - int level = 0; - while (level < b.length && b[level] == '\t') { - level++; - } - final text = listStyle == ListStyle.checklist - ? checklistItemText(b) - : b.substring(level); - final checked = - listStyle == ListStyle.checklist && checklistItemChecked(b); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; - return _ChecklistBulletRow( - bullets: bullets, - itemIndex: entry.key, - column: column, - listStyle: listStyle, - checked: checked, - text: text, - level: level, - fontSize: fontSize, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - font: font, - profile: profile, - ); - }), - ], - ); - } -} - -class _ChecklistBulletRow extends StatelessWidget { - final List bullets; - final int itemIndex; - final int column; - final ListStyle listStyle; - final bool checked; - final String text; - final int level; - final double fontSize; - final double bulletSize; - final double bulletGap; - final double scale; - final String font; - final ThemeProfile profile; - - const _ChecklistBulletRow({ - required this.bullets, - required this.itemIndex, - required this.column, - required this.listStyle, - required this.checked, - required this.text, - required this.level, - required this.fontSize, - required this.bulletSize, - required this.bulletGap, - required this.scale, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final interaction = _ChecklistInteractionScope.maybeOf(context); - Widget row(bool highlighted) => AnimatedContainer( - key: ValueKey('checklist-preview-item-$column-$itemIndex'), - duration: const Duration(milliseconds: 140), - padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0), - decoration: BoxDecoration( - color: highlighted - ? _hexColor(profile.accentColor).withValues(alpha: 0.16) - : Colors.transparent, - borderRadius: BorderRadius.circular(wScale(5)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - key: ValueKey('checklist-preview-toggle-$column-$itemIndex'), - behavior: HitTestBehavior.opaque, - onTap: - listStyle == ListStyle.checklist && interaction?.enabled == true - ? () => interaction!.onToggle?.call(column, itemIndex) - : null, - child: MouseRegion( - cursor: - listStyle == ListStyle.checklist && - interaction?.enabled == true - ? SystemMouseCursors.click - : MouseCursor.defer, - child: Text( - '${_listMarker(bullets, itemIndex, listStyle)} ', - style: TextStyle( - fontSize: fontSize, - color: _hexColor(profile.accentColor), - fontWeight: FontWeight.bold, - ), - ), - ), - ), - Expanded( - child: _md( - context, - text, - _applyFont( - font, - TextStyle( - fontSize: fontSize, - height: _kBulletLineHeight, - color: _hexColor(profile.textColor), - decoration: checked && profile.checklistStrikeThrough - ? TextDecoration.lineThrough - : null, - decorationColor: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - ], - ), - ); - - final padded = Padding( - padding: EdgeInsets.only( - left: level * bulletSize * 1.05 * scale, - top: bulletGap * scale, - bottom: bulletGap * scale, - ), - child: interaction == null || listStyle != ListStyle.checklist - ? row(false) - : ValueListenableBuilder( - valueListenable: interaction.hovered, - builder: (_, hovered, _) => row(hovered == checked), - ), - ); - return padded; - } - - double wScale(double value) => value * scale; -} - -class _ChecklistInteractionHost extends StatefulWidget { - final bool enabled; - final void Function(int column, int itemIndex)? onToggle; - final Widget child; - - const _ChecklistInteractionHost({ - required this.enabled, - required this.onToggle, - required this.child, - }); - - @override - State<_ChecklistInteractionHost> createState() => - _ChecklistInteractionHostState(); -} - -class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> { - final ValueNotifier hovered = ValueNotifier(null); - - @override - void dispose() { - hovered.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _ChecklistInteractionScope( - enabled: widget.enabled, - hovered: hovered, - onToggle: widget.onToggle, - child: widget.child, - ); - } -} - -class _ChecklistInteractionScope extends InheritedWidget { - final bool enabled; - final ValueNotifier hovered; - final void Function(int column, int itemIndex)? onToggle; - - const _ChecklistInteractionScope({ - required this.enabled, - required this.hovered, - required this.onToggle, - required super.child, - }); - - static _ChecklistInteractionScope? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>(); - - @override - bool updateShouldNotify(_ChecklistInteractionScope oldWidget) => - enabled != oldWidget.enabled || onToggle != oldWidget.onToggle; -} - -/// Upper bound for growing bullet text to fill otherwise empty vertical space. -const double _kBulletsMaxScale = 3.2; - -/// Split slides have a much narrower column, so short bullet lists can stay -/// visually timid unless they are allowed to grow a little further. -const double _kSplitBulletsMaxScale = 4.35; - -/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a -/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's -/// 960pt-wide canvas). Presentation-design guidance consistently puts body -/// text at 24–32pt — beyond that it stops aiding readability and starts -/// competing with the title. The fit scale multiplies title and bullets -/// alike, so capping the bullet size also keeps the hierarchy intact. -const double _kBulletMaxFontFraction = 0.0335; - -/// The largest auto-fit scale that keeps bullets at or under -/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound. -double _bulletScaleCap(double w, double bulletSize, double layoutMax) => - math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize); - -/// Line height used for bullet body text, shared by rendering and measuring. -const double _kBulletLineHeight = 1.16; - -String _bulletMarkerForLevel(int level) { - const markers = ['•', '◦', '▪', '▫', '–']; - return markers[level.clamp(0, markers.length - 1)]; -} - -String _listMarker(List items, int index, ListStyle style) { - int levelOf(String item) { - var level = 0; - while (level < item.length && item[level] == '\t') { - level++; - } - return level; - } - - final level = levelOf(items[index]); - if (style == ListStyle.bullets) return _bulletMarkerForLevel(level); - if (style == ListStyle.checklist) { - return checklistItemChecked(items[index]) ? '☑' : '☐'; - } - var number = 0; - for (var i = 0; i <= index; i++) { - final itemLevel = levelOf(items[i]); - if (itemLevel == level) number++; - if (itemLevel < level) number = 0; - } - return '$number.'; -} - -double _bulletLevelScale(int level) { - if (level <= 0) return 1.0; - if (level == 1) return 0.86; - if (level == 2) return 0.80; - return 0.76; -} - -/// Largest scale in [minScale, maxScale] for which the bullet block fits -/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this -/// also grows the text *above* its design size when there is spare vertical -/// room, so short slides use the full height instead of clustering at the top. -double _bulletsFitScale({ - required double availW, - required double availH, - required bool hasTitle, - required String title, - required List bullets, - required double titleSize, - 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, - ListStyle listStyle = ListStyle.bullets, -}) { - if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0; - // 2% safety margin so minor measurement differences never overflow. - final budget = availH * 0.98; - double measure(double scale) => _bulletsBlockHeight( - scale: scale, - availW: availW, - listStyle: listStyle, - hasTitle: hasTitle, - title: title, - bullets: bullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - subtitle: subtitle, - subtitleSize: subtitleSize, - ); - - // Everything already fits at the largest allowed size → use it. - if (measure(maxScale) <= budget) return maxScale; - - // Otherwise binary-search the largest scale that fits. Search upward from the - // design size when it fits, downward when even the design size overflows. - double lo, hi; - if (maxScale > 1.0 && measure(1.0) <= budget) { - lo = 1.0; - hi = maxScale; - } else { - lo = minScale; - hi = maxScale > 1.0 ? 1.0 : maxScale; - } - for (var i = 0; i < 24; i++) { - final mid = (lo + hi) / 2; - if (measure(mid) <= budget) { - lo = mid; - } else { - hi = mid; - } - } - return lo; -} - -double _bulletsBlockHeight({ - required double scale, - required double availW, - required bool hasTitle, - required String title, - required List bullets, - required double titleSize, - required double bulletSize, - required double spacing, - required double bulletGap, - required String font, - String subtitle = '', - double subtitleSize = 0, - ListStyle listStyle = ListStyle.bullets, -}) { - var height = 0.0; - if (hasTitle) { - 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; - } - for (var i = 0; i < bullets.length; i++) { - final b = bullets[i]; - int level = 0; - while (level < b.length && b[level] == '\t') { - level++; - } - // Measure exactly what gets rendered: checklists strip the `[x] ` prefix - // and use a checkbox marker, numbered lists use `N.`. Measuring the raw - // string with a bullet marker over-counts the height and would shrink the - // text below the space it actually needs. - final text = listStyle == ListStyle.checklist - ? checklistItemText(b) - : b.substring(level); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; - final indent = level * bulletSize * 1.05 * scale; - final marker = '${_listMarker(bullets, i, listStyle)} '; - 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, - ); - height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH); - } - return height; -} - -double _measureTextHeight( - String text, - double fontSize, - 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, - ), - ), - textDirection: TextDirection.ltr, - )..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity); - return painter.height; -} - -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, - ), - ), - textDirection: TextDirection.ltr, - )..layout(); - return painter.width; -} - -class _TwoImagesPreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _TwoImagesPreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5) - .clamp(0.1, 0.9); - final leftW = w * splitFraction; - final rightW = w * (1 - splitFraction); - final titleSize = w * 0.032; - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Stack( - fit: StackFit.expand, - children: [ - // Twee afbeeldingen naast elkaar - Row( - children: [ - SizedBox( - width: leftW, - child: Stack( - fit: StackFit.expand, - children: [ - _resolvedImage(context, slide.imagePath, projectPath), - _captionOverlay(context, slide.imageCaption, w), - ], - ), - ), - SizedBox( - width: rightW, - child: Stack( - fit: StackFit.expand, - children: [ - _resolvedImage(context, slide.imagePath2, projectPath), - _captionOverlay(context, slide.imageCaption2, w), - ], - ), - ), - ], - ), - // Optionele ondertitel - if (slide.title.isNotEmpty) - Positioned( - left: 0, - right: 0, - bottom: w * 0.04, - child: Container( - color: Colors.black54, - padding: EdgeInsets.symmetric( - horizontal: w * 0.04, - vertical: w * 0.015, - ), - child: _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: titleSize, - fontWeight: FontWeight.w500, - ), - ), - linkColor: const Color(0xFF8BB8FF), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ); - } -} - -class _ImagePreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _ImagePreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final hasTitle = slide.title.isNotEmpty; - return Stack( - fit: StackFit.expand, - children: [ - _zoomedImage( - context, - slide.imagePath, - projectPath, - slide.imageSize, - bgColor: _hexColor(profile.slideBackgroundColor), - // When zoomed out, anchor the image to the top so the bottom title - // banner sits in the freed-up space instead of over the picture. - alignment: hasTitle ? Alignment.topCenter : Alignment.center, - ), - if (slide.title.isNotEmpty) - Positioned( - left: w * 0.06, - right: w * 0.06, - bottom: w * 0.06, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.04, - vertical: w * 0.02, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), - child: _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: w * 0.038, - fontWeight: FontWeight.bold, - ), - ), - linkColor: const Color(0xFF8BB8FF), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - _captionOverlay(context, slide.imageCaption, w), - ], - ); - } -} - -class _VideoPreview extends StatefulWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - final bool autoplay; - final VoidCallback? onComplete; - - const _VideoPreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - this.autoplay = false, - this.onComplete, - }); - - @override - State<_VideoPreview> createState() => _VideoPreviewState(); -} - -class _VideoPreviewState extends State<_VideoPreview> { - VideoPlayerController? _controller; - String? _path; - bool _completed = false; - - @override - void initState() { - super.initState(); - _init(); - } - - @override - void didUpdateWidget(_VideoPreview oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.slide.videoPath != widget.slide.videoPath || - oldWidget.autoplay != widget.autoplay) { - _init(); - } - } - - Future _init() async { - _controller?.removeListener(_onTick); - await _controller?.dispose(); - _controller = null; - _completed = false; - _path = _resolvePath(widget.slide.videoPath, widget.projectPath); - if (_path == null) { - if (mounted) setState(() {}); - return; - } - final controller = VideoPlayerController.file(File(_path!)); - _controller = controller; - try { - await controller.initialize(); - controller.addListener(_onTick); - await controller.setLooping(false); - if (widget.autoplay) await controller.play(); - } catch (_) { - // Keep the placeholder visible when the platform cannot open the file. - } - if (mounted) setState(() {}); - } - - void _onTick() { - final controller = _controller; - if (controller == null || - !controller.value.isInitialized || - _completed || - !widget.autoplay) { - return; - } - final duration = controller.value.duration; - final position = controller.value.position; - if (duration > Duration.zero && - position.inMilliseconds >= duration.inMilliseconds - 200 && - !controller.value.isPlaying) { - _completed = true; - widget.onComplete?.call(); - } - } - - @override - void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final controller = _controller; - return Container( - color: _hexColor(widget.profile.slideBackgroundColor), - child: Stack( - fit: StackFit.expand, - children: [ - if (controller != null && controller.value.isInitialized) - Center( - child: AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), - ), - ) - else - _mediaPlaceholder(Icons.movie_outlined, 'Video'), - if (widget.slide.title.isNotEmpty) - Positioned( - left: widget.w * 0.06, - right: widget.w * 0.06, - top: widget.w * 0.04, - child: _md( - context, - widget.slide.title, - _applyFont( - widget.font, - TextStyle( - color: _hexColor(widget.profile.textColor), - fontSize: widget.w * 0.038, - fontWeight: FontWeight.bold, - ), - ), - linkColor: _hexColor(widget.profile.accentColor), - ), - ), - Positioned( - left: widget.w * 0.04, - bottom: widget.w * 0.035, - child: IconButton( - onPressed: controller == null || !controller.value.isInitialized - ? null - : () { - setState(() { - controller.value.isPlaying - ? controller.pause() - : controller.play(); - }); - }, - icon: Icon( - controller?.value.isPlaying == true - ? Icons.pause_circle - : Icons.play_circle, - ), - iconSize: widget.w * 0.045, - ), - ), - ], - ), - ); - } -} - -class _QuotePreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final String? projectPath; - final ThemeProfile profile; - - const _QuotePreview({ - required this.slide, - required this.w, - required this.font, - this.projectPath, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.08; - final hasBg = slide.imagePath.isNotEmpty; - final textColor = hasBg ? Colors.white : _hexColor(profile.textColor); - final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!; - final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor); - - final content = FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.all(pad), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: w * 0.008, - height: w * 0.12, - color: accentColor, - margin: EdgeInsets.only(right: pad * 0.4), - ), - Expanded( - child: _md( - context, - slide.quote.isEmpty ? '' : '"${slide.quote}"', - _applyFont( - font, - TextStyle( - fontSize: w * 0.033, - fontStyle: FontStyle.italic, - color: textColor, - height: 1.4, - ), - ), - linkColor: accentColor, - ), - ), - ], - ), - if (slide.quoteAuthor.isNotEmpty) ...[ - SizedBox(height: pad * 0.6), - _md( - context, - '— ${slide.quoteAuthor}', - _applyFont( - font, - TextStyle( - fontSize: w * 0.026, - color: authorColor, - fontWeight: FontWeight.w500, - ), - ), - linkColor: accentColor, - ), - ], - ], - ), - ), - ), - ); - - if (!hasBg) { - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: SizedBox.expand(child: content), - ); - } - - return Stack( - fit: StackFit.expand, - children: [ - _zoomedImage( - context, - slide.imagePath, - projectPath, - slide.imageSize, - bgColor: _hexColor(profile.slideBackgroundColor), - ), - Container(color: Colors.black.withValues(alpha: 0.52)), - content, - _captionOverlay(context, slide.imageCaption, w), - ], - ); - } -} - -class _LogoOverlay extends StatelessWidget { - final String logoPath; - final String? projectPath; - final String position; - final double size; - - const _LogoOverlay({ - required this.logoPath, - required this.projectPath, - required this.position, - required this.size, - }); - - @override - Widget build(BuildContext context) { - final horizontalInset = size * 0.28; - final topInset = size * 0.42; - final bottomInset = size * 0.12; - return Positioned( - top: position.startsWith('top') ? topInset : null, - bottom: position.startsWith('bottom') ? bottomInset : null, - left: position.endsWith('left') ? horizontalInset : null, - right: position.endsWith('right') ? horizontalInset : null, - child: SizedBox( - width: size, - height: size, - child: _resolvedImage( - context, - logoPath, - projectPath, - fit: BoxFit.contain, - ), - ), - ); - } -} - -class _MarkdownPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _MarkdownPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.07; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - - return Container( - color: Colors.white, - child: SizedBox.expand( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: _buildBlocks(context), - ), - ), - ), - ), - ), - ); - } - - /// Parse the free Markdown into block widgets: fenced ```code``` (syntax - /// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines. - List _buildBlocks(BuildContext context) { - final link = _hexColor(profile.accentColor); - final lines = slide.customMarkdown.split('\n'); - final widgets = []; - var i = 0; - // Cap rendered blocks so a huge slide can't blow up layout (the preview is a - // thumbnail; FittedBox scales the rest down). - while (i < lines.length && widgets.length < 24) { - final line = lines[i]; - - // Fenced code block: ``` or ```language … ``` - final fence = RegExp(r'^\s*```(.*)$').firstMatch(line); - if (fence != null) { - final language = fence.group(1)!.trim(); - final code = []; - i++; - while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) { - code.add(lines[i]); - i++; - } - if (i < lines.length) i++; // consume the closing fence - widgets.add(_codeBlock(code.join('\n'), language)); - continue; - } - - // Display math fenced by lines containing only `$$`. - if (line.trim() == r'$$') { - final tex = []; - i++; - while (i < lines.length && lines[i].trim() != r'$$') { - tex.add(lines[i]); - i++; - } - if (i < lines.length) i++; // consume the closing $$ - widgets.add(_mathBlock(tex.join('\n'))); - continue; - } - // Single-line display math: $$ … $$ - final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line); - if (oneLine != null) { - widgets.add(_mathBlock(oneLine.group(1)!.trim())); - i++; - continue; - } - - widgets.add(_textLine(context, line, link)); - i++; - } - return widgets; - } - - Widget _textLine(BuildContext context, String line, Color link) { - if (line.startsWith('# ')) { - return _md( - context, - line.substring(2), - _applyFont( - font, - TextStyle( - fontSize: w * 0.04, - fontWeight: FontWeight.bold, - color: AppTheme.navy, - ), - ), - linkColor: link, - ); - } else if (line.startsWith('## ')) { - return _md( - context, - line.substring(3), - _applyFont( - font, - TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600), - ), - linkColor: link, - ); - } else if (line.startsWith('- ')) { - return _md( - context, - '• ${line.substring(2)}', - _applyFont(font, TextStyle(fontSize: w * 0.024)), - linkColor: link, - ); - } else if (line.isEmpty) { - return SizedBox(height: w * 0.01); - } - return _md( - context, - line, - _applyFont(font, TextStyle(fontSize: w * 0.024)), - linkColor: link, - ); - } - - Widget _codeBlock(String code, String language) { - _ensureHighlightLanguages(); - final mono = TextStyle( - fontFamily: 'monospace', - fontSize: w * 0.02, - height: 1.3, - color: const Color(0xFF24292E), - ); - // HighlightView throws on an unregistered language, so only use it for ones - // we actually know; otherwise fall back to plain monospace. - final known = language.isNotEmpty && allLanguages.containsKey(language); - final Widget content = known - ? HighlightView( - code, - language: language, - theme: githubTheme, - padding: EdgeInsets.zero, - textStyle: mono, - ) - : Text(code, style: mono); - return Container( - width: double.infinity, - margin: EdgeInsets.symmetric(vertical: w * 0.008), - padding: EdgeInsets.all(w * 0.018), - decoration: BoxDecoration( - color: const Color(0xFFF6F8FA), - borderRadius: BorderRadius.circular(w * 0.008), - border: Border.all(color: const Color(0xFFE1E4E8)), - ), - child: content, - ); - } - - Widget _mathBlock(String tex) { - return Padding( - padding: EdgeInsets.symmetric(vertical: w * 0.012), - child: Math.tex( - tex, - textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)), - onErrorFallback: (err) => Text( - '\$\$$tex\$\$', - style: TextStyle( - fontFamily: 'monospace', - fontSize: w * 0.022, - color: Colors.red, - ), - ), - ), - ); - } -} - -/// Een 'broncode-sheet': de code op een donker editor-vlak, met -/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar -/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de -/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen. -class _CodePreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _CodePreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - /// Natural (unwrapped) size of [text] in [style]: width is the longest line, - /// height the full block. Used to scale code to the available space. - static Size _measureMono(String text, TextStyle style) { - final painter = TextPainter( - text: TextSpan(text: text.isEmpty ? ' ' : text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - return painter.size; - } - - @override - Widget build(BuildContext context) { - _ensureHighlightLanguages(); - final pad = w * 0.05; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final code = slide.customMarkdown; - final lang = slide.codeLanguage.trim(); - final known = lang.isNotEmpty && allLanguages.containsKey(lang); - - final codeBg = _hexColor(profile.codeBackgroundColor); - final codeFg = _hexColor(profile.codeTextColor); - - // The chosen monospace family, always backed by a generic monospace fallback - // so an uninstalled face still renders fixed-width. - final fallback = ['Menlo', 'Consolas', 'Courier New', 'monospace'] - ..removeWhere((f) => f == profile.codeFontFamily); - final baseFont = w * 0.024; - final maxFont = w * 0.040; // grow to fill, but never huge - TextStyle monoAt(double size) => TextStyle( - fontFamily: profile.codeFontFamily, - fontFamilyFallback: fallback, - fontSize: size, - height: 1.4, - color: codeFg, - ); - - // HighlightView throws on an unknown language, so fall back to plain (but - // monospace) text. When syntax highlighting is off we always render plain - // text so the whole block is one colour — needed for a CRT-green screen. - final useHighlight = known && profile.codeHighlightSyntax; - final highlightTheme = { - ...atomOneDarkTheme, - // Keep atom-one-dark's per-token colours but drop its own background so - // our themed [codeBg] shows through unchanged. - 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( - backgroundColor: codeBg, - color: codeFg, - ), - }; - Widget buildCode(TextStyle style) => useHighlight - ? HighlightView( - code, - language: lang, - theme: highlightTheme, - padding: EdgeInsets.zero, - textStyle: style, - ) - : Text(code, style: style); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // The slide title belongs to the slide, not inside the code window, - // so it sits above the panel like other slide types. - if (slide.title.isNotEmpty) ...[ - Container( - width: double.infinity, - padding: EdgeInsets.symmetric( - horizontal: w * 0.025, - vertical: w * 0.01, - ), - decoration: BoxDecoration( - color: _hexColor(profile.titleBackgroundColor), - borderRadius: BorderRadius.circular(w * 0.012), - border: Border( - left: BorderSide( - color: _hexColor(profile.accentColor), - width: w * 0.006, - ), - ), - ), - child: _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: w * 0.032, - height: 1.1, - fontWeight: FontWeight.bold, - color: _hexColor(profile.titleTextColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - SizedBox(height: w * 0.018), - ], - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: codeBg, - borderRadius: BorderRadius.circular(w * 0.012), - border: Border.all(color: codeFg.withValues(alpha: 0.22)), - ), - padding: EdgeInsets.all(w * 0.03), - child: LayoutBuilder( - builder: (context, constraints) { - // Size the code to fill the panel: scale up to use spare - // space (capped at [maxFont]) and down so long fragments - // still fit, rather than leaving a small block in a big box. - final measured = useHighlight - ? code.replaceAll('\t', ' ') - : code; - final natural = _measureMono(measured, monoAt(baseFont)); - final availW = math.max(1.0, constraints.maxWidth - 1); - final availH = math.max(1.0, constraints.maxHeight - 1); - var scale = math.min( - availW / natural.width, - availH / natural.height, - ); - if (!scale.isFinite || scale <= 0) scale = 1; - final size = math.min(baseFont * scale, maxFont); - return Align( - alignment: Alignment.topLeft, - child: buildCode(monoAt(size)), - ); - }, - ), - ), - ), - ], - ), - ), - ); - } -} - -/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec. -class _ChartPreview extends StatefulWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - final bool presentationMode; - - const _ChartPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - required this.presentationMode, - }); - - @override - State<_ChartPreview> createState() => _ChartPreviewState(); -} - -class _ChartPreviewState extends State<_ChartPreview> { - Slide get slide => widget.slide; - double get w => widget.w; - String get font => widget.font; - ThemeProfile get profile => widget.profile; - bool get presentationMode => widget.presentationMode; - - /// Legend entry the pointer is over: a series index for bar/line charts, or a - /// slice (category) index for pie charts. Null when nothing is hovered. - int? _hovered; - - /// The radar vertex under the pointer, used to draw its tooltip. Null when not - /// hovering a point. - ({int series, int entry, double value, Offset offset})? _radarTouch; - - void _setHover(int? index) { - if (_hovered != index) setState(() => _hovered = index); - } - - /// True when another legend entry is hovered, so [index] should fade back. - bool _dimmed(int index) => _hovered != null && _hovered != index; - - /// Series colour with legend-hover feedback: non-hovered series fade out so - /// the hovered one stands out in the plot. - Color _seriesDisplayColor(ChartSeries series, int i) { - final base = _seriesColor(series, i); - return _dimmed(i) ? base.withValues(alpha: 0.2) : base; - } - - double get _labelScale => presentationMode ? 1.12 : 1; - - Color _seriesColor(ChartSeries series, int i) { - if (series.color == null && i == 0) { - return _hexColor(profile.accentColor); - } - return _hexColor(chartSeriesColor(series, i)); - } - - /// Text alternative for the chart (WCAG 1.1.1): chart type, title and the - /// underlying values per series, so a screen reader conveys the same - /// information the visual encodes. - String _semanticsLabel(BuildContext context, ChartSpec spec) { - final l10n = context.l10n; - final typeName = switch (spec.type) { - ChartType.bar => l10n.d('Staaf'), - ChartType.line => l10n.d('Lijn'), - ChartType.pie => l10n.d('Cirkel'), - ChartType.radar => l10n.d('Spider'), - }; - final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)'); - if (spec.title.isNotEmpty) { - buffer.write(': ${stripInlineMarkdown(spec.title)}'); - } - if (!spec.hasInlineData) return buffer.toString(); - for (var si = 0; si < spec.series.length; si++) { - final series = spec.series[si]; - final name = series.name.isEmpty - ? '${l10n.d('Reeks')} ${si + 1}' - : series.name; - final values = [ - for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++) - '${spec.x[xi]} ${_fmtNum(series.data[xi])}', - ]; - buffer.write('. $name: ${values.join(', ')}'); - } - return buffer.toString(); - } - - @override - Widget build(BuildContext context) { - final spec = ChartSpec.parse(slide.customMarkdown); - final horizontalPad = w * 0.05; - final verticalPad = w * 0.018; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final textColor = _hexColor(profile.textColor); - - return Semantics( - image: true, - label: _semanticsLabel(context, spec), - // The visual chart (axis labels, legend chips, tooltips) would read as - // disconnected fragments; the label above carries the full story. - child: ExcludeSemantics( - child: _chartBody( - context, - spec, - horizontalPad, - verticalPad, - safe, - textColor, - ), - ), - ); - } - - Widget _chartBody( - BuildContext context, - ChartSpec spec, - double horizontalPad, - double verticalPad, - EdgeInsets safe, - Color textColor, - ) { - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Padding( - padding: EdgeInsets.fromLTRB( - horizontalPad, - verticalPad + safe.top, - horizontalPad, - verticalPad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (spec.title.isNotEmpty) ...[ - Container( - width: double.infinity, - padding: EdgeInsets.symmetric( - horizontal: w * 0.025, - vertical: w * 0.01, - ), - decoration: BoxDecoration( - color: _hexColor(profile.titleBackgroundColor), - borderRadius: BorderRadius.circular(w * 0.012), - border: Border( - left: BorderSide( - color: _hexColor(profile.accentColor), - width: w * 0.006, - ), - ), - ), - child: _md( - context, - spec.title, - _applyFont( - font, - TextStyle( - fontSize: w * 0.032, - height: 1.1, - fontWeight: FontWeight.bold, - color: _hexColor(profile.titleTextColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - SizedBox(height: w * 0.012), - ], - Expanded( - child: Container( - key: const ValueKey('chart-surface'), - padding: EdgeInsets.fromLTRB( - w * 0.02, - w * 0.01, - w * 0.025, - w * 0.01, - ), - decoration: BoxDecoration( - color: textColor.withValues(alpha: 0.035), - borderRadius: BorderRadius.circular(w * 0.014), - border: Border.all(color: textColor.withValues(alpha: 0.09)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: spec.hasInlineData - ? _chart(spec, textColor) - : _placeholder(context), - ), - if (spec.hasInlineData && spec.series.isNotEmpty) ...[ - SizedBox(height: w * 0.006), - spec.type == ChartType.pie - ? _pieLegend(spec, textColor) - : _legend(spec, textColor), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _legend(ChartSpec spec, Color textColor) { - return SizedBox( - height: w * 0.03, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var i = 0; i < spec.series.length; i++) ...[ - if (i > 0) SizedBox(width: w * 0.01), - MouseRegion( - onEnter: (_) => _setHover(i), - onExit: (_) => _setHover(null), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _dimmed(i) ? 0.4 : 1, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.01, - vertical: w * 0.004, - ), - decoration: BoxDecoration( - color: _hovered == i - ? _seriesColor( - spec.series[i], - i, - ).withValues(alpha: 0.18) - : textColor.withValues(alpha: 0.045), - borderRadius: BorderRadius.circular(w), - border: Border.all( - color: _hovered == i - ? _seriesColor(spec.series[i], i) - : Colors.transparent, - width: w * 0.0015, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: w * 0.012, - height: w * 0.012, - decoration: BoxDecoration( - color: _seriesColor(spec.series[i], i), - shape: BoxShape.circle, - ), - ), - SizedBox(width: w * 0.006), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: w * 0.16), - child: Text( - spec.series[i].name.isEmpty - ? 'Reeks ${i + 1}' - : spec.series[i].name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.013, - fontWeight: FontWeight.w600, - color: textColor.withValues(alpha: 0.82), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ], - ), - ), - ); - } - - Widget _pieLegend(ChartSpec spec, Color textColor) { - final itemCount = math.min(spec.x.length, 18); - final columns = math.min(itemCount, presentationMode ? 4 : 6); - final rows = (itemCount / columns).ceil(); - return LayoutBuilder( - builder: (context, constraints) { - final gap = w * 0.006; - final itemWidth = - (constraints.maxWidth - gap * (columns - 1)) / columns; - return SizedBox( - height: rows * w * 0.03 * _labelScale + (rows - 1) * gap, - child: Wrap( - spacing: gap, - runSpacing: gap, - children: [ - for (var i = 0; i < itemCount; i++) - MouseRegion( - onEnter: (_) => _setHover(i), - onExit: (_) => _setHover(null), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _dimmed(i) ? 0.4 : 1, - child: Container( - width: itemWidth, - height: w * 0.03 * _labelScale, - padding: EdgeInsets.symmetric(horizontal: w * 0.008), - decoration: BoxDecoration( - color: _hovered == i - ? _hexColor( - chartRowColor(spec, i), - ).withValues(alpha: 0.18) - : textColor.withValues(alpha: 0.045), - borderRadius: BorderRadius.circular(w), - border: Border.all( - color: _hovered == i - ? _hexColor(chartRowColor(spec, i)) - : Colors.transparent, - width: w * 0.0015, - ), - ), - child: Row( - children: [ - Container( - width: w * 0.012, - height: w * 0.012, - decoration: BoxDecoration( - color: _hexColor(chartRowColor(spec, i)), - shape: BoxShape.circle, - ), - ), - SizedBox(width: w * 0.006), - Expanded( - child: Text( - spec.x[i], - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.013 * _labelScale, - fontWeight: FontWeight.w600, - color: textColor.withValues(alpha: 0.82), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _chart(ChartSpec spec, Color textColor) { - switch (spec.type) { - case ChartType.bar: - return _barChart(spec, textColor); - case ChartType.line: - return _lineChart(spec, textColor); - case ChartType.pie: - return _pieChart(spec, textColor); - case ChartType.radar: - return _radarChart(spec, textColor); - } - } - - double _maxY(ChartSpec spec) { - var m = 0.0; - for (final s in spec.series) { - for (final v in s.data) { - if (v > m) m = v; - } - } - // Keep any bound line comfortably inside the plot so its label is visible. - if (spec.supportsBounds) { - for (final b in [spec.minBound, spec.maxBound]) { - if (b != null && b > m) m = b; - } - } - return m <= 0 ? 1 : m * 1.15; - } - - double _minY(ChartSpec spec) { - var m = 0.0; - for (final s in spec.series) { - for (final v in s.data) { - if (v < m) m = v; - } - } - if (spec.supportsBounds) { - for (final b in [spec.minBound, spec.maxBound]) { - if (b != null && b < m) m = b; - } - } - return m >= 0 ? 0 : m * 1.15; - } - - /// Optional min/max threshold lines drawn across the plot (bar/line only). - ExtraLinesData _boundLines(ChartSpec spec) { - if (!spec.supportsBoundLines) return const ExtraLinesData(); - final dash = [ - (w * 0.018).round().clamp(4, 14), - (w * 0.01).round().clamp(3, 9), - ]; - HorizontalLine line(double value, Color color, String prefix) => - HorizontalLine( - y: value, - color: color, - strokeWidth: w * 0.0035, - dashArray: dash, - label: HorizontalLineLabel( - show: true, - alignment: Alignment.topRight, - padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002), - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.0115 * _labelScale, - color: color, - fontWeight: FontWeight.w700, - ), - ), - labelResolver: (_) => '$prefix ${_fmtNum(value)}', - ), - ); - return ExtraLinesData( - horizontalLines: [ - if (spec.minBound != null) - line(spec.minBound!, const Color(0xFFF59E0B), 'min'), - if (spec.maxBound != null) - line(spec.maxBound!, const Color(0xFFEF4444), 'max'), - ], - ); - } - - FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) { - final style = _applyFont( - font, - TextStyle( - fontSize: w * 0.0115 * _labelScale, - color: textColor.withValues(alpha: 0.88), - fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal, - ), - ); - return FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: w * 0.05 * _labelScale, - getTitlesWidget: (value, meta) => Text( - _fmtNum(value), - style: style.copyWith(fontSize: w * 0.0105 * _labelScale), - ), - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 1, - reservedSize: w * 0.044 * _labelScale, - getTitlesWidget: (value, meta) { - final i = value.round(); - final n = spec.x.length; - if (i < 0 || i >= n) return const SizedBox.shrink(); - // Show as many labels as fit without colliding: keep at least - // [minSlot] of horizontal room per label, then thin them out - // evenly based on the actual pixel spacing between points. Line - // charts spread n points over n-1 intervals; bar groups are laid - // out spaceEvenly, which puts their centres (axis + groupWidth) / - // (n + 1) apart. - final spacing = bars - ? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1) - : (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize); - final minSlot = w * 0.085 * _labelScale; - final step = math.max(1, (minSlot / spacing).ceil()); - final lastMultiple = ((n - 1) ~/ step) * step; - final lastGap = n - 1 - lastMultiple; - final showLast = i == n - 1 && lastGap > step / 2; - if (i % step != 0 && !showLast) return const SizedBox.shrink(); - // The extra end label can sit closer than a full step to its - // neighbour; shrink both of their slots to the real gap so they - // never run through each other. - var slotSteps = step.toDouble(); - if (showLast || (i == lastMultiple && lastGap > step / 2)) { - slotSteps = math.min(slotSteps, lastGap.toDouble()); - } - final slot = (slotSteps * spacing - w * 0.012).clamp( - w * 0.04, - w * 0.16, - ); - return Padding( - padding: EdgeInsets.only(top: w * 0.008), - child: SizedBox( - width: slot, - child: Text( - spec.x[i], - style: style, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - ), - ); - } - - String _fmtNum(double v) { - if (v == v.roundToDouble()) return v.toInt().toString(); - return v.toStringAsFixed(1); - } - - FlGridData _grid(Color textColor) => FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (v) => - FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1), - ); - - /// Width of one bar rod, shared by the chart and the axis-label spacing. - double _barRodWidth(ChartSpec spec) => - (w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022); - - /// Total width of one bar group: its rods plus fl_chart's default 2px - /// spacing between rods within a group. - double _barGroupWidth(ChartSpec spec) { - final rods = math.max(1, spec.series.length); - return rods * _barRodWidth(spec) + (rods - 1) * 2; - } - - Widget _barChart(ChartSpec spec, Color textColor) { - final groups = []; - for (var xi = 0; xi < spec.x.length; xi++) { - groups.add( - BarChartGroupData( - x: xi, - barRods: [ - for (var si = 0; si < spec.series.length; si++) - if (xi < spec.series[si].data.length) - BarChartRodData( - toY: spec.series[si].data[xi], - color: _seriesDisplayColor(spec.series[si], si), - width: _barRodWidth(spec), - borderRadius: BorderRadius.vertical( - top: Radius.circular(w * 0.006), - ), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: _maxY(spec), - color: textColor.withValues(alpha: 0.025), - ), - ), - ], - ), - ); - } - return BarChart( - BarChartData( - minY: _minY(spec), - maxY: _maxY(spec), - // The axis-label spacing in _titles assumes this layout; keep it - // explicit rather than relying on fl_chart's default. - alignment: BarChartAlignment.spaceEvenly, - barGroups: groups, - titlesData: _titles(spec, textColor, bars: true), - gridData: _grid(textColor), - borderData: FlBorderData(show: false), - extraLinesData: _boundLines(spec), - barTouchData: BarTouchData( - enabled: true, - mouseCursorResolver: (event, response) => response?.spot == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchTooltipData: BarTouchTooltipData( - fitInsideHorizontally: true, - fitInsideVertically: true, - getTooltipColor: (_) => const Color(0xFF0F172A), - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final label = group.x >= 0 && group.x < spec.x.length - ? spec.x[group.x] - : ''; - final series = rodIndex < spec.series.length - ? spec.series[rodIndex].name - : ''; - return BarTooltipItem( - '$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}', - _tooltipStyle(), - ); - }, - ), - ), - ), - duration: Duration.zero, - ); - } - - Widget _lineChart(ChartSpec spec, Color textColor) { - final bars = []; - for (var si = 0; si < spec.series.length; si++) { - bars.add( - LineChartBarData( - spots: [ - for (var xi = 0; xi < spec.series[si].data.length; xi++) - FlSpot(xi.toDouble(), spec.series[si].data[xi]), - ], - color: _seriesDisplayColor(spec.series[si], si), - barWidth: w * (_hovered == si ? 0.0065 : 0.0045), - isCurved: true, - curveSmoothness: 0.22, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( - radius: w * 0.005, - color: _seriesDisplayColor(spec.series[si], si), - strokeWidth: w * 0.0025, - strokeColor: _hexColor(profile.slideBackgroundColor), - ), - ), - belowBarData: BarAreaData( - show: true, - color: _seriesDisplayColor( - spec.series[si], - si, - ).withValues(alpha: spec.series.length == 1 ? 0.14 : 0.05), - ), - ), - ); - } - return LineChart( - LineChartData( - minY: _minY(spec), - maxY: _maxY(spec), - lineBarsData: bars, - titlesData: _titles(spec, textColor), - gridData: _grid(textColor), - borderData: FlBorderData(show: false), - extraLinesData: _boundLines(spec), - lineTouchData: LineTouchData( - enabled: true, - // Measure proximity to the actual dot (x *and* y), not just the - // column, so the tooltip belongs to the point under the cursor. - distanceCalculator: (touch, spot) => (touch - spot).distance, - touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(), - mouseCursorResolver: (event, response) => - response?.lineBarSpots?.isEmpty ?? true - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchTooltipData: LineTouchTooltipData( - fitInsideHorizontally: true, - fitInsideVertically: true, - getTooltipColor: (_) => const Color(0xFF0F172A), - // Show every dot near the cursor. When several dots sit on (almost) - // the same spot they all appear; the font shrinks to keep them - // readable when stacked. - getTooltipItems: (spots) { - final style = _lineTooltipStyle(spots.length); - return [ - for (final spot in spots) - LineTooltipItem( - '${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n' - '${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}', - style, - ), - ]; - }, - ), - ), - ), - duration: Duration.zero, - ); - } - - Widget _pieChart(ChartSpec spec, Color textColor) { - if (spec.series.isEmpty || spec.x.isEmpty) { - return _placeholderText('—'); - } - return LayoutBuilder( - builder: (context, constraints) { - final visibleSeries = math.min(spec.series.length, 2); - final columns = visibleSeries; - const rows = 1; - final tileHeight = constraints.maxHeight / rows; - final tileWidth = constraints.maxWidth / columns; - return GridView.builder( - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - childAspectRatio: tileWidth / tileHeight, - crossAxisSpacing: w * 0.012, - mainAxisSpacing: w * 0.008, - ), - itemCount: visibleSeries, - itemBuilder: (context, si) { - final series = spec.series[si]; - final values = [ - for (var xi = 0; xi < spec.x.length; xi++) - xi < series.data.length && series.data[xi] > 0 - ? series.data[xi] - : 0.0, - ]; - final total = values.fold(0, (a, b) => a + b); - return Row( - children: [ - Expanded( - flex: 4, - child: total <= 0 - ? Center( - child: Text( - '0', - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.025, - color: textColor.withValues(alpha: 0.5), - ), - ), - ), - ) - : LayoutBuilder( - builder: (context, pieConstraints) { - final available = - pieConstraints.biggest.shortestSide; - final radius = (available * 0.42).clamp( - w * 0.018, - w * 0.075, - ); - return ClipRect( - child: _HoverPieChart( - externalHover: _hovered, - values: values, - labels: spec.x, - colors: [ - for (var xi = 0; xi < values.length; xi++) - _hexColor(chartRowColor(spec, xi)), - ], - radius: radius, - centerSpaceRadius: radius * 0.42, - sectionSpace: w * 0.002, - titleStyle: _applyFont( - font, - TextStyle( - fontSize: (radius * 0.18).clamp( - w * 0.009, - w * 0.013, - ), - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - tooltipStyle: _tooltipStyle(), - ), - ); - }, - ), - ), - SizedBox(width: w * 0.008), - Expanded( - flex: 2, - child: Text( - series.name.isEmpty ? 'Reeks ${si + 1}' : series.name, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.015, - height: 1.1, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - ), - ), - ], - ); - }, - ); - }, - ); - } - - Widget _radarChart(ChartSpec spec, Color textColor) { - if (spec.x.length < 3 || spec.series.isEmpty) { - return _placeholderText( - context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'), - ); - } - final grid = textColor.withValues(alpha: 0.18); - final scale = radarScale(spec); - - return Padding( - padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012), - child: LayoutBuilder( - builder: (context, constraints) { - // Reserve a slim column on the right for the scale legend; the rest - // of the area is shared between the spider and its axis labels. - final legendWidth = w * 0.075; - final boxW = math.max( - 0.0, - constraints.maxWidth - legendWidth - w * 0.02, - ); - final boxH = constraints.maxHeight; - if (boxW <= 0 || !boxH.isFinite || boxH <= 0) { - return const SizedBox.shrink(); - } - // Measure every axis label and grow the spider until the labels just - // fit between the polygon and the edges of the available area, so - // the diagram uses the space the old fixed label bands wasted. - final layout = _radarLabelLayout(spec, boxW, boxH, textColor); - final chartSide = layout.chartSide; - - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: SizedBox( - width: boxW, - height: boxH, - child: Stack( - children: [ - for (var i = 0; i < spec.x.length; i++) - _radarAxisLabel( - label: spec.x[i], - index: i, - count: spec.x.length, - layout: layout, - textColor: textColor, - ), - Positioned( - left: (boxW - chartSide) / 2, - top: (boxH - chartSide) / 2, - width: chartSide, - height: chartSide, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: RadarChart( - RadarChartData( - dataSets: [ - for ( - var si = 0; - si < spec.series.length; - si++ - ) - RadarDataSet( - dataEntries: [ - for ( - var xi = 0; - xi < spec.x.length; - xi++ - ) - RadarEntry( - value: - xi < - spec - .series[si] - .data - .length - ? spec.series[si].data[xi] - : 0, - ), - ], - fillColor: - _seriesDisplayColor( - spec.series[si], - si, - ).withValues( - alpha: _dimmed(si) - ? 0.04 - : 0.16, - ), - borderColor: _seriesDisplayColor( - spec.series[si], - si, - ), - borderWidth: - w * - (_hovered == si - ? 0.0055 - : 0.0035), - entryRadius: - w * - (_hovered == si ? 0.006 : 0.004), - ), - // Invisible anchor pinning the scale to [lo, hi] - // so the rings represent a fixed scale. - RadarDataSet( - dataEntries: [ - for ( - var xi = 0; - xi < spec.x.length; - xi++ - ) - RadarEntry( - value: xi == 0 - ? scale.hi - : scale.lo, - ), - ], - fillColor: Colors.transparent, - borderColor: Colors.transparent, - borderWidth: 0, - entryRadius: 0, - ), - ], - radarShape: RadarShape.polygon, - radarBackgroundColor: Colors.transparent, - radarBorderData: BorderSide( - color: grid, - width: 1, - ), - gridBorderData: BorderSide( - color: grid, - width: 1, - ), - tickBorderData: BorderSide( - color: grid, - width: 1, - ), - tickCount: scale.ticks, - isMinValueAtCenter: true, - // The scale now lives in a side legend, so hide - // fl_chart's in-chart ring numbers. - ticksTextStyle: const TextStyle( - color: Colors.transparent, - fontSize: 0.001, - ), - titlePositionPercentageOffset: 0, - getTitle: (index, angle) => RadarChartTitle( - text: index < spec.x.length - ? spec.x[index] - : '', - ), - // Labels are rendered as constrained widgets - // around the chart so long text can wrap. - titleTextStyle: const TextStyle( - color: Colors.transparent, - fontSize: 0.001, - ), - radarTouchData: RadarTouchData( - enabled: true, - touchSpotThreshold: (w * 0.02) - .clamp(8.0, 24.0) - .toDouble(), - mouseCursorResolver: (event, response) => - _radarSpotFrom(response, spec) == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchCallback: (event, response) { - final next = - event.isInterestedForInteractions - ? _radarSpotFrom(response, spec) - : null; - if (next != _radarTouch) { - setState(() => _radarTouch = next); - } - }, - ), - ), - duration: Duration.zero, - ), - ), - if (_radarTouch != null) - _radarTooltip(spec, chartSide, _radarTouch!), - ], - ), - ), - ], - ), - ), - ), - ), - SizedBox( - width: legendWidth, - child: _radarScaleLegend(scale, textColor), - ), - ], - ); - }, - ), - ); - } - - TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont( - font, - TextStyle( - fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, - height: 1.05, - color: textColor.withValues(alpha: 0.88), - fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, - ), - ); - - /// True when the vertex in [direction] gets its label placed beside the - /// polygon (left/right) rather than above/below it. - static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35; - - /// Sizes the spider and places every axis label around it. - /// - /// Each label is measured at its real text size, then the polygon radius is - /// grown until the tightest label exactly fits between the polygon and the - /// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the - /// side of its (square) widget, which is what ties [chartSide] to the - /// resulting radius. - ({double chartSide, List rects, List aligns, int maxLines}) - _radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) { - const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8 - final n = spec.x.length; - final style = _radarLabelStyle(n, textColor); - final gap = w * 0.008; - final maxLines = n <= 6 ? 3 : 2; - final sideCap = math.min(boxW * 0.28, w * 0.2); - final topCap = math.min(boxW * 0.5, w * 0.3); - - Size measure(String text, double maxWidth) { - final painter = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - maxLines: maxLines, - ellipsis: '…', - )..layout(maxWidth: math.max(0.0, maxWidth)); - final size = Size(painter.width, painter.height); - painter.dispose(); - return size; - } - - final directions = []; - final sizes = []; - for (var i = 0; i < n; i++) { - final angle = (2 * math.pi * i / n) - math.pi / 2; - final dir = Offset(math.cos(angle), math.sin(angle)); - directions.add(dir); - sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap)); - } - - // The largest polygon radius every label still fits next to. - var radius = radiusFactor * math.min(boxW, boxH); - for (var i = 0; i < n; i++) { - final dx = directions[i].dx.abs(); - final dy = directions[i].dy.abs(); - if (_radarLabelBeside(directions[i])) { - radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx); - if (dy > 0.01) { - radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy); - } - } else { - radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy); - if (dx > 0.01) { - radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx); - } - } - } - // Never let extreme labels crush the spider entirely; below this floor the - // labels get clamped (and ellipsized) instead. - final floor = 0.18 * math.min(boxW, boxH); - radius = radius.clamp( - math.min(floor, radiusFactor * math.min(boxW, boxH)), - radiusFactor * math.min(boxW, boxH), - ); - final chartSide = radius / radiusFactor; - - final center = Offset(boxW / 2, boxH / 2); - final rects = []; - final aligns = []; - for (var i = 0; i < n; i++) { - final dir = directions[i]; - final anchor = center + dir * (radius + gap); - var size = sizes[i]; - double left; - double top; - if (_radarLabelBeside(dir)) { - // Re-measure against the room actually left beside the polygon, so a - // clamped radius still produces a label that wraps inside the box. - final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx; - if (size.width > room) size = measure(spec.x[i], room); - left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width; - top = anchor.dy - size.height / 2; - aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right); - } else { - left = anchor.dx - size.width / 2; - top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy; - aligns.add(TextAlign.center); - } - rects.add( - Rect.fromLTWH( - left.clamp(0.0, math.max(0.0, boxW - size.width)), - top.clamp(0.0, math.max(0.0, boxH - size.height)), - size.width, - size.height, - ), - ); - } - return ( - chartSide: chartSide, - rects: rects, - aligns: aligns, - maxLines: maxLines, - ); - } - - Widget _radarAxisLabel({ - required String label, - required int index, - required int count, - required ({ - double chartSide, - List rects, - List aligns, - int maxLines, - }) - layout, - required Color textColor, - }) { - final rect = layout.rects[index]; - return Positioned( - key: ValueKey('radar-axis-label-$index'), - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - child: Text( - label, - maxLines: layout.maxLines, - overflow: TextOverflow.ellipsis, - textAlign: layout.aligns[index], - style: _radarLabelStyle(count, textColor), - ), - ); - } - - /// Extract the touched real-series vertex from a radar touch response, - /// ignoring the invisible scale anchor dataset. - ({int series, int entry, double value, Offset offset})? _radarSpotFrom( - RadarTouchResponse? response, - ChartSpec spec, - ) { - final spot = response?.touchedSpot; - if (spot == null) return null; - if (spot.touchedDataSetIndex < 0 || - spot.touchedDataSetIndex >= spec.series.length) { - return null; // the anchor dataset, or out of range - } - return ( - series: spot.touchedDataSetIndex, - entry: spot.touchedRadarEntryIndex, - value: spot.touchedRadarEntry.value, - offset: spot.offset, - ); - } - - /// A small floating tooltip for the hovered radar vertex, like the other - /// charts: the axis label, the series name and the value. - Widget _radarTooltip( - ChartSpec spec, - double side, - ({int series, int entry, double value, Offset offset}) touch, - ) { - final axis = touch.entry >= 0 && touch.entry < spec.x.length - ? spec.x[touch.entry] - : ''; - final series = touch.series < spec.series.length - ? spec.series[touch.series].name - : ''; - final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series; - final onLeftHalf = touch.offset.dx <= side / 2; - return Positioned( - left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null, - right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012), - top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)), - child: IgnorePointer( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: side * 0.6), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.012, - vertical: w * 0.006, - ), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(w * 0.008), - boxShadow: const [ - BoxShadow(color: Color(0x33000000), blurRadius: 6), - ], - ), - child: Text( - '${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}', - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: _tooltipStyle(), - ), - ), - ), - ), - ); - } - - /// Vertical scale legend shown to the right of a radar chart: the tick values - /// from the outer ring (top) down to the centre (bottom), in a small font. - Widget _radarScaleLegend( - ({double lo, double hi, int ticks}) scale, - Color textColor, - ) { - final style = _applyFont( - font, - TextStyle( - fontSize: w * 0.012 * _labelScale, - color: textColor.withValues(alpha: 0.62), - fontWeight: FontWeight.w600, - ), - ); - final tickColor = textColor.withValues(alpha: 0.3); - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var k = scale.ticks; k >= 0; k--) ...[ - if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: w * 0.012, height: 1, color: tickColor), - SizedBox(width: w * 0.006), - Flexible( - child: Text( - _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), - style: style, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ], - ); - } - - /// Resolves the radar scale: a low/high pair plus an even tick count. Honours - /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data - /// range to a tidy scale so the rings read as round numbers. - ({double lo, double hi, int ticks}) radarScale(ChartSpec spec) { - var dataMin = 0.0; - var dataMax = 0.0; - var seen = false; - for (final s in spec.series) { - for (final v in s.data) { - if (!seen) { - dataMin = v; - dataMax = v; - seen = true; - } else { - if (v < dataMin) dataMin = v; - if (v > dataMax) dataMax = v; - } - } - } - if (!seen) { - dataMin = 0; - dataMax = 1; - } - final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); - final rawHi = spec.maxBound ?? dataMax; - final nice = _niceScale(rawLo, rawHi); - final lo = spec.minBound ?? nice.lo; - var hi = spec.maxBound ?? nice.hi; - if (hi <= lo) hi = lo + nice.step; - final ticks = math.max(2, ((hi - lo) / nice.step).round()); - return (lo: lo, hi: hi, ticks: ticks); - } - - ({double lo, double hi, double step}) _niceScale(double lo, double hi) { - final range = (hi - lo).abs(); - final r = range <= 0 ? 1.0 : range; - final rawStep = r / 4; - final mag = math - .pow(10, (math.log(rawStep) / math.ln10).floor()) - .toDouble(); - final norm = rawStep / mag; - final niceNorm = norm < 1.5 - ? 1.0 - : norm < 3 - ? 2.0 - : norm < 7 - ? 5.0 - : 10.0; - final step = niceNorm * mag; - return ( - lo: (lo / step).floor() * step, - hi: (hi / step).ceil() * step, - step: step, - ); - } - - TextStyle _tooltipStyle() => _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: (w * 0.013 * _labelScale).clamp(11, 18), - height: 1.25, - fontWeight: FontWeight.w700, - ), - ); - - /// Tooltip style for line charts. Each touched dot adds two lines, so when - /// several dots overlap the font shrinks a step to keep the stack readable. - TextStyle _lineTooltipStyle(int count) { - final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0); - final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0); - return _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: (base * shrink).clamp(8.0, 18.0), - height: 1.2, - fontWeight: FontWeight.w700, - ), - ); - } - - Widget _placeholder(BuildContext context) => - _placeholderText(context.l10n.d('Geen grafiekgegevens')); - - Widget _placeholderText(String text) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.bar_chart_outlined, - size: w * 0.08, - color: const Color(0xFF94A3B8), - ), - SizedBox(height: w * 0.01), - Text( - text, - style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02), - ), - ], - ), - ); -} - -class _HoverPieChart extends StatefulWidget { - final List values; - final List labels; - final List colors; - final double radius; - final double centerSpaceRadius; - final double sectionSpace; - final TextStyle titleStyle; - final TextStyle tooltipStyle; - - /// Slice index highlighted from outside (e.g. hovering the legend), combined - /// with this chart's own touch hover. - final int? externalHover; - - const _HoverPieChart({ - required this.values, - required this.labels, - required this.colors, - required this.radius, - required this.centerSpaceRadius, - required this.sectionSpace, - required this.titleStyle, - required this.tooltipStyle, - this.externalHover, - }); - - @override - State<_HoverPieChart> createState() => _HoverPieChartState(); -} - -class _HoverPieChartState extends State<_HoverPieChart> { - int? _hovered; - - @override - Widget build(BuildContext context) { - final total = widget.values.fold(0, (a, b) => a + b); - final external = widget.externalHover; - final hovered = - _hovered ?? - (external != null && external >= 0 && external < widget.values.length - ? external - : null); - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: PieChart( - PieChartData( - sections: [ - for (var i = 0; i < widget.values.length; i++) - PieChartSectionData( - value: widget.values[i], - color: widget.colors[i], - title: widget.values[i] / total >= 0.08 - ? '${(widget.values[i] / total * 100).round()}%' - : '', - radius: widget.radius * (hovered == i ? 1.08 : 1), - titleStyle: widget.titleStyle, - ), - ], - sectionsSpace: widget.sectionSpace, - centerSpaceRadius: widget.centerSpaceRadius, - pieTouchData: PieTouchData( - enabled: true, - mouseCursorResolver: (event, response) => - response?.touchedSection == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchCallback: (event, response) { - final next = event.isInterestedForInteractions - ? response?.touchedSection?.touchedSectionIndex - : null; - if (next != _hovered) setState(() => _hovered = next); - }, - ), - ), - duration: Duration.zero, - ), - ), - if (hovered != null && hovered >= 0 && hovered < widget.values.length) - Positioned( - top: 4, - left: 4, - right: 4, - child: IgnorePointer( - child: Center( - child: Container( - key: const ValueKey('pie-hover-tooltip'), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(8), - boxShadow: const [ - BoxShadow(color: Color(0x33000000), blurRadius: 6), - ], - ), - child: Text( - '${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: widget.tooltipStyle, - ), - ), - ), - ), - ), - ], - ); - } -} - -String _formatChartValue(double value) => value == value.roundToDouble() - ? value.toInt().toString() - : value.toStringAsFixed(1); - -/// Register highlight.js language definitions once, so [HighlightView] can -/// colour any common language without throwing. -bool _highlightReady = false; -void _ensureHighlightLanguages() { - if (_highlightReady) return; - allLanguages.forEach(highlight.registerLanguage); - _highlightReady = true; -} - -// ── Shared helper ───────────────────────────────────────────────────────────── - -/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain. -/// imageSize = 0 → cover (Marp-standaard, vult frame, snijdt bij) -/// imageSize = 100 → volledige afbeelding zichtbaar (contain, evt. randen) -/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect -/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain -Widget _zoomedImage( - BuildContext context, - String imagePath, - String? projectPath, - int imageSize, { - Color bgColor = Colors.black, - Alignment alignment = Alignment.center, -}) { - if (imageSize == 0) { - return _resolvedImage( - context, - imagePath, - projectPath, - ); // BoxFit.cover standaard - } - final scale = imageSize / 100.0; - // Size the image box to `scale` × the available area and let BoxFit.contain - // fit the picture inside it. This produces the same visual result as a - // Transform.scale but without a transform layer, which `RepaintBoundary - // .toImage` (used for exports) captures far more reliably — a scaled - // transform layer would frequently render blank in the exported PNG. - return ClipRect( - child: ColoredBox( - color: bgColor, - child: LayoutBuilder( - builder: (context, constraints) { - final boxW = constraints.maxWidth * scale; - final boxH = constraints.maxHeight * scale; - return Align( - alignment: alignment, - child: SizedBox( - width: boxW, - height: boxH, - // BoxFit.contain: toont de volledige afbeelding zonder bijsnijden - child: _resolvedImage( - context, - imagePath, - projectPath, - fit: BoxFit.contain, - ), - ), - ); - }, - ), - ), - ); -} - -Widget _resolvedImage( - BuildContext context, - String imagePath, - String? projectPath, { - BoxFit fit = BoxFit.cover, -}) { - if (imagePath.isEmpty) return _imagePlaceholder(context); - - final String resolved; - if (imagePath.startsWith('/') || imagePath.contains(':\\')) { - resolved = imagePath; - } else if (projectPath != null) { - resolved = '$projectPath/$imagePath'; - } else { - resolved = imagePath; - } - - return Image.file( - File(resolved), - fit: fit, - width: double.infinity, - height: double.infinity, - // Keep showing the previous frame while the next image decodes. Without - // this the widget paints nothing for a frame on a source change, which - // shows up as a black flash between slides — fatal when recording video. - gaplessPlayback: true, - errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context), - ); -} - -Widget _captionOverlay( - BuildContext context, - String caption, - double w, { - double? right, - double? bottom, -}) { - final text = caption.trim(); - if (text.isEmpty) return const SizedBox.shrink(); - // Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering - // staat, schuift het bijschrift erboven zodat het niet wordt overschreven. - final lift = _SlideLinkScope.hasBottomTlpOf(context) - ? _tlpVerticalReserve(w) - : 0.0; - return Positioned( - right: right ?? w * _kTlpEdge, - bottom: (bottom ?? _tlpBottomInset(w)) + lift, - child: Container( - constraints: BoxConstraints(maxWidth: w * 0.5), - padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.58), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - text, - textAlign: TextAlign.right, - style: TextStyle( - color: Colors.white, - fontSize: w * 0.011, - height: 1.25, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ); -} - String? _resolvePath(String path, String? projectPath) => resolveSlideAssetPath(path, projectPath); @@ -4453,70 +395,6 @@ String? resolveSlideAssetPath(String path, String? projectPath) { return path; } -// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ────── -const double _kTlpFont = 0.018; // × slidebreedte -const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte) -const double _kTlpHPad = 0.011; -const double _kTlpVPad = 0.005; - -double _tlpBottomInset(double w) => w * 0.022; - -/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken. -double _tlpBadgeWidth(double w, TlpLevel tlp) => - tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad); - -/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften). -double _tlpVerticalReserve(double w) => - w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w); - -/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak, -/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. -class _TlpOverlay extends StatelessWidget { - final TlpLevel tlp; - final double w; - final ThemeProfile profile; - final bool hasLogo; - - const _TlpOverlay({ - required this.tlp, - required this.w, - required this.profile, - required this.hasLogo, - }); - - @override - Widget build(BuildContext context) { - final toLeft = hasLogo && profile.logoPosition == 'bottom-right'; - return Positioned( - bottom: _tlpBottomInset(w), - left: toLeft ? w * _kTlpEdge : null, - right: toLeft ? null : w * _kTlpEdge, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * _kTlpHPad, - vertical: w * _kTlpVPad, - ), - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(w * 0.005), - ), - child: Text( - tlp.label, - style: TextStyle( - color: Color(tlp.foreground), - fontSize: w * _kTlpFont, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - fontFamily: 'monospace', - fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], - height: 1.0, - ), - ), - ), - ); - } -} - /// Footer onderaan een slide: vrije tekst (links) + paginanummers (rechts), /// op basis van het stijlprofiel. Verborgen op titel-/sectieslides (daar is /// een footer ongebruikelijk en valt 'ie weg tegen de donkere achtergrond). @@ -4546,199 +424,3 @@ double _contentLeftInset(Slide slide, double w) { return w * 0.04; } } - -class _FooterOverlay extends StatelessWidget { - final Slide slide; - final double w; - final ThemeProfile profile; - final int? slideNumber; - final int? slideCount; - final TlpLevel tlp; - - const _FooterOverlay({ - required this.slide, - required this.w, - required this.profile, - this.slideNumber, - this.slideCount, - this.tlp = TlpLevel.none, - }); - - String _applyTokens(String s) { - final now = DateTime.now(); - String two(int v) => v.toString().padLeft(2, '0'); - final date = '${two(now.day)}-${two(now.month)}-${now.year}'; - return s - .replaceAll('{page}', slideNumber?.toString() ?? '') - .replaceAll('{total}', slideCount?.toString() ?? '') - .replaceAll('{date}', date) - .replaceAll('{title}', slide.title); - } - - @override - Widget build(BuildContext context) { - if (!slide.showFooter) return const SizedBox.shrink(); - if (slide.type == SlideType.title || slide.type == SlideType.section) { - return const SizedBox.shrink(); - } - - final footerText = _applyTokens(profile.footerText).trim(); - final showPages = profile.footerShowPageNumbers && slideNumber != null; - if (footerText.isEmpty && !showPages) return const SizedBox.shrink(); - - // Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt. - final fontSize = w * 0.0145; - final style = TextStyle( - color: _hexColor(profile.textColor).withValues(alpha: 0.7), - fontSize: fontSize, - // Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft. - shadows: [ - Shadow( - color: Colors.white.withValues(alpha: 0.5), - blurRadius: w * 0.003, - ), - ], - ); - - // Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en - // de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen. - double mx(double a, double b) => a > b ? a : b; - final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo; - final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom'); - final logoOnLeft = profile.logoPosition.endsWith('left'); - final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012; - final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28; - final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right'); - final tlpSpan = tlp == TlpLevel.none - ? 0.0 - : w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012; - final footerLeftAligned = profile.footerPosition == 'left'; - - // Links uitgelijnd begint de footer waar het logo of de bullets beginnen, - // voor een consistente linkermarge. Anders de standaardmarge. - var left = footerLeftAligned - ? (logoBottom && logoOnLeft - ? logoLeftEdge - : _contentLeftInset(slide, w)) - : w * 0.04; - var right = w * 0.04; - if (logoBottom) { - if (logoOnLeft) { - // Een links-uitgelijnde footer mag bewust met de logo-linkerkant - // uitlijnen; anders schuift 'ie rechts van het logo om overlap te - // voorkomen. - if (!footerLeftAligned) left = mx(left, logoSpan); - } else { - right = mx(right, logoSpan); - } - } - if (tlp != TlpLevel.none) { - if (tlpOnRight) { - right = mx(right, tlpSpan); - } else { - left = mx(left, tlpSpan); - } - } - - final alignment = switch (profile.footerPosition) { - 'left' => Alignment.centerLeft, - 'center' => Alignment.center, - _ => Alignment.centerRight, - }; - final textAlign = switch (profile.footerPosition) { - 'left' => TextAlign.left, - 'center' => TextAlign.center, - _ => TextAlign.right, - }; - - return Positioned( - left: left, - right: right, - bottom: w * 0.02, - child: Align( - alignment: alignment, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: w - left - right), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (footerText.isNotEmpty) - Flexible( - child: Text( - footerText, - style: style, - textAlign: textAlign, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02), - if (showPages) - Text( - '$slideNumber / ${slideCount ?? slideNumber}', - style: style, - ), - ], - ), - ), - ), - ); - } -} - -Widget _mediaPlaceholder(IconData icon, String label) { - return Container( - color: const Color(0xFFE2E8F0), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: const Color(0xFF94A3B8), size: 32), - const SizedBox(height: 6), - Text( - label, - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - ), - ], - ), - ), - ); -} - -Widget _imagePlaceholder(BuildContext context) { - return ColoredBox( - color: const Color(0xFFE2E8F0), - child: LayoutBuilder( - builder: (context, constraints) { - final shortestSide = constraints.biggest.shortestSide; - if (shortestSide < 48) { - return Center( - child: Icon( - Icons.image_outlined, - color: const Color(0xFF94A3B8), - size: shortestSide * 0.65, - ), - ); - } - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.image_outlined, - color: Color(0xFF94A3B8), - size: 24, - ), - const SizedBox(height: 4), - Text( - context.l10n.d('Afbeelding'), - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), - ), - ], - ), - ); - }, - ), - ); -}