1398 lines
46 KiB
Dart
1398 lines
46 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:window_manager/window_manager.dart';
|
|
import '../../models/deck.dart';
|
|
import '../../models/settings.dart';
|
|
import '../../models/slide.dart';
|
|
import '../../utils/url_launcher_util.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../slides/slide_preview.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;
|
|
|
|
const FullscreenPresenter({
|
|
super.key,
|
|
required this.slides,
|
|
required this.projectPath,
|
|
required this.themeProfile,
|
|
required this.initialIndex,
|
|
this.tlp = TlpLevel.none,
|
|
});
|
|
|
|
static Future<void> show(
|
|
BuildContext context, {
|
|
required List<Slide> slides,
|
|
required String? projectPath,
|
|
required ThemeProfile themeProfile,
|
|
required int initialIndex,
|
|
TlpLevel tlp = TlpLevel.none,
|
|
}) async {
|
|
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,
|
|
),
|
|
transitionsBuilder: (context, animation, secondary, child) =>
|
|
FadeTransition(opacity: animation, child: child),
|
|
transitionDuration: const Duration(milliseconds: 200),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
|
|
}
|
|
|
|
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();
|
|
|
|
/// Starttijd voor de verstreken-tijd-teller (resetbaar met R).
|
|
late DateTime _startTime;
|
|
|
|
/// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief).
|
|
String _typed = '';
|
|
Timer? _typedTimer;
|
|
|
|
/// 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 de audio op de slide i.p.v. op de tijdwissel.
|
|
/// Met M te wisselen.
|
|
bool _advanceOnAudioEnd = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_index = widget.initialIndex;
|
|
_startTime = DateTime.now();
|
|
_focusNode = FocusNode();
|
|
// 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();
|
|
_scheduleAdvance();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_advanceTimer?.cancel();
|
|
_clockTimer?.cancel();
|
|
_typedTimer?.cancel();
|
|
_gridScroll.dispose();
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scheduleAdvance() {
|
|
_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)];
|
|
|
|
// Audio-gestuurd: heeft deze slide audio die vanzelf speelt én is de keuze
|
|
// 'na audio doorgaan' actief? Dan wachten we op het audio-einde (de
|
|
// _AudioPlayback meldt zich via onAudioComplete) en zetten we geen timer.
|
|
final audioDriven =
|
|
_advanceOnAudioEnd && slide.audioPath.isNotEmpty && slide.audioAutoplay;
|
|
if (audioDriven) 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 audio-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();
|
|
}
|
|
}
|
|
|
|
/// Aangeroepen door de audiospeler zodra de audio op de huidige slide klaar
|
|
/// is. In automatische modus met 'na audio doorgaan' schakelen we dan door.
|
|
void _onAudioCompleted() {
|
|
if (_autoPlay && _advanceOnAudioEnd) _autoAdvance();
|
|
}
|
|
|
|
void _toggleAutoPlay() {
|
|
setState(() => _autoPlay = !_autoPlay);
|
|
_scheduleAdvance();
|
|
}
|
|
|
|
void _toggleLoop() {
|
|
setState(() => _loop = !_loop);
|
|
_scheduleAdvance();
|
|
}
|
|
|
|
void _toggleAudioAdvance() {
|
|
setState(() => _advanceOnAudioEnd = !_advanceOnAudioEnd);
|
|
_scheduleAdvance();
|
|
}
|
|
|
|
Future<void> _exit() async {
|
|
_advanceTimer?.cancel();
|
|
await windowManager.setFullScreen(false);
|
|
if (mounted) Navigator.pop(context);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
void _prev() {
|
|
if (_blank != _Blank.none) {
|
|
setState(() => _blank = _Blank.none);
|
|
return;
|
|
}
|
|
if (_index > 0) {
|
|
setState(() => _index--);
|
|
_scheduleAdvance();
|
|
}
|
|
}
|
|
|
|
void _togglePresenterView() {
|
|
setState(() => _presenterView = !_presenterView);
|
|
}
|
|
|
|
void _resetTimer() {
|
|
setState(() => _startTime = DateTime.now());
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
// 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.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:
|
|
_toggleAudioAdvance();
|
|
return KeyEventResult.handled;
|
|
case LogicalKeyboardKey.escape:
|
|
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
|
|
if (_typed.isNotEmpty) {
|
|
_clearTyped();
|
|
} else if (_blank != _Blank.none) {
|
|
setState(() => _blank = _Blank.none);
|
|
} else {
|
|
_exit();
|
|
}
|
|
return KeyEventResult.handled;
|
|
default:
|
|
return KeyEventResult.ignored;
|
|
}
|
|
}
|
|
|
|
/// 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';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final total = widget.slides.length;
|
|
if (total == 0) {
|
|
_exit();
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
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 (_typed.isNotEmpty)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 60,
|
|
child: Center(child: _buildTypedBadge(total)),
|
|
),
|
|
if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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)')),
|
|
('B · W', l10n.d('Zwart · wit scherm')),
|
|
('R', l10n.d('Verstreken tijd resetten')),
|
|
('A', l10n.d('Automatische modus aan/uit')),
|
|
('L', l10n.d('Herhalen (loop) aan/uit')),
|
|
('M', l10n.d('Na audio automatisch doorgaan')),
|
|
('? · H', l10n.d('Dit overzicht')),
|
|
('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('Sneltoetsen'),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Subtiele statusindicator (linksonder) voor de automatische modus. Toont
|
|
/// of auto-play, herhalen en 'na audio doorgaan' actief zijn.
|
|
Widget _autoPlayStatus() {
|
|
final l10n = context.l10n;
|
|
final items = <(IconData, String, bool)>[
|
|
(
|
|
_autoPlay ? Icons.play_circle_outline : Icons.pause_circle_outline,
|
|
_autoPlay ? l10n.d('Auto (A)') : l10n.d('Handmatig (A)'),
|
|
_autoPlay,
|
|
),
|
|
(Icons.repeat, l10n.d('Herhalen (L)'), _loop),
|
|
(Icons.graphic_eq, l10n.d('Na audio (M)'), _advanceOnAudioEnd),
|
|
];
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final (icon, label, active) in items)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: Opacity(
|
|
opacity: active ? 0.7 : 0.28,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: Colors.white),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 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: SlidePreviewWidget(
|
|
slide: slide,
|
|
projectPath: widget.projectPath,
|
|
themeProfile: widget.themeProfile,
|
|
onLinkTap: openExternalUrl,
|
|
slideNumber: _index + 1,
|
|
slideCount: widget.slides.length,
|
|
tlp: widget.tlp,
|
|
// Tijdens het presenteren speelt media en starten audio/video
|
|
// vanzelf; het audio-einde stuurt de auto-advance aan.
|
|
enableMedia: true,
|
|
autoplayMedia: true,
|
|
onAudioComplete: _onAudioCompleted,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── 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: Stack(
|
|
children: [
|
|
// ── Slide canvas ─────────────────────────────────────────────────
|
|
Positioned.fill(child: _slideCanvas(slide)),
|
|
|
|
// ── Voortgangsbalk (auto-advance) ────────────────────────────────
|
|
if (_progress > 0)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: LinearProgressIndicator(
|
|
value: _progress,
|
|
backgroundColor: Colors.white12,
|
|
color: Colors.white54,
|
|
minHeight: 3,
|
|
),
|
|
),
|
|
|
|
// ── Slide counter ────────────────────────────────────────────────
|
|
Positioned(
|
|
right: 24,
|
|
bottom: 10,
|
|
child: Text(
|
|
'${_index + 1} / $total',
|
|
style: const TextStyle(
|
|
color: Colors.white38,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
|
|
// ── Auto-play status (linksonder) ────────────────────────────────
|
|
Positioned(left: 24, bottom: 10, child: _autoPlayStatus()),
|
|
|
|
// ── Navigation arrows ────────────────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: MouseRegion(
|
|
cursor: _index > 0 ? SystemMouseCursors.click : MouseCursor.defer,
|
|
child: GestureDetector(
|
|
onTap: _prev,
|
|
child: Container(
|
|
width: 60,
|
|
color: Colors.transparent,
|
|
child: _index > 0
|
|
? const Icon(
|
|
Icons.chevron_left,
|
|
color: Colors.white24,
|
|
size: 40,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: MouseRegion(
|
|
cursor: _index < total - 1
|
|
? SystemMouseCursors.click
|
|
: MouseCursor.defer,
|
|
child: GestureDetector(
|
|
onTap: _next,
|
|
child: Container(
|
|
width: 60,
|
|
color: Colors.transparent,
|
|
child: _index < total - 1
|
|
? const Icon(
|
|
Icons.chevron_right,
|
|
color: Colors.white24,
|
|
size: 40,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// ── Top-right controls (presenter view + afsluiten) ──────────────
|
|
Positioned(
|
|
top: 16,
|
|
right: 16,
|
|
child: Row(
|
|
children: [
|
|
Tooltip(
|
|
message: context.l10n.d('Sneltoetsen (?)'),
|
|
child: IconButton(
|
|
onPressed: _toggleHelp,
|
|
icon: const Icon(Icons.help_outline),
|
|
color: Colors.white,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: context.l10n.d('Slide-overzicht (G)'),
|
|
child: IconButton(
|
|
onPressed: _toggleGrid,
|
|
icon: const Icon(Icons.grid_view_rounded),
|
|
color: Colors.white,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: context.l10n.d('Presenter view (P)'),
|
|
child: IconButton(
|
|
onPressed: _togglePresenterView,
|
|
icon: const Icon(Icons.co_present_outlined),
|
|
color: Colors.white,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: context.l10n.d('Afsluiten (Escape)'),
|
|
child: IconButton(
|
|
onPressed: _exit,
|
|
icon: const Icon(Icons.close),
|
|
color: Colors.white,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── 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,
|
|
),
|
|
)
|
|
: 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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildClockBar() {
|
|
final l10n = context.l10n;
|
|
final elapsed = DateTime.now().difference(_startTime);
|
|
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: Row(
|
|
children: [
|
|
// Verstreken tijd
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.d('Verstreken'),
|
|
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_fmtElapsed(elapsed),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Reset-knop
|
|
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),
|
|
// Wandklok
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
l10n.d('Klok'),
|
|
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_fmtClock(DateTime.now()),
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
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(
|
|
'P publiek · 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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|