Ocideck/lib/widgets/editors/two_bullets_editor.dart
Brenno de Winter 68725341a7 Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
  file per group (preferring the most-used, then the oldest), merges
  the tags/descriptions and captions of the copies, repoints slides in
  open decks and in .md presentations on disk, and deletes the copies
  after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
  easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
  reference the image (marked "not open"), next to the open decks.

Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
  keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
  the whole grid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00

432 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/slide.dart';
import '../../l10n/app_localizations.dart';
import '_editor_field.dart';
import 'list_style_selector.dart';
typedef _Mutate = void Function(VoidCallback fn);
class TwoBulletsEditor extends StatefulWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
const TwoBulletsEditor({
super.key,
required this.slide,
required this.onUpdate,
});
@override
State<TwoBulletsEditor> createState() => _TwoBulletsEditorState();
}
class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
late final TextEditingController _title;
late final TextEditingController _heading1;
late final TextEditingController _heading2;
late _BulletSet _left;
late _BulletSet _right;
late ListStyle _listStyle;
late bool _showChecklistProgress;
@override
void initState() {
super.initState();
_title = TextEditingController(text: widget.slide.title);
_title.addListener(_emit);
_heading1 = TextEditingController(text: widget.slide.columnTitle1);
_heading2 = TextEditingController(text: widget.slide.columnTitle2);
_heading1.addListener(_emit);
_heading2.addListener(_emit);
_listStyle = widget.slide.listStyle;
_showChecklistProgress = widget.slide.showChecklistProgress;
_left = _BulletSet(widget.slide.bullets, _emit);
_right = _BulletSet(widget.slide.bullets2, _emit);
}
void _emit() {
widget.onUpdate(
widget.slide.copyWith(
title: _title.text,
columnTitle1: _heading1.text,
columnTitle2: _heading2.text,
listStyle: _listStyle,
showChecklistProgress: _showChecklistProgress,
bullets: _left.values(_listStyle),
bullets2: _right.values(_listStyle),
),
);
}
@override
void dispose() {
_title.dispose();
_heading1.dispose();
_heading2.dispose();
_left.dispose();
_right.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'),
const SizedBox(height: 16),
ListStyleSelector(
value: _listStyle,
onChanged: (value) {
setState(() => _listStyle = value);
_emit();
},
),
if (_listStyle == ListStyle.checklist)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.d('Voortgangsgrafiek tonen')),
subtitle: Text(
context.l10n.d(
'Toont afgevinkt en niet afgevinkt als percentages.',
),
),
value: _showChecklistProgress,
onChanged: (value) {
setState(() => _showChecklistProgress = value);
_emit();
},
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 560;
final columns = [
_BulletColumn(
label: 'Bullets links',
set: _left,
emit: _emit,
headingController: _heading1,
listStyle: _listStyle,
),
_BulletColumn(
label: 'Bullets rechts',
set: _right,
emit: _emit,
headingController: _heading2,
listStyle: _listStyle,
),
];
if (narrow) {
return Column(
children: [columns[0], const SizedBox(height: 18), columns[1]],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: columns[0]),
const SizedBox(width: 16),
Expanded(child: columns[1]),
],
);
},
),
],
);
}
}
class _BulletSet {
static const maxLevel = 4;
final VoidCallback emit;
late List<TextEditingController> controllers;
late List<int> levels;
late List<bool> checked;
late List<FocusNode> focusNodes;
_BulletSet(List<String> raw, this.emit) {
final list = raw.isEmpty ? [''] : raw;
levels = list.map(_levelOf).toList();
checked = list.map(checklistItemChecked).toList();
controllers = list.map((b) => _makeCtrl(checklistItemText(b))).toList();
focusNodes = List.generate(controllers.length, (_) => FocusNode());
}
List<String> values(ListStyle listStyle) => List.generate(
controllers.length,
(i) => listStyle == ListStyle.checklist
? checklistBullet(
level: levels[i],
text: controllers[i].text,
checked: checked[i],
)
: '\t' * levels[i] + controllers[i].text,
);
static int _levelOf(String b) {
int l = 0;
while (l < b.length && b[l] == '\t' && l < maxLevel) {
l++;
}
return l;
}
TextEditingController _makeCtrl(String text) {
final c = TextEditingController(text: text);
c.addListener(emit);
return c;
}
void addAfter(_Mutate mutate, int i) {
mutate(() {
controllers.insert(i + 1, _makeCtrl(''));
levels.insert(i + 1, levels[i]);
checked.insert(i + 1, false);
focusNodes.insert(i + 1, FocusNode());
});
emit();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (i + 1 < focusNodes.length) focusNodes[i + 1].requestFocus();
});
}
void removeAndFocus(_Mutate mutate, int i) {
if (controllers.length == 1) {
mutate(() {
controllers[i].removeListener(emit);
controllers[i].clear();
controllers[i].addListener(emit);
levels[i] = 0;
checked[i] = false;
});
emit();
focusNodes[i].requestFocus();
return;
}
final target = (i - 1).clamp(0, controllers.length - 2);
mutate(() {
controllers[i].removeListener(emit);
controllers[i].dispose();
controllers.removeAt(i);
levels.removeAt(i);
checked.removeAt(i);
focusNodes[i].dispose();
focusNodes.removeAt(i);
});
emit();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (target < focusNodes.length) focusNodes[target].requestFocus();
});
}
Future<void> paste(_Mutate mutate, int i) async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text == null) return;
final lines = data!.text!
.split('\n')
.map((l) => l.trim().replaceAll(RegExp(r'^[-*•◦▪▫]\s*'), ''))
.where((l) => l.isNotEmpty)
.toList();
if (lines.isEmpty) return;
if (lines.length == 1) {
final ctrl = controllers[i];
final sel = ctrl.selection;
final start = sel.isValid ? sel.start : ctrl.text.length;
final end = sel.isValid ? sel.end : ctrl.text.length;
ctrl.value = TextEditingValue(
text: ctrl.text.replaceRange(start, end, lines[0]),
selection: TextSelection.collapsed(offset: start + lines[0].length),
);
return;
}
mutate(() {
controllers[i].removeListener(emit);
controllers[i].dispose();
controllers[i] = _makeCtrl(lines[0]);
for (int j = 1; j < lines.length; j++) {
controllers.insert(i + j, _makeCtrl(lines[j]));
levels.insert(i + j, levels[i]);
checked.insert(i + j, false);
focusNodes.insert(i + j, FocusNode());
}
});
emit();
WidgetsBinding.instance.addPostFrameCallback((_) {
final last = i + lines.length - 1;
if (last < focusNodes.length) focusNodes[last].requestFocus();
});
}
void dispose() {
for (final c in controllers) {
c.dispose();
}
for (final f in focusNodes) {
f.dispose();
}
}
}
class _BulletColumn extends StatefulWidget {
final String label;
final _BulletSet set;
final VoidCallback emit;
final TextEditingController headingController;
final ListStyle listStyle;
const _BulletColumn({
required this.label,
required this.set,
required this.emit,
required this.headingController,
required this.listStyle,
});
@override
State<_BulletColumn> createState() => _BulletColumnState();
}
class _BulletColumnState extends State<_BulletColumn> {
_BulletSet get set => widget.set;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: widget.headingController,
decoration: InputDecoration(
labelText: l10n.d('Kop (optioneel)'),
isDense: true,
),
),
const SizedBox(height: 12),
SectionLabel(widget.label),
const SizedBox(height: 6),
for (int i = 0; i < set.controllers.length; i++) _buildRow(i),
const SizedBox(height: 4),
TextButton.icon(
onPressed: () =>
set.addAfter((fn) => setState(fn), set.controllers.length - 1),
icon: const Icon(Icons.add, size: 16),
label: Text(l10n.d('Bullet toevoegen')),
),
],
);
}
Widget _buildRow(int i) {
final l10n = context.l10n;
final level = set.levels[i];
return Padding(
key: ValueKey(set.controllers[i]),
padding: EdgeInsets.only(left: level * 20.0, top: 4, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.listStyle == ListStyle.checklist)
SizedBox(
width: 24,
height: 24,
child: Checkbox(
key: ValueKey('checklist-item-${widget.label}-$i'),
value: set.checked[i],
onChanged: (value) {
setState(() => set.checked[i] = value ?? false);
widget.emit();
},
visualDensity: VisualDensity.compact,
),
)
else
Text(
_markerForItem(i),
style: const TextStyle(fontSize: 16, color: Color(0xFF64748B)),
),
const SizedBox(width: 8),
Expanded(
child: Focus(
onKeyEvent: (_, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.enter) {
set.addAfter((fn) => setState(fn), i);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.backspace &&
set.controllers[i].text.isEmpty &&
set.controllers.length > 1) {
set.removeAndFocus((fn) => setState(fn), i);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.tab) {
if (HardwareKeyboard.instance.isShiftPressed) {
if (set.levels[i] > 0) setState(() => set.levels[i]--);
} else {
if (set.levels[i] < _BulletSet.maxLevel) {
setState(() => set.levels[i]++);
}
}
widget.emit();
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.keyV &&
(HardwareKeyboard.instance.isMetaPressed ||
HardwareKeyboard.instance.isControlPressed)) {
set.paste((fn) => setState(fn), i);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: TextField(
controller: set.controllers[i],
focusNode: set.focusNodes[i],
decoration: InputDecoration(
hintText: '${l10n.d('Bullet')} ${i + 1}',
isDense: true,
),
),
),
),
IconButton(
key: ValueKey('remove-bullet-${widget.label}-$i'),
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF64748B),
),
onPressed: () => set.removeAndFocus((fn) => setState(fn), i),
tooltip: l10n.d('Verwijder'),
padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: const BoxConstraints(minWidth: 28),
),
],
),
);
}
String _markerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
String _markerForItem(int index) {
if (widget.listStyle == ListStyle.bullets) {
return _markerForLevel(set.levels[index]);
}
if (widget.listStyle == ListStyle.checklist) return '';
final level = set.levels[index];
var number = 0;
for (var i = 0; i <= index; i++) {
if (set.levels[i] == level) number++;
if (set.levels[i] < level) number = 0;
}
return '$number.';
}
}