Répare la régression de packaging introduite par le reset/remerge de develop : l'APK montre était packagée avec com.gametime.app au lieu de com.gametime.watch (namespace correct mais applicationId hérité du téléphone), ce qui cassait la détection du bridge. Aligne applicationId sur com.gametime.watch, nettoie la config de signing partagée sur les deux modules android/app et watch_app/android/app, et ajuste use_cases + watch_session_screen en conséquence. Validé : APK montre release package = com.gametime.watch, même SHA-256 de certificat sur les deux APKs (70cc69e8...45c7815), tests use_cases et watch_session_screen OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1431 lines
40 KiB
Dart
1431 lines
40 KiB
Dart
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 (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(),
|
||
),
|
||
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 _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 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}';
|
||
}
|
||
|
||
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();
|
||
}
|