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>
115 lines
3.7 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|