chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
@ -17,6 +18,7 @@ void main() {
late local.DriftProgressionStatsRepository progressionStatsRepository;
late local.DriftLocalSyncChangeRepository syncChangeRepository;
late local.DriftLocalDataBackupRepository localDataBackupRepository;
late local.DriftWorkoutTelemetryRepository telemetryRepository;
late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository;
@ -32,6 +34,7 @@ void main() {
);
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
telemetryRepository = local.DriftWorkoutTelemetryRepository(database);
performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database);
});
@ -121,9 +124,289 @@ void main() {
expect(await columnNames('exercises'), contains('tags_json'));
expect(await columnNames('programs'), contains('tags_json'));
expect(await columnNames('workout_templates'), contains('tags_json'));
expect(database.schemaVersion, 19);
expect(
await columnNames('workout_history'),
contains('min_heart_rate_bpm'),
);
expect(
await columnNames('workout_history'),
contains('total_distance_meters'),
);
expect(
await columnNames('workout_history'),
contains('total_calories_kcal'),
);
expect(await columnNames('workout_telemetry_samples'), contains('id'));
expect(
await columnNames('workout_telemetry_aggregates'),
contains('sample_count'),
);
expect(database.schemaVersion, 24);
});
test(
'telemetry repository persists samples and replaces aggregates',
() async {
final first = WorkoutTelemetrySample(
id: 'sample-1',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10),
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 120,
distanceMeters: 500,
caloriesKcal: 42,
);
final second = WorkoutTelemetrySample(
id: 'sample-2',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
heartRateBpm: 150,
distanceMeters: 620,
caloriesKcal: 48,
);
expect(await telemetryRepository.saveSample(first), isTrue);
expect(await telemetryRepository.saveSample(first), isFalse);
expect(await telemetryRepository.saveSample(second), isTrue);
final samples = await telemetryRepository.listSamples('session-1');
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
await telemetryRepository.replaceAggregatesForSession(
sessionId: 'session-1',
aggregates: [
WorkoutTelemetryAggregate(
sessionId: 'session-1',
scope: WorkoutTelemetryAggregateScope.session,
sampleCount: 2,
minHeartRateBpm: 120,
averageHeartRateBpm: 135,
maxHeartRateBpm: 150,
totalDistanceMeters: 620,
totalCaloriesKcal: 48,
),
],
);
final aggregate = await telemetryRepository.findAggregate(
sessionId: 'session-1',
scope: WorkoutTelemetryAggregateScope.session,
);
expect(aggregate?.sampleCount, 2);
expect(aggregate?.minHeartRateBpm, 120);
expect(aggregate?.averageHeartRateBpm, 135);
expect(aggregate?.maxHeartRateBpm, 150);
expect(aggregate?.totalDistanceMeters, 620);
expect(aggregate?.totalCaloriesKcal, 48);
await telemetryRepository.replaceAggregatesForSession(
sessionId: 'session-1',
aggregates: const [],
);
expect(await telemetryRepository.listAggregates('session-1'), isEmpty);
},
);
test('share inbox and pending actions persist workout packs', () async {
final now = DateTime.utc(2026, 7, 28, 10);
final inboxRepository = local.DriftShareInboxRepository(database);
final pendingRepository = local.DriftPendingShareActionRepository(database);
await inboxRepository.upsert(
ShareInboxItem(
shareId: 'share-pack-1',
senderUserId: 'sender-1',
resourceType: ShareResourceType.pack,
payloadJson: jsonEncode({
'kind': 'pack',
'name': 'Pack reprise',
'workouts': const [],
}),
status: ShareInboxStatus.pending,
createdAt: now,
),
);
await pendingRepository.add(
PendingShareAction(
id: 'pending-pack-1',
actionType: PendingShareActionType.send,
resourceType: ShareResourceType.pack,
payloadJson: jsonEncode({
'kind': 'pack',
'name': 'Pack reprise',
'workouts': const [],
}),
recipientEmailsJson: jsonEncode(['coach@example.com']),
createdAt: now,
),
);
final inboxItem = await inboxRepository.findByShareId('share-pack-1');
final pendingActions = await pendingRepository.listPending();
expect(inboxItem, isNotNull);
expect(inboxItem!.resourceType, ShareResourceType.pack);
expect(pendingActions.single.resourceType, ShareResourceType.pack);
});
test('migration 22 to 24 preserves share rows and accepts packs', () async {
final file = File(
'${Directory.systemTemp.path}/gametime_schema24_${DateTime.now().microsecondsSinceEpoch}.sqlite',
);
addTearDown(() async {
if (await file.exists()) {
await file.delete();
}
});
final seedDatabase = local.AppDatabase(NativeDatabase(file));
await seedDatabase.customSelect('SELECT 1').getSingle();
await seedDatabase.customStatement('DROP TABLE share_inbox_items');
await seedDatabase.customStatement('DROP TABLE pending_share_actions');
await seedDatabase.customStatement('''
CREATE TABLE share_inbox_items (
share_id TEXT NOT NULL PRIMARY KEY,
sender_user_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (
resource_type IN ('program', 'workoutTemplate')
),
payload_json TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('pending', 'accepted', 'declined', 'revoked')
),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
responded_at INTEGER
)
''');
await seedDatabase.customStatement('''
CREATE TABLE pending_share_actions (
id TEXT NOT NULL PRIMARY KEY,
action_type TEXT NOT NULL CHECK (
action_type IN ('send', 'accept', 'decline', 'revoke')
),
share_id TEXT,
resource_type TEXT CHECK (
resource_type IS NULL OR
resource_type IN ('program', 'workoutTemplate')
),
payload_json TEXT,
recipient_emails_json TEXT,
created_at INTEGER NOT NULL,
last_attempt_at INTEGER,
attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed'))
)
''');
await seedDatabase.customStatement(
'INSERT INTO share_inbox_items VALUES '
'''('share-program-1', 'sender-1', 'program', '{"name":"P"}', '''
"'pending', 1785225600000, 1785225600000, NULL)",
);
await seedDatabase.customStatement(
'INSERT INTO pending_share_actions VALUES '
'''('pending-program-1', 'send', NULL, 'program', '{"name":"P"}', '''
''''["coach@example.com"]', 1785225600000, NULL, 0, 'pending')''',
);
await seedDatabase.customStatement('PRAGMA user_version = 22');
await seedDatabase.close();
final migratedDatabase = local.AppDatabase(NativeDatabase(file));
addTearDown(migratedDatabase.close);
final inboxRepository = local.DriftShareInboxRepository(migratedDatabase);
final pendingRepository = local.DriftPendingShareActionRepository(
migratedDatabase,
);
final now = DateTime.utc(2026, 7, 28, 11);
await inboxRepository.upsert(
ShareInboxItem(
shareId: 'share-pack-1',
senderUserId: 'sender-2',
resourceType: ShareResourceType.pack,
payloadJson: jsonEncode({
'kind': 'pack',
'name': 'Pack reprise',
'workouts': const [],
}),
status: ShareInboxStatus.pending,
createdAt: now,
),
);
await pendingRepository.add(
PendingShareAction(
id: 'pending-pack-1',
actionType: PendingShareActionType.send,
resourceType: ShareResourceType.pack,
payloadJson: jsonEncode({
'kind': 'pack',
'name': 'Pack reprise',
'workouts': const [],
}),
recipientEmailsJson: jsonEncode(['coach@example.com']),
createdAt: now,
),
);
final version = await migratedDatabase
.customSelect('PRAGMA user_version')
.getSingle();
final inboxItems = await inboxRepository.listAll();
final pendingActions = await pendingRepository.listPending();
expect(version.data['user_version'], 24);
expect(inboxItems.map((item) => item.shareId), contains('share-program-1'));
expect(inboxItems.map((item) => item.shareId), contains('share-pack-1'));
expect(
pendingActions.map((action) => action.id),
containsAll(['pending-program-1', 'pending-pack-1']),
);
});
test(
'workout template saveAll rolls back every template on failure',
() async {
final now = DateTime.utc(2026, 7, 28, 12);
final valid = WorkoutTemplate(
metadata: _metadata('template-valid', now),
name: 'Séance valide',
);
final invalid = WorkoutTemplate(
metadata: _metadata('template-invalid', now),
name: 'Séance invalide',
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-invalid-program-1', now),
workoutTemplateId: 'template-invalid',
position: 0,
programNameSnapshot: 'Programme A',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
WorkoutTemplateProgram(
metadata: _metadata('template-invalid-program-2', now),
workoutTemplateId: 'template-invalid',
position: 0,
programNameSnapshot: 'Programme B',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
);
await expectLater(
templateRepository.saveAll([valid, invalid]),
throwsA(isA<Exception>()),
);
expect(await templateRepository.findById(valid.metadata.id), isNull);
expect(await templateRepository.findById(invalid.metadata.id), isNull);
},
);
test('repositories save and load normalized tags', () async {
final now = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(

View File

@ -4,7 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/infrastructure.dart'
hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult;
hide
WorkoutHistory,
WorkoutHistorySetResult,
WorkoutHistoryStepResult,
WorkoutTelemetryAggregate,
WorkoutTelemetrySample;
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
@ -221,6 +226,48 @@ void main() {
await adapter.stop();
await sensorUseCases.dispose();
});
test('persists telemetry samples from the native stream', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_runningProjection(revision: 1));
final telemetryRepository = _FakeWorkoutTelemetryRepository();
final adapter = _adapter(
native: native,
source: source,
telemetryUseCases: WorkoutTelemetryUseCases(
repository: telemetryRepository,
clock: _FakeClock(_now),
ids: _FakeIds(),
),
);
await adapter.start();
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-1',
sessionId: 'session-1',
recordedAtEpochMs: _now.millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 120,
distanceMeters: 500,
caloriesKcal: 42,
),
);
await Future<void>.delayed(Duration.zero);
expect(telemetryRepository.samples.single.id, 'sample-1');
final aggregate = telemetryRepository.aggregates.singleWhere(
(aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session,
);
expect(aggregate.sampleCount, 1);
expect(aggregate.minHeartRateBpm, 120);
expect(aggregate.totalDistanceMeters, 500);
expect(aggregate.totalCaloriesKcal, 42);
await adapter.stop();
});
}
WatchWearDataLayerAdapter _adapter({
@ -229,6 +276,7 @@ WatchWearDataLayerAdapter _adapter({
required _FakeProjectionSource source,
WorkoutHistoryUseCases? historyUseCases,
ActiveWorkoutSensorUseCases? sensorUseCases,
WorkoutTelemetryUseCases? telemetryUseCases,
Duration heartbeatInterval = const Duration(seconds: 5),
}) {
return WatchWearDataLayerAdapter(
@ -237,6 +285,7 @@ WatchWearDataLayerAdapter _adapter({
projectionSource: source,
workoutHistoryUseCases: historyUseCases,
activeWorkoutSensorUseCases: sensorUseCases,
workoutTelemetryUseCases: telemetryUseCases,
projectionRefreshInterval: heartbeatInterval,
);
}
@ -312,6 +361,16 @@ final class _FakeClock implements Clock {
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeProjectionSource implements WatchProjectionSource {
_FakeProjectionSource(this.current);
@ -466,8 +525,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> patchHeartRateSummary({
required String historyId,
int? minHeartRateBpm,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
@ -497,6 +559,72 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
Future<void> delete(String id, DateTime deletedAt) async {}
}
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
final aggregates = <WorkoutTelemetryAggregate>[];
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
if (samples.any((existing) => existing.id == sample.id)) {
return false;
}
samples.add(sample);
return true;
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,
}) async {
this.aggregates.removeWhere(
(aggregate) => aggregate.sessionId == sessionId,
);
this.aggregates.addAll(aggregates);
}
@override
Future<List<WorkoutTelemetryAggregate>> listAggregates(
String sessionId,
) async {
return aggregates
.where((aggregate) => aggregate.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<WorkoutTelemetryAggregate?> findAggregate({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
return aggregates
.where(
(aggregate) =>
aggregate.sessionId == sessionId &&
aggregate.scope == scope &&
aggregate.programIndex == programIndex &&
aggregate.exerciseIndex == exerciseIndex &&
aggregate.setIndex == setIndex &&
aggregate.passageIndex == passageIndex &&
aggregate.stepIndex == stepIndex,
)
.firstOrNull;
}
}
final class _SentAck {
const _SentAck(this.command, this.ack, this.revisionAtAck);