Files
GameTime/watch_app/lib/presentation/watch_session_screen.dart

1755 lines
50 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<WatchSessionScreen> createState() => _WatchSessionScreenState();
}
final class _WatchSessionScreenState extends State<WatchSessionScreen> {
late final PageController _pageController;
Timer? _ticker;
Timer? _failureNoticeTimer;
String? _failureNoticeMessage;
var _lastFailureNoticeSerial = 0;
var _returnToSessionAfterSecondarySuccess = false;
var _secondaryActionStartFailureSerial = 0;
final _timerRemainingMsByKey = <String, int>{};
final _completionHapticTimerKeys = <String>{};
@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<WatchSessionUiState>(
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 = <Widget>[
_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,
physics: const _WatchPageScrollPhysics(),
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<void> _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;
}
_triggerTimerCompletionHaptic();
}
void _triggerTimerCompletionHaptic() {
unawaited(HapticFeedback.heavyImpact());
unawaited(
Future<void>.delayed(const Duration(milliseconds: 140), () {
return HapticFeedback.heavyImpact();
}),
);
unawaited(
Future<void>.delayed(const Duration(milliseconds: 320), () {
return HapticFeedback.heavyImpact();
}),
);
}
Future<bool> _confirm({
required String title,
required String message,
required String confirmLabel,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return _ConfirmSheet(
title: title,
message: message,
confirmLabel: confirmLabel,
);
},
);
return result ?? false;
}
}
final class _WatchPageScrollPhysics extends PageScrollPhysics {
const _WatchPageScrollPhysics({super.parent});
@override
_WatchPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _WatchPageScrollPhysics(parent: buildParent(ancestor));
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
(velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
return super.createBallisticSimulation(position, velocity);
}
final viewport = position is PageMetrics
? position.viewportDimension * position.viewportFraction
: position.viewportDimension;
if (viewport <= 0) {
return null;
}
final target = (position.pixels / viewport).roundToDouble() * viewport;
final clampedTarget = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if (clampedTarget == position.pixels) {
return null;
}
return ScrollSpringSimulation(
spring,
position.pixels,
clampedTarget,
velocity,
tolerance: toleranceFor(position),
);
}
}
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;
final captionSegments = [
if (projection.manualScoreRepsTargetValue != null)
'Répétitions : ${projection.manualScoreRepsTargetValue}',
if (target != null && targetLabel != null && targetLabel.isNotEmpty)
'$targetLabel : ${_scoreText(target)}',
];
return _ScaledContent(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ExerciseName(projection.exerciseName),
_StepNameBand(projection.stepName),
if (captionSegments.isNotEmpty) ...[
Text(
captionSegments.join(' · '),
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<WatchSecondaryAction> 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 lexercice',
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<WatchTimerProjection> _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();
}