feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
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>
This commit is contained in:
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
198
lib/application/session_notification_use_cases.dart
Normal file
198
lib/application/session_notification_use_cases.dart
Normal 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
@ -375,6 +375,11 @@ final class Exercise {
|
||||
defaultTargetScoreTimeMs,
|
||||
'Default target score time ms',
|
||||
);
|
||||
_requireLinkedStepSeriesScoreShape(
|
||||
steps: steps,
|
||||
scoreEnabled: hasScoreMeasure,
|
||||
scoreInputMode: scoreInputMode,
|
||||
);
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
@ -508,6 +513,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');
|
||||
@ -519,6 +525,7 @@ final class ExerciseStep {
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
linkedToSeriesScore: linkedToSeriesScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -533,6 +540,7 @@ final class ExerciseStep {
|
||||
final String? scoreUnit;
|
||||
final double? defaultTargetScore;
|
||||
final int? defaultTargetScoreTimeMs;
|
||||
final bool linkedToSeriesScore;
|
||||
|
||||
Map<String, Object?> toSnapshotJson() => {
|
||||
'id': id,
|
||||
@ -546,6 +554,7 @@ final class ExerciseStep {
|
||||
'scoreUnit': scoreUnit,
|
||||
'defaultTargetScore': defaultTargetScore,
|
||||
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
|
||||
'linkedToSeriesScore': linkedToSeriesScore,
|
||||
};
|
||||
}
|
||||
|
||||
@ -658,6 +667,11 @@ final class ProgramExercise {
|
||||
targetScore: targetScore,
|
||||
targetScoreTimeMs: targetScoreTimeMs,
|
||||
);
|
||||
_requireLinkedStepSeriesScoreShape(
|
||||
steps: exerciseStepsSnapshot,
|
||||
scoreEnabled: scoreEnabled,
|
||||
scoreInputMode: scoreInputModeSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
@ -1100,6 +1114,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,
|
||||
@ -1405,7 +1460,7 @@ final class ActiveExerciseStepResult {
|
||||
}
|
||||
|
||||
final class WorkoutHistory {
|
||||
const WorkoutHistory({
|
||||
WorkoutHistory({
|
||||
required this.metadata,
|
||||
this.sourceWorkoutTemplateId,
|
||||
this.sourceActiveWorkoutSessionId,
|
||||
@ -1415,9 +1470,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;
|
||||
@ -1428,8 +1491,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 {
|
||||
@ -1721,6 +1812,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,
|
||||
@ -1753,12 +1850,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.',
|
||||
);
|
||||
@ -1784,6 +1883,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) {
|
||||
@ -1820,6 +1924,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,
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export 'session_notification_gateway.dart';
|
||||
@ -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>((_, _) {});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
? 'L’unité 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
|
||||
? 'L’unité 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 d’enregistrer l’exercice. 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 '
|
||||
'd’en 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});
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 l’app reste utilisable sans compte.',
|
||||
'GameTime.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user