import 'dart:async'; import 'package:flutter/material.dart'; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; import '../application/watch_session_view_model.dart'; final class WatchSessionScreen extends StatefulWidget { const WatchSessionScreen({required this.viewModel, super.key}); final WatchSessionViewModel viewModel; @override State createState() => _WatchSessionScreenState(); } final class _WatchSessionScreenState extends State { late final PageController _pageController; Timer? _ticker; @override void initState() { super.initState(); _pageController = PageController(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) { setState(() {}); } }); } @override void dispose() { _ticker?.cancel(); _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.viewModel, builder: (context, state, _) { final projection = state.projection; if (projection.phase == WatchSessionPhase.noActiveSession) { return _RoundScaffold( child: _NoSessionView( projection: projection, pending: state.commandPending, onRefresh: widget.viewModel.refresh, ), ); } return PageView( controller: _pageController, children: [ _RoundScaffold( child: _SessionMainView( state: state, onPrimary: widget.viewModel.sendPrimaryAction, onRetry: widget.viewModel.refresh, onActions: _showActions, ), ), _RoundScaffold( child: _ActionsView( state: state, onAction: _handleSecondaryAction, onSession: _showSession, ), ), ], ); }, ); } void _showActions() { _pageController.animateToPage( 1, duration: const Duration(milliseconds: 180), curve: Curves.easeOut, ); } void _showSession() { _pageController.animateToPage( 0, duration: const Duration(milliseconds: 180), curve: Curves.easeOut, ); } Future _handleSecondaryAction(WatchSecondaryAction action) async { final confirmed = switch (action) { WatchSecondaryAction.skipCurrentPassage => await _confirm( title: 'Passer le passage ?', message: "L'étape en cours sera ignorée.", confirmLabel: 'Passer', ), WatchSecondaryAction.skipCurrentSet => await _confirm( title: 'Passer la série ?', message: 'Le chrono en cours sera ignoré.', confirmLabel: 'Passer', ), _ => true, }; if (confirmed && mounted) { unawaited(widget.viewModel.sendSecondaryAction(action)); _showSession(); } } Future _confirm({ required String title, required String message, required String confirmLabel, }) async { final result = await showDialog( context: context, builder: (context) { return AlertDialog( title: Text(title), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(confirmLabel), ), ], ); }, ); return result ?? false; } } final class _RoundScaffold extends StatelessWidget { const _RoundScaffold({required this.child}); final Widget child; @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( minimum: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 210, maxHeight: 210), child: child, ), ), ), ); } } final class _NoSessionView extends StatelessWidget { const _NoSessionView({ required this.projection, required this.pending, required this.onRefresh, }); final WatchSessionProjection projection; final bool pending; final VoidCallback onRefresh; @override Widget build(BuildContext context) { final phoneReachable = projection.phoneReachable; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( phoneReachable ? 'Aucune séance en cours' : 'Téléphone indisponible', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 10), Text( phoneReachable ? 'Lance une séance sur le téléphone.' : 'Rouvre GameTime sur le téléphone.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), FilledButton( onPressed: pending ? null : onRefresh, child: Text(pending ? 'Envoi...' : 'Actualiser'), ), ], ); } } final class _SessionMainView extends StatelessWidget { const _SessionMainView({ required this.state, required this.onPrimary, required this.onRetry, required this.onActions, }); final WatchSessionUiState state; final VoidCallback onPrimary; final VoidCallback onRetry; final VoidCallback onActions; @override Widget build(BuildContext context) { final projection = state.projection; final isRest = projection.phase == WatchSessionPhase.restRunning || projection.phase == WatchSessionPhase.restPaused; if (state.connectionLost || !projection.phoneReachable) { return _ConnectionLostView(onRetry: onRetry); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( alignment: Alignment.centerRight, child: TextButton( onPressed: onActions, style: TextButton.styleFrom( visualDensity: VisualDensity.compact, minimumSize: const Size(56, 26), padding: const EdgeInsets.symmetric(horizontal: 8), ), child: const Text('Actions'), ), ), Expanded( child: isRest ? _RestContent(projection: projection) : _ActiveContent(projection: projection), ), if (state.staleProjection) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( 'Dernier état reçu', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), FilledButton( onPressed: state.actionsEnabled ? onPrimary : null, child: Text(_primaryLabel(state)), ), ], ); } } final class _ActiveContent extends StatelessWidget { const _ActiveContent({required this.projection}); final WatchSessionProjection projection; @override Widget build(BuildContext context) { final timer = projection.dominantTimer; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'SÉRIE ${projection.seriesIndex} / ${projection.seriesTotal}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(height: 3), Text( projection.exerciseName, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall, ), if (_contextLine(projection) case final contextLine?) Padding( padding: const EdgeInsets.only(top: 4), child: Text( contextLine, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), const SizedBox(height: 8), if (timer == null) Text( projection.statusLabel ?? '', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ) else ...[ Text( _timerText(timer), textAlign: TextAlign.center, style: Theme.of(context).textTheme.displayLarge, ), Text( projection.statusLabel ?? timer.label, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ], if (projection.secondaryTimers.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 7), child: Text( projection.secondaryTimers.map(_compactTimerText).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), ], ); } } final class _RestContent extends StatelessWidget { const _RestContent({required this.projection}); final WatchSessionProjection projection; @override Widget build(BuildContext context) { final timer = projection.dominantTimer; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'REPOS', textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(height: 3), Text( 'Après série ${projection.seriesIndex} / ${projection.seriesTotal}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 10), Text( timer == null ? '--:--' : _timerText(timer), textAlign: TextAlign.center, style: Theme.of(context).textTheme.displayLarge, ), Text( projection.statusLabel ?? timer?.label ?? '', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), if (projection.nextExerciseName case final next?) Padding( padding: const EdgeInsets.only(top: 9), child: Column( children: [ Text( 'Exercice suivant', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), Text( next, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ], ); } } final class _ActionsView extends StatelessWidget { const _ActionsView({ required this.state, required this.onAction, required this.onSession, }); final WatchSessionUiState state; final ValueChanged onAction; final VoidCallback onSession; @override Widget build(BuildContext context) { final actions = state.projection.secondaryActions; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: Text( 'Actions', style: Theme.of(context).textTheme.titleSmall, ), ), IconButton( onPressed: onSession, tooltip: 'Séance', visualDensity: VisualDensity.compact, icon: const Icon(Icons.chevron_left), ), ], ), Expanded( child: actions.isEmpty || !state.projection.phoneReachable || state.connectionLost ? Center( child: Text( state.projection.phoneReachable && !state.connectionLost ? 'Aucune action' : 'Connexion perdue', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ) : ListView.separated( padding: const EdgeInsets.only(top: 4, bottom: 12), itemBuilder: (context, index) { final action = actions[index]; return OutlinedButton( onPressed: state.actionsEnabled ? () => onAction(action) : null, child: Text(_secondaryLabel(action)), ); }, separatorBuilder: (_, _) => const SizedBox(height: 8), itemCount: actions.length, ), ), ], ); } } final class _ConnectionLostView extends StatelessWidget { const _ConnectionLostView({required this.onRetry}); final VoidCallback onRetry; @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Connexion perdue', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), Text( 'Dernier état reçu il y a quelques secondes', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), FilledButton(onPressed: onRetry, child: const Text('Réessayer')), ], ); } } String _primaryLabel(WatchSessionUiState state) { if (state.commandPending) { return state.waitingForPhone ? 'En attente du téléphone' : 'Envoi...'; } return switch (state.projection.primaryAction) { WatchPrimaryAction.none => 'Actualiser', WatchPrimaryAction.startCurrentExercise => 'Démarrer l’exercice', WatchPrimaryAction.pauseSession => 'Pause', WatchPrimaryAction.resumeSession => 'Reprendre', WatchPrimaryAction.startPreparedTimedStep => 'Démarrer le chrono', WatchPrimaryAction.skipCurrentRest => 'Passer le repos', }; } String _secondaryLabel(WatchSecondaryAction action) { return switch (action) { WatchSecondaryAction.skipCurrentStep => 'Passer l’étape', WatchSecondaryAction.skipCurrentPassage => 'Passer le passage', WatchSecondaryAction.finishCurrentSet => 'Terminer la série', WatchSecondaryAction.skipCurrentSet => 'Passer la série', WatchSecondaryAction.skipCurrentRest => 'Passer le repos', }; } String? _contextLine(WatchSessionProjection projection) { final parts = [ if (projection.passageIndex != null && projection.passageTotal != null) 'Passage ${projection.passageIndex} / ${projection.passageTotal}', if (projection.stepIndex != null && projection.stepTotal != null) 'Étape ${projection.stepIndex} / ${projection.stepTotal}', ]; if (parts.isEmpty) { return null; } return parts.join(' · '); } String _timerText(WatchTimerProjection timer) { final duration = _displayDuration(timer); final totalSeconds = duration.inSeconds; final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } String _compactTimerText(WatchTimerProjection timer) { return '${timer.label} ${_timerText(timer)}'; } Duration _displayDuration(WatchTimerProjection timer) { final elapsedMs = _interpolatedElapsedMs(timer); final displayMs = switch (timer.displayMode) { WatchTimerDisplayMode.elapsed => elapsedMs, WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs, }; return Duration(milliseconds: displayMs < 0 ? 0 : displayMs); } int _interpolatedElapsedMs(WatchTimerProjection timer) { if (timer.runState != WatchTimerRunState.running || timer.startedAtEpochMs == null) { return timer.accumulatedMs; } final nowMs = DateTime.now().millisecondsSinceEpoch; return timer.accumulatedMs + nowMs - timer.startedAtEpochMs!; }