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 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-09 15:43:46 +02:00
parent 2fd5054603
commit 0bc3f62ede
4 changed files with 226 additions and 66 deletions

View file

@ -305,6 +305,50 @@ class DeckNotifier extends StateNotifier<DeckState> {
_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 = <Slide>[];
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.

View file

@ -886,6 +886,53 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
);
}
Future<void> 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<bool>(
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<void> 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,

View file

@ -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<bool?>(
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<bool?>(
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 {
),
),
),
],
),
],
),
);
},
);
}
}

View file

@ -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);