import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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; Timer? _failureNoticeTimer; String? _failureNoticeMessage; var _lastFailureNoticeSerial = 0; var _returnToSessionAfterSecondarySuccess = false; var _secondaryActionStartFailureSerial = 0; final _timerRemainingMsByKey = {}; final _completionHapticTimerKeys = {}; @override void initState() { super.initState(); _pageController = PageController(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) { setState(() {}); } }); } @override void dispose() { _ticker?.cancel(); _failureNoticeTimer?.cancel(); _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.viewModel, builder: (context, state, _) { _syncFailureNotice(state); _syncSecondaryNavigation(state); final projection = state.projection; _syncTimerCompletionHaptic(projection); if (projection.phase == WatchSessionPhase.noActiveSession) { final phoneReachable = projection.phoneReachable && !state.connectionLost; return _RoundScaffold( notice: _failureNoticeMessage, child: _NoSessionView( state: state, phoneReachable: phoneReachable, onPrimaryAction: widget.viewModel.sendPrimaryAction, ), ); } final pages = [ _RoundScaffold( key: const ValueKey('watch-session-page'), notice: _failureNoticeMessage, child: _SessionMainView( state: state, onActions: _showActions, onStats: state.hasLiveSensors ? _showStats : null, onTogglePause: widget.viewModel.sendPrimaryAction, onIncrementScore: widget.viewModel.incrementScore, onDecrementScore: widget.viewModel.decrementScore, onCompleteStep: widget.viewModel.completeCurrentStep, ), ), _RoundScaffold( key: const ValueKey('watch-actions-page'), notice: _failureNoticeMessage, child: _ActionsView( state: state, onAction: _handleSecondaryAction, onSession: _showSession, ), ), ]; if (state.hasLiveSensors) { pages.add( _RoundScaffold( key: const ValueKey('watch-stats-page'), notice: _failureNoticeMessage, child: _StatsView(state: state, onSession: _showSession), ), ); } return PageView(controller: _pageController, children: pages); }, ); } void _syncFailureNotice(WatchSessionUiState state) { if (state.commandFailureSerial == _lastFailureNoticeSerial || state.commandFailureMessage == null) { return; } _lastFailureNoticeSerial = state.commandFailureSerial; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } _failureNoticeTimer?.cancel(); setState(() { _failureNoticeMessage = state.commandFailureMessage; }); _failureNoticeTimer = Timer(const Duration(milliseconds: 1800), () { if (mounted) { setState(() { _failureNoticeMessage = null; }); } }); }); } void _showActions() { _pageController.animateToPage( 1, duration: const Duration(milliseconds: 180), curve: Curves.easeOut, ); } void _showStats() { _pageController.animateToPage( 2, 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) { return; } if (action != WatchSecondaryAction.skipCurrentRest) { unawaited(widget.viewModel.sendSecondaryAction(action)); _showSession(); return; } if (mounted) { _returnToSessionAfterSecondarySuccess = true; _secondaryActionStartFailureSerial = widget.viewModel.value.commandFailureSerial; await widget.viewModel.sendSecondaryAction(action); if (mounted) { _syncSecondaryNavigation(widget.viewModel.value); } } } void _syncSecondaryNavigation(WatchSessionUiState state) { if (!_returnToSessionAfterSecondarySuccess || state.commandPending) { return; } final failed = state.connectionLost || state.commandFailureSerial != _secondaryActionStartFailureSerial; _returnToSessionAfterSecondarySuccess = false; if (failed) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _showSession(); } }); } void _syncTimerCompletionHaptic(WatchSessionProjection projection) { final timer = _primaryDisplayTimer(projection); if (timer == null || timer.displayMode != WatchTimerDisplayMode.countdown || timer.runState != WatchTimerRunState.running) { return; } final key = _timerHapticKey(projection, timer); final remainingMs = _displayDuration(timer).inMilliseconds; final previousRemainingMs = _timerRemainingMsByKey[key]; _timerRemainingMsByKey[key] = remainingMs; if (remainingMs > 0 || previousRemainingMs == null || previousRemainingMs <= 0 || !_completionHapticTimerKeys.add(key)) { return; } unawaited(HapticFeedback.heavyImpact()); } Future _confirm({ required String title, required String message, required String confirmLabel, }) async { final result = await showDialog( context: context, builder: (context) { return _ConfirmSheet( title: title, message: message, confirmLabel: confirmLabel, ); }, ); return result ?? false; } } final class _RoundScaffold extends StatelessWidget { const _RoundScaffold({required this.child, this.notice, super.key}); final Widget child; final String? notice; @override Widget build(BuildContext context) { return Scaffold( body: LayoutBuilder( builder: (context, constraints) { final diameter = constraints.biggest.shortestSide; final contentSize = diameter < 210 ? diameter : 210.0; return Center( child: SizedBox.square( dimension: contentSize, child: Padding( padding: EdgeInsets.all(contentSize * 0.09), child: Stack( children: [ Positioned.fill(child: child), if (notice case final message?) Positioned( left: 8, right: 8, bottom: 0, child: _FailureNotice(message), ), ], ), ), ), ); }, ), ); } } final class _FailureNotice extends StatelessWidget { const _FailureNotice(this.message); final String message; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: const Color(0xFF141824), border: Border.all(color: const Color(0xFFD72638)), borderRadius: BorderRadius.circular(6), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), child: Text( message, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 10), ), ), ); } } final class _GtMark extends StatelessWidget { const _GtMark(); @override Widget build(BuildContext context) { const size = 52.0; return Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( color: const Color(0xFF141824), border: Border.all(color: const Color(0xFFC9A24A), width: 2), borderRadius: BorderRadius.circular(6), ), child: Stack( alignment: Alignment.center, children: [ Transform.rotate( angle: -0.48, child: Container( width: size * 0.82, height: size * 0.18, color: const Color(0xFFD72638), ), ), Text( 'GT', style: Theme.of(context).textTheme.displayLarge?.copyWith( fontSize: size * 0.46, color: const Color(0xFFC9A24A), height: 1, ), ), ], ), ); } } final class _NoSessionView extends StatelessWidget { const _NoSessionView({ required this.state, required this.phoneReachable, required this.onPrimaryAction, }); final WatchSessionUiState state; final bool phoneReachable; final VoidCallback onPrimaryAction; @override Widget build(BuildContext context) { final canSend = phoneReachable && state.actionsEnabled; final primaryAction = state.projection.primaryAction; final showsAction = primaryAction != WatchPrimaryAction.none || state.commandPending; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const _GtMark(), const SizedBox(height: 14), Text( phoneReachable ? 'Aucune séance en cours' : 'Téléphone indisponible', maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), if (showsAction) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton( onPressed: canSend ? onPrimaryAction : null, child: _ActionButtonLabel(_primaryLabel(state)), ), ), ], ], ); } } final class _SessionMainView extends StatelessWidget { const _SessionMainView({ required this.state, required this.onActions, required this.onStats, required this.onTogglePause, required this.onIncrementScore, required this.onDecrementScore, required this.onCompleteStep, }); final WatchSessionUiState state; final VoidCallback onActions; final VoidCallback? onStats; final VoidCallback onTogglePause; final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { final projection = state.projection; final isRest = projection.phase == WatchSessionPhase.restRunning || projection.phase == WatchSessionPhase.restPaused; final connectionLost = state.connectionLost || !projection.phoneReachable; final hasActions = projection.secondaryActions.isNotEmpty; final timer = _primaryDisplayTimer(projection); final showsTimer = timer != null; final footerText = _sessionFooterText(state); final canToggleTimer = showsTimer && !connectionLost && !state.commandPending && _timerButtonCommandMatches( timer: timer, primaryAction: projection.primaryAction, ); return Stack( children: [ Positioned( top: 0, left: 58, right: 58, child: Container( height: 2, decoration: BoxDecoration( color: const Color(0xFFD72638), borderRadius: BorderRadius.circular(2), ), ), ), Positioned.fill( top: 18, bottom: 18, child: Opacity( opacity: connectionLost ? 0.48 : 1, child: isRest ? _RestContent( state: state, onTogglePause: canToggleTimer ? onTogglePause : null, ) : _ActiveContent( state: state, onTogglePause: canToggleTimer ? onTogglePause : null, onIncrementScore: onIncrementScore, onDecrementScore: onDecrementScore, onCompleteStep: onCompleteStep, ), ), ), if (hasActions) Positioned( top: -7, right: -8, child: IconButton( onPressed: onActions, tooltip: 'Actions', visualDensity: VisualDensity.compact, iconSize: 18, color: const Color(0xFFC9A24A), icon: const Icon(Icons.more_horiz), ), ), if (state.hasLiveSensors) Positioned( top: -7, left: -8, child: IconButton( onPressed: onStats, tooltip: 'Stats', visualDensity: VisualDensity.compact, iconSize: 17, color: const Color(0xFFC9A24A), icon: const Icon(Icons.monitor_heart_outlined), ), ), if (connectionLost) Positioned( top: 2, left: 0, right: hasActions ? 30 : 0, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.link_off, size: 12, color: Color(0xFFFF4D5E)), const SizedBox(width: 3), Flexible( child: Text( 'Connexion au téléphone perdue', maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: const Color(0xFFA7ADBA), fontSize: 9, ), ), ), ], ), ), if (footerText.isNotEmpty) Positioned( left: 0, right: 0, bottom: 0, child: Text( footerText, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(fontSize: 10), ), ), ], ); } } final class _DominantValue extends StatelessWidget { const _DominantValue(this.value); final String value; @override Widget build(BuildContext context) { return SizedBox( height: 48, child: FittedBox( fit: BoxFit.scaleDown, child: Text( value, maxLines: 1, textAlign: TextAlign.center, style: Theme.of(context).textTheme.displayLarge, ), ), ); } } final class _SmallLabel extends StatelessWidget { const _SmallLabel(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ); } } final class _ExerciseName extends StatelessWidget { const _ExerciseName(this.name); final String name; @override Widget build(BuildContext context) { return Text( name.isEmpty ? 'Séance en cours' : name, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall, ); } } final class _StepNameBand extends StatelessWidget { const _StepNameBand(this.name); final String? name; @override Widget build(BuildContext context) { final value = name?.trim(); if (value == null || value.isEmpty) { return const SizedBox.shrink(); } return Container( width: double.infinity, margin: const EdgeInsets.only(top: 5, bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), alignment: Alignment.center, decoration: BoxDecoration( color: const Color(0xFF141824), border: Border.all(color: const Color(0xFF414754)), borderRadius: BorderRadius.circular(6), ), child: Text( value, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 10), ), ); } } final class _StatusLine extends StatelessWidget { const _StatusLine(this.text); final String? text; @override Widget build(BuildContext context) { final value = text; if (value == null || value.isEmpty) { return const SizedBox(height: 13); } return SizedBox( height: 13, child: Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ); } } final class _MainHeartRateLine extends StatelessWidget { const _MainHeartRateLine({required this.sample}); final WatchSensorSample? sample; @override Widget build(BuildContext context) { final label = _heartRateLabel(sample); if (label == null) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 2), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.favorite, size: 11, color: Color(0xFFFF4D5E)), const SizedBox(width: 3), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: const Color(0xFFA7ADBA), fontSize: 11, ), ), ], ), ); } } final class _ScaledContent extends StatelessWidget { const _ScaledContent({required this.child}); final Widget child; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { return Center( child: FittedBox( fit: BoxFit.scaleDown, child: SizedBox(width: constraints.maxWidth, child: child), ), ); }, ); } } final class _ActiveContent extends StatelessWidget { const _ActiveContent({ required this.state, required this.onTogglePause, required this.onIncrementScore, required this.onDecrementScore, required this.onCompleteStep, }); final WatchSessionUiState state; final VoidCallback? onTogglePause; final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { final projection = state.projection; if (projection.hasManualScore) { return _ManualScoreContent( state: state, onTogglePause: onTogglePause, onIncrement: onIncrementScore, onDecrement: onDecrementScore, ); } final timer = _primaryDisplayTimer(projection); final secondaryTimers = _visibleSecondaryTimers( projection, primaryTimer: timer, ); final repsTarget = _repsStepTarget(projection); final dominantValue = timer == null ? _seriesValue(projection) : _timerText(timer); final dominantLabel = timer == null ? 'SÉRIE' : timer.label; final controlsEnabled = !state.connectionLost && projection.phoneReachable && !state.commandPending; return _ScaledContent( child: Column( mainAxisSize: MainAxisSize.min, children: [ _ExerciseName(projection.exerciseName), _StepNameBand(projection.stepName), const SizedBox(height: 2), _SmallLabel(repsTarget == null ? dominantLabel : 'Répétitions'), const SizedBox(height: 2), if (repsTarget != null) _DominantRepsLine( value: repsTarget.toString(), pending: state.commandPending, onComplete: controlsEnabled ? onCompleteStep : null, ) else if (timer == null) _DominantValue(dominantValue) else _DominantTimerLine( value: dominantValue, timer: timer, pending: state.timerTogglePending, onTogglePause: onTogglePause, ), _MainHeartRateLine(sample: state.sensorSample), if (timer == null) ...[ const SizedBox(height: 5), _StatusLine(projection.statusLabel), ], if (secondaryTimers.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( secondaryTimers.map(_compactTimerText).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ); } } final class _DominantRepsLine extends StatelessWidget { const _DominantRepsLine({ required this.value, required this.pending, required this.onComplete, }); final String value; final bool pending; final VoidCallback? onComplete; @override Widget build(BuildContext context) { return SizedBox( height: 52, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible(child: _DominantValue(value)), const SizedBox(width: 6), _CompleteStepButton(pending: pending, onPressed: onComplete), ], ), ); } } final class _CompleteStepButton extends StatelessWidget { const _CompleteStepButton({required this.pending, required this.onPressed}); final bool pending; final VoidCallback? onPressed; @override Widget build(BuildContext context) { return SizedBox.square( dimension: 48, child: IconButton( onPressed: onPressed, tooltip: 'Valider l’étape', visualDensity: VisualDensity.compact, iconSize: 24, color: const Color(0xFFC9A24A), disabledColor: const Color(0xFF414754), style: IconButton.styleFrom( backgroundColor: const Color(0xFF141824), side: const BorderSide(color: Color(0xFFD72638)), ), icon: pending ? const _PendingDot(key: ValueKey('complete-step-pending-dot')) : const Icon(Icons.check), ), ); } } final class _DominantTimerLine extends StatelessWidget { const _DominantTimerLine({ required this.value, required this.timer, required this.pending, required this.onTogglePause, this.compact = false, }); final String value; final WatchTimerProjection timer; final bool pending; final VoidCallback? onTogglePause; final bool compact; @override Widget build(BuildContext context) { return SizedBox( height: compact ? 34 : 48, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: compact ? Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontSize: 26, height: 1), ) : _DominantValue(value), ), SizedBox(width: compact ? 4 : 6), _TimerToggleButton( runState: timer.runState, pending: pending, onPressed: onTogglePause, compact: compact, ), ], ), ); } } final class _ManualScoreContent extends StatelessWidget { const _ManualScoreContent({ required this.state, required this.onTogglePause, required this.onIncrement, required this.onDecrement, }); final WatchSessionUiState state; final VoidCallback? onTogglePause; final VoidCallback onIncrement; final VoidCallback onDecrement; @override Widget build(BuildContext context) { final projection = state.projection; final score = state.optimisticManualScoreValue ?? projection.currentManualScoreValue ?? 0; final controlsEnabled = !state.connectionLost && projection.phoneReachable; final canDecrement = controlsEnabled && score > 0 && (state.scoreCommandPending || projection.canDecrementScore); final timer = _primaryDisplayTimer(projection); final canToggleTimer = timer != null && controlsEnabled && !state.commandPending && _timerButtonCommandMatches( timer: timer, primaryAction: projection.primaryAction, ); final target = projection.manualScoreTargetValue; final targetLabel = projection.manualScoreTargetLabel; return _ScaledContent( child: Column( mainAxisSize: MainAxisSize.min, children: [ _ExerciseName(projection.exerciseName), _StepNameBand(projection.stepName), if (target != null && targetLabel != null && targetLabel.isNotEmpty) ...[ Text( '$targetLabel : ${_scoreText(target)}', maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: const Color(0xFFA7ADBA), fontSize: 11, ), ), const SizedBox(height: 1), ], const _SmallLabel('SCORE'), const SizedBox(height: 2), SizedBox( height: 54, child: Row( children: [ _ScoreButton( label: '−', onPressed: canDecrement ? onDecrement : null, ), Expanded( child: Opacity( opacity: state.scoreCommandPending ? 0.58 : 1, child: _DominantValue(_scoreText(score)), ), ), _ScoreButton( label: '+', onPressed: controlsEnabled ? onIncrement : null, ), ], ), ), SizedBox( height: 6, child: state.scoreWaitingForPhone ? const _PendingDot(key: ValueKey('score-pending-dot')) : const SizedBox.shrink(), ), _MainHeartRateLine(sample: state.sensorSample), const SizedBox(height: 5), if (timer != null) _CompactTimerLine( timer: timer, pending: state.timerTogglePending, onTogglePause: canToggleTimer ? onTogglePause : null, ) else _StatusLine(projection.statusLabel), ], ), ); } } final class _CompactTimerLine extends StatelessWidget { const _CompactTimerLine({ required this.timer, required this.pending, required this.onTogglePause, }); final WatchTimerProjection timer; final bool pending; final VoidCallback? onTogglePause; @override Widget build(BuildContext context) { return SizedBox( height: 45, child: Column( mainAxisSize: MainAxisSize.min, children: [ _SmallLabel(timer.label), const SizedBox(height: 1), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: Text( _timerText(timer), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontSize: 18, height: 1), ), ), const SizedBox(width: 4), _TimerToggleButton( runState: timer.runState, pending: pending, onPressed: onTogglePause, compact: true, ), ], ), ], ), ); } } final class _ScoreButton extends StatelessWidget { const _ScoreButton({required this.label, required this.onPressed}); final String label; final VoidCallback? onPressed; @override Widget build(BuildContext context) { return SizedBox.square( dimension: 48, child: IconButton( onPressed: onPressed, tooltip: label == '+' ? 'Ajouter' : 'Retirer', visualDensity: VisualDensity.compact, iconSize: 24, color: const Color(0xFFC9A24A), disabledColor: const Color(0xFF414754), icon: Text( label, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: onPressed == null ? const Color(0xFF414754) : const Color(0xFFC9A24A), fontSize: 24, ), ), ), ); } } final class _TimerToggleButton extends StatelessWidget { const _TimerToggleButton({ required this.runState, required this.pending, required this.onPressed, this.compact = false, }); final WatchTimerRunState runState; final bool pending; final VoidCallback? onPressed; final bool compact; @override Widget build(BuildContext context) { return SizedBox.square( dimension: compact ? 28 : 48, child: IconButton( onPressed: onPressed, tooltip: _timerButtonTooltip(runState), visualDensity: VisualDensity.compact, iconSize: compact ? 16 : 26, color: const Color(0xFFC9A24A), disabledColor: const Color(0xFF414754), style: IconButton.styleFrom( backgroundColor: const Color(0xFF141824), side: const BorderSide(color: Color(0xFFD72638)), ), icon: pending ? const _PendingDot(key: ValueKey('timer-toggle-pending-dot')) : Icon(_timerButtonIcon(runState)), ), ); } } final class _PendingDot extends StatelessWidget { const _PendingDot({super.key}); @override Widget build(BuildContext context) { return Center( child: Container( width: 5, height: 5, decoration: const BoxDecoration( color: Color(0xFFC9A24A), shape: BoxShape.circle, ), ), ); } } final class _RestContent extends StatelessWidget { const _RestContent({required this.state, required this.onTogglePause}); final WatchSessionUiState state; final VoidCallback? onTogglePause; @override Widget build(BuildContext context) { final projection = state.projection; final timer = _primaryDisplayTimer(projection); return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const _SmallLabel('REPOS'), const SizedBox(height: 2), _SmallLabel(_afterSeriesLabel(projection)), if (timer != null) ...[ const SizedBox(height: 2), _DominantTimerLine( value: _timerText(timer), timer: timer, pending: state.timerTogglePending, onTogglePause: onTogglePause, compact: true, ), ], _MainHeartRateLine(sample: state.sensorSample), const SizedBox(height: 3), if (projection.nextExerciseName case final next?) ...[ const _SmallLabel('Ensuite'), Text( next, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ] else ...[ _ExerciseName(projection.exerciseName), ], ], ); } } final class _StatsView extends StatelessWidget { const _StatsView({required this.state, required this.onSession}); final WatchSessionUiState state; final VoidCallback onSession; @override Widget build(BuildContext context) { final sample = state.sensorSample; final metrics = [ if (_heartRateLabel(sample) case final value?) _StatsMetric(icon: Icons.favorite, label: 'FC', value: value), if (_distanceLabel(sample) case final value?) _StatsMetric( icon: Icons.directions_run, label: 'Distance', value: value, ), if (_caloriesLabel(sample) case final value?) _StatsMetric( icon: Icons.local_fire_department, label: 'Calories', value: value, ), ]; return Stack( children: [ Positioned( top: 0, left: 0, right: 36, child: Text( 'Stats', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, ), ), Positioned( top: -8, right: -8, child: IconButton( onPressed: onSession, tooltip: 'Séance', visualDensity: VisualDensity.compact, icon: const Icon(Icons.chevron_left), ), ), Positioned.fill( top: 30, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var index = 0; index < metrics.length; index += 1) ...[ metrics[index], if (index < metrics.length - 1) const SizedBox(height: 6), ], ], ), ), ], ); } } final class _StatsMetric extends StatelessWidget { const _StatsMetric({ required this.icon, required this.label, required this.value, }); final IconData icon; final String label; final String value; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), decoration: BoxDecoration( color: const Color(0xFF141824), border: Border.all(color: const Color(0xFF414754)), borderRadius: BorderRadius.circular(6), ), child: Row( children: [ Icon(icon, size: 14, color: const Color(0xFFC9A24A)), const SizedBox(width: 6), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), ), Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontSize: 14, color: const Color(0xFFC9A24A), ), ), ], ), ); } } 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; final connectionLost = state.connectionLost || !state.projection.phoneReachable; if (actions.isEmpty || connectionLost) { return _EmptyActions(connectionLost: connectionLost); } final visibleActions = actions.take(5).toList(); final buttonHeight = visibleActions.length > 3 ? 24.0 : 30.0; final buttonGap = visibleActions.length > 3 ? 4.0 : 6.0; return Stack( children: [ Positioned( top: 0, left: 0, right: 36, child: Text( 'Actions', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, ), ), Positioned( top: -8, right: -8, child: IconButton( onPressed: onSession, tooltip: 'Séance', visualDensity: VisualDensity.compact, icon: const Icon(Icons.chevron_left), ), ), Positioned.fill( top: 34, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ for ( var index = 0; index < visibleActions.length; index += 1 ) ...[ SizedBox( height: buttonHeight, width: double.infinity, child: OutlinedButton( onPressed: state.actionsEnabled ? () => onAction(visibleActions[index]) : null, style: OutlinedButton.styleFrom( minimumSize: Size.fromHeight(buttonHeight), padding: const EdgeInsets.symmetric(horizontal: 8), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), child: _ActionButtonLabel( _secondaryLabel(visibleActions[index]), ), ), ), if (index < visibleActions.length - 1) SizedBox(height: buttonGap), ], if (actions.length > visibleActions.length) Padding( padding: const EdgeInsets.only(top: 5), child: Text( '+${actions.length - visibleActions.length}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ), ], ); } } final class _ActionButtonLabel extends StatelessWidget { const _ActionButtonLabel(this.text); final String text; @override Widget build(BuildContext context) { return FittedBox( fit: BoxFit.scaleDown, child: Text(text, maxLines: 1, textAlign: TextAlign.center), ); } } final class _EmptyActions extends StatelessWidget { const _EmptyActions({required this.connectionLost}); final bool connectionLost; @override Widget build(BuildContext context) { return Center( child: Text( connectionLost ? 'Connexion perdue' : 'Aucune action', maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ); } } final class _ConfirmSheet extends StatelessWidget { const _ConfirmSheet({ required this.title, required this.message, required this.confirmLabel, }); final String title; final String message; final String confirmLabel; @override Widget build(BuildContext context) { return Dialog.fullscreen( backgroundColor: const Color(0xFF080A12), child: Padding( padding: const EdgeInsets.all(18), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( title, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), Text( message, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 14), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: _ActionButtonLabel(confirmLabel), ), const SizedBox(height: 8), OutlinedButton( onPressed: () => Navigator.of(context).pop(false), child: const _ActionButtonLabel('Annuler'), ), ], ), ), ); } } 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', }; } bool _timerButtonCommandMatches({ required WatchTimerProjection timer, required WatchPrimaryAction primaryAction, }) { return switch (timer.runState) { WatchTimerRunState.stopped => primaryAction == WatchPrimaryAction.startCurrentExercise || primaryAction == WatchPrimaryAction.startPreparedTimedStep, WatchTimerRunState.running => primaryAction == WatchPrimaryAction.pauseSession, WatchTimerRunState.paused => primaryAction == WatchPrimaryAction.resumeSession, }; } String _timerButtonTooltip(WatchTimerRunState runState) { return switch (runState) { WatchTimerRunState.stopped => 'Démarrer', WatchTimerRunState.running => 'Pause', WatchTimerRunState.paused => 'Reprendre', }; } IconData _timerButtonIcon(WatchTimerRunState runState) { return switch (runState) { WatchTimerRunState.stopped => Icons.play_arrow, WatchTimerRunState.running => Icons.pause, WatchTimerRunState.paused => Icons.play_arrow, }; } 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 _seriesValue(WatchSessionProjection projection) { if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) { return '--'; } return '${projection.seriesIndex}/${projection.seriesTotal}'; } int? _repsStepTarget(WatchSessionProjection projection) { final target = projection.stepTargetValue; if (projection.stepType != WatchStepType.reps || target == null) { return null; } return target > 0 ? target : null; } String _scoreText(double value) { if (value == value.roundToDouble()) { return value.toInt().toString(); } return value.toStringAsFixed(1); } String _exerciseProgress(WatchSessionProjection projection) { if (projection.passageIndex != null && projection.passageTotal != null) { return '${projection.passageIndex}/${projection.passageTotal}'; } final contextLine = _contextLine(projection); if (contextLine != null) { return contextLine; } if (projection.seriesIndex > 0 && projection.seriesTotal > 0) { return 'Série ${projection.seriesIndex}/${projection.seriesTotal}'; } return projection.statusLabel ?? ''; } String _sessionFooterText(WatchSessionUiState state) { if (state.commandPending) { return _primaryLabel(state); } if (state.staleProjection) { return 'Dernier état reçu'; } final projection = state.projection; if (projection.phase == WatchSessionPhase.restRunning || projection.phase == WatchSessionPhase.restPaused) { return ''; } return _exerciseProgress(projection); } String _afterSeriesLabel(WatchSessionProjection projection) { if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) { return 'Après série'; } return 'Après série ${projection.seriesIndex}/${projection.seriesTotal}'; } WatchTimerProjection? _primaryDisplayTimer(WatchSessionProjection projection) { final dominantTimer = projection.dominantTimer; if (dominantTimer != null && dominantTimer.kind != WatchTimerKind.setTimer) { return dominantTimer; } for (final timer in _visibleSecondaryTimers(projection)) { return timer; } return null; } List _visibleSecondaryTimers( WatchSessionProjection projection, { WatchTimerProjection? primaryTimer, }) { return projection.secondaryTimers .where( (timer) => timer.kind != WatchTimerKind.setTimer && timer != primaryTimer, ) .toList(growable: false); } 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)}'; } String _timerHapticKey( WatchSessionProjection projection, WatchTimerProjection timer, ) { return [ projection.deviceSessionId, projection.seriesIndex, projection.passageIndex ?? '-', projection.stepIndex ?? '-', timer.kind.name, timer.label, timer.referenceEpochMs, timer.startedAtEpochMs ?? '-', timer.targetMs ?? '-', ].join(':'); } String? _heartRateLabel(WatchSensorSample? sample) { final bpm = sample?.heartRateBpm; if (bpm == null || bpm <= 0) { return null; } return '$bpm bpm'; } String? _distanceLabel(WatchSensorSample? sample) { final meters = sample?.distanceMeters; if (meters == null || meters < 0) { return null; } if (meters >= 1000) { return '${(meters / 1000).toStringAsFixed(2)} km'; } return '${meters.round()} m'; } String? _caloriesLabel(WatchSensorSample? sample) { final calories = sample?.caloriesKcal; if (calories == null || calories < 0) { return null; } return '${calories.round()} kcal'; } 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().toUtc().millisecondsSinceEpoch; final elapsedSinceReference = (nowMs - timer.referenceEpochMs).clamp( 0, 1 << 31, ); return timer.accumulatedMs + elapsedSinceReference.toInt(); }