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 onUpdate; const TwoBulletsEditor({ super.key, required this.slide, required this.onUpdate, }); @override State createState() => _TwoBulletsEditorState(); } class _TwoBulletsEditorState extends State { 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 controllers; late List levels; late List checked; late List focusNodes; _BulletSet(List 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 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 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.'; } }