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>
178 lines
5.9 KiB
Dart
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')),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|