Files
GameTime/watch_app/lib/presentation/watch_session_screen.dart
Blomios 65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00

1466 lines
42 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: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;
@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;
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,
),
),
_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<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();
}
});
}
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 _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,
});
final WatchSessionUiState state;
final VoidCallback onActions;
final VoidCallback? onStats;
final VoidCallback onTogglePause;
final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore;
@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 = projection.dominantTimer;
final showsTimer = timer != null;
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,
),
),
),
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,
),
),
),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Text(
state.commandPending
? _primaryLabel(state)
: state.staleProjection
? 'Dernier état reçu'
: _exerciseProgress(projection),
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 _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,
});
final WatchSessionUiState state;
final VoidCallback? onTogglePause;
final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore;
@override
Widget build(BuildContext context) {
final projection = state.projection;
if (projection.hasManualScore) {
return _ManualScoreContent(
state: state,
onTogglePause: onTogglePause,
onIncrement: onIncrementScore,
onDecrement: onDecrementScore,
);
}
final timer = projection.dominantTimer;
final dominantValue = timer == null
? _seriesValue(projection)
: _timerText(timer);
final dominantLabel = timer == null ? 'SÉRIE' : timer.label;
return _ScaledContent(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ExerciseName(projection.exerciseName),
_StepNameBand(projection.stepName),
const SizedBox(height: 2),
_SmallLabel(dominantLabel),
const SizedBox(height: 2),
_DominantValue(dominantValue),
if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[
const SizedBox(height: 1),
_LiveHeartRateLine(heartRate),
],
if (timer != null) ...[
const SizedBox(height: 3),
_TimerToggleButton(
runState: timer.runState,
pending: state.timerTogglePending,
onPressed: onTogglePause,
),
],
const SizedBox(height: 5),
_StatusLine(projection.statusLabel ?? timer?.label),
if (projection.secondaryTimers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
projection.secondaryTimers.map(_compactTimerText).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
}
}
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 = projection.dominantTimer;
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()
: const SizedBox.shrink(),
),
if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[
const SizedBox(height: 1),
_LiveHeartRateLine(heartRate),
],
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: 28,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
_compactTimerText(timer),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (onTogglePause != null || pending) ...[
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 _LiveHeartRateLine extends StatelessWidget {
const _LiveHeartRateLine(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.favorite, size: 11, color: Color(0xFFD72638)),
const SizedBox(width: 3),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFFA7ADBA),
fontSize: 10,
),
),
],
);
}
}
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 = projection.dominantTimer;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _SmallLabel('REPOS'),
const SizedBox(height: 2),
_DominantValue(timer == null ? '--:--' : _timerText(timer)),
if (timer != null) ...[
const SizedBox(height: 3),
_TimerToggleButton(
runState: timer.runState,
pending: state.timerTogglePending,
onPressed: onTogglePause,
),
],
const SizedBox(height: 6),
if (projection.nextExerciseName case final next?) ...[
Text(
'Ensuite : $next',
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 5),
] else ...[
_ExerciseName(projection.exerciseName),
const SizedBox(height: 5),
],
_StatusLine(
projection.statusLabel ??
'Série ${projection.seriesIndex}/${projection.seriesTotal}',
),
],
);
}
}
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}';
}
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 _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? _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();
}