merge(main): validation client online offline-first (ticket #70)
Fusionne feature/online-client-70-qa — pub get OK, dart format appliqué (aucun changement), analyze propre (mêmes infos préexistantes), 137/137 tests verts, build APK debug validé. Fix d'acceptation de partage hors-ligne relu manuellement et confirmé. Clôture le chantier "Ajouter les features serveur au client" (#63, tickets #64 à #70). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#72"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784493074891
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784527867008
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "b388e21b-5262-4b3e-b075-5654d2a478f2"
|
||||
number: 72
|
||||
title: "[Bug] les étapes des exercices ne sont pas affichés pendant la séance"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
createdAt: 1784492460868
|
||||
updatedAt: 1784493074891
|
||||
version: 3
|
||||
updatedAt: 1784527867008
|
||||
version: 4
|
||||
---
|
||||
Pendant la séance, il n'y a aucune étape d'exercice affiché. J'ia un exercice divisé en 3 étapes avec des temps par étapes ou des répétitions, et quand je suis a cet exercices pendant ma séance, je n'ai pas les étapes d'affichées à l'écran. Je ne sais donc pas ce que je dois faire, ni combien de temps ou combien de fois. Je pense qu'il faudrait au moins le titre de chaque étape avec le nombre de répétitions ou le chrono qui défile et un bouton pour start/stop les chronos des étapes si les étapes possèdent un temps.
|
||||
@ -811,13 +811,13 @@
|
||||
"issueRef": "#72",
|
||||
"path": "72",
|
||||
"title": "[Bug] les étapes des exercices ne sont pas affichés pendant la séance",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"57695b92-24d0-4876-837c-76116e70a6ae"
|
||||
],
|
||||
"updatedAt": 1784493074891
|
||||
"updatedAt": 1784527867008
|
||||
},
|
||||
{
|
||||
"issueRef": "#73",
|
||||
|
||||
@ -171,6 +171,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
syncGateway: const NoOpSyncGateway(),
|
||||
);
|
||||
|
||||
@ -250,6 +250,7 @@ final class ShareUseCases {
|
||||
required this.templateRepository,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
required this.originDeviceId,
|
||||
});
|
||||
|
||||
final AuthTokenStore tokenStore;
|
||||
@ -261,6 +262,7 @@ final class ShareUseCases {
|
||||
final WorkoutTemplateRepository templateRepository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
|
||||
Future<ShareSendResult> sendShare({
|
||||
required ShareResourceType resourceType,
|
||||
@ -322,8 +324,10 @@ final class ShareUseCases {
|
||||
}
|
||||
|
||||
Future<void> acceptShare(String shareId) async {
|
||||
final cachedItem = await inboxRepository.findByShareId(shareId);
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
await _acceptCachedShareAndQueueAck(shareId, cachedItem);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -335,12 +339,7 @@ final class ShareUseCases {
|
||||
clock.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
await pendingActionRepository.add(
|
||||
_pendingShareAction(
|
||||
actionType: PendingShareActionType.accept,
|
||||
shareId: shareId,
|
||||
),
|
||||
);
|
||||
await _acceptCachedShareAndQueueAck(shareId, cachedItem);
|
||||
}
|
||||
}
|
||||
|
||||
@ -488,6 +487,257 @@ final class ShareUseCases {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptCachedShareAndQueueAck(
|
||||
String shareId,
|
||||
ShareInboxItem? item,
|
||||
) async {
|
||||
if (item != null && item.status == ShareInboxStatus.pending) {
|
||||
await _importSharedPayload(item);
|
||||
await inboxRepository.markStatus(
|
||||
shareId,
|
||||
ShareInboxStatus.accepted,
|
||||
clock.now(),
|
||||
);
|
||||
}
|
||||
await pendingActionRepository.add(
|
||||
_pendingShareAction(
|
||||
actionType: PendingShareActionType.accept,
|
||||
shareId: shareId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _importSharedPayload(ShareInboxItem item) async {
|
||||
final payload = _jsonObject(item.payloadJson);
|
||||
final now = clock.now();
|
||||
switch (item.resourceType) {
|
||||
case ShareResourceType.program:
|
||||
await programRepository.save(
|
||||
_programCopyFromSharePayload(payload, now),
|
||||
);
|
||||
return;
|
||||
case ShareResourceType.workoutTemplate:
|
||||
await templateRepository.save(
|
||||
_workoutTemplateCopyFromSharePayload(payload, now),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Program _programCopyFromSharePayload(
|
||||
Map<String, Object?> payload,
|
||||
DateTime now,
|
||||
) {
|
||||
final programId = ids.newId();
|
||||
final programMetadata = EntityMetadata(
|
||||
id: programId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
originDeviceId: originDeviceId,
|
||||
);
|
||||
return Program(
|
||||
metadata: programMetadata,
|
||||
name: _stringFromSharePayload(payload, 'name', 'Programme partagé'),
|
||||
defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
|
||||
exercises: _programExerciseCopiesFromSharePayload(
|
||||
payload['exercises'],
|
||||
programId,
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ProgramExercise> _programExerciseCopiesFromSharePayload(
|
||||
Object? value,
|
||||
String programId,
|
||||
DateTime now,
|
||||
) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
final exercises = <ProgramExercise>[];
|
||||
for (var index = 0; index < value.length; index++) {
|
||||
final raw = value[index];
|
||||
if (raw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final map = Map<String, Object?>.from(raw);
|
||||
exercises.add(
|
||||
ProgramExercise(
|
||||
metadata: EntityMetadata(
|
||||
id: ids.newId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
programId: programId,
|
||||
sourceExerciseId: null,
|
||||
position: map['position'] as int? ?? index,
|
||||
exerciseNameSnapshot: _stringFromSharePayload(
|
||||
map,
|
||||
'exerciseNameSnapshot',
|
||||
'Exercice partagé',
|
||||
),
|
||||
exerciseDescriptionSnapshot:
|
||||
map['exerciseDescriptionSnapshot'] as String?,
|
||||
exerciseImageMediaIdSnapshot:
|
||||
map['exerciseImageMediaIdSnapshot'] as String?,
|
||||
exerciseImageMediaIdsSnapshot: _stringListFromSharePayload(
|
||||
map['exerciseImageMediaIdsSnapshot'] ??
|
||||
map['imageMediaIdsSnapshot'],
|
||||
),
|
||||
exerciseVideoMediaIdSnapshot:
|
||||
map['exerciseVideoMediaIdSnapshot'] as String?,
|
||||
exerciseStepsSnapshot: _exerciseStepsFromSnapshot(
|
||||
map['exerciseStepsSnapshot'],
|
||||
),
|
||||
exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true,
|
||||
availableTimeSnapshot: map['availableTimeSnapshot'] == true,
|
||||
availableRepsSnapshot: map['availableRepsSnapshot'] == true,
|
||||
availableScoreSnapshot: map['availableScoreSnapshot'] == true,
|
||||
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
||||
map['scoreInputModeSnapshot'],
|
||||
),
|
||||
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
|
||||
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
|
||||
setsCount: map['setsCount'] as int? ?? 1,
|
||||
timeEnabled: map['timeEnabled'] == true,
|
||||
repsEnabled: map['repsEnabled'] == true,
|
||||
scoreEnabled: map['scoreEnabled'] == true,
|
||||
targetTimeSeconds: map['targetTimeSeconds'] as int?,
|
||||
targetReps: map['targetReps'] as int?,
|
||||
targetScore: (map['targetScore'] as num?)?.toDouble(),
|
||||
targetScoreTimeMs: map['targetScoreTimeMs'] as int?,
|
||||
restSecondsOverride: map['restSecondsOverride'] as int?,
|
||||
),
|
||||
);
|
||||
}
|
||||
return exercises;
|
||||
}
|
||||
|
||||
WorkoutTemplate _workoutTemplateCopyFromSharePayload(
|
||||
Map<String, Object?> payload,
|
||||
DateTime now,
|
||||
) {
|
||||
final templateId = ids.newId();
|
||||
final programIdBySharedId = <String, String>{};
|
||||
final programs = _workoutTemplateProgramCopiesFromSharePayload(
|
||||
payload['programs'],
|
||||
templateId,
|
||||
programIdBySharedId,
|
||||
now,
|
||||
);
|
||||
return WorkoutTemplate(
|
||||
metadata: EntityMetadata(
|
||||
id: templateId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
name: _stringFromSharePayload(payload, 'name', 'Séance partagée'),
|
||||
lastStartedAt: null,
|
||||
programs: programs,
|
||||
overrides: _workoutTemplateOverrideCopiesFromSharePayload(
|
||||
payload['overrides'],
|
||||
programIdBySharedId,
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<WorkoutTemplateProgram> _workoutTemplateProgramCopiesFromSharePayload(
|
||||
Object? value,
|
||||
String templateId,
|
||||
Map<String, String> programIdBySharedId,
|
||||
DateTime now,
|
||||
) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
final programs = <WorkoutTemplateProgram>[];
|
||||
for (var index = 0; index < value.length; index++) {
|
||||
final raw = value[index];
|
||||
if (raw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final map = Map<String, Object?>.from(raw);
|
||||
final copiedId = ids.newId();
|
||||
final sharedId = map['id'] as String?;
|
||||
if (sharedId != null) {
|
||||
programIdBySharedId[sharedId] = copiedId;
|
||||
}
|
||||
programs.add(
|
||||
WorkoutTemplateProgram(
|
||||
metadata: EntityMetadata(
|
||||
id: copiedId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
workoutTemplateId: templateId,
|
||||
sourceProgramId: null,
|
||||
position: map['position'] as int? ?? index,
|
||||
programNameSnapshot: _stringFromSharePayload(
|
||||
map,
|
||||
'programNameSnapshot',
|
||||
'Programme partagé',
|
||||
),
|
||||
defaultRestSecondsSnapshot:
|
||||
map['defaultRestSecondsSnapshot'] as int? ?? 0,
|
||||
programSnapshotJson:
|
||||
map['programSnapshotJson'] as String? ?? '{"exercises":[]}',
|
||||
),
|
||||
);
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
List<WorkoutTemplateExerciseOverride>
|
||||
_workoutTemplateOverrideCopiesFromSharePayload(
|
||||
Object? value,
|
||||
Map<String, String> programIdBySharedId,
|
||||
DateTime now,
|
||||
) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
final overrides = <WorkoutTemplateExerciseOverride>[];
|
||||
for (final raw in value) {
|
||||
if (raw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final map = Map<String, Object?>.from(raw);
|
||||
final sharedProgramId = map['workoutTemplateProgramId'] as String?;
|
||||
final copiedProgramId = programIdBySharedId[sharedProgramId];
|
||||
if (copiedProgramId == null) {
|
||||
continue;
|
||||
}
|
||||
final snapshotProgramExerciseId = map['snapshotProgramExerciseId'];
|
||||
if (snapshotProgramExerciseId is! String ||
|
||||
snapshotProgramExerciseId.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
overrides.add(
|
||||
WorkoutTemplateExerciseOverride(
|
||||
metadata: EntityMetadata(
|
||||
id: ids.newId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
workoutTemplateProgramId: copiedProgramId,
|
||||
snapshotProgramExerciseId: snapshotProgramExerciseId,
|
||||
setsCountOverride: map['setsCountOverride'] as int?,
|
||||
targetTimeSecondsOverride: map['targetTimeSecondsOverride'] as int?,
|
||||
targetRepsOverride: map['targetRepsOverride'] as int?,
|
||||
targetScoreOverride: (map['targetScoreOverride'] as num?)?.toDouble(),
|
||||
targetScoreTimeMsOverride: map['targetScoreTimeMsOverride'] as int?,
|
||||
),
|
||||
);
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
}
|
||||
|
||||
final class ExerciseUseCases {
|
||||
@ -2965,6 +3215,22 @@ Map<String, Object?> _jsonObject(String? json) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
String _stringFromSharePayload(
|
||||
Map<String, Object?> payload,
|
||||
String key,
|
||||
String fallback,
|
||||
) {
|
||||
final value = payload[key];
|
||||
return value is String && value.trim().isNotEmpty ? value : fallback;
|
||||
}
|
||||
|
||||
List<String> _stringListFromSharePayload(Object? value) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
return value.whereType<String>().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _jsonStringList(String? json) {
|
||||
if (json == null) {
|
||||
return const [];
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
@ -64,6 +66,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
builder: (context) => LoginScreen(
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -83,6 +86,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
builder: (context) => RegisterScreen(
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -420,11 +424,13 @@ final class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({
|
||||
required this.authUseCases,
|
||||
required this.syncUseCases,
|
||||
this.shareUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AuthUseCases authUseCases;
|
||||
final SyncUseCases syncUseCases;
|
||||
final ShareUseCases? shareUseCases;
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
@ -486,6 +492,7 @@ final class _LoginScreenState extends State<LoginScreen> {
|
||||
builder: (context) => RegisterScreen(
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -515,6 +522,11 @@ final class _LoginScreenState extends State<LoginScreen> {
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
unawaited(widget.syncUseCases.synchronize(manual: false));
|
||||
final shareUseCases = widget.shareUseCases;
|
||||
if (shareUseCases != null) {
|
||||
unawaited(shareUseCases.processPendingShareActions());
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
} on RemoteAuthException catch (error) {
|
||||
@ -537,11 +549,13 @@ final class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({
|
||||
required this.authUseCases,
|
||||
required this.syncUseCases,
|
||||
this.shareUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AuthUseCases authUseCases;
|
||||
final SyncUseCases syncUseCases;
|
||||
final ShareUseCases? shareUseCases;
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
@ -628,6 +642,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
|
||||
builder: (context) => LoginScreen(
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -660,6 +675,11 @@ final class _RegisterScreenState extends State<RegisterScreen> {
|
||||
password: _passwordController.text,
|
||||
displayName: displayName.isEmpty ? null : displayName,
|
||||
);
|
||||
unawaited(widget.syncUseCases.synchronize(manual: false));
|
||||
final shareUseCases = widget.shareUseCases;
|
||||
if (shareUseCases != null) {
|
||||
unawaited(shareUseCases.processPendingShareActions());
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
} on RemoteAuthException catch (error) {
|
||||
|
||||
@ -118,6 +118,7 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareAccountRequiredScreen(
|
||||
resourceType: widget.resourceType,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
),
|
||||
@ -137,12 +138,14 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
|
||||
final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
const ShareAccountRequiredScreen({
|
||||
required this.resourceType,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ShareResourceType resourceType;
|
||||
final ShareUseCases? shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
|
||||
@ -179,6 +182,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
builder: (context) => LoginScreen(
|
||||
authUseCases: authUseCases!,
|
||||
syncUseCases: syncUseCases!,
|
||||
shareUseCases: shareUseCases,
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -193,6 +197,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
builder: (context) => RegisterScreen(
|
||||
authUseCases: authUseCases!,
|
||||
syncUseCases: syncUseCases!,
|
||||
shareUseCases: shareUseCases,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@ -1434,6 +1434,50 @@ void main() {
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
});
|
||||
|
||||
test(
|
||||
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
|
||||
() async {
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
final pendingRepository = _FakePendingShareActionRepository();
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final inboxRepository = _FakeShareInboxRepository()
|
||||
..items.add(
|
||||
_shareInboxItem(
|
||||
status: ShareInboxStatus.pending,
|
||||
payloadJson: jsonEncode(_sharedProgramPayload()),
|
||||
),
|
||||
);
|
||||
|
||||
await _shareUseCase(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: pendingRepository,
|
||||
programRepository: programRepository,
|
||||
).acceptShare('share-1');
|
||||
|
||||
expect(remoteShareApi.acceptCalls, 0);
|
||||
expect(
|
||||
pendingRepository.actions.single.actionType,
|
||||
PendingShareActionType.accept,
|
||||
);
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
final imported = programRepository.programs.single;
|
||||
expect(imported.metadata.id, isNot('program-from-sender'));
|
||||
expect(imported.metadata.originDeviceId, 'device-1');
|
||||
expect(imported.name, 'Programme partagé');
|
||||
expect(
|
||||
imported.exercises.single.metadata.id,
|
||||
isNot('exercise-from-sender'),
|
||||
);
|
||||
expect(imported.exercises.single.sourceExerciseId, isNull);
|
||||
expect(
|
||||
imported.exercises.single.exerciseStepsSnapshot.single.name,
|
||||
'Sprint',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('ShareUseCases refreshInbox updates the local cache', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
||||
@ -1866,6 +1910,7 @@ ShareUseCases _shareUseCase({
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
@ -2027,17 +2072,58 @@ Program _shareableProgram() {
|
||||
ShareInboxItem _shareInboxItem({
|
||||
String shareId = 'share-1',
|
||||
required ShareInboxStatus status,
|
||||
String payloadJson = '{"name":"Programme partagé"}',
|
||||
}) {
|
||||
return ShareInboxItem(
|
||||
shareId: shareId,
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: ShareResourceType.program,
|
||||
payloadJson: '{"name":"Programme partagé"}',
|
||||
payloadJson: payloadJson,
|
||||
status: status,
|
||||
createdAt: DateTime.utc(2026, 7, 17, 12),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _sharedProgramPayload() {
|
||||
return {
|
||||
'id': 'program-from-sender',
|
||||
'name': 'Programme partagé',
|
||||
'defaultRestSeconds': 45,
|
||||
'exercises': [
|
||||
{
|
||||
'id': 'exercise-from-sender',
|
||||
'sourceExerciseId': 'source-exercise-from-sender',
|
||||
'position': 0,
|
||||
'exerciseNameSnapshot': 'Sprint',
|
||||
'exerciseStepsSnapshot': [
|
||||
{
|
||||
'id': 'step-from-sender',
|
||||
'position': 0,
|
||||
'name': 'Sprint',
|
||||
'type': 'time',
|
||||
'defaultTargetValue': 20,
|
||||
'hasScore': false,
|
||||
'scoreInputMode': 'manual',
|
||||
'scoreLabel': null,
|
||||
'scoreUnit': null,
|
||||
'defaultTargetScore': null,
|
||||
'defaultTargetScoreTimeMs': null,
|
||||
},
|
||||
],
|
||||
'availableTimeSnapshot': true,
|
||||
'availableRepsSnapshot': false,
|
||||
'availableScoreSnapshot': false,
|
||||
'scoreInputModeSnapshot': 'manual',
|
||||
'setsCount': 2,
|
||||
'timeEnabled': true,
|
||||
'repsEnabled': false,
|
||||
'scoreEnabled': false,
|
||||
'targetTimeSeconds': 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
RemoteSyncedItem _remoteSharedProgramItem() {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.program,
|
||||
|
||||
@ -156,6 +156,7 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeRepository,
|
||||
|
||||
@ -93,6 +93,7 @@ void main() {
|
||||
expect(find.text('alex@example.com'), findsOneWidget);
|
||||
expect(find.text('Synchronisation'), findsOneWidget);
|
||||
expect(find.text('Partages reçus'), findsOneWidget);
|
||||
expect(harness.syncRemote.pullCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('la déconnexion repasse au profil déconnecté', (tester) async {
|
||||
@ -273,6 +274,7 @@ final class _AuthHarness {
|
||||
templateRepository: templateRepository,
|
||||
clock: const _FakeClock(),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -604,6 +604,7 @@ ShareUseCases _shareUseCases({
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -70,22 +70,78 @@ void main() {
|
||||
ShareInboxStatus.declined,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'accepter un partage en cache hors connexion importe une copie locale',
|
||||
(tester) async {
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
final inboxRepository = _FakeShareInboxRepository()
|
||||
..items.add(
|
||||
_shareItem(
|
||||
shareId: 'share-program',
|
||||
resourceType: ShareResourceType.program,
|
||||
name: 'Programme tirs',
|
||||
status: ShareInboxStatus.pending,
|
||||
),
|
||||
);
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final pendingRepository = _FakePendingShareActionRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ShareInboxScreen(
|
||||
shareUseCases: _shareUseCases(
|
||||
tokenStore: _FakeAuthTokenStore(null),
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: pendingRepository,
|
||||
programRepository: programRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Accepter'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(remoteShareApi.acceptCalls, 0);
|
||||
expect(programRepository.saved.single.name, 'Programme tirs');
|
||||
expect(
|
||||
programRepository.saved.single.metadata.id,
|
||||
isNot('share-program'),
|
||||
);
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
expect(
|
||||
pendingRepository.actions.single.actionType,
|
||||
PendingShareActionType.accept,
|
||||
);
|
||||
expect(find.text('Programme ajouté.'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ShareUseCases _shareUseCases({
|
||||
_FakeAuthTokenStore? tokenStore,
|
||||
required _FakeRemoteShareApi remoteShareApi,
|
||||
required _FakeShareInboxRepository inboxRepository,
|
||||
_FakePendingShareActionRepository? pendingActionRepository,
|
||||
_FakeProgramRepository? programRepository,
|
||||
}) {
|
||||
return ShareUseCases(
|
||||
tokenStore: _FakeAuthTokenStore('token-1'),
|
||||
tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: _FakePendingShareActionRepository(),
|
||||
pendingActionRepository:
|
||||
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
||||
localChanges: _FakeLocalSyncChangeRepository(),
|
||||
programRepository: _FakeProgramRepository(),
|
||||
programRepository: programRepository ?? _FakeProgramRepository(),
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,7 +155,17 @@ ShareInboxItem _shareItem({
|
||||
ShareResourceType.program => {
|
||||
'name': name,
|
||||
'exercises': const [
|
||||
{'setsCount': 3},
|
||||
{
|
||||
'exerciseNameSnapshot': 'Sprint',
|
||||
'setsCount': 3,
|
||||
'availableTimeSnapshot': false,
|
||||
'availableRepsSnapshot': true,
|
||||
'availableScoreSnapshot': false,
|
||||
'timeEnabled': false,
|
||||
'repsEnabled': true,
|
||||
'scoreEnabled': false,
|
||||
'targetReps': 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
ShareResourceType.workoutTemplate => {
|
||||
@ -267,8 +333,12 @@ final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||||
|
||||
final class _FakePendingShareActionRepository
|
||||
implements PendingShareActionRepository {
|
||||
final actions = <PendingShareAction>[];
|
||||
|
||||
@override
|
||||
Future<void> add(PendingShareAction action) async {}
|
||||
Future<void> add(PendingShareAction action) async {
|
||||
actions.add(action);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PendingShareAction>> listPending() async => const [];
|
||||
@ -296,6 +366,8 @@ final class _FakeLocalSyncChangeRepository
|
||||
}
|
||||
|
||||
final class _FakeProgramRepository implements ProgramRepository {
|
||||
final saved = <Program>[];
|
||||
|
||||
@override
|
||||
Future<Program?> findById(String id) async => null;
|
||||
|
||||
@ -306,7 +378,9 @@ final class _FakeProgramRepository implements ProgramRepository {
|
||||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||||
|
||||
@override
|
||||
Future<void> save(Program program) async {}
|
||||
Future<void> save(Program program) async {
|
||||
saved.add(program);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||||
|
||||
@ -537,6 +537,7 @@ ShareUseCases _shareUseCases({
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user