Ocideck/lib/widgets/editors/two_bullets_editor.dart
Brenno de Winter 9827715873
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
Add bullet subheadings and font-accurate slide auto-fit
- Bullet slides can carry an optional subheading under the title (stored as
  `## …` in the slide's subtitle, round-tripped losslessly).
- The two-bullet-column subheadings and the bullets subheading participate in
  the auto-fit so the text keeps scaling to fill the slide.
- Slide text auto-sizing now measures with the deck's own font, so the fit
  matches what is rendered and the text uses the available space instead of
  staying smaller than needed.
- Editor fields, translations (all languages), docs and tests included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:48:06 +02:00

348 lines
10 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';
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;
@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);
_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,
bullets: _left.values,
bullets2: _right.values,
),
);
}
@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),
LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 560;
final columns = [
_BulletColumn(
label: 'Bullets links',
set: _left,
emit: _emit,
headingController: _heading1,
),
_BulletColumn(
label: 'Bullets rechts',
set: _right,
emit: _emit,
headingController: _heading2,
),
];
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<FocusNode> focusNodes;
_BulletSet(List<String> raw, this.emit) {
final list = raw.isEmpty ? [''] : raw;
levels = list.map(_levelOf).toList();
controllers = list.map((b) => _makeCtrl(b.trimLeft())).toList();
focusNodes = List.generate(controllers.length, (_) => FocusNode());
}
List<String> get values => List.generate(
controllers.length,
(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]);
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) return;
final target = (i - 1).clamp(0, controllers.length - 2);
mutate(() {
controllers[i].removeListener(emit);
controllers[i].dispose();
controllers.removeAt(i);
levels.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]);
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;
const _BulletColumn({
required this.label,
required this.set,
required this.emit,
required this.headingController,
});
@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: [
Text(
_markerForLevel(level),
style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
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(
icon: const Icon(
Icons.remove_circle_outline,
size: 18,
color: Color(0xFF94A3B8),
),
onPressed: set.controllers.length > 1
? () => set.removeAndFocus((fn) => setState(fn), i)
: null,
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)];
}
}