Ocideck/lib/services/rehearsal_controller.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

115 lines
3.7 KiB
Dart

import '../models/rehearsal.dart';
/// Meet verstreken tijd, resterende tijd t.o.v. een doeltijd, en de tijd per
/// slide tijdens het presenteren/oefenen.
///
/// Bewust *alleen een klok*: het registreert wandkloktijd en rekent geen
/// pacing-advies uit ("ga sneller/langzamer"). Alles is sessie-only; er wordt
/// niets gepersisteerd.
///
/// De klokbron is injecteerbaar ([now]) zodat de timing in tests deterministisch
/// is — productie gebruikt [DateTime.now].
class RehearsalController {
RehearsalController({DateTime Function()? now, Duration? target})
: _now = now ?? DateTime.now,
_target = (target != null && target > Duration.zero) ? target : null {
final t = _now();
_runStart = t;
_slideEntered = t;
}
final DateTime Function() _now;
Duration? _target;
late DateTime _runStart;
late DateTime _slideEntered;
String? _currentId;
/// Opgetelde tijd per slide-id over de hele run.
final Map<String, Duration> _spent = {};
/// Eerste positie waarop een slide werd gezien (voor stabiele volgorde).
final Map<String, int> _firstIndex = {};
/// Volgorde van eerste verschijning, voor een leesbare samenvatting.
final List<String> _order = [];
/// Huidige doeltijd, of null als er geen aftelling loopt.
Duration? get target => _target;
/// Zet de doeltijd. Een waarde van nul of minder zet de aftelling uit.
set target(Duration? value) =>
_target = (value != null && value > Duration.zero) ? value : null;
/// Verstreken tijd sinds de (her)start van de run.
Duration get elapsed => _now().difference(_runStart);
/// Resterende tijd t.o.v. de doeltijd; null zonder doeltijd. Kan negatief
/// worden wanneer je over de tijd gaat.
Duration? get remaining {
final t = _target;
return t == null ? null : t - elapsed;
}
/// Tijd op de huidige slide sinds binnenkomst.
Duration get currentSlideElapsed => _now().difference(_slideEntered);
/// Heeft de run genoeg gegevens om een zinvolle samenvatting te tonen?
/// Voorkomt een dialoog bij het meteen weer sluiten van de presentatie.
bool get hasMeaningfulData => _order.isNotEmpty && elapsed.inSeconds >= 10;
/// Registreer dat slide [id] op positie [index] nu zichtbaar is.
///
/// Idempotent: alleen een échte wissel sluit de vorige slide af. Wordt elke
/// build aangeroepen, dus moet goedkoop blijven.
void observe(String id, int index) {
if (_currentId == id) return;
final t = _now();
final prev = _currentId;
if (prev != null) {
_spent[prev] = (_spent[prev] ?? Duration.zero) + t.difference(_slideEntered);
}
_currentId = id;
_slideEntered = t;
if (!_firstIndex.containsKey(id)) {
_firstIndex[id] = index;
_order.add(id);
}
}
/// Reset de hele run (verstreken tijd én per-slide-tijden). De doeltijd blijft
/// staan. De eerstvolgende [observe] registreert de huidige slide opnieuw.
void reset() {
final t = _now();
_runStart = t;
_slideEntered = t;
_spent.clear();
_firstIndex.clear();
_order.clear();
_currentId = null;
}
/// Sluit de lopende slide af en geef de samenvatting van deze run terug.
/// Niet-destructief: je kunt erna gewoon doorpresenteren.
RehearsalRun finish() {
final t = _now();
final spent = Map<String, Duration>.from(_spent);
final cur = _currentId;
if (cur != null) {
spent[cur] = (spent[cur] ?? Duration.zero) + t.difference(_slideEntered);
}
final perSlide = [
for (final id in _order)
SlideTiming(
slideId: id,
index: _firstIndex[id] ?? 0,
spent: spent[id] ?? Duration.zero,
),
];
return RehearsalRun(
total: t.difference(_runStart),
target: _target,
perSlide: perSlide,
);
}
}