merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre

Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
This commit is contained in:
2026-07-28 11:35:22 +02:00
80 changed files with 12296 additions and 891 deletions

View File

@ -1,6 +1,7 @@
import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart';
import '../infrastructure/session_notification/session_notification.dart';
import '../infrastructure/watch_bridge/watch_bridge.dart';
import 'application.dart';
@ -12,6 +13,7 @@ abstract interface class AppDependencies {
WorkoutTemplateUseCases get workoutTemplateUseCases;
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase;
@ -32,9 +34,11 @@ final class AppBootstrap implements AppDependencies {
required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases,
required this.activeWorkoutSensorUseCases,
required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler,
required this.watchWearDataLayerAdapter,
required this.sessionNotificationCoordinator,
required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases,
required this.progressionStatsUseCase,
@ -61,9 +65,12 @@ final class AppBootstrap implements AppDependencies {
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
final SessionNotificationCoordinator sessionNotificationCoordinator;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override
@ -122,6 +129,10 @@ final class AppBootstrap implements AppDependencies {
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
activeSessionUseCases: activeWorkoutSessionUseCases,
);
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: clock,
);
final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: activeSessionRepository,
@ -135,12 +146,23 @@ final class AppBootstrap implements AppDependencies {
stepUseCases: activeExerciseStepUseCases,
projectionSource: watchCompanionProjectionUseCases,
);
final workoutHistoryUseCases = WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
);
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
commandIngress: watchCompanionCommandHandler,
projectionSource: watchCompanionProjectionUseCases,
workoutHistoryUseCases: workoutHistoryUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
);
final sessionNotificationCoordinator = SessionNotificationCoordinator(
projections: watchCompanionProjectionUseCases.projections,
gateway: const MethodChannelSessionNotificationGateway(),
);
await watchWearDataLayerAdapter.start();
sessionNotificationCoordinator.start();
await SeedStarterContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
@ -198,9 +220,11 @@ final class AppBootstrap implements AppDependencies {
),
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler,
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
sessionNotificationCoordinator: sessionNotificationCoordinator,
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository,
historyRepository: historyRepository,
@ -208,10 +232,7 @@ final class AppBootstrap implements AppDependencies {
ids: ids,
originDeviceId: originDeviceId,
),
workoutHistoryUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
workoutHistoryUseCases: workoutHistoryUseCases,
progressionStatsUseCase: ProgressionStatsUseCase(
repository: progressionStatsRepository,
clock: clock,
@ -255,8 +276,10 @@ final class AppBootstrap implements AppDependencies {
}
Future<void> dispose() async {
await sessionNotificationCoordinator.dispose();
await watchWearDataLayerAdapter.stop();
await watchCompanionProjectionUseCases.dispose();
await activeWorkoutSensorUseCases.dispose();
await database.close();
}
}

View File

@ -5,6 +5,7 @@
library;
export 'ports.dart';
export 'session_notification_use_cases.dart';
export 'starter_content/basket_starter_seed_v1.dart';
export 'starter_content/starter_content.dart';
export 'use_cases.dart';

View File

@ -485,6 +485,7 @@ enum RemoteAuthFailure {
invalidCredentials,
emailAlreadyUsed,
network,
server,
unknown,
}
@ -857,6 +858,7 @@ abstract interface class ActiveSessionRepository {
Future<void> saveSetTimerState(ActiveSetTimerState state);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveManualScoreState(ActiveManualScoreState state);
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
);
@ -868,6 +870,13 @@ abstract interface class ActiveSessionRepository {
required int setIndex,
required DateTime deletedAt,
});
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
});
Future<ActiveRestState?> findRestStateById(String id);
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
@ -875,6 +884,12 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex,
required int setIndex,
});
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
@ -899,12 +914,19 @@ abstract interface class ActiveSessionRepository {
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
);
Future<List<ActiveManualScoreState>> listManualScoreStates(String sessionId);
}
abstract interface class WorkoutHistoryRepository {
Future<WorkoutHistory?> findById(String id);
Future<List<WorkoutHistory>> listActive();
Future<void> save(WorkoutHistory history);
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
});
Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> saveStepResult(WorkoutHistoryStepResult result);
Future<void> delete(String id, DateTime deletedAt);

View File

@ -0,0 +1,198 @@
import 'dart:async';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
abstract interface class SessionNotificationGateway {
Future<void> show(SessionNotificationContent content);
Future<void> clear();
}
final class SessionNotificationContent {
const SessionNotificationContent({
required this.title,
required this.primaryLine,
this.secondaryLine,
});
final String title;
final String primaryLine;
final String? secondaryLine;
Map<String, Object?> toJson() {
return {
'title': title,
'primaryLine': primaryLine,
'secondaryLine': secondaryLine,
};
}
}
final class SessionNotificationCoordinator {
SessionNotificationCoordinator({
required Stream<WatchSessionProjection> projections,
required SessionNotificationGateway gateway,
Duration tickInterval = const Duration(seconds: 1),
}) : _projections = projections,
_gateway = gateway,
_tickInterval = tickInterval;
final Stream<WatchSessionProjection> _projections;
final SessionNotificationGateway _gateway;
final Duration _tickInterval;
StreamSubscription<WatchSessionProjection>? _subscription;
Timer? _timer;
WatchSessionProjection? _latestProjection;
void start() {
if (_subscription != null) {
return;
}
_subscription = _projections.listen(_handleProjection);
}
Future<void> dispose() async {
_timer?.cancel();
_timer = null;
await _subscription?.cancel();
_subscription = null;
}
void _handleProjection(WatchSessionProjection projection) {
_latestProjection = projection;
if (projection.phase == WatchSessionPhase.noActiveSession ||
projection.deviceSessionId.isEmpty) {
_timer?.cancel();
_timer = null;
unawaited(_gateway.clear().catchError((_) {}));
return;
}
_show(projection);
if (_timerShouldRun(projection)) {
_timer ??= Timer.periodic(_tickInterval, (_) {
final latest = _latestProjection;
if (latest != null) {
_show(latest);
}
});
} else {
_timer?.cancel();
_timer = null;
}
}
void _show(WatchSessionProjection projection) {
unawaited(
_gateway
.show(buildSessionNotificationContent(projection))
.catchError((_) {}),
);
}
}
SessionNotificationContent buildSessionNotificationContent(
WatchSessionProjection projection, {
DateTime? now,
}) {
final phase = projection.phase;
final paused =
phase == WatchSessionPhase.paused ||
phase == WatchSessionPhase.restPaused;
final timer = projection.dominantTimer;
final title =
phase == WatchSessionPhase.restRunning ||
phase == WatchSessionPhase.restPaused
? 'Repos'
: projection.exerciseName.isEmpty
? 'Séance en cours'
: projection.exerciseName;
final primary = switch (phase) {
WatchSessionPhase.restRunning ||
WatchSessionPhase.restPaused => _restLine(projection, now: now),
_ when timer != null => _timerText(timer, now: now),
_ => _measureLine(projection),
};
return SessionNotificationContent(
title: title,
primaryLine: paused ? 'En pause · $primary' : primary,
secondaryLine: _secondaryLine(projection),
);
}
bool _timerShouldRun(WatchSessionProjection projection) {
final timer = projection.dominantTimer;
return timer != null && timer.runState == WatchTimerRunState.running;
}
String _restLine(WatchSessionProjection projection, {DateTime? now}) {
final timer = projection.dominantTimer;
final value = timer == null ? '--:--' : _timerText(timer, now: now);
final next = projection.nextExerciseName;
if (next == null || next.isEmpty) {
return '$value restant';
}
return '$value restant · Ensuite : $next';
}
String _measureLine(WatchSessionProjection projection) {
final score = projection.currentManualScoreValue;
if (projection.hasManualScore && score != null) {
return 'Série ${projection.seriesIndex}/${projection.seriesTotal} · ${_scoreText(score)}';
}
if (projection.stepName case final stepName? when stepName.isNotEmpty) {
return stepName;
}
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
return 'Série ${projection.seriesIndex}/${projection.seriesTotal}';
}
return projection.statusLabel ?? 'Séance en cours';
}
String? _secondaryLine(WatchSessionProjection projection) {
final parts = <String>[];
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
parts.add('Série ${projection.seriesIndex}/${projection.seriesTotal}');
}
if (projection.stepIndex != null && projection.stepTotal != null) {
parts.add('Étape ${projection.stepIndex}/${projection.stepTotal}');
}
return parts.isEmpty ? null : parts.join(' · ');
}
String _timerText(WatchTimerProjection timer, {DateTime? now}) {
final duration = _displayDuration(timer, now: now);
final totalSeconds = duration.inSeconds;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
Duration _displayDuration(WatchTimerProjection timer, {DateTime? now}) {
final elapsed = _elapsedMs(timer, now: now);
if (timer.displayMode == WatchTimerDisplayMode.countdown &&
timer.targetMs != null) {
return Duration(
milliseconds: (timer.targetMs! - elapsed).clamp(0, 1 << 31).toInt(),
);
}
return Duration(milliseconds: elapsed);
}
int _elapsedMs(WatchTimerProjection timer, {DateTime? now}) {
if (timer.runState != WatchTimerRunState.running ||
timer.startedAtEpochMs == null) {
return timer.accumulatedMs;
}
final reference =
now?.toUtc().millisecondsSinceEpoch ??
DateTime.now().toUtc().millisecondsSinceEpoch;
return timer.accumulatedMs +
(reference - timer.startedAtEpochMs!).clamp(0, 1 << 31).toInt();
}
String _scoreText(double value) {
if (value == value.roundToDouble()) {
return value.toInt().toString();
}
return value.toStringAsFixed(1);
}

File diff suppressed because it is too large Load Diff

View File

@ -381,6 +381,11 @@ final class Exercise {
targetScore: defaultTargetScore,
targetScoreTimeMs: defaultTargetScoreTimeMs,
);
_requireLinkedStepSeriesScoreShape(
steps: steps,
scoreEnabled: hasScoreMeasure,
scoreInputMode: scoreInputMode,
);
}
final EntityMetadata metadata;
@ -514,6 +519,7 @@ final class ExerciseStep {
this.scoreUnit,
this.defaultTargetScore,
this.defaultTargetScoreTimeMs,
this.linkedToSeriesScore = false,
}) : id = _nonBlank(id, 'Exercise step id'),
name = _nonBlank(name, 'Exercise step name') {
_requireNonNegative(position, 'Exercise step position');
@ -525,6 +531,7 @@ final class ExerciseStep {
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}
@ -539,6 +546,7 @@ final class ExerciseStep {
final String? scoreUnit;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
final bool linkedToSeriesScore;
Map<String, Object?> toSnapshotJson() => {
'id': id,
@ -552,6 +560,7 @@ final class ExerciseStep {
'scoreUnit': scoreUnit,
'defaultTargetScore': defaultTargetScore,
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
'linkedToSeriesScore': linkedToSeriesScore,
};
}
@ -664,6 +673,11 @@ final class ProgramExercise {
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
);
_requireLinkedStepSeriesScoreShape(
steps: exerciseStepsSnapshot,
scoreEnabled: scoreEnabled,
scoreInputMode: scoreInputModeSnapshot,
);
}
final EntityMetadata metadata;
@ -1106,6 +1120,47 @@ final class ActiveScoreStopwatchState {
}
}
final class ActiveManualScoreState {
ActiveManualScoreState({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.value,
required this.updatedAt,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNullableNonNegativeDouble(value, 'Manual score value');
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final double value;
final DateTime updatedAt;
ActiveManualScoreState copyWith({
EntityMetadata? metadata,
double? value,
DateTime? updatedAt,
}) {
return ActiveManualScoreState(
metadata: metadata ?? this.metadata,
activeWorkoutSessionId: activeWorkoutSessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
final class ActiveSetTimerState {
ActiveSetTimerState({
required this.metadata,
@ -1411,7 +1466,7 @@ final class ActiveExerciseStepResult {
}
final class WorkoutHistory {
const WorkoutHistory({
WorkoutHistory({
required this.metadata,
this.sourceWorkoutTemplateId,
this.sourceActiveWorkoutSessionId,
@ -1421,9 +1476,17 @@ final class WorkoutHistory {
required this.totalActiveMs,
required this.completed,
required this.historySnapshotJson,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.results = const [],
this.stepResults = const [],
});
}) {
_requireNullablePositiveDouble(
averageHeartRateBpm,
'Average heart rate bpm',
);
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
}
final EntityMetadata metadata;
final String? sourceWorkoutTemplateId;
@ -1434,8 +1497,36 @@ final class WorkoutHistory {
final int totalActiveMs;
final bool completed;
final String historySnapshotJson;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final List<WorkoutHistorySetResult> results;
final List<WorkoutHistoryStepResult> stepResults;
WorkoutHistory copyWith({
EntityMetadata? metadata,
Object? averageHeartRateBpm = _unchanged,
Object? maxHeartRateBpm = _unchanged,
}) {
return WorkoutHistory(
metadata: metadata ?? this.metadata,
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
sourceActiveWorkoutSessionId: sourceActiveWorkoutSessionId,
nameSnapshot: nameSnapshot,
startedAt: startedAt,
endedAt: endedAt,
totalActiveMs: totalActiveMs,
completed: completed,
historySnapshotJson: historySnapshotJson,
averageHeartRateBpm: averageHeartRateBpm == _unchanged
? this.averageHeartRateBpm
: averageHeartRateBpm as double?,
maxHeartRateBpm: maxHeartRateBpm == _unchanged
? this.maxHeartRateBpm
: maxHeartRateBpm as int?,
results: results,
stepResults: stepResults,
);
}
}
final class WorkoutHistorySetResult {
@ -1727,6 +1818,12 @@ void _requireNullableNonNegativeDouble(double? value, String label) {
}
}
void _requireNullablePositiveDouble(double? value, String label) {
if (value != null && value <= 0) {
throw DomainException('$label must be positive.');
}
}
void _requireScoreTargetShape({
required bool scoreEnabled,
required ScoreInputMode scoreInputMode,
@ -1759,12 +1856,14 @@ void _validateExerciseStepScoreShape({
required String? scoreUnit,
required double? defaultTargetScore,
required int? defaultTargetScoreTimeMs,
required bool linkedToSeriesScore,
}) {
if (!hasScore) {
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null ||
defaultTargetScoreTimeMs != null) {
defaultTargetScoreTimeMs != null ||
linkedToSeriesScore) {
throw const DomainException(
'Disabled exercise step score must not define score values.',
);
@ -1790,6 +1889,11 @@ void _validateExerciseStepScoreShape({
);
}
case ScoreInputMode.stopwatch:
if (linkedToSeriesScore) {
throw const DomainException(
'Linked series score requires manual exercise step score.',
);
}
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null) {
@ -1826,6 +1930,19 @@ void _requireScoreResultShape({
}
}
void _requireLinkedStepSeriesScoreShape({
required List<ExerciseStep> steps,
required bool scoreEnabled,
required ScoreInputMode scoreInputMode,
}) {
if (steps.any((step) => step.linkedToSeriesScore) &&
(!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) {
throw const DomainException(
'Linked step scores require manual series score.',
);
}
}
void _validateExerciseStepResult({
required int programIndex,
required int exerciseIndex,

View File

@ -10,6 +10,7 @@ part 'app_database.g.dart';
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveRestStates,
ActiveManualScoreStates,
ActiveScoreStopwatchStates,
ActiveSetTimerStates,
ActiveSetResults,
@ -48,7 +49,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 19;
int get schemaVersion => 22;
@override
MigrationStrategy get migration {
@ -119,6 +120,15 @@ final class AppDatabase extends _$AppDatabase {
if (from < 19) {
await _migrateToSchema19();
}
if (from < 20) {
await _migrateToSchema20(migrator);
}
if (from < 21) {
await _migrateToSchema21();
}
if (from < 22) {
await _migrateToSchema22();
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -199,6 +209,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
'ON active_set_results (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_manual_score_states_session_id '
'ON active_manual_score_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
'ON active_score_stopwatch_states (active_workout_session_id)',
@ -288,6 +302,7 @@ final class AppDatabase extends _$AppDatabase {
const _syncableTableNames = [
'active_exercise_step_progress_states',
'active_exercise_step_results',
'active_manual_score_states',
'active_rest_states',
'active_score_stopwatch_states',
'active_set_timer_states',
@ -770,6 +785,37 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
);
}
Future<void> _migrateToSchema20(Migrator migrator) async {
await migrator.createTable(activeManualScoreStates);
}
Future<void> _migrateToSchema21() async {
await _addColumnIfMissing(
tableName: 'workout_history',
columnName: 'average_heart_rate_bpm',
definition:
'average_heart_rate_bpm REAL CHECK '
'(average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
);
await _addColumnIfMissing(
tableName: 'workout_history',
columnName: 'max_heart_rate_bpm',
definition:
'max_heart_rate_bpm INTEGER CHECK '
'(max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
);
}
Future<void> _migrateToSchema22() async {
await _addColumnIfMissing(
tableName: 'exercise_steps',
columnName: 'linked_to_series_score',
definition:
'linked_to_series_score INTEGER NOT NULL DEFAULT 0 '
'CHECK (linked_to_series_score IN (0, 1))',
);
}
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
await customStatement(r'''
UPDATE workout_history_set_results AS result

File diff suppressed because it is too large Load Diff

View File

@ -1192,6 +1192,19 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_manual_score_states',
entityType: 'ActiveManualScoreState',
metadata: state.metadata,
write: () => database
.into(database.activeManualScoreStates)
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
);
}
@override
Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state,
@ -1260,6 +1273,42 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.delete(
database.activeManualScoreStates,
)..where((table) => table.id.equals(row.id))).go();
await _writeChangeLog(
database: database,
entityType: 'ActiveManualScoreState',
entityId: row.id,
operation: 'delete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
@override
Future<domain.ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
@ -1280,6 +1329,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
}
@override
Future<domain.ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
return row == null ? null : _activeManualScoreStateFromRow(row);
}
@override
Future<domain.ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -1432,6 +1501,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
.get();
return rows.map(_activeScoreStopwatchStateFromRow).toList();
}
@override
Future<List<domain.ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
final rows =
await (database.select(database.activeManualScoreStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeManualScoreStateFromRow).toList();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
@ -1522,6 +1611,51 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
});
}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
return;
}
final row =
await (database.select(database.workoutHistories)..where(
(table) =>
table.id.equals(historyId) &
table.deletedAt.isNull() &
table.averageHeartRateBpm.isNull() &
table.maxHeartRateBpm.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.update(
database.workoutHistories,
)..where((table) => table.id.equals(historyId))).write(
db.WorkoutHistoriesCompanion(
updatedAt: Value(patchedAt.toUtc()),
syncState: const Value('dirty'),
localRevision: Value(revision),
averageHeartRateBpm: Value(averageHeartRateBpm),
maxHeartRateBpm: Value(maxHeartRateBpm),
),
);
await _writeChangeLog(
database: database,
entityType: 'WorkoutHistory',
entityId: historyId,
operation: 'update',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: patchedAt,
);
}
@override
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
await _upsertWithChangeLog(
@ -2777,6 +2911,7 @@ Future<void> _replaceExerciseSteps(
scoreUnit: Value(step.scoreUnit),
defaultTargetScore: Value(step.defaultTargetScore),
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
linkedToSeriesScore: Value(step.linkedToSeriesScore),
),
),
);
@ -3252,6 +3387,7 @@ domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
scoreUnit: row.scoreUnit,
defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
linkedToSeriesScore: row.linkedToSeriesScore,
);
}
@ -4013,6 +4149,45 @@ db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
);
}
db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion(
domain.ActiveManualScoreState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveManualScoreStatesCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
programIndex: Value(state.programIndex),
exerciseIndex: Value(state.exerciseIndex),
setIndex: Value(state.setIndex),
value: Value(state.value),
scoreUpdatedAt: Value(state.updatedAt.toUtc()),
);
}
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
db.ActiveManualScoreState row,
) {
return domain.ActiveManualScoreState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
value: row.value,
updatedAt: _utc(row.scoreUpdatedAt),
);
}
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
db.ActiveScoreStopwatchState row,
) {
@ -4188,6 +4363,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
totalActiveMs: Value(history.totalActiveMs),
completed: Value(history.completed),
historySnapshotJson: Value(history.historySnapshotJson),
averageHeartRateBpm: Value(history.averageHeartRateBpm),
maxHeartRateBpm: Value(history.maxHeartRateBpm),
);
}
@ -4303,6 +4480,8 @@ domain.WorkoutHistory _workoutHistoryFromRow(
totalActiveMs: row.totalActiveMs,
completed: row.completed,
historySnapshotJson: row.historySnapshotJson,
averageHeartRateBpm: row.averageHeartRateBpm,
maxHeartRateBpm: row.maxHeartRateBpm,
results: results,
stepResults: stepResults,
);
@ -4560,6 +4739,8 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'totalActiveMs': history.totalActiveMs,
'completed': history.completed,
'historySnapshotJson': history.historySnapshotJson,
'averageHeartRateBpm': history.averageHeartRateBpm,
'maxHeartRateBpm': history.maxHeartRateBpm,
};
Map<String, Object?> _localWorkoutHistoryPayload(
@ -4714,6 +4895,8 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
completed: payload['completed'] as bool? ?? false,
historySnapshotJson:
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'],
@ -5142,6 +5325,7 @@ List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
json,
'defaultTargetScoreTimeMs',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);
@ -5487,6 +5671,7 @@ List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
json,
'defaultTargetScoreTimeMs',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);

View File

@ -137,7 +137,7 @@ class PendingShareActions extends Table {
@override
List<String> get customConstraints => [
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
"CHECK (resource_type IS NULL OR resource_type IN "
'CHECK (resource_type IS NULL OR resource_type IN '
"('program', 'workoutTemplate'))",
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
];
@ -250,6 +250,8 @@ class ExerciseSteps extends SyncableTable {
TextColumn get scoreUnit => text().nullable()();
RealColumn get defaultTargetScore => real().nullable()();
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
BoolColumn get linkedToSeriesScore =>
boolean().withDefault(const Constant(false))();
@override
List<String> get customConstraints => [
@ -276,6 +278,8 @@ class ExerciseSteps extends SyncableTable {
'AND default_target_score IS NULL))',
'CHECK (default_target_score IS NULL OR '
'default_target_score_time_ms IS NULL)',
'CHECK (NOT linked_to_series_score OR '
"(has_score AND score_input_mode = 'manual'))",
];
}
@ -548,6 +552,29 @@ class ActiveScoreStopwatchStates extends SyncableTable {
];
}
class ActiveManualScoreStates extends SyncableTable {
@override
String get tableName => 'active_manual_score_states';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
RealColumn get value => real()();
DateTimeColumn get scoreUpdatedAt => dateTime()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (value >= 0)',
];
}
class ActiveSetTimerStates extends SyncableTable {
@override
String get tableName => 'active_set_timer_states';
@ -723,9 +750,15 @@ class WorkoutHistories extends SyncableTable {
IntColumn get totalActiveMs => integer()();
BoolColumn get completed => boolean()();
TextColumn get historySnapshotJson => text().withLength(min: 1)();
RealColumn get averageHeartRateBpm => real().nullable()();
IntColumn get maxHeartRateBpm => integer().nullable()();
@override
List<String> get customConstraints => ['CHECK (total_active_ms >= 0)'];
List<String> get customConstraints => [
'CHECK (total_active_ms >= 0)',
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
];
}
class WorkoutHistorySetResults extends SyncableTable {

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@ -12,11 +13,28 @@ final class HttpApiClient {
this.timeout = const Duration(seconds: 10),
}) : client = client ?? http.Client();
static const defaultBaseUrl = String.fromEnvironment(
static const _configuredBaseUrl = String.fromEnvironment(
'GAMETIME_API_BASE_URL',
defaultValue: 'http://localhost:8080',
defaultValue: '',
);
static String get defaultBaseUrl =>
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
static String defaultBaseUrlFor({
required bool isAndroid,
String configuredBaseUrl = _configuredBaseUrl,
}) {
final configured = configuredBaseUrl.trim();
if (configured.isNotEmpty) {
return configured;
}
if (isAndroid) {
return 'http://10.0.2.2:8080';
}
return 'http://localhost:8080';
}
final Uri baseUrl;
final http.Client client;
final Duration timeout;
@ -142,7 +160,7 @@ final class HttpApiClient {
return switch (statusCode) {
401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message),
409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message),
>= 500 => RemoteAuthException(RemoteAuthFailure.network, message),
>= 500 => RemoteAuthException(RemoteAuthFailure.server, message),
_ => RemoteAuthException(RemoteAuthFailure.unknown, message),
};
}

View File

@ -0,0 +1 @@
export 'session_notification_gateway.dart';

View File

@ -0,0 +1,33 @@
import 'package:flutter/services.dart';
import '../../application/application.dart';
final class MethodChannelSessionNotificationGateway
implements SessionNotificationGateway {
const MethodChannelSessionNotificationGateway({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
}) : _methodChannel = methodChannel;
static const _methodChannelName = 'gametime.session_notification/methods';
final MethodChannel _methodChannel;
@override
Future<void> show(SessionNotificationContent content) {
return _invokeIgnoringMissingPlugin('show', content.toJson());
}
@override
Future<void> clear() {
return _invokeIgnoringMissingPlugin('clear');
}
Future<void> _invokeIgnoringMissingPlugin(
String method, [
Object? arguments,
]) {
return _methodChannel
.invokeMethod<void>(method, arguments)
.onError<MissingPluginException>((_, _) {});
}
}

View File

@ -16,6 +16,10 @@ final class WatchBridgeConnectionEvent {
abstract interface class WatchBridgeNativeChannel {
Stream<WatchCommandEnvelope> get commands;
Stream<WatchSensorSummary> get sensorSummaries;
Stream<WatchSensorSample> get sensorSamples;
Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> publishProjection(WatchSessionProjection projection);
@ -38,17 +42,31 @@ final class MethodChannelWatchBridgeNativeChannel
const MethodChannelWatchBridgeNativeChannel({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
EventChannel commandChannel = const EventChannel(_commandChannelName),
EventChannel sensorSummaryChannel = const EventChannel(
_sensorSummaryChannelName,
),
EventChannel sensorSampleChannel = const EventChannel(
_sensorSampleChannelName,
),
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
}) : _methodChannel = methodChannel,
_commandChannel = commandChannel,
_sensorSummaryChannel = sensorSummaryChannel,
_sensorSampleChannel = sensorSampleChannel,
_connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods';
static const _commandChannelName = 'gametime.watch_bridge/commands';
static const _sensorSummaryChannelName =
'gametime.watch_bridge/sensor_summaries';
static const _sensorSampleChannelName =
'gametime.watch_bridge/sensor_samples';
static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel;
final EventChannel _commandChannel;
final EventChannel _sensorSummaryChannel;
final EventChannel _sensorSampleChannel;
final EventChannel _connectionChannel;
@override
@ -63,6 +81,30 @@ final class MethodChannelWatchBridgeNativeChannel
});
}
@override
Stream<WatchSensorSummary> get sensorSummaries {
return _sensorSummaryChannel
.receiveBroadcastStream()
.where((event) {
return event is Map;
})
.map((event) {
return WatchSensorSummary.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchSensorSample> get sensorSamples {
return _sensorSampleChannel
.receiveBroadcastStream()
.where((event) {
return event is Map;
})
.map((event) {
return WatchSensorSample.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents {
return _connectionChannel

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../../application/use_cases.dart';
import '../../application/watch_companion_use_cases.dart';
import 'native_watch_bridge_channel.dart';
@ -10,20 +11,26 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
required WatchBridgeNativeChannel nativeChannel,
required WatchCommandIngress commandIngress,
required WatchProjectionSource projectionSource,
Duration heartbeatInterval = const Duration(seconds: 5),
WorkoutHistoryUseCases? workoutHistoryUseCases,
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
Duration projectionRefreshInterval = const Duration(seconds: 2),
}) : _nativeChannel = nativeChannel,
_commandIngress = commandIngress,
_projectionSource = projectionSource,
_heartbeatInterval = heartbeatInterval;
_workoutHistoryUseCases = workoutHistoryUseCases,
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
_projectionRefreshInterval = projectionRefreshInterval;
final WatchBridgeNativeChannel _nativeChannel;
final WatchCommandIngress _commandIngress;
final WatchProjectionSource _projectionSource;
final Duration _heartbeatInterval;
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
final Duration _projectionRefreshInterval;
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
final _subscriptions = <StreamSubscription<dynamic>>[];
Future<void> _commandTail = Future<void>.value();
Timer? _heartbeatTimer;
Timer? _projectionRefreshTimer;
WatchSessionProjection? _latestProjection;
bool _started = false;
bool _foregroundActive = false;
@ -33,6 +40,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
return;
}
_started = true;
_ensureProjectionRefreshLoop();
_subscriptions.add(
_projectionSource.projections.listen((projection) {
unawaited(publish(projection));
@ -43,6 +51,22 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
unawaited(_enqueueCommand(command));
}),
);
final workoutHistoryUseCases = _workoutHistoryUseCases;
if (workoutHistoryUseCases != null) {
_subscriptions.add(
_nativeChannel.sensorSummaries.listen((summary) {
unawaited(workoutHistoryUseCases.updateHeartRateSummary(summary));
}),
);
}
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
if (activeWorkoutSensorUseCases != null) {
_subscriptions.add(
_nativeChannel.sensorSamples.listen((sample) {
activeWorkoutSensorUseCases.recordTelemetrySample(sample);
}),
);
}
_subscriptions.add(
_nativeChannel.connectionEvents.listen((event) {
if (event.isReachable || event.requestsResync) {
@ -55,8 +79,8 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
}
Future<void> stop() async {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_projectionRefreshTimer?.cancel();
_projectionRefreshTimer = null;
for (final subscription in _subscriptions) {
await subscription.cancel();
}
@ -66,10 +90,16 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
@override
Future<void> publish(WatchSessionProjection projection) async {
final previousProjection = _latestProjection;
_latestProjection = projection;
if (projection.phase == WatchSessionPhase.noActiveSession) {
final previousSessionId = previousProjection?.deviceSessionId;
if (previousSessionId != null && previousSessionId.isNotEmpty) {
_activeWorkoutSensorUseCases?.clear(previousSessionId);
}
}
await _nativeChannel.publishProjection(projection);
await _syncForegroundService(projection);
_syncHeartbeat(projection);
}
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
@ -136,26 +166,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
}
}
void _syncHeartbeat(WatchSessionProjection projection) {
if (!_hasRunningTimer(projection)) {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
return;
}
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
void _ensureProjectionRefreshLoop() {
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
unawaited(_projectionSource.emitCurrentProjection());
});
}
}
bool _hasRunningTimer(WatchSessionProjection projection) {
final timers = [
if (projection.dominantTimer != null) projection.dominantTimer!,
...projection.secondaryTimers,
];
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
}
final class _WatchAdapterCommandKey {
_WatchAdapterCommandKey(WatchCommandEnvelope command)
: sessionId = command.sessionId,

View File

@ -681,26 +681,27 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
'Mode de saisie',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
subtitle: const Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
RadioGroup<ScoreInputMode>(
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
child: const Column(
children: [
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Saisie libre'),
value: ScoreInputMode.manual,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Chrono intégré'),
subtitle: Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
),
],
),
),
if (_scoreInputMode == ScoreInputMode.manual) ...[
const SizedBox(height: 12),
@ -865,7 +866,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
onReorderItem: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
@ -901,6 +902,13 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
int index,
_ExerciseStepDraft draft,
) {
final canLinkToSeriesScore =
_hasScore && _scoreInputMode == ScoreInputMode.manual;
if ((!canLinkToSeriesScore ||
draft.scoreInputMode != ScoreInputMode.manual) &&
draft.linkedToSeriesScore) {
draft.linkedToSeriesScore = false;
}
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
@ -979,25 +987,26 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
RadioGroup<ExerciseStepType>(
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
child: const Column(
children: [
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: Text('Temps'),
value: ExerciseStepType.time,
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: Text('Répétitions'),
value: ExerciseStepType.reps,
),
],
),
),
TextFormField(
controller: draft.targetController,
@ -1021,79 +1030,103 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
'Mode de score',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
RadioGroup<ScoreInputMode>(
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
setState(() {
draft.scoreInputMode = value;
draft.linkedToSeriesScore = false;
});
},
child: const Column(
children: [
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Saisie libre'),
value: ScoreInputMode.manual,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
),
],
),
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
if (canLinkToSeriesScore) ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Utiliser le score de la série'),
subtitle: const Text(
'Le score de cette étape alimente directement le score '
'de la série.',
),
value: draft.linkedToSeriesScore,
onChanged: (value) {
setState(() => draft.linkedToSeriesScore = value);
},
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
const SizedBox(height: 8),
],
if (!draft.linkedToSeriesScore) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
],
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
@ -1163,9 +1196,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
@ -1254,6 +1284,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
linkedToSeriesScore: step.linkedToSeriesScore,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
@ -1268,6 +1299,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
seriesScoreLabel: _scoreLabelController.text.trim(),
seriesScoreUnit: _scoreUnitController.text.trim(),
canLinkToSeriesScore:
_hasScore && _scoreInputMode == ScoreInputMode.manual,
defaultTargetScoreTimeMs:
_stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
@ -1386,32 +1421,34 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
setState(() => _saving = true);
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
try {
final scoreInputMode = _hasScore
? _scoreInputMode
: ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
if (exercise == null) {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
@ -1462,8 +1499,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
navigator.pop(true);
}
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} on Exception {
_showSnackBar(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
);
} finally {
if (mounted) {
setState(() => _saving = false);
@ -1556,6 +1595,7 @@ final class _ExerciseStepDraft {
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
this.linkedToSeriesScore = false,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
@ -1569,6 +1609,7 @@ final class _ExerciseStepDraft {
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
bool linkedToSeriesScore;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
@ -1584,6 +1625,7 @@ final class _ExerciseStepDraft {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
linkedToSeriesScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
@ -1597,6 +1639,7 @@ final class _ExerciseStepDraft {
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
linkedToSeriesScore: linkedToSeriesScore,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
@ -1605,9 +1648,17 @@ final class _ExerciseStepDraft {
ExerciseStep toExerciseStep({
required int position,
required String seriesScoreLabel,
required String seriesScoreUnit,
required bool canLinkToSeriesScore,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
final linksToSeries =
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
canLinkToSeriesScore &&
linkedToSeriesScore;
return ExerciseStep(
id: id,
position: position,
@ -1617,14 +1668,19 @@ final class _ExerciseStepDraft {
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
? linksToSeries
? seriesScoreLabel
: scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
? linksToSeries
? seriesScoreUnit
: scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
!linksToSeries &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
@ -1632,6 +1688,7 @@ final class _ExerciseStepDraft {
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
linkedToSeriesScore: linksToSeries,
);
}

View File

@ -15,6 +15,7 @@ final class HistoryListScreen extends StatefulWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.onOpenProgression,
super.key,
@ -24,6 +25,7 @@ final class HistoryListScreen extends StatefulWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -110,6 +112,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sensorUseCases: widget.sensorUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
@ -133,6 +136,7 @@ final class HistoryDetailScreen extends StatelessWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
super.key,
});
@ -142,6 +146,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -157,6 +162,14 @@ final class HistoryDetailScreen extends StatelessWidget {
Text(_formatDateTime(history.startedAt)),
const SizedBox(height: 4),
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
if (history.averageHeartRateBpm != null &&
history.maxHeartRateBpm != null) ...[
const SizedBox(height: 16),
_HistoryHeartRateSummary(
averageHeartRateBpm: history.averageHeartRateBpm!,
maxHeartRateBpm: history.maxHeartRateBpm!,
),
],
const SizedBox(height: 16),
for (final program in detail.programs) ...[
Text(program.name, style: Theme.of(context).textTheme.titleMedium),
@ -202,13 +215,28 @@ final class HistoryDetailScreen extends StatelessWidget {
}
Future<void> _restart(BuildContext context) async {
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!context.mounted) return;
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
'den lancer une nouvelle.',
),
),
);
return;
}
ActiveWorkoutSession session;
final sourceId = history.sourceWorkoutTemplateId;
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
if (context.mounted) {
try {
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
session = await activeUseCases.startFromHistory(history);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -217,7 +245,16 @@ final class HistoryDetailScreen extends StatelessWidget {
),
);
}
session = await activeUseCases.startFromHistory(history);
} on DomainException {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
),
);
return;
}
if (!context.mounted) return;
await Navigator.of(context).push(
@ -226,6 +263,7 @@ final class HistoryDetailScreen extends StatelessWidget {
initialSession: session,
activeUseCases: activeUseCases,
stepUseCases: stepUseCases,
sensorUseCases: sensorUseCases,
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
@ -236,6 +274,22 @@ final class HistoryDetailScreen extends StatelessWidget {
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a restart.
}
return false;
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
@ -261,6 +315,79 @@ final class HistoryDetailScreen extends StatelessWidget {
}
}
final class _HistoryHeartRateSummary extends StatelessWidget {
const _HistoryHeartRateSummary({
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final double averageHeartRateBpm;
final int maxHeartRateBpm;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Fréquence cardiaque',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _HistoryHeartRateMetric(
label: 'Moyenne',
value: '${averageHeartRateBpm.round()}',
),
),
const SizedBox(width: 12),
Expanded(
child: _HistoryHeartRateMetric(
label: 'Max',
value: '$maxHeartRateBpm',
),
),
],
),
],
),
);
}
}
final class _HistoryHeartRateMetric extends StatelessWidget {
const _HistoryHeartRateMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelMedium),
RichText(
text: TextSpan(
style: AppTextStyles.scoreNumber(context),
children: [
TextSpan(text: value),
TextSpan(
text: ' bpm',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
);
}
}
final class HistoryDetailData {
const HistoryDetailData({required this.programs});

View File

@ -93,7 +93,10 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
if (session == null) {
return const SizedBox.shrink();
}
final plan = WorkoutExecutionPlan.fromSession(session);
final plan = WorkoutExecutionPlan.tryFromSession(session);
if (plan == null) {
return const SizedBox.shrink();
}
final position = ExecutionPosition(
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
@ -158,6 +161,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
programUseCases: widget.bootstrap.programUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -182,6 +186,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.workoutTemplateUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -257,21 +262,33 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
}
Future<void> _resume(ActiveWorkoutSession session) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
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();
}

View File

@ -89,14 +89,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
),
),
);
if (connected == true && mounted) {
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
if (connected != true || !mounted || !context.mounted) return;
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
Future<void> _openRegister(BuildContext context) async {
@ -109,14 +108,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
),
),
);
if (connected == true && mounted) {
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
if (connected != true || !mounted || !context.mounted) return;
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
Future<void> _confirmLogout(BuildContext context) async {
@ -189,13 +187,12 @@ final class _SignedOutProfile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Compte optionnel',
'Compte GameTime',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
const Text(
'GameTime fonctionne entièrement sans compte. Connecte-toi '
'seulement si tu veux sauvegarder tes données en ligne ou '
'Connecte-toi pour sauvegarder tes données en ligne et '
'partager des programmes et séances.',
),
const SizedBox(height: 16),
@ -481,7 +478,8 @@ abstract interface class LocalBackupFileExporter {
});
}
final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter {
final class SharePlusLocalBackupFileExporter
implements LocalBackupFileExporter {
const SharePlusLocalBackupFileExporter();
@override
@ -950,11 +948,6 @@ final class _LoginScreenState extends State<LoginScreen> {
},
child: const Text('Créer un compte'),
),
const SizedBox(height: 16),
Text(
'Tu peux continuer à utiliser GameTime sans compte.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
@ -1103,7 +1096,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
const SizedBox(height: 16),
Text(
'Le compte sert à synchroniser tes données et partager tes '
"contenus. L'app reste utilisable sans compte.",
'contenus.',
style: Theme.of(context).textTheme.bodySmall,
),
],
@ -1211,6 +1204,8 @@ String _loginErrorMessage(RemoteAuthFailure failure) {
RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.',
RemoteAuthFailure.network =>
'Connexion impossible pour le moment. Réessaie plus tard.',
RemoteAuthFailure.server =>
'Serveur indisponible pour le moment. Réessaie plus tard.',
_ => 'Connexion impossible pour le moment. Réessaie plus tard.',
};
}
@ -1221,6 +1216,8 @@ String _registerErrorMessage(RemoteAuthFailure failure) {
'Un compte existe déjà avec cet email.',
RemoteAuthFailure.network =>
'Création impossible pour le moment. Réessaie plus tard.',
RemoteAuthFailure.server =>
'Serveur indisponible pour le moment. Réessaie plus tard.',
_ => 'Création impossible pour le moment. Réessaie plus tard.',
};
}

View File

@ -420,6 +420,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
@override
Widget build(BuildContext context) {
final canSave = !_saving && _exercises.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(
@ -436,15 +437,21 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
@ -495,7 +502,8 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
child: _exercises.isEmpty
? const _CenteredMessage(
title: 'Aucun exercice ajouté',
message: 'Ajoute un exercice depuis la bibliothèque.',
message:
'Ajoute au moins un exercice pour enregistrer ce programme.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
@ -595,6 +603,9 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
}
Future<void> _save() async {
if (_exercises.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@ -1402,7 +1413,7 @@ final class _CenteredMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@ -172,7 +172,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
const SizedBox(height: 8),
Text(
'Connecte-toi pour envoyer $targetLabel à un autre compte '
'GameTime. Le reste de lapp reste utilisable sans compte.',
'GameTime.',
),
const SizedBox(height: 16),
FilledButton(

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.historyUseCases,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.shareUseCases,
this.authUseCases,
@ -30,6 +31,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
@ -264,9 +266,16 @@ final class _WorkoutTemplateListScreenState
}
Future<void> _start(WorkoutTemplate template) async {
final openSession = await widget.activeUseCases.findOpen();
if (_templateExerciseCount(template) == 0) {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!mounted) return;
if (openSession != null) {
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -277,9 +286,18 @@ final class _WorkoutTemplateListScreenState
);
return;
}
final session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
final ActiveWorkoutSession session;
try {
session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
} on DomainException {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
@ -292,11 +310,28 @@ final class _WorkoutTemplateListScreenState
workoutTemplateUseCases: widget.workoutTemplateUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases,
sensorUseCases: widget.sensorUseCases,
),
),
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await widget.activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await widget.activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a new start.
}
return false;
}
Future<void> _confirmDelete(WorkoutTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
@ -458,6 +493,7 @@ final class _WorkoutTemplateFormScreenState
@override
Widget build(BuildContext context) {
final canSave = !_saving && _programs.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
@ -472,15 +508,21 @@ final class _WorkoutTemplateFormScreenState
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
@ -521,13 +563,14 @@ final class _WorkoutTemplateFormScreenState
child: _programs.isEmpty
? const _CenteredMessage(
title: 'Aucun programme ajouté',
message: 'Ajoute un programme pour composer la séance.',
message:
'Ajoute au moins un programme pour enregistrer cette séance.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _programs.length,
onReorder: _reorderProgram,
onReorderItem: _reorderProgram,
itemBuilder: (context, index) {
final program = _programs[index];
return Card(
@ -579,9 +622,6 @@ final class _WorkoutTemplateFormScreenState
void _reorderProgram(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _programs.removeAt(oldIndex);
_programs.insert(newIndex, item);
});
@ -599,6 +639,9 @@ final class _WorkoutTemplateFormScreenState
}
Future<void> _save() async {
if (_programs.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@ -1050,6 +1093,7 @@ List<ExerciseStep> _parseExerciseSteps(Object? value) {
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
);
}).toList();
}
@ -1062,12 +1106,16 @@ String _autoStartHelpText(bool active) {
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = template.programs.fold<int>(
final exerciseCount = _templateExerciseCount(template);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
int _templateExerciseCount(WorkoutTemplate template) {
return template.programs.fold<int>(
0,
(total, program) => total + _exerciseCount(program.programSnapshotJson),
);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
@ -1099,8 +1147,12 @@ String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
}
int _exerciseCount(String programSnapshotJson) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
try {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
} on Exception {
return 0;
}
}
final class _CenteredMessage extends StatelessWidget {
@ -1119,7 +1171,7 @@ final class _CenteredMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,