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>
2153 lines
72 KiB
Dart
2153 lines
72 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/semantics.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:screen_retriever/screen_retriever.dart';
|
||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||
import 'package:window_manager/window_manager.dart';
|
||
import '../../models/annotation.dart';
|
||
import '../../models/deck.dart';
|
||
import '../../models/settings.dart';
|
||
import '../../models/slide.dart';
|
||
import '../../services/markdown_service.dart';
|
||
import '../../services/rehearsal_controller.dart';
|
||
import '../../utils/log.dart';
|
||
import '../../utils/url_launcher_util.dart';
|
||
import '../../l10n/app_localizations.dart';
|
||
import '../slides/inline_markdown.dart';
|
||
import '../slides/slide_preview.dart';
|
||
import 'annotation_overlay.dart';
|
||
import 'audience_window.dart';
|
||
import 'rehearsal_summary.dart';
|
||
|
||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||
enum _Blank { none, black, white }
|
||
|
||
class FullscreenPresenter extends StatefulWidget {
|
||
final List<Slide> slides;
|
||
final String? projectPath;
|
||
final ThemeProfile themeProfile;
|
||
final int initialIndex;
|
||
final TlpLevel tlp;
|
||
|
||
/// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling.
|
||
/// Sessie-only; live aanpasbaar in de presenter (toets K).
|
||
final Duration? targetDuration;
|
||
|
||
/// When set, this presenter drives a separate audience (beamer) window: the
|
||
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
|
||
/// for the classic single-screen mode.
|
||
final WindowController? audienceWindow;
|
||
|
||
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
|
||
/// made while presenting back to the deck.
|
||
final Map<String, List<InkStroke>> initialAnnotations;
|
||
final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged;
|
||
final ValueChanged<Slide>? onSlideChanged;
|
||
|
||
const FullscreenPresenter({
|
||
super.key,
|
||
required this.slides,
|
||
required this.projectPath,
|
||
required this.themeProfile,
|
||
required this.initialIndex,
|
||
this.tlp = TlpLevel.none,
|
||
this.targetDuration,
|
||
this.audienceWindow,
|
||
this.initialAnnotations = const {},
|
||
this.onAnnotationsChanged,
|
||
this.onSlideChanged,
|
||
});
|
||
|
||
/// Entry point used by the app: pick dual-screen mode when a second display is
|
||
/// available on desktop, otherwise the single-window presenter. Any failure
|
||
/// to open the second window falls back to single-window mode.
|
||
static Future<void> present(
|
||
BuildContext context, {
|
||
required List<Slide> slides,
|
||
required String? projectPath,
|
||
required ThemeProfile themeProfile,
|
||
required int initialIndex,
|
||
TlpLevel tlp = TlpLevel.none,
|
||
Duration? targetDuration,
|
||
Map<String, List<InkStroke>> annotations = const {},
|
||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||
ValueChanged<Slide>? onSlideChanged,
|
||
}) async {
|
||
var displayCount = 0;
|
||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||
try {
|
||
final displays = await screenRetriever.getAllDisplays();
|
||
displayCount = displays.length;
|
||
} catch (e) {
|
||
logWarning('FullscreenPresenter.present: display detection failed', e);
|
||
displayCount = 0;
|
||
}
|
||
}
|
||
final dual = shouldUseDualScreen(
|
||
isMacOS: Platform.isMacOS,
|
||
isWindows: Platform.isWindows,
|
||
isLinux: Platform.isLinux,
|
||
displayCount: displayCount,
|
||
);
|
||
if (!context.mounted) return;
|
||
if (dual) {
|
||
await showDualScreen(
|
||
context,
|
||
slides: slides,
|
||
projectPath: projectPath,
|
||
themeProfile: themeProfile,
|
||
initialIndex: initialIndex,
|
||
tlp: tlp,
|
||
targetDuration: targetDuration,
|
||
annotations: annotations,
|
||
onAnnotationsChanged: onAnnotationsChanged,
|
||
onSlideChanged: onSlideChanged,
|
||
);
|
||
} else {
|
||
await show(
|
||
context,
|
||
slides: slides,
|
||
projectPath: projectPath,
|
||
themeProfile: themeProfile,
|
||
initialIndex: initialIndex,
|
||
tlp: tlp,
|
||
targetDuration: targetDuration,
|
||
annotations: annotations,
|
||
onAnnotationsChanged: onAnnotationsChanged,
|
||
onSlideChanged: onSlideChanged,
|
||
);
|
||
}
|
||
}
|
||
|
||
static Future<void> show(
|
||
BuildContext context, {
|
||
required List<Slide> slides,
|
||
required String? projectPath,
|
||
required ThemeProfile themeProfile,
|
||
required int initialIndex,
|
||
TlpLevel tlp = TlpLevel.none,
|
||
Duration? targetDuration,
|
||
Map<String, List<InkStroke>> annotations = const {},
|
||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||
ValueChanged<Slide>? onSlideChanged,
|
||
}) async {
|
||
final hadWakeLock = await _wakeLockEnabled();
|
||
await _enableWakeLock();
|
||
try {
|
||
await windowManager.setFullScreen(true);
|
||
if (context.mounted) {
|
||
await Navigator.push(
|
||
context,
|
||
PageRouteBuilder(
|
||
opaque: true,
|
||
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
|
||
slides: slides,
|
||
projectPath: projectPath,
|
||
themeProfile: themeProfile,
|
||
initialIndex: initialIndex,
|
||
tlp: tlp,
|
||
targetDuration: targetDuration,
|
||
initialAnnotations: annotations,
|
||
onAnnotationsChanged: onAnnotationsChanged,
|
||
onSlideChanged: onSlideChanged,
|
||
),
|
||
transitionsBuilder: (context, animation, secondary, child) =>
|
||
FadeTransition(opacity: animation, child: child),
|
||
transitionDuration: const Duration(milliseconds: 200),
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
await _restoreWakeLock(hadWakeLock);
|
||
}
|
||
}
|
||
|
||
/// Dual-screen mode: open a borderless audience window on the beamer showing
|
||
/// the slide, and run the presenter view (current/next/notes/timer) in the
|
||
/// main window on the laptop. The two windows stay in sync over method
|
||
/// channels. Falls back to [show] if the second window can't be created.
|
||
static Future<void> showDualScreen(
|
||
BuildContext context, {
|
||
required List<Slide> slides,
|
||
required String? projectPath,
|
||
required ThemeProfile themeProfile,
|
||
required int initialIndex,
|
||
TlpLevel tlp = TlpLevel.none,
|
||
Duration? targetDuration,
|
||
Map<String, List<InkStroke>> annotations = const {},
|
||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||
ValueChanged<Slide>? onSlideChanged,
|
||
}) async {
|
||
// A self-contained markdown deck is the payload for the audience window; it
|
||
// carries the slides, the style profile and the TLP level in one string.
|
||
// This payload never touches disk, so it inlines the style profile — the
|
||
// beamer has no other way to learn the deck's styling.
|
||
final markdown = MarkdownService().generateDeck(
|
||
Deck(
|
||
title: 'Presentatie',
|
||
slides: slides,
|
||
projectPath: projectPath,
|
||
themeProfile: themeProfile,
|
||
tlp: tlp,
|
||
),
|
||
inlineStyleProfile: true,
|
||
);
|
||
// Pre-existing annotations re-keyed by index so the beamer shows them
|
||
// immediately (the audience window has no stable slide ids of its own).
|
||
final inkByIndex = <String, dynamic>{};
|
||
for (var i = 0; i < slides.length; i++) {
|
||
final strokes = annotations[slides[i].id];
|
||
if (strokes != null && strokes.isNotEmpty) {
|
||
inkByIndex['$i'] = encodeStrokes(strokes);
|
||
}
|
||
}
|
||
final argument = jsonEncode({
|
||
'markdown': markdown,
|
||
'projectPath': projectPath,
|
||
'index': initialIndex,
|
||
'ink': inkByIndex,
|
||
});
|
||
|
||
WindowController? audience;
|
||
try {
|
||
audience = await WindowController.create(
|
||
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||
);
|
||
await audience.coverScreen(external: true);
|
||
} catch (e) {
|
||
logError(
|
||
'FullscreenPresenter.showDualScreen: audience window setup failed',
|
||
e,
|
||
);
|
||
audience = null;
|
||
}
|
||
|
||
if (audience == null) {
|
||
if (context.mounted) {
|
||
await show(
|
||
context,
|
||
slides: slides,
|
||
projectPath: projectPath,
|
||
themeProfile: themeProfile,
|
||
initialIndex: initialIndex,
|
||
tlp: tlp,
|
||
annotations: annotations,
|
||
onAnnotationsChanged: onAnnotationsChanged,
|
||
onSlideChanged: onSlideChanged,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final hadWakeLock = await _wakeLockEnabled();
|
||
await _enableWakeLock();
|
||
try {
|
||
if (context.mounted) {
|
||
await Navigator.push(
|
||
context,
|
||
PageRouteBuilder(
|
||
opaque: true,
|
||
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
|
||
slides: slides,
|
||
projectPath: projectPath,
|
||
themeProfile: themeProfile,
|
||
initialIndex: initialIndex,
|
||
tlp: tlp,
|
||
audienceWindow: audience,
|
||
initialAnnotations: annotations,
|
||
onAnnotationsChanged: onAnnotationsChanged,
|
||
onSlideChanged: onSlideChanged,
|
||
),
|
||
transitionsBuilder: (context, animation, secondary, child) =>
|
||
FadeTransition(opacity: animation, child: child),
|
||
transitionDuration: const Duration(milliseconds: 200),
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
await _restoreWakeLock(hadWakeLock);
|
||
// Make sure the audience window is gone even if exit didn't close it.
|
||
audience.close().catchError((_) => null);
|
||
}
|
||
}
|
||
|
||
@override
|
||
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
|
||
}
|
||
|
||
@visibleForTesting
|
||
bool shouldUseDualScreen({
|
||
required bool isMacOS,
|
||
required bool isWindows,
|
||
required bool isLinux,
|
||
required int displayCount,
|
||
}) {
|
||
return (isMacOS || isWindows || isLinux) && displayCount >= 2;
|
||
}
|
||
|
||
@visibleForTesting
|
||
bool autoAdvanceWaitsForMedia(Slide slide) {
|
||
final autoplayVideo =
|
||
slide.type == SlideType.video &&
|
||
slide.videoPath.isNotEmpty &&
|
||
slide.videoAutoplay;
|
||
final autoplayAudio = slide.audioPath.isNotEmpty && slide.audioAutoplay;
|
||
return autoplayVideo || autoplayAudio;
|
||
}
|
||
|
||
Future<bool> _wakeLockEnabled() async {
|
||
try {
|
||
return await WakelockPlus.enabled;
|
||
} catch (e) {
|
||
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<void> _enableWakeLock() async {
|
||
try {
|
||
await WakelockPlus.enable();
|
||
} catch (e) {
|
||
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
|
||
// Best-effort: unsupported platforms should not interrupt presenting.
|
||
}
|
||
}
|
||
|
||
Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
|
||
try {
|
||
if (enabledBeforePresentation) {
|
||
await WakelockPlus.enable();
|
||
} else {
|
||
await WakelockPlus.disable();
|
||
}
|
||
} catch (e) {
|
||
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
|
||
// Best-effort cleanup.
|
||
}
|
||
}
|
||
|
||
class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||
late int _index;
|
||
late FocusNode _focusNode;
|
||
Timer? _advanceTimer;
|
||
Timer? _clockTimer;
|
||
double _progress = 0; // 0..1 voor de voortgangsbalk
|
||
|
||
/// Presenter view (notities, klok, volgende slide) vs. publieksweergave.
|
||
bool _presenterView = false;
|
||
|
||
/// Blanco scherm (zwart/wit) tijdens het presenteren.
|
||
_Blank _blank = _Blank.none;
|
||
|
||
/// Rasteroverzicht van alle slides om snel te springen.
|
||
bool _gridOpen = false;
|
||
|
||
/// Gemarkeerde positie in het raster (los van de getoonde slide) plus de
|
||
/// huidige kolom-/rijmaat, nodig om met de pijltjes te navigeren en mee te
|
||
/// scrollen.
|
||
int _gridCursor = 0;
|
||
int _gridCols = 3;
|
||
double _gridRowExtent = 220;
|
||
final ScrollController _gridScroll = ScrollController();
|
||
|
||
/// Oefenklok: verstreken tijd, aftelling en per-slide-tijd. Sessie-only,
|
||
/// puur meten (geen pacing). Resetbaar met R.
|
||
late RehearsalController _rehearsal;
|
||
|
||
/// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief).
|
||
String _typed = '';
|
||
Timer? _typedTimer;
|
||
|
||
/// Doeltijd-invoermodus (toets K): cijfers worden als MMSS gelezen i.p.v. als
|
||
/// slidenummer. [_targetTyped] houdt de invoer tot Enter/Esc.
|
||
bool _targetInput = false;
|
||
String _targetTyped = '';
|
||
|
||
/// Sneltoets-overzicht (cheatsheet) zichtbaar.
|
||
bool _helpOpen = false;
|
||
|
||
/// Automatische modus: slides wisselen vanzelf (op tijd of na audio). Staat
|
||
/// standaard aan zodat ingestelde tijdwissels meteen werken; met A te pauzeren.
|
||
bool _autoPlay = true;
|
||
|
||
/// Herhaling: na de laatste slide terug naar de eerste (anders blijft de
|
||
/// laatste slide staan). Met L te wisselen.
|
||
bool _loop = false;
|
||
|
||
/// Wissel ná het afspelen van autoplay-media i.p.v. op de tijdwissel.
|
||
/// Met M te wisselen.
|
||
bool _advanceOnMediaEnd = true;
|
||
|
||
/// Known displays for moving the fullscreen presentation window. This is not
|
||
/// a second presenter window; it keeps the current output movable between
|
||
/// screens with S or the presenter-view button.
|
||
List<Display> _displays = const [];
|
||
int _displayIndex = 0;
|
||
|
||
/// True when this presenter drives a separate audience (beamer) window.
|
||
bool get _dual => widget.audienceWindow != null;
|
||
|
||
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
|
||
int? _lastSentIndex;
|
||
int? _lastSentBlank;
|
||
|
||
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||
/// Strokes per slide, keyed by [Slide.id] (stable within the session).
|
||
late Map<String, List<InkStroke>> _ink;
|
||
|
||
/// Active annotation tool, or null when annotation is off.
|
||
InkTool? _tool;
|
||
int _inkColor = 0xFFEF4444; // rood
|
||
static const _penWidth = 0.004;
|
||
static const _highlighterWidth = 0.022;
|
||
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||
DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||
|
||
double get _toolWidth =>
|
||
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
||
|
||
List<InkStroke> get _currentStrokes {
|
||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||
return _ink[id] ?? const [];
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_index = widget.initialIndex;
|
||
_rehearsal = RehearsalController(target: widget.targetDuration);
|
||
_focusNode = FocusNode();
|
||
_ink = {
|
||
for (final e in widget.initialAnnotations.entries)
|
||
e.key: List<InkStroke>.from(e.value),
|
||
};
|
||
if (_dual) {
|
||
// The laptop shows the presenter view; the slide lives on the beamer.
|
||
_presenterView = true;
|
||
// Navigation triggered on the beamer (clicks) and its audio-end events
|
||
// come back over this channel.
|
||
presenterChannel.setMethodCallHandler((call) async {
|
||
switch (call.method) {
|
||
case 'next':
|
||
_next();
|
||
case 'prev':
|
||
_prev();
|
||
case 'exit':
|
||
_exit();
|
||
case 'audioComplete':
|
||
_onMediaCompleted(kind: 'audio');
|
||
case 'mediaComplete':
|
||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||
_onMediaCompleted(
|
||
index: (args['index'] as num?)?.toInt(),
|
||
kind: args['kind']?.toString(),
|
||
);
|
||
case 'checklistToggle':
|
||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||
_toggleChecklistItem(
|
||
slideIndex: (args['slideIndex'] as num?)?.toInt() ?? _index,
|
||
column: (args['column'] as num?)?.toInt() ?? 0,
|
||
itemIndex: (args['itemIndex'] as num?)?.toInt() ?? 0,
|
||
);
|
||
}
|
||
return null;
|
||
});
|
||
}
|
||
// Tik elke seconde, maar herbouw alleen in presenter view (klok/teller).
|
||
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted && _presenterView) setState(() {});
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_focusNode.requestFocus();
|
||
_loadDisplays();
|
||
_scheduleAdvance();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_advanceTimer?.cancel();
|
||
_clockTimer?.cancel();
|
||
_typedTimer?.cancel();
|
||
_gridScroll.dispose();
|
||
_focusNode.dispose();
|
||
if (_dual) presenterChannel.setMethodCallHandler(null);
|
||
super.dispose();
|
||
}
|
||
|
||
int get _blankCode =>
|
||
_blank == _Blank.white ? 2 : (_blank == _Blank.black ? 1 : 0);
|
||
|
||
/// Mirror the current index/blank state to the audience window when it changed.
|
||
void _syncAudience() {
|
||
final aw = widget.audienceWindow;
|
||
if (aw == null) return;
|
||
final blank = _blankCode;
|
||
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
||
final indexChanged = _index != _lastSentIndex;
|
||
_lastSentIndex = _index;
|
||
_lastSentBlank = blank;
|
||
audienceChannel
|
||
.invokeMethod('update', {'index': _index, 'blank': blank})
|
||
.catchError((_) => null);
|
||
// On a slide change, push that slide's strokes so saved/earlier ink shows.
|
||
if (indexChanged) _pushInk();
|
||
}
|
||
|
||
void _toggleChecklistItem({
|
||
required int slideIndex,
|
||
required int column,
|
||
required int itemIndex,
|
||
}) {
|
||
if (slideIndex < 0 || slideIndex >= widget.slides.length) return;
|
||
final slide = widget.slides[slideIndex];
|
||
final source = column == 1 ? slide.bullets2 : slide.bullets;
|
||
if (itemIndex < 0 || itemIndex >= source.length) return;
|
||
final updatedItems = List<String>.from(source);
|
||
final item = updatedItems[itemIndex];
|
||
updatedItems[itemIndex] = checklistBullet(
|
||
level: bulletLevel(item),
|
||
text: checklistItemText(item),
|
||
checked: !checklistItemChecked(item),
|
||
);
|
||
final updated = column == 1
|
||
? slide.copyWith(bullets2: updatedItems)
|
||
: slide.copyWith(bullets: updatedItems);
|
||
setState(() => widget.slides[slideIndex] = updated);
|
||
widget.onSlideChanged?.call(updated);
|
||
if (_dual) {
|
||
audienceChannel
|
||
.invokeMethod('checklistUpdate', {
|
||
'slideIndex': slideIndex,
|
||
'bullets': updated.bullets,
|
||
'bullets2': updated.bullets2,
|
||
})
|
||
.catchError((_) => null);
|
||
}
|
||
}
|
||
|
||
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||
|
||
/// Send the current slide's strokes to the beamer (keyed by index there).
|
||
void _pushInk() {
|
||
if (widget.audienceWindow == null) return;
|
||
audienceChannel
|
||
.invokeMethod('ink', {
|
||
'index': _index,
|
||
'strokes': encodeStrokes(_currentStrokes),
|
||
})
|
||
.catchError((_) => null);
|
||
}
|
||
|
||
void _onStrokesChanged(List<InkStroke> strokes) {
|
||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||
setState(() {
|
||
if (strokes.isEmpty) {
|
||
_ink.remove(id);
|
||
} else {
|
||
_ink[id] = strokes;
|
||
}
|
||
});
|
||
widget.onAnnotationsChanged?.call(_ink);
|
||
_pushInk();
|
||
}
|
||
|
||
void _onLaserMove(Offset? point) {
|
||
if (widget.audienceWindow == null) return;
|
||
final now = DateTime.now();
|
||
// Throttle to keep the channel calm; always send the "gone" (null) event.
|
||
if (point != null &&
|
||
now.difference(_lastLaserSent) < const Duration(milliseconds: 33)) {
|
||
return;
|
||
}
|
||
_lastLaserSent = now;
|
||
audienceChannel
|
||
.invokeMethod('laser', {
|
||
'index': _index,
|
||
'point': point == null ? null : [point.dx, point.dy],
|
||
})
|
||
.catchError((_) => null);
|
||
}
|
||
|
||
/// Mirror the stroke that is being drawn right now to the beamer, so the
|
||
/// audience sees a pen/highlighter line appear live instead of only after the
|
||
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
|
||
/// just keeps the in-progress preview in sync for the same slide.
|
||
void _onActiveStroke(InkStroke? stroke) {
|
||
if (widget.audienceWindow == null) return;
|
||
final now = DateTime.now();
|
||
// Throttle growth events; always send the "done" (null) event so the
|
||
// beamer drops its live preview the moment the stroke commits.
|
||
if (stroke != null &&
|
||
now.difference(_lastInkLiveSent) < const Duration(milliseconds: 33)) {
|
||
return;
|
||
}
|
||
_lastInkLiveSent = now;
|
||
audienceChannel
|
||
.invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()})
|
||
.catchError((_) => null);
|
||
}
|
||
|
||
/// Select a tool, or toggle it off when it is already active.
|
||
void _setTool(InkTool tool) {
|
||
setState(() => _tool = _tool == tool ? null : tool);
|
||
if (_tool != InkTool.laser) _onLaserMove(null); // hide laser on tool switch
|
||
}
|
||
|
||
void _clearCurrentInk() {
|
||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||
if (!_ink.containsKey(id)) return;
|
||
setState(() => _ink.remove(id));
|
||
widget.onAnnotationsChanged?.call(_ink);
|
||
_pushInk();
|
||
}
|
||
|
||
/// Decode the current slide's images plus its neighbours into the image cache
|
||
/// ahead of time. Because a precached [FileImage] resolves synchronously, the
|
||
/// next slide paints its picture on the very first frame instead of flashing
|
||
/// the black Scaffold behind it while the file decodes — essential for a clean
|
||
/// recording. Best-effort: decode errors are swallowed.
|
||
void _precacheNeighbours() {
|
||
if (!mounted) return;
|
||
final logo = widget.themeProfile.logoPath;
|
||
if (logo != null && logo.isNotEmpty) {
|
||
_precachePath(logo);
|
||
}
|
||
// Current first, then the likely next/previous targets.
|
||
for (final offset in const [0, 1, -1, 2]) {
|
||
final i = _index + offset;
|
||
if (i < 0 || i >= widget.slides.length) continue;
|
||
final slide = widget.slides[i];
|
||
_precachePath(slide.imagePath);
|
||
_precachePath(slide.imagePath2);
|
||
}
|
||
}
|
||
|
||
void _precachePath(String path) {
|
||
final resolved = resolveSlideAssetPath(path, widget.projectPath);
|
||
if (resolved == null) return;
|
||
precacheImage(FileImage(File(resolved)), context, onError: (_, _) {});
|
||
}
|
||
|
||
void _scheduleAdvance() {
|
||
// Funnel point for every navigation (next/prev/jump/auto) and the initial
|
||
// frame, so neighbour images are always warm before they are shown.
|
||
_precacheNeighbours();
|
||
_advanceTimer?.cancel();
|
||
_advanceTimer = null;
|
||
setState(() => _progress = 0);
|
||
|
||
// Auto-modus uit: nooit vanzelf wisselen.
|
||
if (!_autoPlay) return;
|
||
|
||
final slide = widget.slides[_index.clamp(0, widget.slides.length - 1)];
|
||
|
||
if (_advanceOnMediaEnd && autoAdvanceWaitsForMedia(slide)) return;
|
||
|
||
final dur = slide.advanceDuration;
|
||
if (dur <= 0) return;
|
||
// Op de laatste slide alleen doortikken als we herhalen.
|
||
if (_index >= widget.slides.length - 1 && !_loop) return;
|
||
|
||
final totalMs = (dur * 1000).round();
|
||
final startTime = DateTime.now();
|
||
|
||
// Tick elke 50ms voor een vloeiende voortgangsbalk
|
||
_advanceTimer = Timer.periodic(const Duration(milliseconds: 50), (t) {
|
||
if (!mounted) {
|
||
t.cancel();
|
||
return;
|
||
}
|
||
final elapsed = DateTime.now().difference(startTime).inMilliseconds;
|
||
final p = (elapsed / totalMs).clamp(0.0, 1.0);
|
||
setState(() => _progress = p);
|
||
if (elapsed >= totalMs) {
|
||
t.cancel();
|
||
_autoAdvance();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Automatisch doorschakelen (tijd of media-einde): naar de volgende slide,
|
||
/// of bij herhaling vanaf de laatste terug naar de eerste. Zonder herhaling
|
||
/// blijft de laatste slide gewoon staan.
|
||
void _autoAdvance() {
|
||
if (_blank != _Blank.none) return;
|
||
if (_index < widget.slides.length - 1) {
|
||
setState(() => _index++);
|
||
_scheduleAdvance();
|
||
} else if (_loop) {
|
||
setState(() => _index = 0);
|
||
_scheduleAdvance();
|
||
}
|
||
}
|
||
|
||
void _onMediaCompleted({int? index, String? kind}) {
|
||
if (index != null && index != _index) return;
|
||
final slide = widget.slides[_index.clamp(0, widget.slides.length - 1)];
|
||
// A video is primary on a video slide. Ignore an attached audio track that
|
||
// happens to finish earlier.
|
||
if (kind == 'audio' &&
|
||
slide.type == SlideType.video &&
|
||
slide.videoPath.isNotEmpty &&
|
||
slide.videoAutoplay) {
|
||
return;
|
||
}
|
||
if (_autoPlay && _advanceOnMediaEnd && autoAdvanceWaitsForMedia(slide)) {
|
||
_autoAdvance();
|
||
}
|
||
}
|
||
|
||
void _toggleAutoPlay() {
|
||
setState(() => _autoPlay = !_autoPlay);
|
||
_scheduleAdvance();
|
||
}
|
||
|
||
void _toggleLoop() {
|
||
setState(() => _loop = !_loop);
|
||
_scheduleAdvance();
|
||
}
|
||
|
||
void _toggleMediaAdvance() {
|
||
setState(() => _advanceOnMediaEnd = !_advanceOnMediaEnd);
|
||
_scheduleAdvance();
|
||
}
|
||
|
||
Future<void> _loadDisplays() async {
|
||
try {
|
||
final displays = await screenRetriever.getAllDisplays();
|
||
if (!mounted || displays.isEmpty) return;
|
||
final bounds = await windowManager.getBounds();
|
||
final center = bounds.center;
|
||
final current = displays.indexWhere((d) {
|
||
final p = d.visiblePosition ?? Offset.zero;
|
||
final s = d.visibleSize ?? d.size;
|
||
return Rect.fromLTWH(p.dx, p.dy, s.width, s.height).contains(center);
|
||
});
|
||
setState(() {
|
||
_displays = displays;
|
||
_displayIndex = current < 0 ? 0 : current;
|
||
});
|
||
} catch (e) {
|
||
logWarning(
|
||
'_FullscreenPresenterState._loadDisplays: screen detection failed',
|
||
e,
|
||
);
|
||
// Screen detection is best-effort; presenting should still work.
|
||
}
|
||
}
|
||
|
||
Future<void> _moveToDisplay(int index) async {
|
||
if (_displays.length < 2) return;
|
||
final display = _displays[index.clamp(0, _displays.length - 1)];
|
||
final position = display.visiblePosition ?? Offset.zero;
|
||
final size = display.visibleSize ?? display.size;
|
||
try {
|
||
await windowManager.setFullScreen(false);
|
||
await windowManager.setBounds(
|
||
Rect.fromLTWH(position.dx, position.dy, size.width, size.height),
|
||
);
|
||
await windowManager.setFullScreen(true);
|
||
if (mounted) setState(() => _displayIndex = index);
|
||
} catch (e) {
|
||
logError(
|
||
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
|
||
e,
|
||
);
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(context.l10n.d('Kon niet van scherm wisselen.')),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _cycleDisplay() async {
|
||
if (_displays.isEmpty) await _loadDisplays();
|
||
if (_displays.length < 2) return;
|
||
await _moveToDisplay((_displayIndex + 1) % _displays.length);
|
||
}
|
||
|
||
Future<void> _exit() async {
|
||
_advanceTimer?.cancel();
|
||
await _maybeShowRehearsalSummary();
|
||
final aw = widget.audienceWindow;
|
||
if (aw != null) {
|
||
// Dual mode: the main window was never put in full screen; just tear down
|
||
// the audience window.
|
||
audienceChannel.invokeMethod('close').catchError((_) => null);
|
||
aw.close().catchError((_) => null);
|
||
} else {
|
||
await windowManager.setFullScreen(false);
|
||
}
|
||
if (mounted) Navigator.pop(context);
|
||
}
|
||
|
||
/// Toon na afloop de oefenrun-samenvatting, mits er genoeg gemeten is.
|
||
/// Sessie-only: niets wordt opgeslagen.
|
||
Future<void> _maybeShowRehearsalSummary() async {
|
||
if (!mounted || !_rehearsal.hasMeaningfulData) return;
|
||
final run = _rehearsal.finish();
|
||
await showRehearsalSummary(context, run: run, slides: widget.slides);
|
||
}
|
||
|
||
/// Meld de slidewissel aan schermlezers (WCAG 4.1.3, statusberichten):
|
||
/// visueel verandert de hele slide, maar zonder aankondiging merkt een
|
||
/// schermlezer-gebruiker de wissel niet op.
|
||
void _announceSlide() {
|
||
final total = widget.slides.length;
|
||
if (total == 0 || !mounted) return;
|
||
final slide = widget.slides[_index.clamp(0, total - 1)];
|
||
final title = stripInlineMarkdown(slide.title).trim();
|
||
SemanticsService.sendAnnouncement(
|
||
View.of(context),
|
||
'${context.l10n.d('Slide')} ${_index + 1}/$total'
|
||
'${title.isEmpty ? '' : ': $title'}',
|
||
TextDirection.ltr,
|
||
);
|
||
}
|
||
|
||
void _next() {
|
||
// Eerste toets/klik op een blanco scherm haalt het scherm terug.
|
||
if (_blank != _Blank.none) {
|
||
setState(() => _blank = _Blank.none);
|
||
return;
|
||
}
|
||
if (_index < widget.slides.length - 1) {
|
||
setState(() => _index++);
|
||
_scheduleAdvance();
|
||
_announceSlide();
|
||
}
|
||
}
|
||
|
||
void _prev() {
|
||
if (_blank != _Blank.none) {
|
||
setState(() => _blank = _Blank.none);
|
||
return;
|
||
}
|
||
if (_index > 0) {
|
||
setState(() => _index--);
|
||
_scheduleAdvance();
|
||
_announceSlide();
|
||
}
|
||
}
|
||
|
||
void _togglePresenterView() {
|
||
setState(() => _presenterView = !_presenterView);
|
||
}
|
||
|
||
void _resetTimer() {
|
||
setState(() => _rehearsal.reset());
|
||
}
|
||
|
||
/// Open de doeltijd-invoer (toets K): cijfers worden voortaan als MMSS
|
||
/// gelezen. Een lege invoer laat de huidige doeltijd ongemoeid.
|
||
void _beginTargetInput() {
|
||
_clearTyped();
|
||
setState(() {
|
||
_targetInput = true;
|
||
_targetTyped = '';
|
||
});
|
||
}
|
||
|
||
void _cancelTargetInput() {
|
||
setState(() {
|
||
_targetInput = false;
|
||
_targetTyped = '';
|
||
});
|
||
}
|
||
|
||
/// Lees [_targetTyped] als MMSS en zet de doeltijd. Leeg = ongewijzigd,
|
||
/// nul = aftelling uit.
|
||
void _commitTarget() {
|
||
final raw = _targetTyped;
|
||
setState(() {
|
||
_targetInput = false;
|
||
_targetTyped = '';
|
||
});
|
||
if (raw.isEmpty) return;
|
||
final n = int.tryParse(raw) ?? 0;
|
||
final secs = (n ~/ 100) * 60 + (n % 100);
|
||
setState(
|
||
() => _rehearsal.target = secs <= 0 ? null : Duration(seconds: secs),
|
||
);
|
||
}
|
||
|
||
void _toggleHelp() {
|
||
setState(() => _helpOpen = !_helpOpen);
|
||
}
|
||
|
||
/// Cijfer (gewoon of numpad) → karakter, of null bij andere toetsen.
|
||
static final Map<LogicalKeyboardKey, String> _digits = {
|
||
LogicalKeyboardKey.digit0: '0',
|
||
LogicalKeyboardKey.digit1: '1',
|
||
LogicalKeyboardKey.digit2: '2',
|
||
LogicalKeyboardKey.digit3: '3',
|
||
LogicalKeyboardKey.digit4: '4',
|
||
LogicalKeyboardKey.digit5: '5',
|
||
LogicalKeyboardKey.digit6: '6',
|
||
LogicalKeyboardKey.digit7: '7',
|
||
LogicalKeyboardKey.digit8: '8',
|
||
LogicalKeyboardKey.digit9: '9',
|
||
LogicalKeyboardKey.numpad0: '0',
|
||
LogicalKeyboardKey.numpad1: '1',
|
||
LogicalKeyboardKey.numpad2: '2',
|
||
LogicalKeyboardKey.numpad3: '3',
|
||
LogicalKeyboardKey.numpad4: '4',
|
||
LogicalKeyboardKey.numpad5: '5',
|
||
LogicalKeyboardKey.numpad6: '6',
|
||
LogicalKeyboardKey.numpad7: '7',
|
||
LogicalKeyboardKey.numpad8: '8',
|
||
LogicalKeyboardKey.numpad9: '9',
|
||
};
|
||
|
||
void _appendDigit(String d) {
|
||
setState(() {
|
||
_typed += d;
|
||
if (_typed.length > 4) _typed = _typed.substring(_typed.length - 4);
|
||
});
|
||
_typedTimer?.cancel();
|
||
_typedTimer = Timer(const Duration(milliseconds: 2500), _clearTyped);
|
||
}
|
||
|
||
void _clearTyped() {
|
||
_typedTimer?.cancel();
|
||
_typedTimer = null;
|
||
if (_typed.isNotEmpty) setState(() => _typed = '');
|
||
}
|
||
|
||
/// Spring naar het getypte slidenummer (1-gebaseerd) en wis de invoer.
|
||
void _commitTyped() {
|
||
final n = int.tryParse(_typed);
|
||
_clearTyped();
|
||
if (n != null) _goTo(n - 1);
|
||
}
|
||
|
||
/// Zet het scherm op zwart/wit, of terug naar de slide bij dezelfde toets.
|
||
void _toggleBlank(_Blank target) {
|
||
setState(() {
|
||
_blank = _blank == target ? _Blank.none : target;
|
||
if (_blank != _Blank.none) _gridOpen = false;
|
||
});
|
||
}
|
||
|
||
void _toggleGrid() {
|
||
setState(() {
|
||
_gridOpen = !_gridOpen;
|
||
if (_gridOpen) {
|
||
_blank = _Blank.none;
|
||
_gridCursor = _index;
|
||
}
|
||
});
|
||
if (_gridOpen) {
|
||
WidgetsBinding.instance.addPostFrameCallback(
|
||
(_) => _scrollGridToCursor(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Spring direct naar een slide (vanuit het rasteroverzicht).
|
||
void _jumpTo(int index) {
|
||
setState(() {
|
||
_index = index.clamp(0, widget.slides.length - 1);
|
||
_blank = _Blank.none;
|
||
_gridOpen = false;
|
||
});
|
||
_scheduleAdvance();
|
||
_announceSlide();
|
||
}
|
||
|
||
/// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End).
|
||
void _goTo(int index) {
|
||
if (_blank != _Blank.none) {
|
||
setState(() => _blank = _Blank.none);
|
||
return;
|
||
}
|
||
final target = index.clamp(0, widget.slides.length - 1);
|
||
if (target == _index) return;
|
||
setState(() => _index = target);
|
||
_scheduleAdvance();
|
||
_announceSlide();
|
||
}
|
||
|
||
/// Verplaats de rastercursor en houd 'm in beeld.
|
||
void _moveGridCursor(int delta) {
|
||
setState(() {
|
||
_gridCursor = (_gridCursor + delta).clamp(0, widget.slides.length - 1);
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGridToCursor());
|
||
}
|
||
|
||
void _setGridCursor(int index) {
|
||
setState(() {
|
||
_gridCursor = index.clamp(0, widget.slides.length - 1);
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGridToCursor());
|
||
}
|
||
|
||
/// Scroll het raster zo dat de cursorrij zichtbaar is (met wat context).
|
||
void _scrollGridToCursor() {
|
||
if (!_gridScroll.hasClients) return;
|
||
final row = _gridCols == 0 ? 0 : _gridCursor ~/ _gridCols;
|
||
final target = (row - 1) * _gridRowExtent; // één rij context erboven
|
||
final max = _gridScroll.position.maxScrollExtent;
|
||
_gridScroll.animateTo(
|
||
target.clamp(0.0, max),
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
|
||
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
|
||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||
final key = event.logicalKey;
|
||
|
||
// Sneltoets-overzicht vangt alles: sluiten met ? / H / Esc.
|
||
if (_helpOpen) {
|
||
if (key == LogicalKeyboardKey.escape ||
|
||
key == LogicalKeyboardKey.keyH ||
|
||
key == LogicalKeyboardKey.question) {
|
||
setState(() => _helpOpen = false);
|
||
}
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
// Doeltijd-invoer vangt cijfers/Enter/Esc tot de invoer klaar is.
|
||
if (_targetInput) return _handleTargetKey(key);
|
||
|
||
// Terwijl het raster open is, sturen de pijltjes een aparte cursor aan.
|
||
if (_gridOpen) return _handleGridKey(key);
|
||
|
||
// Cijfers verzamelen om naar een slidenummer te springen.
|
||
final digit = _digits[key];
|
||
if (digit != null) {
|
||
_appendDigit(digit);
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
final last = widget.slides.length - 1;
|
||
switch (key) {
|
||
case LogicalKeyboardKey.enter:
|
||
case LogicalKeyboardKey.numpadEnter:
|
||
// Met een getypt nummer: springen; anders gewoon door.
|
||
if (_typed.isNotEmpty) {
|
||
_commitTyped();
|
||
} else {
|
||
_next();
|
||
}
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.backspace:
|
||
if (_typed.isNotEmpty) {
|
||
setState(() => _typed = _typed.substring(0, _typed.length - 1));
|
||
}
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyH:
|
||
case LogicalKeyboardKey.question:
|
||
_toggleHelp();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.arrowRight:
|
||
case LogicalKeyboardKey.space:
|
||
case LogicalKeyboardKey.pageDown:
|
||
_next();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.arrowLeft:
|
||
case LogicalKeyboardKey.pageUp:
|
||
_prev();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.home:
|
||
_goTo(0);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.end:
|
||
_goTo(last);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyP:
|
||
_togglePresenterView();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyR:
|
||
_resetTimer();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyK:
|
||
_beginTargetInput();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyB:
|
||
_toggleBlank(_Blank.black);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyW:
|
||
_toggleBlank(_Blank.white);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyG:
|
||
_toggleGrid();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyA:
|
||
_toggleAutoPlay();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyL:
|
||
_toggleLoop();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyM:
|
||
_toggleMediaAdvance();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyS:
|
||
_cycleDisplay();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyD:
|
||
_setTool(InkTool.pen);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyT:
|
||
_setTool(InkTool.highlighter);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyE:
|
||
_setTool(InkTool.eraser);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyX:
|
||
_setTool(InkTool.laser);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.keyC:
|
||
_clearCurrentInk();
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.escape:
|
||
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
|
||
if (_tool != null) {
|
||
setState(() => _tool = null);
|
||
_onLaserMove(null);
|
||
} else if (_typed.isNotEmpty) {
|
||
_clearTyped();
|
||
} else if (_blank != _Blank.none) {
|
||
setState(() => _blank = _Blank.none);
|
||
} else {
|
||
_exit();
|
||
}
|
||
return KeyEventResult.handled;
|
||
default:
|
||
return KeyEventResult.ignored;
|
||
}
|
||
}
|
||
|
||
/// Toetsen terwijl de doeltijd wordt ingevoerd (MMSS). Alles wordt
|
||
/// opgeslokt zodat losse cijfers niet als slidesprong gelden.
|
||
KeyEventResult _handleTargetKey(LogicalKeyboardKey key) {
|
||
final digit = _digits[key];
|
||
if (digit != null) {
|
||
setState(() {
|
||
_targetTyped += digit;
|
||
if (_targetTyped.length > 4) {
|
||
_targetTyped = _targetTyped.substring(_targetTyped.length - 4);
|
||
}
|
||
});
|
||
return KeyEventResult.handled;
|
||
}
|
||
switch (key) {
|
||
case LogicalKeyboardKey.enter:
|
||
case LogicalKeyboardKey.numpadEnter:
|
||
case LogicalKeyboardKey.keyK:
|
||
_commitTarget();
|
||
case LogicalKeyboardKey.backspace:
|
||
if (_targetTyped.isNotEmpty) {
|
||
setState(
|
||
() => _targetTyped = _targetTyped.substring(
|
||
0,
|
||
_targetTyped.length - 1,
|
||
),
|
||
);
|
||
}
|
||
case LogicalKeyboardKey.escape:
|
||
_cancelTargetInput();
|
||
default:
|
||
break;
|
||
}
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
/// Toetsen terwijl het rasteroverzicht open is.
|
||
KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
|
||
final last = widget.slides.length - 1;
|
||
switch (key) {
|
||
case LogicalKeyboardKey.arrowRight:
|
||
_moveGridCursor(1);
|
||
case LogicalKeyboardKey.arrowLeft:
|
||
_moveGridCursor(-1);
|
||
case LogicalKeyboardKey.arrowDown:
|
||
_moveGridCursor(_gridCols);
|
||
case LogicalKeyboardKey.arrowUp:
|
||
_moveGridCursor(-_gridCols);
|
||
case LogicalKeyboardKey.home:
|
||
_setGridCursor(0);
|
||
case LogicalKeyboardKey.end:
|
||
_setGridCursor(last);
|
||
case LogicalKeyboardKey.enter:
|
||
case LogicalKeyboardKey.numpadEnter:
|
||
case LogicalKeyboardKey.space:
|
||
_jumpTo(_gridCursor);
|
||
case LogicalKeyboardKey.keyG:
|
||
case LogicalKeyboardKey.escape:
|
||
setState(() => _gridOpen = false);
|
||
default:
|
||
return KeyEventResult.ignored;
|
||
}
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
// ── Formatters ─────────────────────────────────────────────────────────────
|
||
|
||
String _fmtClock(DateTime t) {
|
||
final h = t.hour.toString().padLeft(2, '0');
|
||
final m = t.minute.toString().padLeft(2, '0');
|
||
return '$h:$m';
|
||
}
|
||
|
||
String _fmtElapsed(Duration d) {
|
||
final mm = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||
final ss = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||
return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss';
|
||
}
|
||
|
||
/// Resterende tijd, met minteken zodra je over de doeltijd gaat.
|
||
String _fmtRemaining(Duration d) {
|
||
final body = _fmtElapsed(d.abs());
|
||
return d.isNegative ? '-$body' : body;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final total = widget.slides.length;
|
||
if (total == 0) {
|
||
_exit();
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
// Keep the beamer window in step with whatever index/blank we now show.
|
||
_syncAudience();
|
||
|
||
// Per-slide-timing: registreer de huidige slide. Idempotent en goedkoop,
|
||
// dus veilig om elke build aan te roepen — vangt álle navigatiepaden.
|
||
final clampedIndex = _index.clamp(0, total - 1);
|
||
_rehearsal.observe(widget.slides[clampedIndex].id, clampedIndex);
|
||
|
||
return Focus(
|
||
focusNode: _focusNode,
|
||
autofocus: true,
|
||
onKeyEvent: _handleKey,
|
||
child: Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: Stack(
|
||
children: [
|
||
_presenterView
|
||
? _buildPresenterView(context)
|
||
: _buildAudienceView(context),
|
||
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
||
if (_tool != null && !_gridOpen && !_helpOpen)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 16,
|
||
child: Center(child: _buildAnnotationToolbar()),
|
||
),
|
||
if (_typed.isNotEmpty)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 60,
|
||
child: Center(child: _buildTypedBadge(total)),
|
||
),
|
||
if (_targetInput)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 60,
|
||
child: Center(child: _buildTargetBadge()),
|
||
),
|
||
if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Zwevende balk met annotatiegereedschap, kleuren en wissen.
|
||
Widget _buildAnnotationToolbar() {
|
||
const palette = [
|
||
0xFFEF4444, // rood
|
||
0xFFF59E0B, // amber
|
||
0xFF22C55E, // groen
|
||
0xFF3B82F6, // blauw
|
||
0xFFFFFFFF, // wit
|
||
0xFF111111, // zwart
|
||
];
|
||
Widget toolBtn(InkTool tool, IconData icon, String tip) {
|
||
final active = _tool == tool;
|
||
return Tooltip(
|
||
message: tip,
|
||
child: IconButton(
|
||
onPressed: () => _setTool(tool),
|
||
icon: Icon(icon, size: 20),
|
||
color: active ? const Color(0xFF60A5FA) : Colors.white70,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: active ? Colors.white10 : Colors.transparent,
|
||
),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.82),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: const Color(0xFF2A2A2A)),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
toolBtn(InkTool.pen, Icons.edit, 'Pen (D)'),
|
||
toolBtn(InkTool.highlighter, Icons.brush, 'Markeerstift (T)'),
|
||
toolBtn(InkTool.eraser, Icons.cleaning_services_outlined, 'Gum (E)'),
|
||
toolBtn(InkTool.laser, Icons.my_location, 'Laser (X)'),
|
||
const SizedBox(width: 8),
|
||
Container(width: 1, height: 22, color: Colors.white24),
|
||
const SizedBox(width: 8),
|
||
for (final c in palette)
|
||
GestureDetector(
|
||
onTap: () => setState(() => _inkColor = c),
|
||
child: Container(
|
||
width: 20,
|
||
height: 20,
|
||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||
decoration: BoxDecoration(
|
||
color: Color(c),
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: _inkColor == c ? Colors.white : Colors.white24,
|
||
width: _inkColor == c ? 2.5 : 1,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(width: 1, height: 22, color: Colors.white24),
|
||
Tooltip(
|
||
message: context.l10n.d('Wis annotaties (C)'),
|
||
child: IconButton(
|
||
onPressed: _clearCurrentInk,
|
||
icon: const Icon(Icons.delete_outline, size: 20),
|
||
color: Colors.white70,
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
Tooltip(
|
||
message: context.l10n.d('Stoppen (Esc)'),
|
||
child: IconButton(
|
||
onPressed: () {
|
||
setState(() => _tool = null);
|
||
_onLaserMove(null);
|
||
},
|
||
icon: const Icon(Icons.close, size: 20),
|
||
color: Colors.white70,
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
||
Widget _buildTypedBadge(int total) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.82),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: const Color(0xFF60A5FA), width: 1.5),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.south_east, color: Color(0xFF60A5FA), size: 20),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
'$_typed / $total',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 26,
|
||
fontWeight: FontWeight.w700,
|
||
fontFeatures: [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
const Text(
|
||
'Enter',
|
||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Badge tijdens het invoeren van de doeltijd ("Doeltijd 20:00 · Enter").
|
||
/// Cijfers schuiven van rechts in als MM:SS (zoals een magnetron).
|
||
Widget _buildTargetBadge() {
|
||
final padded = _targetTyped.padLeft(4, '0');
|
||
final preview = '${padded.substring(0, 2)}:${padded.substring(2)}';
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.82),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: const Color(0xFFF59E0B), width: 1.5),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.timer_outlined, color: Color(0xFFF59E0B), size: 20),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
preview,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 26,
|
||
fontWeight: FontWeight.w700,
|
||
fontFeatures: [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text(
|
||
'${context.l10n.d('Doeltijd')} · Enter · 0 = ${context.l10n.d('uit')}',
|
||
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Sneltoets-overzicht (cheatsheet).
|
||
Widget _buildHelpOverlay() {
|
||
final l10n = context.l10n;
|
||
final rows = <(String, String)>[
|
||
('→ · ${l10n.d('spatie')} · ${l10n.d('klik')}', l10n.d('Volgende slide')),
|
||
('←', l10n.d('Vorige slide')),
|
||
('${l10n.d('cijfers')} + Enter', l10n.d('Naar slidenummer')),
|
||
('Home · End', l10n.d('Eerste · laatste slide')),
|
||
('G', l10n.d('Slide-overzicht (pijltjes + Enter)')),
|
||
('P', l10n.d('Presenter view (notities, klok)')),
|
||
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
||
('B · W', l10n.d('Zwart · wit scherm')),
|
||
('D · T · E', l10n.d('Pen · markeerstift · gum')),
|
||
('X · C', l10n.d('Laser · annotaties wissen')),
|
||
('K', l10n.d('Doeltijd / aftellen instellen (MMSS)')),
|
||
('R', l10n.d('Tijd & oefenrun resetten')),
|
||
('A', l10n.d('Automatische modus aan/uit')),
|
||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||
('M', l10n.d('Na media automatisch doorgaan')),
|
||
('H', l10n.d('Deze legenda')),
|
||
('Esc', l10n.d('Terug / afsluiten')),
|
||
];
|
||
return GestureDetector(
|
||
onTap: _toggleHelp,
|
||
child: Container(
|
||
color: Colors.black.withValues(alpha: 0.85),
|
||
alignment: Alignment.center,
|
||
padding: const EdgeInsets.all(24),
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(
|
||
maxWidth: 460,
|
||
maxHeight: MediaQuery.of(context).size.height - 48,
|
||
),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(28),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF161616),
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: const Color(0xFF2A2A2A)),
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.keyboard_outlined,
|
||
color: Colors.white70,
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
l10n.d('Toetsenlegenda'),
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 18),
|
||
for (final (keys, desc) in rows)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 150,
|
||
child: Text(
|
||
keys,
|
||
style: const TextStyle(
|
||
color: Color(0xFF60A5FA),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
desc,
|
||
style: const TextStyle(
|
||
color: Color(0xFFE5E5E5),
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Center(
|
||
child: Text(
|
||
l10n.d('Klik of druk op H / Esc om te sluiten'),
|
||
style: const TextStyle(
|
||
color: Colors.white30,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Vol-vlak zwart/wit scherm dat met een klik weer verdwijnt.
|
||
Widget _blankFill() {
|
||
return GestureDetector(
|
||
onTap: () => setState(() => _blank = _Blank.none),
|
||
child: Container(
|
||
color: _blank == _Blank.white ? Colors.white : Colors.black,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// A 16:9 slide sized to fit within the given constraints.
|
||
Widget _slideCanvas(Slide slide) {
|
||
return LayoutBuilder(
|
||
builder: (_, constraints) {
|
||
final w = constraints.maxWidth;
|
||
final h = constraints.maxHeight;
|
||
const ratio = 16.0 / 9.0;
|
||
double slideW, slideH;
|
||
if (w / h > ratio) {
|
||
slideH = h;
|
||
slideW = h * ratio;
|
||
} else {
|
||
slideW = w;
|
||
slideH = w / ratio;
|
||
}
|
||
return Center(
|
||
child: SizedBox(
|
||
width: slideW,
|
||
height: slideH,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
SlidePreviewWidget(
|
||
slide: slide,
|
||
projectPath: widget.projectPath,
|
||
themeProfile: widget.themeProfile,
|
||
onLinkTap: openExternalUrl,
|
||
slideNumber: _index + 1,
|
||
slideCount: widget.slides.length,
|
||
tlp: widget.tlp,
|
||
presentationMode: true,
|
||
onChecklistItemToggle: (column, itemIndex) =>
|
||
_toggleChecklistItem(
|
||
slideIndex: _index,
|
||
column: column,
|
||
itemIndex: itemIndex,
|
||
),
|
||
// Tijdens het presenteren speelt media en starten audio/video
|
||
// vanzelf; het media-einde stuurt auto-advance aan. In dual-
|
||
// schermmodus speelt de media op het beamervenster, niet hier,
|
||
// anders zou het geluid dubbel klinken.
|
||
enableMedia: !_dual,
|
||
autoplayMedia: !_dual,
|
||
onAudioComplete: () => _onMediaCompleted(kind: 'audio'),
|
||
onVideoComplete: () => _onMediaCompleted(kind: 'video'),
|
||
),
|
||
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||
AnnotationLayer(
|
||
// Keyed by slide so a slide change (e.g. auto-advance) while a
|
||
// stroke is in progress resets the layer instead of committing
|
||
// the half-drawn stroke onto the next slide.
|
||
key: ValueKey(slide.id),
|
||
strokes: _currentStrokes,
|
||
tool: _tool,
|
||
color: _inkColor,
|
||
width: _toolWidth,
|
||
interactive: true,
|
||
onStrokesChanged: _onStrokesChanged,
|
||
onLaserMove: _onLaserMove,
|
||
onActiveStrokeChanged: _onActiveStroke,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ── Audience view (alleen de slide) ──────────────────────────────────────
|
||
|
||
Widget _buildAudienceView(BuildContext context) {
|
||
final total = widget.slides.length;
|
||
final slide = widget.slides[_index.clamp(0, total - 1)];
|
||
|
||
// Blanco scherm vult in publieksweergave het hele beeld.
|
||
if (_blank != _Blank.none) return _blankFill();
|
||
|
||
return GestureDetector(
|
||
onTap: _next,
|
||
onSecondaryTap: _prev,
|
||
child: SizedBox.expand(child: _slideCanvas(slide)),
|
||
);
|
||
}
|
||
|
||
// ── Presenter view (slide + volgende + notities + tijd) ──────────────────
|
||
|
||
Widget _buildPresenterView(BuildContext context) {
|
||
final l10n = context.l10n;
|
||
final total = widget.slides.length;
|
||
final slide = widget.slides[_index.clamp(0, total - 1)];
|
||
final hasNext = _index < total - 1;
|
||
final nextSlide = hasNext ? widget.slides[_index + 1] : null;
|
||
|
||
return Container(
|
||
color: const Color(0xFF0A0A0A),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// ── Hoofdgebied: huidige slide ───────────────────────────────────
|
||
Expanded(
|
||
flex: 3,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_SectionLabel(l10n.d('HUIDIGE SLIDE')),
|
||
const SizedBox(height: 8),
|
||
Expanded(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: GestureDetector(
|
||
onTap: _next,
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(child: _slideCanvas(slide)),
|
||
// Blanco scherm dekt alleen het slidevlak; jouw
|
||
// notities en klok blijven zichtbaar.
|
||
if (_blank != _Blank.none)
|
||
Positioned.fill(child: _blankFill()),
|
||
if (_progress > 0 && _blank == _Blank.none)
|
||
Positioned(
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: LinearProgressIndicator(
|
||
value: _progress,
|
||
backgroundColor: Colors.white12,
|
||
color: Colors.white54,
|
||
minHeight: 3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildPresenterControls(total),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
|
||
// ── Zijbalk: klok, volgende slide, notities ─────────────────────
|
||
SizedBox(
|
||
width: 400,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildClockBar(),
|
||
const SizedBox(height: 16),
|
||
_SectionLabel(l10n.d('VOLGENDE')),
|
||
const SizedBox(height: 8),
|
||
AspectRatio(
|
||
aspectRatio: 16 / 9,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: nextSlide != null
|
||
? Container(
|
||
color: Colors.black,
|
||
child: SlidePreviewWidget(
|
||
slide: nextSlide,
|
||
projectPath: widget.projectPath,
|
||
themeProfile: widget.themeProfile,
|
||
presentationMode: true,
|
||
),
|
||
)
|
||
: Container(
|
||
color: const Color(0xFF161616),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
l10n.d('Einde van de presentatie'),
|
||
style: const TextStyle(
|
||
color: Colors.white38,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_SectionLabel(l10n.d('NOTITIES')),
|
||
const SizedBox(height: 8),
|
||
Expanded(child: _buildNotes(slide)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Eén tijdwaarde met bijschrift voor de klokbalk.
|
||
Widget _metric(
|
||
String label,
|
||
String value, {
|
||
Color? color,
|
||
CrossAxisAlignment align = CrossAxisAlignment.start,
|
||
double size = 24,
|
||
}) {
|
||
return Column(
|
||
crossAxisAlignment: align,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
value,
|
||
style: TextStyle(
|
||
color: color ?? Colors.white,
|
||
fontSize: size,
|
||
fontWeight: FontWeight.w600,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildClockBar() {
|
||
final l10n = context.l10n;
|
||
final elapsed = _rehearsal.elapsed;
|
||
final remaining = _rehearsal.remaining;
|
||
final slideElapsed = _rehearsal.currentSlideElapsed;
|
||
final overtime = remaining != null && remaining.isNegative;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF161616),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: const Color(0xFF262626)),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// Bovenrij: verstreken tijd, knoppen, wandklok.
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _metric(l10n.d('Verstreken'), _fmtElapsed(elapsed)),
|
||
),
|
||
Tooltip(
|
||
message: l10n.d('Doeltijd / aftellen (K)'),
|
||
child: IconButton(
|
||
onPressed: _beginTargetInput,
|
||
icon: const Icon(Icons.timer_outlined, size: 18),
|
||
color: Colors.white38,
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
Tooltip(
|
||
message: l10n.d('Tijd resetten (R)'),
|
||
child: IconButton(
|
||
onPressed: _resetTimer,
|
||
icon: const Icon(Icons.restart_alt, size: 18),
|
||
color: Colors.white38,
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
_metric(
|
||
l10n.d('Klok'),
|
||
_fmtClock(DateTime.now()),
|
||
color: Colors.white70,
|
||
align: CrossAxisAlignment.end,
|
||
),
|
||
],
|
||
),
|
||
const Divider(height: 18, color: Color(0xFF262626)),
|
||
// Onderrij: aftelling (resterend/over) en tijd op huidige slide.
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _metric(
|
||
overtime ? l10n.d('Over de tijd') : l10n.d('Resterend'),
|
||
remaining == null ? '–:––' : _fmtRemaining(remaining),
|
||
color: remaining == null
|
||
? Colors.white24
|
||
: (overtime
|
||
? const Color(0xFFEF4444)
|
||
: const Color(0xFF22C55E)),
|
||
size: 20,
|
||
),
|
||
),
|
||
_metric(
|
||
l10n.d('Deze slide'),
|
||
_fmtElapsed(slideElapsed),
|
||
color: Colors.white70,
|
||
align: CrossAxisAlignment.end,
|
||
size: 20,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNotes(Slide slide) {
|
||
final l10n = context.l10n;
|
||
final notes = slide.notes.trim();
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF161616),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: const Color(0xFF262626)),
|
||
),
|
||
child: notes.isEmpty
|
||
? Align(
|
||
alignment: Alignment.topLeft,
|
||
child: Text(
|
||
l10n.d('Geen notities voor deze slide.'),
|
||
style: const TextStyle(
|
||
color: Colors.white30,
|
||
fontSize: 14,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
)
|
||
: SingleChildScrollView(
|
||
child: Text(
|
||
notes,
|
||
style: const TextStyle(
|
||
color: Color(0xFFE5E5E5),
|
||
fontSize: 17,
|
||
height: 1.5,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPresenterControls(int total) {
|
||
final l10n = context.l10n;
|
||
return Row(
|
||
children: [
|
||
_NavButton(icon: Icons.chevron_left, onTap: _index > 0 ? _prev : null),
|
||
const SizedBox(width: 8),
|
||
_NavButton(
|
||
icon: Icons.chevron_right,
|
||
onTap: _index < total - 1 ? _next : null,
|
||
),
|
||
if (_displays.length > 1) ...[
|
||
const SizedBox(width: 8),
|
||
Tooltip(
|
||
message: l10n.d('Wissel scherm (S)'),
|
||
child: _NavButton(
|
||
icon: Icons.screen_share_outlined,
|
||
onTap: _cycleDisplay,
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(width: 16),
|
||
Text(
|
||
'${l10n.d('Slide')} ${_index + 1} / $total',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Text(
|
||
l10n.d(
|
||
_displays.length > 1
|
||
? 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop'
|
||
: 'P publiek · H legenda · G overzicht · B/W zwart/wit · R tijd · Esc stop',
|
||
),
|
||
textAlign: TextAlign.right,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(color: Colors.white24, fontSize: 11),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Tooltip(
|
||
message: l10n.d('Afsluiten (Escape)'),
|
||
child: IconButton(
|
||
onPressed: _exit,
|
||
icon: const Icon(Icons.close),
|
||
color: Colors.white,
|
||
style: IconButton.styleFrom(backgroundColor: Colors.black45),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── Rasteroverzicht (snel naar een slide springen) ───────────────────────
|
||
|
||
Widget _buildGridOverlay() {
|
||
final l10n = context.l10n;
|
||
final total = widget.slides.length;
|
||
return Container(
|
||
color: Colors.black.withValues(alpha: 0.94),
|
||
child: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(24, 16, 16, 12),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
l10n.d('Slide-overzicht'),
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text(
|
||
'${l10n.d('pijltjes + Enter of klik om te springen')} · $total ${l10n.t('slides')}',
|
||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||
),
|
||
const Spacer(),
|
||
Tooltip(
|
||
message: l10n.d('Sluiten (G of Esc)'),
|
||
child: IconButton(
|
||
onPressed: _toggleGrid,
|
||
icon: const Icon(Icons.close),
|
||
color: Colors.white,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: Colors.white10,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: LayoutBuilder(
|
||
builder: (_, constraints) {
|
||
// Mik op tegels van ~260px breed, tussen 2 en 6 kolommen.
|
||
const hPad = 24.0, spacing = 16.0;
|
||
const aspect = 16 / 10.4; // slide + nummerregel
|
||
final cols = (constraints.maxWidth ~/ 260).clamp(2, 6);
|
||
// Maten onthouden voor pijltjesnavigatie + auto-scroll.
|
||
_gridCols = cols;
|
||
final tileW =
|
||
(constraints.maxWidth - hPad * 2 - spacing * (cols - 1)) /
|
||
cols;
|
||
_gridRowExtent = tileW / aspect + spacing;
|
||
return GridView.builder(
|
||
controller: _gridScroll,
|
||
padding: const EdgeInsets.fromLTRB(hPad, 0, hPad, 24),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: cols,
|
||
crossAxisSpacing: spacing,
|
||
mainAxisSpacing: spacing,
|
||
childAspectRatio: aspect,
|
||
),
|
||
itemCount: total,
|
||
itemBuilder: (_, i) => _buildGridTile(i),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGridTile(int i) {
|
||
final isCurrent = i == _index; // de slide die nu getoond wordt
|
||
final isCursor = i == _gridCursor; // de toetsenbordcursor
|
||
|
||
// Cursor wint qua markering (witte rand + gloed); de huidige slide krijgt
|
||
// anders een accentrand zodat je beide posities ziet.
|
||
final Color borderColor;
|
||
final double borderWidth;
|
||
if (isCursor) {
|
||
borderColor = Colors.white;
|
||
borderWidth = 3;
|
||
} else if (isCurrent) {
|
||
borderColor = const Color(0xFF60A5FA);
|
||
borderWidth = 2;
|
||
} else {
|
||
borderColor = const Color(0xFF3A3A3A);
|
||
borderWidth = 1;
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: () => _jumpTo(i),
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
onEnter: (_) => _setGridCursor(i),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: borderColor, width: borderWidth),
|
||
boxShadow: isCursor
|
||
? [
|
||
BoxShadow(
|
||
color: Colors.white.withValues(alpha: 0.25),
|
||
blurRadius: 16,
|
||
),
|
||
]
|
||
: null,
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: AspectRatio(
|
||
aspectRatio: 16 / 9,
|
||
child: SlidePreviewWidget(
|
||
slide: widget.slides[i],
|
||
projectPath: widget.projectPath,
|
||
themeProfile: widget.themeProfile,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
'${i + 1}',
|
||
style: TextStyle(
|
||
color: (isCursor || isCurrent)
|
||
? Colors.white
|
||
: Colors.white54,
|
||
fontSize: 12,
|
||
fontWeight: (isCursor || isCurrent)
|
||
? FontWeight.w700
|
||
: FontWeight.w400,
|
||
),
|
||
),
|
||
if (isCurrent) ...[
|
||
const SizedBox(width: 5),
|
||
const Icon(
|
||
Icons.play_arrow_rounded,
|
||
size: 13,
|
||
color: Color(0xFF60A5FA),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Kleine helpers ───────────────────────────────────────────────────────────
|
||
|
||
class _SectionLabel extends StatelessWidget {
|
||
final String text;
|
||
const _SectionLabel(this.text);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Text(
|
||
text,
|
||
style: const TextStyle(
|
||
color: Color(0xFF6B7280),
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 1.2,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavButton extends StatelessWidget {
|
||
final IconData icon;
|
||
final VoidCallback? onTap;
|
||
const _NavButton({required this.icon, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final enabled = onTap != null;
|
||
return Material(
|
||
color: enabled ? const Color(0xFF1F1F1F) : const Color(0xFF141414),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: SizedBox(
|
||
width: 44,
|
||
height: 36,
|
||
child: Icon(
|
||
icon,
|
||
color: enabled ? Colors.white70 : Colors.white12,
|
||
size: 24,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|