1755 lines
50 KiB
Dart
1755 lines
50 KiB
Dart
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 l’exercice',
|
||
WatchPrimaryAction.pauseSession => 'Pause',
|
||
WatchPrimaryAction.resumeSession => 'Reprendre',
|
||
WatchPrimaryAction.startPreparedTimedStep => 'Démarrer le chrono',
|
||
WatchPrimaryAction.skipCurrentRest => 'Passer le repos',
|
||
};
|
||
}
|
||
|
||
String _secondaryLabel(WatchSecondaryAction action) {
|
||
return switch (action) {
|
||
WatchSecondaryAction.skipCurrentStep => 'Passer l’étape',
|
||
WatchSecondaryAction.skipCurrentPassage => 'Passer le passage',
|
||
WatchSecondaryAction.finishCurrentSet => 'Terminer la série',
|
||
WatchSecondaryAction.skipCurrentSet => 'Passer la série',
|
||
WatchSecondaryAction.skipCurrentRest => 'Passer le repos',
|
||
};
|
||
}
|
||
|
||
bool _timerButtonCommandMatches({
|
||
required WatchTimerProjection timer,
|
||
required WatchPrimaryAction primaryAction,
|
||
}) {
|
||
return switch (timer.runState) {
|
||
WatchTimerRunState.stopped =>
|
||
primaryAction == WatchPrimaryAction.startCurrentExercise ||
|
||
primaryAction == WatchPrimaryAction.startPreparedTimedStep,
|
||
WatchTimerRunState.running =>
|
||
primaryAction == WatchPrimaryAction.pauseSession,
|
||
WatchTimerRunState.paused =>
|
||
primaryAction == WatchPrimaryAction.resumeSession,
|
||
};
|
||
}
|
||
|
||
String _timerButtonTooltip(WatchTimerRunState runState) {
|
||
return switch (runState) {
|
||
WatchTimerRunState.stopped => 'Démarrer',
|
||
WatchTimerRunState.running => 'Pause',
|
||
WatchTimerRunState.paused => 'Reprendre',
|
||
};
|
||
}
|
||
|
||
IconData _timerButtonIcon(WatchTimerRunState runState) {
|
||
return switch (runState) {
|
||
WatchTimerRunState.stopped => Icons.play_arrow,
|
||
WatchTimerRunState.running => Icons.pause,
|
||
WatchTimerRunState.paused => Icons.play_arrow,
|
||
};
|
||
}
|
||
|
||
String? _contextLine(WatchSessionProjection projection) {
|
||
final parts = [
|
||
if (projection.passageIndex != null && projection.passageTotal != null)
|
||
'Passage ${projection.passageIndex} / ${projection.passageTotal}',
|
||
if (projection.stepIndex != null && projection.stepTotal != null)
|
||
'Étape ${projection.stepIndex} / ${projection.stepTotal}',
|
||
];
|
||
if (parts.isEmpty) {
|
||
return null;
|
||
}
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
String _seriesValue(WatchSessionProjection projection) {
|
||
if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) {
|
||
return '--';
|
||
}
|
||
return '${projection.seriesIndex}/${projection.seriesTotal}';
|
||
}
|
||
|
||
int? _repsStepTarget(WatchSessionProjection projection) {
|
||
final target = projection.stepTargetValue;
|
||
if (projection.stepType != WatchStepType.reps || target == null) {
|
||
return null;
|
||
}
|
||
return target > 0 ? target : null;
|
||
}
|
||
|
||
String _scoreText(double value) {
|
||
if (value == value.roundToDouble()) {
|
||
return value.toInt().toString();
|
||
}
|
||
return value.toStringAsFixed(1);
|
||
}
|
||
|
||
String _exerciseProgress(WatchSessionProjection projection) {
|
||
if (projection.passageIndex != null && projection.passageTotal != null) {
|
||
return '${projection.passageIndex}/${projection.passageTotal}';
|
||
}
|
||
final contextLine = _contextLine(projection);
|
||
if (contextLine != null) {
|
||
return contextLine;
|
||
}
|
||
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
|
||
return 'Série ${projection.seriesIndex}/${projection.seriesTotal}';
|
||
}
|
||
return projection.statusLabel ?? '';
|
||
}
|
||
|
||
String _sessionFooterText(WatchSessionUiState state) {
|
||
if (state.commandPending) {
|
||
return _primaryLabel(state);
|
||
}
|
||
if (state.staleProjection) {
|
||
return 'Dernier état reçu';
|
||
}
|
||
final projection = state.projection;
|
||
if (projection.phase == WatchSessionPhase.restRunning ||
|
||
projection.phase == WatchSessionPhase.restPaused) {
|
||
return '';
|
||
}
|
||
return _exerciseProgress(projection);
|
||
}
|
||
|
||
String _afterSeriesLabel(WatchSessionProjection projection) {
|
||
if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) {
|
||
return 'Après série';
|
||
}
|
||
return 'Après série ${projection.seriesIndex}/${projection.seriesTotal}';
|
||
}
|
||
|
||
WatchTimerProjection? _primaryDisplayTimer(WatchSessionProjection projection) {
|
||
final dominantTimer = projection.dominantTimer;
|
||
if (dominantTimer != null && dominantTimer.kind != WatchTimerKind.setTimer) {
|
||
return dominantTimer;
|
||
}
|
||
for (final timer in _visibleSecondaryTimers(projection)) {
|
||
return timer;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
List<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();
|
||
}
|