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:
parent
2fd5054603
commit
0bc3f62ede
4 changed files with 226 additions and 66 deletions
|
|
@ -305,6 +305,50 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
_mutate(deck.copyWith(slides: slides));
|
_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 ─────────────────────────────────────────────────────
|
// ── Zoeken & vervangen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Tel hoe vaak [query] in alle tekstvelden van de presentatie voorkomt.
|
/// Tel hoe vaak [query] in alle tekstvelden van de presentatie voorkomt.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
Future<void> openImageCarousel() async {
|
||||||
final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1);
|
final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1);
|
||||||
final slide = deck.slides[idx];
|
final slide = deck.slides[idx];
|
||||||
|
|
@ -1281,6 +1328,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
importUrl();
|
importUrl();
|
||||||
case 'find':
|
case 'find':
|
||||||
openFindReplace();
|
openFindReplace();
|
||||||
|
case 'clear_checklists':
|
||||||
|
clearAllChecklists();
|
||||||
case 'full_preview':
|
case 'full_preview':
|
||||||
openFullDeckPreview();
|
openFullDeckPreview();
|
||||||
case 'properties':
|
case 'properties':
|
||||||
|
|
@ -1323,6 +1372,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
menuItem('import_url', Icons.link, l10n.t('importUrl')),
|
menuItem('import_url', Icons.link, l10n.t('importUrl')),
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
menuItem('find', Icons.find_replace, l10n.t('findReplace')),
|
menuItem('find', Icons.find_replace, l10n.t('findReplace')),
|
||||||
|
menuItem(
|
||||||
|
'clear_checklists',
|
||||||
|
Icons.check_box_outline_blank,
|
||||||
|
l10n.d('Alle checkboxen legen'),
|
||||||
|
),
|
||||||
menuItem(
|
menuItem(
|
||||||
'full_preview',
|
'full_preview',
|
||||||
Icons.preview_outlined,
|
Icons.preview_outlined,
|
||||||
|
|
|
||||||
|
|
@ -1391,72 +1391,92 @@ class _ChecklistProgress extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
final interaction = _ChecklistInteractionScope.maybeOf(context);
|
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(
|
return LayoutBuilder(
|
||||||
label:
|
builder: (context, constraints) {
|
||||||
'${context.l10n.d('Afgevinkt')} $checkedPercent%, '
|
// Grow the pie to fill the width it is handed instead of staying at a
|
||||||
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
|
// fixed, tiny size. Every caller gives this widget a bounded column
|
||||||
child: Column(
|
// width, so the chart now scales with the space that is actually
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
// available next to (or above) the bullets.
|
||||||
mainAxisSize: MainAxisSize.min,
|
final maxW = constraints.maxWidth.isFinite
|
||||||
children: [
|
? constraints.maxWidth
|
||||||
SizedBox(
|
: w * 0.4;
|
||||||
width: w * 0.36,
|
final diameter = maxW.clamp(w * 0.26, w * 0.38).toDouble();
|
||||||
height: w * 0.19,
|
final baseRadius = diameter * 0.44;
|
||||||
child: interaction == null
|
final hoverRadius = diameter * 0.48;
|
||||||
? pie(null)
|
final pieTitleStyle = _applyFont(
|
||||||
: ValueListenableBuilder<bool?>(
|
font,
|
||||||
valueListenable: interaction.hovered,
|
TextStyle(
|
||||||
builder: (_, hovered, _) => pie(hovered),
|
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(
|
MouseRegion(
|
||||||
key: const ValueKey('checklist-progress-checked'),
|
key: const ValueKey('checklist-progress-checked'),
|
||||||
onEnter: interaction?.enabled != true
|
onEnter: interaction?.enabled != true
|
||||||
|
|
@ -1485,8 +1505,10 @@ class _ChecklistProgress extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,46 @@ void main() {
|
||||||
expect(n.state.deck!.slides.first.title, 'Nieuw');
|
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', () {
|
test('updateMeta changes deck title/theme/paginate', () {
|
||||||
final n = _notifier()..newDeck('D');
|
final n = _notifier()..newDeck('D');
|
||||||
n.updateMeta(title: 'Andere titel', theme: 'custom', paginate: false);
|
n.updateMeta(title: 'Andere titel', theme: 'custom', paginate: false);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue