Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
12 KiB
Dart
317 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../application/app_bootstrap.dart';
|
|
import '../domain/domain.dart';
|
|
import 'exercise_library_screen.dart';
|
|
import 'history_screen.dart';
|
|
import 'profile_screen.dart';
|
|
import 'progression_screen.dart';
|
|
import 'program_screen.dart';
|
|
import 'theme.dart';
|
|
import 'workout_execution_screen.dart';
|
|
import 'workout_template_screen.dart';
|
|
|
|
final homeRouteObserver = RouteObserver<ModalRoute<void>>();
|
|
|
|
final class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({
|
|
required this.bootstrap,
|
|
this.themeMode = ThemeMode.system,
|
|
this.onThemeModeChanged,
|
|
super.key,
|
|
});
|
|
|
|
final AppDependencies bootstrap;
|
|
final ThemeMode themeMode;
|
|
final ValueChanged<ThemeMode>? onThemeModeChanged;
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
|
late Future<ActiveWorkoutSession?> _openSession;
|
|
ModalRoute<void>? _route;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_openSession = _findOpenSession();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final route = ModalRoute.of<void>(context);
|
|
if (route == null || identical(route, _route)) {
|
|
return;
|
|
}
|
|
final previousRoute = _route;
|
|
if (previousRoute != null) {
|
|
homeRouteObserver.unsubscribe(this);
|
|
}
|
|
_route = route;
|
|
homeRouteObserver.subscribe(this, route);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
homeRouteObserver.unsubscribe(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didPopNext() {
|
|
_reloadOpenSession();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const GameTimeLogo(),
|
|
actions: [
|
|
PopupMenuButton<ThemeMode>(
|
|
tooltip: 'Thème',
|
|
initialValue: widget.themeMode,
|
|
icon: const Icon(Icons.contrast),
|
|
onSelected: widget.onThemeModeChanged,
|
|
itemBuilder: (context) => const [
|
|
PopupMenuItem(value: ThemeMode.system, child: Text('Système')),
|
|
PopupMenuItem(value: ThemeMode.light, child: Text('Clair')),
|
|
PopupMenuItem(value: ThemeMode.dark, child: Text('Sombre')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
children: [
|
|
FutureBuilder<ActiveWorkoutSession?>(
|
|
future: _openSession,
|
|
builder: (context, snapshot) {
|
|
final session = snapshot.data;
|
|
if (session == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final plan = WorkoutExecutionPlan.tryFromSession(session);
|
|
if (plan == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final position = ExecutionPosition(
|
|
programIndex: session.currentProgramIndex,
|
|
exerciseIndex: session.currentExerciseIndex,
|
|
setIndex: session.currentSetIndex,
|
|
);
|
|
final exercise = plan.exerciseAt(position);
|
|
return CourtBlazerAccentPanel(
|
|
margin: const EdgeInsets.all(12),
|
|
child: MaterialBanner(
|
|
content: Text(
|
|
'Séance en cours — Reprendre · ${exercise.name}, '
|
|
'série ${position.setIndex + 1}/${exercise.setsCount}',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => _resume(session),
|
|
child: const Text('Reprendre'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.fitness_center),
|
|
title: const Text('Exercices'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => ExerciseLibraryScreen(
|
|
exerciseUseCases: widget.bootstrap.exerciseUseCases,
|
|
mediaUseCases: widget.bootstrap.mediaUseCases,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.list_alt),
|
|
title: const Text('Programmes'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => ProgramListScreen(
|
|
programUseCases: widget.bootstrap.programUseCases,
|
|
exerciseUseCases: widget.bootstrap.exerciseUseCases,
|
|
shareUseCases: widget.bootstrap.shareUseCases,
|
|
authUseCases: widget.bootstrap.authUseCases,
|
|
syncUseCases: widget.bootstrap.syncUseCases,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.event_note),
|
|
title: const Text('Séances'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => WorkoutTemplateListScreen(
|
|
workoutTemplateUseCases:
|
|
widget.bootstrap.workoutTemplateUseCases,
|
|
programUseCases: widget.bootstrap.programUseCases,
|
|
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
|
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
|
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
|
performanceReferenceUseCase:
|
|
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
|
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
|
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
|
mediaUseCases: widget.bootstrap.mediaUseCases,
|
|
shareUseCases: widget.bootstrap.shareUseCases,
|
|
authUseCases: widget.bootstrap.authUseCases,
|
|
syncUseCases: widget.bootstrap.syncUseCases,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.history),
|
|
title: const Text('Historique'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => HistoryListScreen(
|
|
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
|
workoutTemplateUseCases:
|
|
widget.bootstrap.workoutTemplateUseCases,
|
|
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
|
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
|
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
|
performanceReferenceUseCase:
|
|
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
|
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
|
mediaUseCases: widget.bootstrap.mediaUseCases,
|
|
onOpenProgression: () => _openProgression(context),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.show_chart),
|
|
title: const Text('Progression'),
|
|
subtitle: const Text(
|
|
'Volume, régularité et évolution par exercice',
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _openProgression(context),
|
|
),
|
|
ListTile(
|
|
leading: FutureBuilder<UserAccountSession?>(
|
|
future: widget.bootstrap.authUseCases.currentSession(),
|
|
builder: (context, snapshot) {
|
|
final session = snapshot.data;
|
|
if (session == null || !session.isLoggedIn) {
|
|
return const Icon(Icons.account_circle_outlined);
|
|
}
|
|
final displayName = session.displayName?.trim();
|
|
final label = displayName == null || displayName.isEmpty
|
|
? session.email
|
|
: displayName;
|
|
return CircleAvatar(
|
|
radius: 12,
|
|
child: Text(_profileInitials(label)),
|
|
);
|
|
},
|
|
),
|
|
title: const Text('Profil'),
|
|
subtitle: const Text('Compte, synchronisation et partages'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => ProfileScreen(
|
|
authUseCases: widget.bootstrap.authUseCases,
|
|
syncUseCases: widget.bootstrap.syncUseCases,
|
|
shareUseCases: widget.bootstrap.shareUseCases,
|
|
dataExportUseCase: widget.bootstrap.dataExportUseCase,
|
|
dataImportUseCase: widget.bootstrap.dataImportUseCase,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openProgression(BuildContext context) {
|
|
return Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => ProgressionScreen(
|
|
progressionStatsUseCase: widget.bootstrap.progressionStatsUseCase,
|
|
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
|
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
|
|
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
|
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
|
performanceReferenceUseCase:
|
|
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
|
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
|
mediaUseCases: widget.bootstrap.mediaUseCases,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _resume(ActiveWorkoutSession session) async {
|
|
try {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => WorkoutExecutionScreen(
|
|
initialSession: session,
|
|
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
|
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
|
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
|
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
|
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
|
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
|
|
performanceReferenceUseCase:
|
|
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
|
mediaUseCases: widget.bootstrap.mediaUseCases,
|
|
),
|
|
),
|
|
);
|
|
} on DomainException {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
_reloadOpenSession();
|
|
}
|
|
|
|
Future<ActiveWorkoutSession?> _findOpenSession() {
|
|
return widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
|
|
}
|
|
|
|
void _reloadOpenSession() {
|
|
setState(() {
|
|
_openSession = _findOpenSession();
|
|
});
|
|
}
|
|
}
|
|
|
|
String _profileInitials(String value) {
|
|
final trimmed = value.trim();
|
|
if (trimmed.isEmpty) return '?';
|
|
final words = trimmed.split(RegExp(r'\s+'));
|
|
if (words.length == 1) {
|
|
return words.first.substring(0, 1).toUpperCase();
|
|
}
|
|
return '${words.first.substring(0, 1)}${words.last.substring(0, 1)}'
|
|
.toUpperCase();
|
|
}
|