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

550 lines
16 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;
@override
void initState() {
super.initState();
_pageController = PageController();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() {});
}
});
}
@override
void dispose() {
_ticker?.cancel();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<WatchSessionUiState>(
valueListenable: widget.viewModel,
builder: (context, state, _) {
final projection = state.projection;
if (projection.phase == WatchSessionPhase.noActiveSession) {
return _RoundScaffold(
child: _NoSessionView(
projection: projection,
pending: state.commandPending,
onRefresh: widget.viewModel.refresh,
),
);
}
return PageView(
controller: _pageController,
children: [
_RoundScaffold(
child: _SessionMainView(
state: state,
onPrimary: widget.viewModel.sendPrimaryAction,
onRetry: widget.viewModel.refresh,
onActions: _showActions,
),
),
_RoundScaffold(
child: _ActionsView(
state: state,
onAction: _handleSecondaryAction,
onSession: _showSession,
),
),
],
);
},
);
}
void _showActions() {
_pageController.animateToPage(
1,
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) {
unawaited(widget.viewModel.sendSecondaryAction(action));
_showSession();
}
}
Future<bool> _confirm({
required String title,
required String message,
required String confirmLabel,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
},
);
return result ?? false;
}
}
final class _RoundScaffold extends StatelessWidget {
const _RoundScaffold({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
minimum: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 210, maxHeight: 210),
child: child,
),
),
),
);
}
}
final class _NoSessionView extends StatelessWidget {
const _NoSessionView({
required this.projection,
required this.pending,
required this.onRefresh,
});
final WatchSessionProjection projection;
final bool pending;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
final phoneReachable = projection.phoneReachable;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
phoneReachable ? 'Aucune séance en cours' : 'Téléphone indisponible',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 10),
Text(
phoneReachable
? 'Lance une séance sur le téléphone.'
: 'Rouvre GameTime sur le téléphone.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton(
onPressed: pending ? null : onRefresh,
child: Text(pending ? 'Envoi...' : 'Actualiser'),
),
],
);
}
}
final class _SessionMainView extends StatelessWidget {
const _SessionMainView({
required this.state,
required this.onPrimary,
required this.onRetry,
required this.onActions,
});
final WatchSessionUiState state;
final VoidCallback onPrimary;
final VoidCallback onRetry;
final VoidCallback onActions;
@override
Widget build(BuildContext context) {
final projection = state.projection;
final isRest = projection.phase == WatchSessionPhase.restRunning ||
projection.phase == WatchSessionPhase.restPaused;
if (state.connectionLost || !projection.phoneReachable) {
return _ConnectionLostView(onRetry: onRetry);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onActions,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
minimumSize: const Size(56, 26),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text('Actions'),
),
),
Expanded(
child: isRest
? _RestContent(projection: projection)
: _ActiveContent(projection: projection),
),
if (state.staleProjection)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'Dernier état reçu',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
FilledButton(
onPressed: state.actionsEnabled ? onPrimary : null,
child: Text(_primaryLabel(state)),
),
],
);
}
}
final class _ActiveContent extends StatelessWidget {
const _ActiveContent({required this.projection});
final WatchSessionProjection projection;
@override
Widget build(BuildContext context) {
final timer = projection.dominantTimer;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'SÉRIE ${projection.seriesIndex} / ${projection.seriesTotal}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 3),
Text(
projection.exerciseName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
if (_contextLine(projection) case final contextLine?)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
contextLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
if (timer == null)
Text(
projection.statusLabel ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
)
else ...[
Text(
_timerText(timer),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
projection.statusLabel ?? timer.label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
if (projection.secondaryTimers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 7),
child: Text(
projection.secondaryTimers.map(_compactTimerText).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
}
final class _RestContent extends StatelessWidget {
const _RestContent({required this.projection});
final WatchSessionProjection projection;
@override
Widget build(BuildContext context) {
final timer = projection.dominantTimer;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'REPOS',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 3),
Text(
'Après série ${projection.seriesIndex} / ${projection.seriesTotal}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
Text(
timer == null ? '--:--' : _timerText(timer),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
projection.statusLabel ?? timer?.label ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
if (projection.nextExerciseName case final next?)
Padding(
padding: const EdgeInsets.only(top: 9),
child: Column(
children: [
Text(
'Exercice suivant',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
next,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
);
}
}
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;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'Actions',
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
onPressed: onSession,
tooltip: 'Séance',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.chevron_left),
),
],
),
Expanded(
child: actions.isEmpty || !state.projection.phoneReachable ||
state.connectionLost
? Center(
child: Text(
state.projection.phoneReachable && !state.connectionLost
? 'Aucune action'
: 'Connexion perdue',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
)
: ListView.separated(
padding: const EdgeInsets.only(top: 4, bottom: 12),
itemBuilder: (context, index) {
final action = actions[index];
return OutlinedButton(
onPressed: state.actionsEnabled
? () => onAction(action)
: null,
child: Text(_secondaryLabel(action)),
);
},
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemCount: actions.length,
),
),
],
);
}
}
final class _ConnectionLostView extends StatelessWidget {
const _ConnectionLostView({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Connexion perdue',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'Dernier état reçu il y a quelques secondes',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Réessayer')),
],
);
}
}
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',
};
}
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 _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)}';
}
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().millisecondsSinceEpoch;
return timer.accumulatedMs + nowMs - timer.startedAtEpochMs!;
}