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));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue