From 0bc3f62ede8e12872f485694c9f13f2fd41cece7 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Tue, 9 Jun 2026 15:43:46 +0200 Subject: [PATCH] Add clear-all-checklists action and enlarge progress chart - Add "Alle checkboxen legen" item to the deck overflow menu that, after a confirmation dialog showing the count, unchecks every checklist item across the whole presentation in a single undoable step. - Make the checklist progress pie responsive: it now scales to the column width it is given instead of a fixed, tiny size, so it fills the available space in all three bullet layouts. Co-Authored-By: Claude Opus 4.8 --- lib/state/deck_provider.dart | 44 ++++++++ lib/widgets/app_shell.dart | 54 +++++++++ lib/widgets/slides/slide_preview.dart | 154 +++++++++++++++----------- test/deck_provider_test.dart | 40 +++++++ 4 files changed, 226 insertions(+), 66 deletions(-) diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart index 949c0a9..81200dc 100644 --- a/lib/state/deck_provider.dart +++ b/lib/state/deck_provider.dart @@ -305,6 +305,50 @@ class DeckNotifier extends StateNotifier { _mutate(deck.copyWith(slides: slides)); } + /// Hoeveel checklist-items in de hele presentatie momenteel afgevinkt zijn. + int get checkedChecklistCount { + final deck = state.deck; + if (deck == null) return 0; + var total = 0; + for (final s in deck.slides) { + total += s.bullets.where(checklistItemChecked).length; + total += s.bullets2.where(checklistItemChecked).length; + } + return total; + } + + /// Vink in één keer alle checklist-items in de hele presentatie uit (bijv. + /// om een ingevulde checklist opnieuw te kunnen aflopen). Eén + /// ongedaan-maken-stap. No-op wanneer er niets is aangevinkt. + void clearAllChecklists() { + final deck = state.deck; + if (deck == null) return; + String uncheck(String bullet) => checklistItemChecked(bullet) + ? checklistBullet( + level: bulletLevel(bullet), + text: checklistItemText(bullet), + checked: false, + ) + : bullet; + var changed = false; + final slides = []; + for (final s in deck.slides) { + if (s.bullets.any(checklistItemChecked) || + s.bullets2.any(checklistItemChecked)) { + changed = true; + slides.add( + s.copyWith( + bullets: [for (final b in s.bullets) uncheck(b)], + bullets2: [for (final b in s.bullets2) uncheck(b)], + ), + ); + } else { + slides.add(s); + } + } + if (changed) _mutate(deck.copyWith(slides: slides)); + } + // ── Zoeken & vervangen ───────────────────────────────────────────────────── /// Tel hoe vaak [query] in alle tekstvelden van de presentatie voorkomt. diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index dbff572..01df09c 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -886,6 +886,53 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { ); } + Future clearAllChecklists() async { + final count = deckNotifier.checkedChecklistCount; + if (count == 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.d('Er zijn geen aangevinkte checklist-items om te legen.'), + ), + ), + ); + return; + } + final confirmed = await showDialog( + context: context, + builder: (ctx) { + final l10n = ctx.l10n; + return AlertDialog( + title: Text(l10n.d('Alle checkboxen legen?')), + content: Text( + '${l10n.d('Hiermee worden alle')} $count ' + '${l10n.d('aangevinkte checklist-items in de hele presentatie uitgevinkt. Dit kun je ongedaan maken met Ctrl/Cmd+Z.')}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.t('cancel')), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.d('Alles legen')), + ), + ], + ); + }, + ); + if (confirmed != true) return; + deckNotifier.clearAllChecklists(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$count ${l10n.d('checklist-items uitgevinkt.')}', + ), + ), + ); + } + Future openImageCarousel() async { final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1); final slide = deck.slides[idx]; @@ -1281,6 +1328,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { importUrl(); case 'find': openFindReplace(); + case 'clear_checklists': + clearAllChecklists(); case 'full_preview': openFullDeckPreview(); case 'properties': @@ -1323,6 +1372,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { menuItem('import_url', Icons.link, l10n.t('importUrl')), const PopupMenuDivider(), menuItem('find', Icons.find_replace, l10n.t('findReplace')), + menuItem( + 'clear_checklists', + Icons.check_box_outline_blank, + l10n.d('Alle checkboxen legen'), + ), menuItem( 'full_preview', Icons.preview_outlined, diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index d1754f1..b374ef1 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -1391,72 +1391,92 @@ class _ChecklistProgress extends StatelessWidget { ); final interaction = _ChecklistInteractionScope.maybeOf(context); - 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: w * (hovered == true ? 0.088 : 0.081), - title: '$checkedPercent%', - titleStyle: labelStyle.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - if (openPercent > 0) - PieChartSectionData( - value: openPercent.toDouble(), - color: openColor, - radius: w * (hovered == false ? 0.088 : 0.081), - title: '$openPercent%', - titleStyle: labelStyle.copyWith(fontWeight: FontWeight.bold), - ), - ], - 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: w * 0.36, - height: w * 0.19, - child: interaction == null - ? pie(null) - : ValueListenableBuilder( - valueListenable: interaction.hovered, - builder: (_, hovered, _) => pie(hovered), - ), + 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; + final diameter = maxW.clamp(w * 0.26, w * 0.38).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, ), - SizedBox(height: w * 0.008), + ); + + 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 @@ -1485,8 +1505,10 @@ class _ChecklistProgress extends StatelessWidget { ), ), ), - ], - ), + ], + ), + ); + }, ); } } diff --git a/test/deck_provider_test.dart b/test/deck_provider_test.dart index 6193e47..e46335f 100644 --- a/test/deck_provider_test.dart +++ b/test/deck_provider_test.dart @@ -137,6 +137,46 @@ void main() { expect(n.state.deck!.slides.first.title, 'Nieuw'); }); + test('clearAllChecklists unchecks every checklist item across the deck', () { + final n = _notifier()..newDeck('D'); + final s1 = Slide.create(SlideType.bullets).copyWith( + listStyle: ListStyle.checklist, + bullets: ['[x] Klaar', '\t[X] Subklaar', '[ ] Open'], + ); + final s2 = Slide.create(SlideType.bullets).copyWith( + listStyle: ListStyle.checklist, + bullets: ['[x] Eerste kolom'], + bullets2: ['[x] Tweede kolom', '[ ] Nog open'], + ); + n.loadDeck(n.state.deck!.copyWith(slides: [s1, s2])); + + expect(n.checkedChecklistCount, 4); + + n.clearAllChecklists(); + + expect(n.checkedChecklistCount, 0); + final out = n.state.deck!.slides; + expect(out[0].bullets, ['[ ] Klaar', '\t[ ] Subklaar', '[ ] Open']); + expect(out[1].bullets, ['[ ] Eerste kolom']); + expect(out[1].bullets2, ['[ ] Tweede kolom', '[ ] Nog open']); + }); + + test('clearAllChecklists is a no-op when nothing is checked', () { + final n = _notifier()..newDeck('D'); + final slide = Slide.create(SlideType.bullets).copyWith( + listStyle: ListStyle.checklist, + bullets: ['[ ] Open'], + ); + n.loadDeck(n.state.deck!.copyWith(slides: [slide])); + expect(n.state.canUndo, isFalse); + + n.clearAllChecklists(); + + // No checked items, so no history entry is recorded. + expect(n.state.canUndo, isFalse); + expect(n.checkedChecklistCount, 0); + }); + test('updateMeta changes deck title/theme/paginate', () { final n = _notifier()..newDeck('D'); n.updateMeta(title: 'Andere titel', theme: 'custom', paginate: false);