Ocideck/lib/widgets/presentation/rehearsal_summary.dart
Brenno de Winter b719c43991 Add presentation timer / rehearsal mode to the presenter
The presenter view now doubles as a rehearsal clock that measures without
coaching: a countdown against a target time, the time spent on the current
slide, and an end-of-run summary (total vs. target and per-slide times, with
copy-to-clipboard). Timing lives in a plain, unit-tested RehearsalController fed
via an idempotent observe() on every build, so it captures every navigation
path. The default target is stored in AppSettings; live adjustment is the K key
(typed as MMSS). All rehearsal state is session-only -- nothing is written to
disk or into the .md file.

- New: models/rehearsal.dart, services/rehearsal_controller.dart,
  widgets/presentation/rehearsal_summary.dart, plus a controller unit test.
- Presenter: countdown + per-slide timer in the clock bar, K to set the target,
  R resets the run, end-of-run summary dialog, and help/cheatsheet entries.
- Settings: presentationTargetSeconds (default target) with a dropdown in the
  General tab, threaded into FullscreenPresenter.present().
- l10n: new Dutch source strings translated in all seven languages.
- Docs: README, CHANGELOG, USER_GUIDE, SHORTCUTS, ARCHITECTURE.

Also bundles a pre-existing in-progress change already in the working tree: wire
the existing ThemeProfile.tableHeaderBackgroundColor into table rendering
(preview, HTML export, file_service) and the settings dialog, plus its
translations.

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

178 lines
5.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
import '../../models/rehearsal.dart';
import '../../models/slide.dart';
/// Toon de samenvatting van een oefenrun (sessie-only). Beschrijvend: totale
/// tijd, doeltijd en de tijd per slide — geen pacing-oordeel.
Future<void> showRehearsalSummary(
BuildContext context, {
required RehearsalRun run,
required List<Slide> slides,
}) {
return showDialog<void>(
context: context,
builder: (_) => _RehearsalSummaryDialog(run: run, slides: slides),
);
}
String _fmt(Duration d) {
final neg = d.isNegative;
final a = d.abs();
final mm = (a.inMinutes % 60).toString().padLeft(2, '0');
final ss = (a.inSeconds % 60).toString().padLeft(2, '0');
final body = a.inHours > 0 ? '${a.inHours}:$mm:$ss' : '$mm:$ss';
return neg ? '-$body' : body;
}
class _RehearsalSummaryDialog extends StatelessWidget {
final RehearsalRun run;
final List<Slide> slides;
const _RehearsalSummaryDialog({required this.run, required this.slides});
String _label(SlideTiming t) {
final slide = slides.firstWhere(
(s) => s.id == t.slideId,
orElse: () => slides.isNotEmpty ? slides.first : (throw StateError('')),
);
final title = slide.title.trim();
return title.isEmpty ? '${t.index + 1}.' : '${t.index + 1}. $title';
}
Future<void> _copy(BuildContext context) async {
final l10n = context.l10n;
final buf = StringBuffer()
..writeln('${l10n.d('Totaal')}: ${_fmt(run.total)}');
if (run.target != null) {
buf.writeln('${l10n.d('Doeltijd')}: ${_fmt(run.target!)}');
}
buf.writeln('');
for (final t in run.perSlide) {
buf.writeln('${_label(t)}\t${_fmt(t.spent)}');
}
await Clipboard.setData(ClipboardData(text: buf.toString()));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.d('Tijden gekopieerd naar klembord.'))),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final delta = run.delta;
return AlertDialog(
title: Text(l10n.d('Oefenrun')),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Totaal vs. doeltijd.
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.d('Totale tijd'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
_fmt(run.total),
style: const TextStyle(
fontWeight: FontWeight.w700,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
if (run.target != null) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.d('Doeltijd')),
Text(
_fmt(run.target!),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
if (delta != null) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
delta.isNegative
? l10n.d('Binnen de tijd')
: l10n.d('Over de tijd'),
),
Text(
_fmt(delta),
style: TextStyle(
fontWeight: FontWeight.w600,
color: delta.isNegative
? Colors.green.shade700
: Colors.red.shade700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
],
],
const Divider(height: 24),
Flexible(
child: run.perSlide.isEmpty
? Text(l10n.d('Geen slides gemeten.'))
: ListView.builder(
shrinkWrap: true,
itemCount: run.perSlide.length,
itemBuilder: (_, i) {
final t = run.perSlide[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
_label(t),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
Text(
_fmt(t.spent),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => _copy(context),
child: Text(l10n.d('Kopieer')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.d('Sluiten')),
),
],
);
}
}