fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)
This commit is contained in:
@ -176,6 +176,10 @@ object WatchBridgePlugin {
|
|||||||
"projectedAtEpochMs",
|
"projectedAtEpochMs",
|
||||||
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
||||||
)
|
)
|
||||||
|
dataMap.putLong(
|
||||||
|
"expiresAtEpochMs",
|
||||||
|
(map["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
||||||
|
)
|
||||||
}.asPutDataRequest().setUrgent()
|
}.asPutDataRequest().setUrgent()
|
||||||
Wearable.getDataClient(context).putDataItem(request)
|
Wearable.getDataClient(context).putDataItem(request)
|
||||||
.addOnSuccessListener { result.success(null) }
|
.addOnSuccessListener { result.success(null) }
|
||||||
|
|||||||
@ -765,7 +765,7 @@ abstract interface class RemoteShareApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<List<ShareInboxItem>> fetchInbox(String token);
|
Future<List<ShareInboxItem>> fetchInbox(String token);
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token);
|
Future<List<RemoteSyncedItem>> acceptShare(String shareId, String token);
|
||||||
Future<void> declineShare(String shareId, String token);
|
Future<void> declineShare(String shareId, String token);
|
||||||
Future<void> revokeShare(String shareId, String token);
|
Future<void> revokeShare(String shareId, String token);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -632,15 +632,15 @@ final class ShareUseCases {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (cachedItem?.resourceType == ShareResourceType.pack) {
|
final createdResources = await remoteShareApi.acceptShare(shareId, token);
|
||||||
await remoteShareApi.acceptShare(shareId, token);
|
final cachedPackItem = cachedItem;
|
||||||
await _importSharedPayload(cachedItem!);
|
if (cachedPackItem != null &&
|
||||||
|
cachedPackItem.resourceType == ShareResourceType.pack) {
|
||||||
|
await _importSharedPayload(cachedPackItem);
|
||||||
} else {
|
} else {
|
||||||
final createdResource = await remoteShareApi.acceptShare(
|
for (final createdResource in createdResources) {
|
||||||
shareId,
|
await localChanges.applyRemoteItem(createdResource);
|
||||||
token,
|
}
|
||||||
);
|
|
||||||
await localChanges.applyRemoteItem(createdResource);
|
|
||||||
}
|
}
|
||||||
await inboxRepository.markStatus(
|
await inboxRepository.markStatus(
|
||||||
shareId,
|
shareId,
|
||||||
@ -794,11 +794,13 @@ final class ShareUseCases {
|
|||||||
if (item?.resourceType == ShareResourceType.pack) {
|
if (item?.resourceType == ShareResourceType.pack) {
|
||||||
await remoteShareApi.acceptShare(action.shareId!, token);
|
await remoteShareApi.acceptShare(action.shareId!, token);
|
||||||
} else {
|
} else {
|
||||||
final created = await remoteShareApi.acceptShare(
|
final createdResources = await remoteShareApi.acceptShare(
|
||||||
action.shareId!,
|
action.shareId!,
|
||||||
token,
|
token,
|
||||||
);
|
);
|
||||||
await localChanges.applyRemoteItem(created);
|
for (final createdResource in createdResources) {
|
||||||
|
await localChanges.applyRemoteItem(createdResource);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await inboxRepository.markStatus(
|
await inboxRepository.markStatus(
|
||||||
action.shareId!,
|
action.shareId!,
|
||||||
@ -3505,6 +3507,7 @@ bool _hasSameWatchCommandRevisionState(
|
|||||||
left.deviceSessionId == right.deviceSessionId &&
|
left.deviceSessionId == right.deviceSessionId &&
|
||||||
left.phase == right.phase &&
|
left.phase == right.phase &&
|
||||||
left.phoneReachable == right.phoneReachable &&
|
left.phoneReachable == right.phoneReachable &&
|
||||||
|
left.expiresAtEpochMs == right.expiresAtEpochMs &&
|
||||||
left.seriesIndex == right.seriesIndex &&
|
left.seriesIndex == right.seriesIndex &&
|
||||||
left.seriesTotal == right.seriesTotal &&
|
left.seriesTotal == right.seriesTotal &&
|
||||||
left.exerciseName == right.exerciseName &&
|
left.exerciseName == right.exerciseName &&
|
||||||
@ -3533,6 +3536,7 @@ bool _hasSameWatchCommandRevisionState(
|
|||||||
left.canDecrementScore == right.canDecrementScore &&
|
left.canDecrementScore == right.canDecrementScore &&
|
||||||
left.manualScoreTargetValue == right.manualScoreTargetValue &&
|
left.manualScoreTargetValue == right.manualScoreTargetValue &&
|
||||||
left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
|
left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
|
||||||
|
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
|
||||||
left.manualScoreScope == right.manualScoreScope;
|
left.manualScoreScope == right.manualScoreScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4055,6 +4059,7 @@ final class WatchSessionProjectionProjector {
|
|||||||
deviceSessionId: '',
|
deviceSessionId: '',
|
||||||
revision: revision,
|
revision: revision,
|
||||||
projectedAtEpochMs: _epochMs(now),
|
projectedAtEpochMs: _epochMs(now),
|
||||||
|
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
|
||||||
phase: WatchSessionPhase.noActiveSession,
|
phase: WatchSessionPhase.noActiveSession,
|
||||||
phoneReachable: true,
|
phoneReachable: true,
|
||||||
seriesIndex: 0,
|
seriesIndex: 0,
|
||||||
@ -4075,6 +4080,7 @@ final class WatchSessionProjectionProjector {
|
|||||||
deviceSessionId: session.metadata.id,
|
deviceSessionId: session.metadata.id,
|
||||||
revision: revision,
|
revision: revision,
|
||||||
projectedAtEpochMs: _epochMs(now),
|
projectedAtEpochMs: _epochMs(now),
|
||||||
|
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
|
||||||
phase: WatchSessionPhase.noActiveSession,
|
phase: WatchSessionPhase.noActiveSession,
|
||||||
phoneReachable: true,
|
phoneReachable: true,
|
||||||
seriesIndex: session.currentSetIndex + 1,
|
seriesIndex: session.currentSetIndex + 1,
|
||||||
@ -4150,6 +4156,7 @@ final class WatchSessionProjectionProjector {
|
|||||||
deviceSessionId: session.metadata.id,
|
deviceSessionId: session.metadata.id,
|
||||||
revision: revision,
|
revision: revision,
|
||||||
projectedAtEpochMs: projectedAtEpochMs,
|
projectedAtEpochMs: projectedAtEpochMs,
|
||||||
|
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
|
||||||
phase: phase,
|
phase: phase,
|
||||||
phoneReachable: true,
|
phoneReachable: true,
|
||||||
seriesIndex: session.currentSetIndex + 1,
|
seriesIndex: session.currentSetIndex + 1,
|
||||||
@ -4202,6 +4209,7 @@ final class WatchSessionProjectionProjector {
|
|||||||
canDecrementScore: (manualScoreProjection?.value ?? 0) > 0,
|
canDecrementScore: (manualScoreProjection?.value ?? 0) > 0,
|
||||||
manualScoreTargetValue: manualScoreProjection?.targetValue,
|
manualScoreTargetValue: manualScoreProjection?.targetValue,
|
||||||
manualScoreTargetLabel: manualScoreProjection?.targetLabel,
|
manualScoreTargetLabel: manualScoreProjection?.targetLabel,
|
||||||
|
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
|
||||||
manualScoreScope: manualScoreProjection?.scope,
|
manualScoreScope: manualScoreProjection?.scope,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -4274,12 +4282,14 @@ final class _WatchManualScoreProjectionData {
|
|||||||
required this.value,
|
required this.value,
|
||||||
this.targetValue,
|
this.targetValue,
|
||||||
this.targetLabel,
|
this.targetLabel,
|
||||||
|
this.repsTargetValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WatchManualScoreScope scope;
|
final WatchManualScoreScope scope;
|
||||||
final double value;
|
final double value;
|
||||||
final double? targetValue;
|
final double? targetValue;
|
||||||
final String? targetLabel;
|
final String? targetLabel;
|
||||||
|
final int? repsTargetValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_WatchManualScoreProjectionData? _watchManualScoreProjection({
|
_WatchManualScoreProjectionData? _watchManualScoreProjection({
|
||||||
@ -4304,6 +4314,9 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({
|
|||||||
value: result?.actualScore ?? 0,
|
value: result?.actualScore ?? 0,
|
||||||
targetValue: step.defaultTargetScore,
|
targetValue: step.defaultTargetScore,
|
||||||
targetLabel: step.defaultTargetScore == null ? null : 'Cible',
|
targetLabel: step.defaultTargetScore == null ? null : 'Cible',
|
||||||
|
repsTargetValue: step.type == ExerciseStepType.reps
|
||||||
|
? step.defaultTargetValue
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (snapshot.scoreEnabled &&
|
if (snapshot.scoreEnabled &&
|
||||||
@ -4612,6 +4625,13 @@ String? _betweenSetsNextExerciseName(
|
|||||||
|
|
||||||
int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch;
|
int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
int _watchProjectionExpiresAtEpochMs(DateTime projectedAt) {
|
||||||
|
return projectedAt
|
||||||
|
.toUtc()
|
||||||
|
.add(const Duration(seconds: 12))
|
||||||
|
.millisecondsSinceEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
final class ActiveExerciseStepUseCases {
|
final class ActiveExerciseStepUseCases {
|
||||||
const ActiveExerciseStepUseCases({
|
const ActiveExerciseStepUseCases({
|
||||||
required this.sessionRepository,
|
required this.sessionRepository,
|
||||||
|
|||||||
@ -492,10 +492,14 @@ final class DriftLocalSyncChangeRepository
|
|||||||
switch (item.resourceType) {
|
switch (item.resourceType) {
|
||||||
case SyncResourceType.exercise:
|
case SyncResourceType.exercise:
|
||||||
final exercise = _exerciseFromPayload(item);
|
final exercise = _exerciseFromPayload(item);
|
||||||
await database
|
await database.transaction(() async {
|
||||||
.into(database.exercises)
|
await database
|
||||||
.insertOnConflictUpdate(_exerciseCompanion(exercise));
|
.into(database.exercises)
|
||||||
await _writeExerciseStarterMetadata(database, exercise);
|
.insertOnConflictUpdate(_exerciseCompanion(exercise));
|
||||||
|
await _writeExerciseStarterMetadata(database, exercise);
|
||||||
|
await _replaceRemoteExerciseImages(exercise);
|
||||||
|
await _replaceRemoteExerciseSteps(exercise);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
case SyncResourceType.mediaAsset:
|
case SyncResourceType.mediaAsset:
|
||||||
await database
|
await database
|
||||||
@ -542,7 +546,11 @@ final class DriftLocalSyncChangeRepository
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
case SyncResourceType.workoutHistory:
|
case SyncResourceType.workoutHistory:
|
||||||
return false;
|
await _replaceRemoteWorkoutHistory(
|
||||||
|
_workoutHistoryFromLocalBackupPayload(item),
|
||||||
|
item.clientUpdatedAt,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,7 +612,7 @@ final class DriftLocalSyncChangeRepository
|
|||||||
}
|
}
|
||||||
return _LocalSyncSnapshot.fromMetadata(
|
return _LocalSyncSnapshot.fromMetadata(
|
||||||
history.metadata,
|
history.metadata,
|
||||||
_workoutHistoryPayload(history),
|
_localWorkoutHistoryPayload(history),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -627,6 +635,212 @@ final class DriftLocalSyncChangeRepository
|
|||||||
payload: {'id': id},
|
payload: {'id': id},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _replaceRemoteWorkoutHistory(
|
||||||
|
domain.WorkoutHistory history,
|
||||||
|
DateTime deletedAt,
|
||||||
|
) async {
|
||||||
|
await database.transaction(() async {
|
||||||
|
await database
|
||||||
|
.into(database.workoutHistories)
|
||||||
|
.insertOnConflictUpdate(_workoutHistoryCompanion(history));
|
||||||
|
for (final result in history.results) {
|
||||||
|
await database
|
||||||
|
.into(database.workoutHistorySetResults)
|
||||||
|
.insertOnConflictUpdate(_workoutHistorySetResultCompanion(result));
|
||||||
|
}
|
||||||
|
for (final result in history.stepResults) {
|
||||||
|
await database
|
||||||
|
.into(database.workoutHistoryStepResults)
|
||||||
|
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeResultIds = history.results
|
||||||
|
.map((result) => result.metadata.id)
|
||||||
|
.toSet();
|
||||||
|
await _softDeleteRemoteWorkoutHistoryChildren(
|
||||||
|
tableName: 'workout_history_set_results',
|
||||||
|
historyId: history.metadata.id,
|
||||||
|
keepIds: activeResultIds,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
);
|
||||||
|
final activeStepResultIds = history.stepResults
|
||||||
|
.map((result) => result.metadata.id)
|
||||||
|
.toSet();
|
||||||
|
await _softDeleteRemoteWorkoutHistoryChildren(
|
||||||
|
tableName: 'workout_history_step_results',
|
||||||
|
historyId: history.metadata.id,
|
||||||
|
keepIds: activeStepResultIds,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _softDeleteRemoteWorkoutHistoryChildren({
|
||||||
|
required String tableName,
|
||||||
|
required String historyId,
|
||||||
|
required Set<String> keepIds,
|
||||||
|
required DateTime deletedAt,
|
||||||
|
}) async {
|
||||||
|
final keepPredicate = keepIds.isEmpty
|
||||||
|
? ''
|
||||||
|
: 'AND id NOT IN (${List.filled(keepIds.length, '?').join(', ')})';
|
||||||
|
await database.customUpdate(
|
||||||
|
'''
|
||||||
|
UPDATE $tableName
|
||||||
|
SET deleted_at = ?, updated_at = ?, sync_state = 'deleted'
|
||||||
|
WHERE workout_history_id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
$keepPredicate
|
||||||
|
''',
|
||||||
|
variables: [
|
||||||
|
Variable<DateTime>(deletedAt.toUtc()),
|
||||||
|
Variable<DateTime>(deletedAt.toUtc()),
|
||||||
|
Variable<String>(historyId),
|
||||||
|
for (final id in keepIds) Variable<String>(id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replaceRemoteExerciseImages(domain.Exercise exercise) async {
|
||||||
|
final rows = await (database.select(
|
||||||
|
database.exerciseImages,
|
||||||
|
)..where((table) => table.exerciseId.equals(exercise.metadata.id))).get();
|
||||||
|
final activeRowsByMediaId = {
|
||||||
|
for (final row in rows)
|
||||||
|
if (row.deletedAt == null) row.mediaAssetId: row,
|
||||||
|
};
|
||||||
|
final desiredIds = exercise.imageMediaIds.toSet();
|
||||||
|
final removedIds = activeRowsByMediaId.values
|
||||||
|
.where((row) => !desiredIds.contains(row.mediaAssetId))
|
||||||
|
.map((row) => row.id)
|
||||||
|
.toList();
|
||||||
|
await _softDeleteRemoteRows(
|
||||||
|
tableName: 'exercise_images',
|
||||||
|
ids: removedIds,
|
||||||
|
deletedAt: exercise.metadata.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var index = 0; index < exercise.imageMediaIds.length; index++) {
|
||||||
|
final mediaId = exercise.imageMediaIds[index];
|
||||||
|
final existing = activeRowsByMediaId[mediaId];
|
||||||
|
final metadata = existing == null
|
||||||
|
? domain.EntityMetadata(
|
||||||
|
id: _exerciseImageId(exercise.metadata.id, mediaId),
|
||||||
|
createdAt: exercise.metadata.updatedAt,
|
||||||
|
updatedAt: exercise.metadata.updatedAt,
|
||||||
|
syncState: exercise.metadata.syncState,
|
||||||
|
originDeviceId: exercise.metadata.originDeviceId,
|
||||||
|
)
|
||||||
|
: _metadataFromRow(existing).touch(exercise.metadata.updatedAt);
|
||||||
|
await database
|
||||||
|
.into(database.exerciseImages)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
db.ExerciseImagesCompanion(
|
||||||
|
id: Value(metadata.id),
|
||||||
|
createdAt: Value(metadata.createdAt.toUtc()),
|
||||||
|
updatedAt: Value(metadata.updatedAt.toUtc()),
|
||||||
|
deletedAt: Value(_utcOrNull(metadata.deletedAt)),
|
||||||
|
schemaVersion: Value(metadata.schemaVersion),
|
||||||
|
syncState: Value(_syncStateToDb(metadata.syncState)),
|
||||||
|
localRevision: Value(metadata.localRevision),
|
||||||
|
originDeviceId: Value(metadata.originDeviceId),
|
||||||
|
futureOwnerProfileId: Value(metadata.futureOwnerProfileId),
|
||||||
|
lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)),
|
||||||
|
remoteRevision: Value(metadata.remoteRevision),
|
||||||
|
exerciseId: Value(exercise.metadata.id),
|
||||||
|
mediaAssetId: Value(mediaId),
|
||||||
|
position: Value(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replaceRemoteExerciseSteps(domain.Exercise exercise) async {
|
||||||
|
final rows = await (database.select(
|
||||||
|
database.exerciseSteps,
|
||||||
|
)..where((table) => table.exerciseId.equals(exercise.metadata.id))).get();
|
||||||
|
final activeRowsByStepId = {
|
||||||
|
for (final row in rows)
|
||||||
|
if (row.deletedAt == null) row.id: row,
|
||||||
|
};
|
||||||
|
final desiredIds = exercise.steps.map((step) => step.id).toSet();
|
||||||
|
final removedIds = activeRowsByStepId.values
|
||||||
|
.where((row) => !desiredIds.contains(row.id))
|
||||||
|
.map((row) => row.id)
|
||||||
|
.toList();
|
||||||
|
await _softDeleteRemoteRows(
|
||||||
|
tableName: 'exercise_steps',
|
||||||
|
ids: removedIds,
|
||||||
|
deletedAt: exercise.metadata.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final step in exercise.steps) {
|
||||||
|
final existing = activeRowsByStepId[step.id];
|
||||||
|
final metadata = existing == null
|
||||||
|
? domain.EntityMetadata(
|
||||||
|
id: step.id,
|
||||||
|
createdAt: exercise.metadata.updatedAt,
|
||||||
|
updatedAt: exercise.metadata.updatedAt,
|
||||||
|
syncState: exercise.metadata.syncState,
|
||||||
|
originDeviceId: exercise.metadata.originDeviceId,
|
||||||
|
)
|
||||||
|
: _metadataFromRow(existing).touch(exercise.metadata.updatedAt);
|
||||||
|
await database
|
||||||
|
.into(database.exerciseSteps)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
db.ExerciseStepsCompanion(
|
||||||
|
id: Value(metadata.id),
|
||||||
|
createdAt: Value(metadata.createdAt.toUtc()),
|
||||||
|
updatedAt: Value(metadata.updatedAt.toUtc()),
|
||||||
|
deletedAt: Value(_utcOrNull(metadata.deletedAt)),
|
||||||
|
schemaVersion: Value(metadata.schemaVersion),
|
||||||
|
syncState: Value(_syncStateToDb(metadata.syncState)),
|
||||||
|
localRevision: Value(metadata.localRevision),
|
||||||
|
originDeviceId: Value(metadata.originDeviceId),
|
||||||
|
futureOwnerProfileId: Value(metadata.futureOwnerProfileId),
|
||||||
|
lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)),
|
||||||
|
remoteRevision: Value(metadata.remoteRevision),
|
||||||
|
exerciseId: Value(exercise.metadata.id),
|
||||||
|
position: Value(step.position),
|
||||||
|
name: Value(step.name),
|
||||||
|
type: Value(_exerciseStepTypeToDb(step.type)),
|
||||||
|
defaultTargetValue: Value(step.defaultTargetValue),
|
||||||
|
hasScore: Value(step.hasScore),
|
||||||
|
scoreInputMode: Value(
|
||||||
|
step.hasScore ? _scoreInputModeToDb(step.scoreInputMode) : null,
|
||||||
|
),
|
||||||
|
scoreLabel: Value(step.scoreLabel),
|
||||||
|
scoreUnit: Value(step.scoreUnit),
|
||||||
|
defaultTargetScore: Value(step.defaultTargetScore),
|
||||||
|
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
|
||||||
|
linkedToSeriesScore: Value(step.linkedToSeriesScore),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _softDeleteRemoteRows({
|
||||||
|
required String tableName,
|
||||||
|
required List<String> ids,
|
||||||
|
required DateTime deletedAt,
|
||||||
|
}) async {
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await database.customUpdate(
|
||||||
|
'''
|
||||||
|
UPDATE $tableName
|
||||||
|
SET deleted_at = ?, updated_at = ?, sync_state = 'deleted'
|
||||||
|
WHERE id IN (${List.filled(ids.length, '?').join(', ')})
|
||||||
|
''',
|
||||||
|
variables: [
|
||||||
|
Variable<DateTime>(deletedAt.toUtc()),
|
||||||
|
Variable<DateTime>(deletedAt.toUtc()),
|
||||||
|
for (final id in ids) Variable<String>(id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class DriftShareInboxRepository implements ShareInboxRepository {
|
final class DriftShareInboxRepository implements ShareInboxRepository {
|
||||||
|
|||||||
@ -19,11 +19,11 @@ final class HttpRemoteShareApi implements RemoteShareApi {
|
|||||||
final response = await client.postJson(
|
final response = await client.postJson(
|
||||||
'/shares',
|
'/shares',
|
||||||
bearerToken: token,
|
bearerToken: token,
|
||||||
body: {
|
body: _shareRequestBody(
|
||||||
'resourceType': _shareResourceTypeToWire(resourceType),
|
resourceType: resourceType,
|
||||||
'payload': payload,
|
payload: payload,
|
||||||
'recipientEmails': recipientEmails,
|
recipientEmails: recipientEmails,
|
||||||
},
|
),
|
||||||
expectedStatuses: const {201},
|
expectedStatuses: const {201},
|
||||||
);
|
);
|
||||||
return RemoteShareSendResult(
|
return RemoteShareSendResult(
|
||||||
@ -43,12 +43,22 @@ final class HttpRemoteShareApi implements RemoteShareApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
|
String shareId,
|
||||||
|
String token,
|
||||||
|
) async {
|
||||||
final response = await client.postJson(
|
final response = await client.postJson(
|
||||||
'/shares/$shareId/accept',
|
'/shares/$shareId/accept',
|
||||||
bearerToken: token,
|
bearerToken: token,
|
||||||
);
|
);
|
||||||
return _syncedItemFromJson(_map(response['createdResource']));
|
final resources = _list(
|
||||||
|
response,
|
||||||
|
'createdResources',
|
||||||
|
).map((item) => _syncedItemFromJson(_map(item))).toList(growable: false);
|
||||||
|
if (resources.isNotEmpty) {
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
return [_syncedItemFromJson(_map(response['createdResource']))];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -62,20 +72,91 @@ final class HttpRemoteShareApi implements RemoteShareApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _shareRequestBody({
|
||||||
|
required ShareResourceType resourceType,
|
||||||
|
required Map<String, Object?> payload,
|
||||||
|
required List<String> recipientEmails,
|
||||||
|
}) {
|
||||||
|
if (resourceType != ShareResourceType.pack) {
|
||||||
|
return {
|
||||||
|
'shareKind': 'single',
|
||||||
|
'resourceType': _shareResourceTypeToWire(resourceType),
|
||||||
|
'payload': payload,
|
||||||
|
'recipientEmails': recipientEmails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
final rawWorkouts = payload['workouts'];
|
||||||
|
if (rawWorkouts is! List) {
|
||||||
|
throw const RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'Pack payload must contain workouts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'shareKind': 'pack',
|
||||||
|
'packName': _stringFromObject(payload['name'], 'Pack'),
|
||||||
|
'items': [
|
||||||
|
for (final rawWorkout in rawWorkouts)
|
||||||
|
{'resourceType': 'workoutTemplate', 'payload': _map(rawWorkout)},
|
||||||
|
],
|
||||||
|
'recipientEmails': recipientEmails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ShareInboxItem _inboxItemFromJson(Map<String, Object?> json) {
|
ShareInboxItem _inboxItemFromJson(Map<String, Object?> json) {
|
||||||
|
final resourceType = _inboxResourceType(json);
|
||||||
return ShareInboxItem(
|
return ShareInboxItem(
|
||||||
shareId: _requiredString(json, 'shareId'),
|
shareId: _requiredString(json, 'shareId'),
|
||||||
senderUserId: _requiredString(json, 'senderUserId'),
|
senderUserId: _requiredString(json, 'senderUserId'),
|
||||||
resourceType: _shareResourceTypeFromWire(
|
resourceType: resourceType,
|
||||||
_requiredString(json, 'resourceType'),
|
payloadJson: _payloadJsonString(json, resourceType),
|
||||||
),
|
|
||||||
payloadJson: _jsonObjectString(json['payload']),
|
|
||||||
status: _shareInboxStatusFromWire(_requiredString(json, 'status')),
|
status: _shareInboxStatusFromWire(_requiredString(json, 'status')),
|
||||||
createdAt: _requiredDateTime(json, 'createdAt'),
|
createdAt: _requiredDateTime(json, 'createdAt'),
|
||||||
respondedAt: _optionalDateTime(json, 'respondedAt'),
|
respondedAt: _optionalDateTime(json, 'respondedAt'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ShareResourceType _inboxResourceType(Map<String, Object?> json) {
|
||||||
|
final shareKind = json['shareKind'];
|
||||||
|
if (shareKind == 'pack') {
|
||||||
|
return ShareResourceType.pack;
|
||||||
|
}
|
||||||
|
return _shareResourceTypeFromWire(_requiredString(json, 'resourceType'));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _payloadJsonString(
|
||||||
|
Map<String, Object?> json,
|
||||||
|
ShareResourceType resourceType,
|
||||||
|
) {
|
||||||
|
final payload = _map(json['payload']);
|
||||||
|
if (resourceType != ShareResourceType.pack) {
|
||||||
|
return jsonEncode(payload);
|
||||||
|
}
|
||||||
|
if (payload['workouts'] is List) {
|
||||||
|
return jsonEncode({
|
||||||
|
'name': json['packName'] as String? ?? payload['name'] ?? 'Pack',
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final rawItems = payload['items'];
|
||||||
|
if (rawItems is! List) {
|
||||||
|
return jsonEncode({
|
||||||
|
'name': json['packName'] as String? ?? 'Pack',
|
||||||
|
'workouts': const <Object?>[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonEncode({
|
||||||
|
'name': json['packName'] as String? ?? 'Pack',
|
||||||
|
'workouts': [
|
||||||
|
for (final rawItem in rawItems)
|
||||||
|
if (rawItem is Map &&
|
||||||
|
Map<String, Object?>.from(rawItem)['resourceType'] ==
|
||||||
|
'workoutTemplate')
|
||||||
|
_map(Map<String, Object?>.from(rawItem)['payload']),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
|
RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
|
||||||
return RemoteSyncedItem(
|
return RemoteSyncedItem(
|
||||||
resourceType: _syncResourceTypeFromWire(
|
resourceType: _syncResourceTypeFromWire(
|
||||||
@ -163,6 +244,10 @@ String _requiredString(Map<String, Object?> json, String key) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _stringFromObject(Object? value, String fallback) {
|
||||||
|
return value is String && value.trim().isNotEmpty ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
int _requiredInt(Map<String, Object?> json, String key) {
|
int _requiredInt(Map<String, Object?> json, String key) {
|
||||||
final value = json[key];
|
final value = json[key];
|
||||||
if (value is int) {
|
if (value is int) {
|
||||||
@ -184,7 +269,3 @@ DateTime? _optionalDateTime(Map<String, Object?> json, String key) {
|
|||||||
? DateTime.parse(value).toUtc()
|
? DateTime.parse(value).toUtc()
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _jsonObjectString(Object? value) {
|
|
||||||
return jsonEncode(_map(value));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2570,6 +2570,10 @@ final class _StepSequencePanel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final currentStep = view.currentStep;
|
final currentStep = view.currentStep;
|
||||||
|
final currentStepIsRepsOnly =
|
||||||
|
currentStep != null &&
|
||||||
|
currentStep.type == ExerciseStepType.reps &&
|
||||||
|
!currentStep.hasScore;
|
||||||
final sequenceComplete =
|
final sequenceComplete =
|
||||||
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
|
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
|
||||||
final completedPassages = sequenceComplete
|
final completedPassages = sequenceComplete
|
||||||
@ -2596,20 +2600,13 @@ final class _StepSequencePanel extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
SingleChildScrollView(
|
if (currentStepIsRepsOnly)
|
||||||
scrollDirection: Axis.horizontal,
|
Row(children: _stepProgressChips(view))
|
||||||
child: Row(
|
else
|
||||||
children: [
|
SingleChildScrollView(
|
||||||
for (var index = 0; index < view.steps.length; index++) ...[
|
scrollDirection: Axis.horizontal,
|
||||||
_StepProgressChip(
|
child: Row(children: _stepProgressChips(view)),
|
||||||
index: index,
|
|
||||||
status: _stepDisplayStatus(view, index),
|
|
||||||
),
|
|
||||||
if (index < view.steps.length - 1) const SizedBox(width: 6),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: sequenceComplete || currentStep == null
|
child: sequenceComplete || currentStep == null
|
||||||
@ -2645,6 +2642,15 @@ final class _StepSequencePanel extends StatelessWidget {
|
|||||||
|
|
||||||
enum _StepSkipAction { passage, sequence }
|
enum _StepSkipAction { passage, sequence }
|
||||||
|
|
||||||
|
List<Widget> _stepProgressChips(ActiveExerciseStepProgressView view) {
|
||||||
|
return [
|
||||||
|
for (var index = 0; index < view.steps.length; index++) ...[
|
||||||
|
_StepProgressChip(index: index, status: _stepDisplayStatus(view, index)),
|
||||||
|
if (index < view.steps.length - 1) const SizedBox(width: 6),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
final class _BoundedAccentPanel extends StatelessWidget {
|
final class _BoundedAccentPanel extends StatelessWidget {
|
||||||
const _BoundedAccentPanel({required this.child, required this.padding});
|
const _BoundedAccentPanel({required this.child, required this.padding});
|
||||||
|
|
||||||
@ -2802,96 +2808,96 @@ final class _CurrentStepPane extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SingleChildScrollView(
|
return Column(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: ConstrainedBox(
|
children: [
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
Text(
|
||||||
child: IntrinsicHeight(
|
step.name,
|
||||||
child: Column(
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
maxLines: 1,
|
||||||
children: [
|
overflow: TextOverflow.ellipsis,
|
||||||
Text(
|
),
|
||||||
step.name,
|
const SizedBox(height: 4),
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
Expanded(
|
||||||
maxLines: 1,
|
child: step.type == ExerciseStepType.time
|
||||||
overflow: TextOverflow.ellipsis,
|
? _TimedStepBody(
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Expanded(
|
|
||||||
child: step.type == ExerciseStepType.time
|
|
||||||
? _TimedStepBody(
|
|
||||||
step: step,
|
|
||||||
remainingLabel: remainingLabel,
|
|
||||||
running:
|
|
||||||
view.state.status ==
|
|
||||||
ActiveExerciseStepProgressStatus.runningTimer,
|
|
||||||
readyToStart: _isNextTimedStepReady(view),
|
|
||||||
onStartTimer: onStartTimer,
|
|
||||||
)
|
|
||||||
: Align(
|
|
||||||
alignment: Alignment.topCenter,
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
alignment: Alignment.topCenter,
|
|
||||||
child: _RepsStepBody(
|
|
||||||
step: step,
|
|
||||||
onCompleteStep: onCompleteStep,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (step.hasScore) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_StepScoreInput(
|
|
||||||
step: step,
|
step: step,
|
||||||
controller: stepScoreController,
|
remainingLabel: remainingLabel,
|
||||||
elapsedLabel: stepScoreElapsedLabel,
|
running:
|
||||||
running: stepScoreRunning,
|
view.state.status ==
|
||||||
onStart: onStartStepScore,
|
ActiveExerciseStepProgressStatus.runningTimer,
|
||||||
onStop: onStopStepScore,
|
readyToStart: _isNextTimedStepReady(view),
|
||||||
onReset: onResetStepScore,
|
onStartTimer: onStartTimer,
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: _RepsStepBody(step: step),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (step.hasScore) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_StepScoreInput(
|
||||||
|
step: step,
|
||||||
|
controller: stepScoreController,
|
||||||
|
elapsedLabel: stepScoreElapsedLabel,
|
||||||
|
running: stepScoreRunning,
|
||||||
|
onStart: onStartStepScore,
|
||||||
|
onStop: onStopStepScore,
|
||||||
|
onReset: onResetStepScore,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onSkipStep,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(44),
|
||||||
|
),
|
||||||
|
child: const Text('Passer l’étape'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PopupMenuButton<_StepSkipAction>(
|
||||||
|
tooltip: 'Plus d’actions',
|
||||||
|
icon: const Icon(Icons.more_horiz),
|
||||||
|
onSelected: (action) {
|
||||||
|
if (action == _StepSkipAction.passage) {
|
||||||
|
onSkipPassage();
|
||||||
|
} else {
|
||||||
|
onSkipSequence();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _StepSkipAction.passage,
|
||||||
|
child: Text('Passer ce passage'),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _StepSkipAction.sequence,
|
||||||
|
child: Text('Passer la séquence'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
Row(
|
if (step.type == ExerciseStepType.reps) ...[
|
||||||
children: [
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: FilledButton.icon(
|
||||||
onPressed: onSkipStep,
|
onPressed: onCompleteStep,
|
||||||
style: OutlinedButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(44),
|
minimumSize: const Size.fromHeight(44),
|
||||||
),
|
|
||||||
child: const Text('Passer l’étape'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
icon: const Icon(Icons.check),
|
||||||
PopupMenuButton<_StepSkipAction>(
|
label: const Text('Étape suivante'),
|
||||||
tooltip: 'Plus d’actions',
|
),
|
||||||
icon: const Icon(Icons.more_horiz),
|
|
||||||
onSelected: (action) {
|
|
||||||
if (action == _StepSkipAction.passage) {
|
|
||||||
onSkipPassage();
|
|
||||||
} else {
|
|
||||||
onSkipSequence();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => const [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: _StepSkipAction.passage,
|
|
||||||
child: Text('Passer ce passage'),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: _StepSkipAction.sequence,
|
|
||||||
child: Text('Passer la séquence'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -3241,10 +3247,9 @@ bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class _RepsStepBody extends StatelessWidget {
|
final class _RepsStepBody extends StatelessWidget {
|
||||||
const _RepsStepBody({required this.step, required this.onCompleteStep});
|
const _RepsStepBody({required this.step});
|
||||||
|
|
||||||
final ExerciseStep step;
|
final ExerciseStep step;
|
||||||
final VoidCallback onCompleteStep;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -3257,12 +3262,6 @@ final class _RepsStepBody extends StatelessWidget {
|
|||||||
).copyWith(color: Theme.of(context).colorScheme.primary),
|
).copyWith(color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
|
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: onCompleteStep,
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
label: const Text('Étape suivante'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const int watchBridgeSchemaVersion = 4;
|
const int watchBridgeSchemaVersion = 5;
|
||||||
|
|
||||||
enum WatchCommandType {
|
enum WatchCommandType {
|
||||||
startCurrentExercise,
|
startCurrentExercise,
|
||||||
@ -140,6 +140,7 @@ final class WatchSessionProjection {
|
|||||||
required this.deviceSessionId,
|
required this.deviceSessionId,
|
||||||
required this.revision,
|
required this.revision,
|
||||||
required this.projectedAtEpochMs,
|
required this.projectedAtEpochMs,
|
||||||
|
this.expiresAtEpochMs = 0,
|
||||||
required this.phase,
|
required this.phase,
|
||||||
required this.phoneReachable,
|
required this.phoneReachable,
|
||||||
required this.seriesIndex,
|
required this.seriesIndex,
|
||||||
@ -166,6 +167,7 @@ final class WatchSessionProjection {
|
|||||||
this.canDecrementScore = false,
|
this.canDecrementScore = false,
|
||||||
this.manualScoreTargetValue,
|
this.manualScoreTargetValue,
|
||||||
this.manualScoreTargetLabel,
|
this.manualScoreTargetLabel,
|
||||||
|
this.manualScoreRepsTargetValue,
|
||||||
this.manualScoreScope,
|
this.manualScoreScope,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -178,6 +180,7 @@ final class WatchSessionProjection {
|
|||||||
deviceSessionId: _stringFromJson(json['deviceSessionId']),
|
deviceSessionId: _stringFromJson(json['deviceSessionId']),
|
||||||
revision: _intFromJson(json['revision'], 0),
|
revision: _intFromJson(json['revision'], 0),
|
||||||
projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0),
|
projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0),
|
||||||
|
expiresAtEpochMs: _intFromJson(json['expiresAtEpochMs'], 0),
|
||||||
phase: _enumFromJson(
|
phase: _enumFromJson(
|
||||||
json['phase'],
|
json['phase'],
|
||||||
WatchSessionPhase.values,
|
WatchSessionPhase.values,
|
||||||
@ -221,6 +224,9 @@ final class WatchSessionProjection {
|
|||||||
manualScoreTargetLabel: _nullableStringFromJson(
|
manualScoreTargetLabel: _nullableStringFromJson(
|
||||||
json['manualScoreTargetLabel'],
|
json['manualScoreTargetLabel'],
|
||||||
),
|
),
|
||||||
|
manualScoreRepsTargetValue: _nullableIntFromJson(
|
||||||
|
json['manualScoreRepsTargetValue'],
|
||||||
|
),
|
||||||
manualScoreScope: _nullableEnumFromJson(
|
manualScoreScope: _nullableEnumFromJson(
|
||||||
json['manualScoreScope'],
|
json['manualScoreScope'],
|
||||||
WatchManualScoreScope.values,
|
WatchManualScoreScope.values,
|
||||||
@ -232,6 +238,7 @@ final class WatchSessionProjection {
|
|||||||
final String deviceSessionId;
|
final String deviceSessionId;
|
||||||
final int revision;
|
final int revision;
|
||||||
final int projectedAtEpochMs;
|
final int projectedAtEpochMs;
|
||||||
|
final int expiresAtEpochMs;
|
||||||
final WatchSessionPhase phase;
|
final WatchSessionPhase phase;
|
||||||
final bool phoneReachable;
|
final bool phoneReachable;
|
||||||
final int seriesIndex;
|
final int seriesIndex;
|
||||||
@ -258,6 +265,7 @@ final class WatchSessionProjection {
|
|||||||
final bool canDecrementScore;
|
final bool canDecrementScore;
|
||||||
final double? manualScoreTargetValue;
|
final double? manualScoreTargetValue;
|
||||||
final String? manualScoreTargetLabel;
|
final String? manualScoreTargetLabel;
|
||||||
|
final int? manualScoreRepsTargetValue;
|
||||||
final WatchManualScoreScope? manualScoreScope;
|
final WatchManualScoreScope? manualScoreScope;
|
||||||
|
|
||||||
Map<String, Object?> toJson() {
|
Map<String, Object?> toJson() {
|
||||||
@ -266,6 +274,7 @@ final class WatchSessionProjection {
|
|||||||
'deviceSessionId': deviceSessionId,
|
'deviceSessionId': deviceSessionId,
|
||||||
'revision': revision,
|
'revision': revision,
|
||||||
'projectedAtEpochMs': projectedAtEpochMs,
|
'projectedAtEpochMs': projectedAtEpochMs,
|
||||||
|
'expiresAtEpochMs': expiresAtEpochMs,
|
||||||
'phase': phase.name,
|
'phase': phase.name,
|
||||||
'phoneReachable': phoneReachable,
|
'phoneReachable': phoneReachable,
|
||||||
'seriesIndex': seriesIndex,
|
'seriesIndex': seriesIndex,
|
||||||
@ -296,6 +305,7 @@ final class WatchSessionProjection {
|
|||||||
'canDecrementScore': canDecrementScore,
|
'canDecrementScore': canDecrementScore,
|
||||||
'manualScoreTargetValue': manualScoreTargetValue,
|
'manualScoreTargetValue': manualScoreTargetValue,
|
||||||
'manualScoreTargetLabel': manualScoreTargetLabel,
|
'manualScoreTargetLabel': manualScoreTargetLabel,
|
||||||
|
'manualScoreRepsTargetValue': manualScoreRepsTargetValue,
|
||||||
'manualScoreScope': manualScoreScope?.name,
|
'manualScoreScope': manualScoreScope?.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -308,6 +318,7 @@ final class WatchSessionProjection {
|
|||||||
deviceSessionId == other.deviceSessionId &&
|
deviceSessionId == other.deviceSessionId &&
|
||||||
revision == other.revision &&
|
revision == other.revision &&
|
||||||
projectedAtEpochMs == other.projectedAtEpochMs &&
|
projectedAtEpochMs == other.projectedAtEpochMs &&
|
||||||
|
expiresAtEpochMs == other.expiresAtEpochMs &&
|
||||||
phase == other.phase &&
|
phase == other.phase &&
|
||||||
phoneReachable == other.phoneReachable &&
|
phoneReachable == other.phoneReachable &&
|
||||||
seriesIndex == other.seriesIndex &&
|
seriesIndex == other.seriesIndex &&
|
||||||
@ -334,6 +345,7 @@ final class WatchSessionProjection {
|
|||||||
canDecrementScore == other.canDecrementScore &&
|
canDecrementScore == other.canDecrementScore &&
|
||||||
manualScoreTargetValue == other.manualScoreTargetValue &&
|
manualScoreTargetValue == other.manualScoreTargetValue &&
|
||||||
manualScoreTargetLabel == other.manualScoreTargetLabel &&
|
manualScoreTargetLabel == other.manualScoreTargetLabel &&
|
||||||
|
manualScoreRepsTargetValue == other.manualScoreRepsTargetValue &&
|
||||||
manualScoreScope == other.manualScoreScope;
|
manualScoreScope == other.manualScoreScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -344,6 +356,7 @@ final class WatchSessionProjection {
|
|||||||
deviceSessionId,
|
deviceSessionId,
|
||||||
revision,
|
revision,
|
||||||
projectedAtEpochMs,
|
projectedAtEpochMs,
|
||||||
|
expiresAtEpochMs,
|
||||||
phase,
|
phase,
|
||||||
phoneReachable,
|
phoneReachable,
|
||||||
seriesIndex,
|
seriesIndex,
|
||||||
@ -370,6 +383,7 @@ final class WatchSessionProjection {
|
|||||||
canDecrementScore,
|
canDecrementScore,
|
||||||
manualScoreTargetValue,
|
manualScoreTargetValue,
|
||||||
manualScoreTargetLabel,
|
manualScoreTargetLabel,
|
||||||
|
manualScoreRepsTargetValue,
|
||||||
manualScoreScope,
|
manualScoreScope,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,6 +148,7 @@ void main() {
|
|||||||
deviceSessionId: 'session-${phase.name}-${primaryAction.name}',
|
deviceSessionId: 'session-${phase.name}-${primaryAction.name}',
|
||||||
revision: 4,
|
revision: 4,
|
||||||
projectedAtEpochMs: 1710000000100,
|
projectedAtEpochMs: 1710000000100,
|
||||||
|
expiresAtEpochMs: 1710000012100,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
phoneReachable: true,
|
phoneReachable: true,
|
||||||
seriesIndex: 2,
|
seriesIndex: 2,
|
||||||
@ -174,6 +175,7 @@ void main() {
|
|||||||
canDecrementScore: true,
|
canDecrementScore: true,
|
||||||
manualScoreTargetValue: 10,
|
manualScoreTargetValue: 10,
|
||||||
manualScoreTargetLabel: 'Cible',
|
manualScoreTargetLabel: 'Cible',
|
||||||
|
manualScoreRepsTargetValue: 12,
|
||||||
manualScoreScope: WatchManualScoreScope.step,
|
manualScoreScope: WatchManualScoreScope.step,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -220,6 +222,7 @@ void main() {
|
|||||||
expect(projection.canDecrementScore, isFalse);
|
expect(projection.canDecrementScore, isFalse);
|
||||||
expect(projection.manualScoreTargetValue, isNull);
|
expect(projection.manualScoreTargetValue, isNull);
|
||||||
expect(projection.manualScoreTargetLabel, isNull);
|
expect(projection.manualScoreTargetLabel, isNull);
|
||||||
|
expect(projection.manualScoreRepsTargetValue, isNull);
|
||||||
expect(projection.manualScoreScope, isNull);
|
expect(projection.manualScoreScope, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -230,6 +233,7 @@ void main() {
|
|||||||
expect(projection.deviceSessionId, '');
|
expect(projection.deviceSessionId, '');
|
||||||
expect(projection.revision, 0);
|
expect(projection.revision, 0);
|
||||||
expect(projection.projectedAtEpochMs, 0);
|
expect(projection.projectedAtEpochMs, 0);
|
||||||
|
expect(projection.expiresAtEpochMs, 0);
|
||||||
expect(projection.phase, WatchSessionPhase.noActiveSession);
|
expect(projection.phase, WatchSessionPhase.noActiveSession);
|
||||||
expect(projection.phoneReachable, false);
|
expect(projection.phoneReachable, false);
|
||||||
expect(projection.seriesIndex, 0);
|
expect(projection.seriesIndex, 0);
|
||||||
@ -244,6 +248,7 @@ void main() {
|
|||||||
expect(projection.canDecrementScore, isFalse);
|
expect(projection.canDecrementScore, isFalse);
|
||||||
expect(projection.manualScoreTargetValue, isNull);
|
expect(projection.manualScoreTargetValue, isNull);
|
||||||
expect(projection.manualScoreTargetLabel, isNull);
|
expect(projection.manualScoreTargetLabel, isNull);
|
||||||
|
expect(projection.manualScoreRepsTargetValue, isNull);
|
||||||
expect(projection.manualScoreScope, isNull);
|
expect(projection.manualScoreScope, isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2611,6 +2611,68 @@ void main() {
|
|||||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ShareUseCases acceptShare imports all returned resources locally',
|
||||||
|
() async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi()
|
||||||
|
..acceptResults = [
|
||||||
|
_remoteSharedProgramItem(),
|
||||||
|
_remoteSharedTemplateItem(),
|
||||||
|
];
|
||||||
|
final localChanges = _FakeLocalSyncChangeRepository();
|
||||||
|
final inboxRepository = _FakeShareInboxRepository()
|
||||||
|
..items.add(_shareInboxItem(status: ShareInboxStatus.pending));
|
||||||
|
|
||||||
|
await _shareUseCase(
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
localChanges: localChanges,
|
||||||
|
inboxRepository: inboxRepository,
|
||||||
|
).acceptShare('share-1');
|
||||||
|
|
||||||
|
expect(localChanges.appliedItems.map((item) => item.resourceType), [
|
||||||
|
SyncResourceType.program,
|
||||||
|
SyncResourceType.workoutTemplate,
|
||||||
|
]);
|
||||||
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ShareUseCases acceptShare imports cached pack payload after remote ack',
|
||||||
|
() async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi();
|
||||||
|
final templateRepository = _FakeWorkoutTemplateRepository();
|
||||||
|
final inboxRepository = _FakeShareInboxRepository()
|
||||||
|
..items.add(
|
||||||
|
_shareInboxItem(
|
||||||
|
status: ShareInboxStatus.pending,
|
||||||
|
resourceType: ShareResourceType.pack,
|
||||||
|
payloadJson: jsonEncode({
|
||||||
|
'name': 'Pack reprise',
|
||||||
|
'workouts': [
|
||||||
|
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
|
||||||
|
_sharedTemplatePayload(id: 'template-b', name: 'Séance B'),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _shareUseCase(
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
inboxRepository: inboxRepository,
|
||||||
|
templateRepository: templateRepository,
|
||||||
|
).acceptShare('share-1');
|
||||||
|
|
||||||
|
expect(remoteShareApi.acceptCalls, 1);
|
||||||
|
expect(templateRepository.templates, hasLength(2));
|
||||||
|
expect(
|
||||||
|
templateRepository.templates.map((template) => template.name),
|
||||||
|
containsAll(['Séance A', 'Séance B']),
|
||||||
|
);
|
||||||
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
|
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
|
||||||
() async {
|
() async {
|
||||||
@ -4559,6 +4621,7 @@ ShareUseCases _shareUseCase({
|
|||||||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||||
Exception? exception;
|
Exception? exception;
|
||||||
RemoteSyncedItem? acceptResult;
|
RemoteSyncedItem? acceptResult;
|
||||||
|
List<RemoteSyncedItem>? acceptResults;
|
||||||
List<ShareInboxItem> inboxItems = const [];
|
List<ShareInboxItem> inboxItems = const [];
|
||||||
var sendCalls = 0;
|
var sendCalls = 0;
|
||||||
var acceptCalls = 0;
|
var acceptCalls = 0;
|
||||||
@ -4568,13 +4631,16 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
|||||||
Map<String, Object?>? lastPayload;
|
Map<String, Object?>? lastPayload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
|
String shareId,
|
||||||
|
String token,
|
||||||
|
) async {
|
||||||
acceptCalls += 1;
|
acceptCalls += 1;
|
||||||
final error = exception;
|
final error = exception;
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return acceptResult ?? _remoteSharedProgramItem();
|
return acceptResults ?? [acceptResult ?? _remoteSharedProgramItem()];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -4804,6 +4870,19 @@ RemoteSyncedItem _remoteSharedProgramItem() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RemoteSyncedItem _remoteSharedTemplateItem() {
|
||||||
|
return RemoteSyncedItem(
|
||||||
|
resourceType: SyncResourceType.workoutTemplate,
|
||||||
|
clientId: 'shared-template-1',
|
||||||
|
serverId: 'server-template-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12, 2),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 3),
|
||||||
|
deletedAt: null,
|
||||||
|
payload: const {'id': 'shared-template-1', 'name': 'Séance partagée'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ExerciseUseCases _exerciseUseCase(
|
ExerciseUseCases _exerciseUseCase(
|
||||||
_FakeExerciseRepository repository, {
|
_FakeExerciseRepository repository, {
|
||||||
_FakeProgramRepository? programRepository,
|
_FakeProgramRepository? programRepository,
|
||||||
|
|||||||
@ -377,6 +377,43 @@ void main() {
|
|||||||
expect(env.repository.stepResults.last.actualScore, 0);
|
expect(env.repository.stepResults.last.actualScore, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('projects reps target for independent step manual score', () async {
|
||||||
|
final session = _session(
|
||||||
|
steps: [
|
||||||
|
_step(
|
||||||
|
type: ExerciseStepType.reps,
|
||||||
|
defaultTargetValue: 10,
|
||||||
|
hasScore: true,
|
||||||
|
defaultTargetScore: 8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final repository = _FakeActiveSessionRepository()..session = session;
|
||||||
|
repository.stepProgressStates['step-state'] = _stepState(
|
||||||
|
sessionId: session.metadata.id,
|
||||||
|
);
|
||||||
|
final projectionUseCases = WatchCompanionProjectionUseCases(
|
||||||
|
sessionRepository: repository,
|
||||||
|
clock: _FakeClock(_now),
|
||||||
|
ids: _FakeIds(),
|
||||||
|
originDeviceId: 'device-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final projection = await projectionUseCases.emitCurrentProjection();
|
||||||
|
|
||||||
|
expect(projection.hasManualScore, isTrue);
|
||||||
|
expect(projection.manualScoreScope, WatchManualScoreScope.step);
|
||||||
|
expect(projection.manualScoreRepsTargetValue, 10);
|
||||||
|
expect(projection.manualScoreTargetValue, 8);
|
||||||
|
expect(projection.manualScoreTargetLabel, 'Cible');
|
||||||
|
expect(
|
||||||
|
projection.expiresAtEpochMs - projection.projectedAtEpochMs,
|
||||||
|
const Duration(seconds: 12).inMilliseconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
await projectionUseCases.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('decrementScore at zero is accepted no-op', () async {
|
test('decrementScore at zero is accepted no-op', () async {
|
||||||
final env = _env(
|
final env = _env(
|
||||||
session: _session(scoreEnabled: true),
|
session: _session(scoreEnabled: true),
|
||||||
@ -740,6 +777,7 @@ final class _FakeProjectionSource implements WatchProjectionSource {
|
|||||||
deviceSessionId: projection.deviceSessionId,
|
deviceSessionId: projection.deviceSessionId,
|
||||||
revision: projection.revision + 1,
|
revision: projection.revision + 1,
|
||||||
projectedAtEpochMs: projection.projectedAtEpochMs,
|
projectedAtEpochMs: projection.projectedAtEpochMs,
|
||||||
|
expiresAtEpochMs: projection.expiresAtEpochMs,
|
||||||
phase: projection.phase,
|
phase: projection.phase,
|
||||||
phoneReachable: projection.phoneReachable,
|
phoneReachable: projection.phoneReachable,
|
||||||
seriesIndex: projection.seriesIndex,
|
seriesIndex: projection.seriesIndex,
|
||||||
@ -752,6 +790,9 @@ final class _FakeProjectionSource implements WatchProjectionSource {
|
|||||||
hasManualScore: projection.hasManualScore,
|
hasManualScore: projection.hasManualScore,
|
||||||
currentManualScoreValue: projection.currentManualScoreValue,
|
currentManualScoreValue: projection.currentManualScoreValue,
|
||||||
canDecrementScore: projection.canDecrementScore,
|
canDecrementScore: projection.canDecrementScore,
|
||||||
|
manualScoreTargetValue: projection.manualScoreTargetValue,
|
||||||
|
manualScoreTargetLabel: projection.manualScoreTargetLabel,
|
||||||
|
manualScoreRepsTargetValue: projection.manualScoreRepsTargetValue,
|
||||||
manualScoreScope: projection.manualScoreScope,
|
manualScoreScope: projection.manualScoreScope,
|
||||||
);
|
);
|
||||||
return projection;
|
return projection;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import 'package:gametime/infrastructure/local/local.dart' as local;
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late local.AppDatabase database;
|
late local.AppDatabase database;
|
||||||
|
late local.DriftMediaAssetRepository mediaAssetRepository;
|
||||||
late local.DriftExerciseRepository exerciseRepository;
|
late local.DriftExerciseRepository exerciseRepository;
|
||||||
late local.DriftProgramRepository programRepository;
|
late local.DriftProgramRepository programRepository;
|
||||||
late local.DriftActiveSessionRepository activeRepository;
|
late local.DriftActiveSessionRepository activeRepository;
|
||||||
@ -24,6 +25,7 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
database = local.AppDatabase(NativeDatabase.memory());
|
database = local.AppDatabase(NativeDatabase.memory());
|
||||||
|
mediaAssetRepository = local.DriftMediaAssetRepository(database);
|
||||||
exerciseRepository = local.DriftExerciseRepository(database);
|
exerciseRepository = local.DriftExerciseRepository(database);
|
||||||
programRepository = local.DriftProgramRepository(database);
|
programRepository = local.DriftProgramRepository(database);
|
||||||
activeRepository = local.DriftActiveSessionRepository(database);
|
activeRepository = local.DriftActiveSessionRepository(database);
|
||||||
@ -482,6 +484,209 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
|
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local sync payload includes full workout history aggregate', () async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 10, 45);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'sync-history-full',
|
||||||
|
startedAt: now,
|
||||||
|
result: _historySetResult(
|
||||||
|
id: 'sync-history-set-result',
|
||||||
|
historyId: 'sync-history-full',
|
||||||
|
sourceExerciseId: 'exercise-sync-full',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now,
|
||||||
|
actualScore: 12,
|
||||||
|
),
|
||||||
|
stepResults: [
|
||||||
|
_historyStepResult(
|
||||||
|
id: 'sync-history-step-result',
|
||||||
|
historyId: 'sync-history-full',
|
||||||
|
sourceExerciseId: 'exercise-sync-full',
|
||||||
|
startedAt: now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
minHeartRateBpm: 90,
|
||||||
|
averageHeartRateBpm: 120,
|
||||||
|
maxHeartRateBpm: 150,
|
||||||
|
totalDistanceMeters: 42,
|
||||||
|
totalCaloriesKcal: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final changes = await syncChangeRepository.listPendingChanges();
|
||||||
|
final payload = changes
|
||||||
|
.singleWhere((change) => change.item.clientId == 'sync-history-full')
|
||||||
|
.item
|
||||||
|
.payload;
|
||||||
|
|
||||||
|
expect(payload['minHeartRateBpm'], 90);
|
||||||
|
expect(payload['averageHeartRateBpm'], 120);
|
||||||
|
expect(payload['maxHeartRateBpm'], 150);
|
||||||
|
expect(payload['totalDistanceMeters'], 42);
|
||||||
|
expect(payload['totalCaloriesKcal'], 12);
|
||||||
|
expect(payload['results'], hasLength(1));
|
||||||
|
expect(payload['stepResults'], hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local sync pull restores exercise images and steps', () async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 10, 50);
|
||||||
|
await mediaAssetRepository.save(
|
||||||
|
MediaAsset(
|
||||||
|
metadata: _metadata('remote-image', now),
|
||||||
|
kind: MediaKind.image,
|
||||||
|
localUri: 'file:///remote-image.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final applied = await syncChangeRepository.applyRemoteItem(
|
||||||
|
RemoteSyncedItem(
|
||||||
|
resourceType: SyncResourceType.exercise,
|
||||||
|
clientId: 'remote-exercise-with-children',
|
||||||
|
serverId: 'server-exercise-with-children',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: now,
|
||||||
|
serverUpdatedAt: now,
|
||||||
|
deletedAt: null,
|
||||||
|
payload: {
|
||||||
|
'id': 'remote-exercise-with-children',
|
||||||
|
'name': 'Remote exercise',
|
||||||
|
'imageMediaIds': const ['remote-image'],
|
||||||
|
'iconMediaId': 'remote-image',
|
||||||
|
'hasTimeMeasure': false,
|
||||||
|
'hasRepsMeasure': true,
|
||||||
|
'hasScoreMeasure': true,
|
||||||
|
'scoreInputMode': 'manual',
|
||||||
|
'scoreLabel': 'Paniers',
|
||||||
|
'scoreUnit': 'pts',
|
||||||
|
'steps': [
|
||||||
|
_exerciseStep(
|
||||||
|
id: 'remote-step',
|
||||||
|
position: 0,
|
||||||
|
name: 'Tir main droite',
|
||||||
|
type: ExerciseStepType.reps,
|
||||||
|
defaultTargetValue: 10,
|
||||||
|
hasScore: true,
|
||||||
|
scoreLabel: 'Paniers',
|
||||||
|
scoreUnit: 'pts',
|
||||||
|
linkedToSeriesScore: true,
|
||||||
|
).toSnapshotJson(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final exercise = await exerciseRepository.findById(
|
||||||
|
'remote-exercise-with-children',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(applied, isTrue);
|
||||||
|
expect(exercise!.imageMediaIds, ['remote-image']);
|
||||||
|
expect(exercise.steps, hasLength(1));
|
||||||
|
expect(exercise.steps.single.linkedToSeriesScore, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local sync pull restores full workout history aggregate', () async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 10, 55);
|
||||||
|
final payload = <String, Object?>{
|
||||||
|
'metadata': {
|
||||||
|
'id': 'remote-history-full',
|
||||||
|
'createdAt': now.toUtc().toIso8601String(),
|
||||||
|
'updatedAt': now.toUtc().toIso8601String(),
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'syncState': 'synced',
|
||||||
|
'localRevision': 0,
|
||||||
|
'originDeviceId': 'device-remote',
|
||||||
|
},
|
||||||
|
'id': 'remote-history-full',
|
||||||
|
'nameSnapshot': 'Remote history',
|
||||||
|
'startedAt': now.toUtc().toIso8601String(),
|
||||||
|
'endedAt': now.add(const Duration(minutes: 5)).toUtc().toIso8601String(),
|
||||||
|
'totalActiveMs': 300000,
|
||||||
|
'completed': true,
|
||||||
|
'historySnapshotJson': '{"name":"remote-history-full"}',
|
||||||
|
'minHeartRateBpm': 95,
|
||||||
|
'averageHeartRateBpm': 125,
|
||||||
|
'maxHeartRateBpm': 155,
|
||||||
|
'totalDistanceMeters': 84,
|
||||||
|
'totalCaloriesKcal': 24,
|
||||||
|
'results': [
|
||||||
|
{
|
||||||
|
'id': 'remote-history-set-result',
|
||||||
|
'workoutHistoryId': 'remote-history-full',
|
||||||
|
'programSnapshotId': 'program-snapshot',
|
||||||
|
'exerciseSnapshotId': 'exercise-snapshot-remote-exercise',
|
||||||
|
'programIndex': 0,
|
||||||
|
'exerciseIndex': 0,
|
||||||
|
'setIndex': 0,
|
||||||
|
'programNameSnapshot': 'Program',
|
||||||
|
'exerciseNameSnapshot': 'Exercise',
|
||||||
|
'timeEnabledSnapshot': false,
|
||||||
|
'repsEnabledSnapshot': false,
|
||||||
|
'scoreEnabledSnapshot': true,
|
||||||
|
'scoreInputModeSnapshot': 'stopwatch',
|
||||||
|
'actualScoreTimeMs': 12000,
|
||||||
|
'sourceExerciseIdSnapshot': 'remote-exercise',
|
||||||
|
'completedAt': now
|
||||||
|
.add(const Duration(minutes: 1))
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String(),
|
||||||
|
'status': 'completed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'stepResults': [
|
||||||
|
{
|
||||||
|
'id': 'remote-history-step-result',
|
||||||
|
'workoutHistoryId': 'remote-history-full',
|
||||||
|
'programSnapshotId': 'program-snapshot',
|
||||||
|
'exerciseSnapshotId': 'exercise-snapshot-remote-exercise',
|
||||||
|
'programIndex': 0,
|
||||||
|
'exerciseIndex': 0,
|
||||||
|
'setIndex': 0,
|
||||||
|
'passageIndex': 0,
|
||||||
|
'stepIndex': 0,
|
||||||
|
'stepSnapshotId': 'step-snapshot',
|
||||||
|
'stepNameSnapshot': 'Step',
|
||||||
|
'stepTypeSnapshot': 'reps',
|
||||||
|
'targetValueSnapshot': 10,
|
||||||
|
'hasScoreSnapshot': false,
|
||||||
|
'status': 'completed',
|
||||||
|
'startedAt': now.toUtc().toIso8601String(),
|
||||||
|
'completedAt': now
|
||||||
|
.add(const Duration(seconds: 10))
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String(),
|
||||||
|
'actualReps': 10,
|
||||||
|
'sourceExerciseIdSnapshot': 'remote-exercise',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
final applied = await syncChangeRepository.applyRemoteItem(
|
||||||
|
RemoteSyncedItem(
|
||||||
|
resourceType: SyncResourceType.workoutHistory,
|
||||||
|
clientId: 'remote-history-full',
|
||||||
|
serverId: 'server-history-full',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: now,
|
||||||
|
serverUpdatedAt: now,
|
||||||
|
deletedAt: null,
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = await historyRepository.findById('remote-history-full');
|
||||||
|
|
||||||
|
expect(applied, isTrue);
|
||||||
|
expect(restored!.minHeartRateBpm, 95);
|
||||||
|
expect(restored.averageHeartRateBpm, 125);
|
||||||
|
expect(restored.maxHeartRateBpm, 155);
|
||||||
|
expect(restored.totalDistanceMeters, 84);
|
||||||
|
expect(restored.totalCaloriesKcal, 24);
|
||||||
|
expect(restored.results.single.actualScoreTimeMs, 12000);
|
||||||
|
expect(restored.stepResults.single.actualReps, 10);
|
||||||
|
});
|
||||||
|
|
||||||
test('local sync pull defaults missing tags to empty lists', () async {
|
test('local sync pull defaults missing tags to empty lists', () async {
|
||||||
final now = DateTime.utc(2026, 7, 22, 11);
|
final now = DateTime.utc(2026, 7, 22, 11);
|
||||||
await syncChangeRepository.applyRemoteItem(
|
await syncChangeRepository.applyRemoteItem(
|
||||||
@ -2616,8 +2821,14 @@ WorkoutHistory _history({
|
|||||||
required DateTime startedAt,
|
required DateTime startedAt,
|
||||||
WorkoutHistorySetResult? result,
|
WorkoutHistorySetResult? result,
|
||||||
List<WorkoutHistorySetResult>? results,
|
List<WorkoutHistorySetResult>? results,
|
||||||
|
List<WorkoutHistoryStepResult> stepResults = const [],
|
||||||
bool completed = true,
|
bool completed = true,
|
||||||
int totalActiveMs = 300000,
|
int totalActiveMs = 300000,
|
||||||
|
int? minHeartRateBpm,
|
||||||
|
double? averageHeartRateBpm,
|
||||||
|
int? maxHeartRateBpm,
|
||||||
|
double? totalDistanceMeters,
|
||||||
|
double? totalCaloriesKcal,
|
||||||
}) {
|
}) {
|
||||||
return WorkoutHistory(
|
return WorkoutHistory(
|
||||||
metadata: _metadata(id, startedAt),
|
metadata: _metadata(id, startedAt),
|
||||||
@ -2628,6 +2839,12 @@ WorkoutHistory _history({
|
|||||||
completed: completed,
|
completed: completed,
|
||||||
historySnapshotJson: '{"name":"$id"}',
|
historySnapshotJson: '{"name":"$id"}',
|
||||||
results: results ?? [result!],
|
results: results ?? [result!],
|
||||||
|
stepResults: stepResults,
|
||||||
|
minHeartRateBpm: minHeartRateBpm,
|
||||||
|
averageHeartRateBpm: averageHeartRateBpm,
|
||||||
|
maxHeartRateBpm: maxHeartRateBpm,
|
||||||
|
totalDistanceMeters: totalDistanceMeters,
|
||||||
|
totalCaloriesKcal: totalCaloriesKcal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2744,6 +2961,7 @@ ExerciseStep _exerciseStep({
|
|||||||
String? scoreUnit,
|
String? scoreUnit,
|
||||||
double? defaultTargetScore,
|
double? defaultTargetScore,
|
||||||
int? defaultTargetScoreTimeMs,
|
int? defaultTargetScoreTimeMs,
|
||||||
|
bool linkedToSeriesScore = false,
|
||||||
}) {
|
}) {
|
||||||
return ExerciseStep(
|
return ExerciseStep(
|
||||||
id: id,
|
id: id,
|
||||||
@ -2757,6 +2975,7 @@ ExerciseStep _exerciseStep({
|
|||||||
scoreUnit: scoreUnit,
|
scoreUnit: scoreUnit,
|
||||||
defaultTargetScore: defaultTargetScore,
|
defaultTargetScore: defaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||||
|
linkedToSeriesScore: linkedToSeriesScore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
181
test/infrastructure/remote/share_api_test.dart
Normal file
181
test/infrastructure/remote/share_api_test.dart
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gametime/application/application.dart';
|
||||||
|
import 'package:gametime/domain/domain.dart';
|
||||||
|
import 'package:gametime/infrastructure/remote/remote.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'sendShare maps local workout pack payload to server pack contract',
|
||||||
|
() async {
|
||||||
|
Map<String, Object?>? capturedBody;
|
||||||
|
final api = HttpRemoteShareApi(
|
||||||
|
HttpApiClient(
|
||||||
|
baseUrl: Uri.parse('http://api.example.test'),
|
||||||
|
client: MockClient((request) async {
|
||||||
|
capturedBody = _jsonMap(request.body);
|
||||||
|
expect(request.method, 'POST');
|
||||||
|
expect(request.url.path, '/shares');
|
||||||
|
expect(request.headers['authorization'], 'Bearer token-1');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'shareId': 'share-1',
|
||||||
|
'recipientUserIds': ['user-2'],
|
||||||
|
'unresolvedEmails': const <String>[],
|
||||||
|
}),
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.sendShare(
|
||||||
|
resourceType: ShareResourceType.pack,
|
||||||
|
payload: const {
|
||||||
|
'name': 'Pack reprise',
|
||||||
|
'workouts': [
|
||||||
|
{'id': 'template-1', 'name': 'Séance 1'},
|
||||||
|
{'id': 'template-2', 'name': 'Séance 2'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
recipientEmails: const ['friend@example.com'],
|
||||||
|
token: 'token-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedBody, {
|
||||||
|
'shareKind': 'pack',
|
||||||
|
'packName': 'Pack reprise',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'resourceType': 'workoutTemplate',
|
||||||
|
'payload': {'id': 'template-1', 'name': 'Séance 1'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'resourceType': 'workoutTemplate',
|
||||||
|
'payload': {'id': 'template-2', 'name': 'Séance 2'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'recipientEmails': ['friend@example.com'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fetchInbox maps server pack payload without resourceType to local pack',
|
||||||
|
() async {
|
||||||
|
final api = HttpRemoteShareApi(
|
||||||
|
HttpApiClient(
|
||||||
|
baseUrl: Uri.parse('http://api.example.test'),
|
||||||
|
client: MockClient((request) async {
|
||||||
|
expect(request.method, 'GET');
|
||||||
|
expect(request.url.path, '/shares/inbox');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'shareId': 'share-pack-1',
|
||||||
|
'senderUserId': 'sender-1',
|
||||||
|
'shareKind': 'pack',
|
||||||
|
'packName': 'Pack été',
|
||||||
|
'resourceType': null,
|
||||||
|
'payload': {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'resourceType': 'workoutTemplate',
|
||||||
|
'payload': {'id': 'template-1', 'name': 'Séance 1'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'status': 'pending',
|
||||||
|
'createdAt': '2026-07-17T12:00:00.000Z',
|
||||||
|
'respondedAt': null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final items = await api.fetchInbox('token-1');
|
||||||
|
final payload = _jsonMap(items.single.payloadJson);
|
||||||
|
|
||||||
|
expect(items.single.resourceType, ShareResourceType.pack);
|
||||||
|
expect(payload, {
|
||||||
|
'name': 'Pack été',
|
||||||
|
'workouts': [
|
||||||
|
{'id': 'template-1', 'name': 'Séance 1'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'acceptShare returns every created resource from server packs',
|
||||||
|
() async {
|
||||||
|
final api = HttpRemoteShareApi(
|
||||||
|
HttpApiClient(
|
||||||
|
baseUrl: Uri.parse('http://api.example.test'),
|
||||||
|
client: MockClient((request) async {
|
||||||
|
expect(request.method, 'POST');
|
||||||
|
expect(request.url.path, '/shares/share-pack-1/accept');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'createdResources': [
|
||||||
|
_createdResourceJson(
|
||||||
|
resourceType: 'program',
|
||||||
|
clientId: 'program-1',
|
||||||
|
serverId: 'server-program-1',
|
||||||
|
),
|
||||||
|
_createdResourceJson(
|
||||||
|
resourceType: 'workoutTemplate',
|
||||||
|
clientId: 'template-1',
|
||||||
|
serverId: 'server-template-1',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final resources = await api.acceptShare('share-pack-1', 'token-1');
|
||||||
|
|
||||||
|
expect(resources.map((item) => item.resourceType), [
|
||||||
|
SyncResourceType.program,
|
||||||
|
SyncResourceType.workoutTemplate,
|
||||||
|
]);
|
||||||
|
expect(resources.map((item) => item.clientId), [
|
||||||
|
'program-1',
|
||||||
|
'template-1',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _createdResourceJson({
|
||||||
|
required String resourceType,
|
||||||
|
required String clientId,
|
||||||
|
required String serverId,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'resourceType': resourceType,
|
||||||
|
'clientId': clientId,
|
||||||
|
'serverId': serverId,
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'clientUpdatedAt': '2026-07-17T12:00:00.000Z',
|
||||||
|
'serverUpdatedAt': '2026-07-17T12:01:00.000Z',
|
||||||
|
'deletedAt': null,
|
||||||
|
'payload': {'id': clientId},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _jsonMap(String source) {
|
||||||
|
final decoded = jsonDecode(source);
|
||||||
|
return Map<String, Object?>.from(decoded as Map);
|
||||||
|
}
|
||||||
@ -520,7 +520,10 @@ final class _FakeLocalSyncChangeRepository
|
|||||||
|
|
||||||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
|
String shareId,
|
||||||
|
String token,
|
||||||
|
) async {
|
||||||
throw const RemoteAuthException(RemoteAuthFailure.network);
|
throw const RemoteAuthException(RemoteAuthFailure.network);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -887,17 +887,22 @@ final class _FakeLocalSyncChangeRepository
|
|||||||
|
|
||||||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
return RemoteSyncedItem(
|
String shareId,
|
||||||
resourceType: SyncResourceType.program,
|
String token,
|
||||||
clientId: 'program-remote',
|
) async {
|
||||||
serverId: 'program-server',
|
return [
|
||||||
schemaVersion: 1,
|
RemoteSyncedItem(
|
||||||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
resourceType: SyncResourceType.program,
|
||||||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
clientId: 'program-remote',
|
||||||
deletedAt: null,
|
serverId: 'program-server',
|
||||||
payload: const {'name': 'Programme partagé'},
|
schemaVersion: 1,
|
||||||
);
|
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
deletedAt: null,
|
||||||
|
payload: const {'name': 'Programme partagé'},
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1241,17 +1241,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
|||||||
List<String> lastRecipientEmails = const [];
|
List<String> lastRecipientEmails = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
return RemoteSyncedItem(
|
String shareId,
|
||||||
resourceType: SyncResourceType.program,
|
String token,
|
||||||
clientId: 'program-remote',
|
) async {
|
||||||
serverId: 'program-server',
|
return [
|
||||||
schemaVersion: 1,
|
RemoteSyncedItem(
|
||||||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
resourceType: SyncResourceType.program,
|
||||||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
clientId: 'program-remote',
|
||||||
deletedAt: null,
|
serverId: 'program-server',
|
||||||
payload: const {'name': 'Programme partagé'},
|
schemaVersion: 1,
|
||||||
);
|
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
deletedAt: null,
|
||||||
|
payload: const {'name': 'Programme partagé'},
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -365,19 +365,24 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
|||||||
Map<String, Object?>? lastPayload;
|
Map<String, Object?>? lastPayload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
|
String shareId,
|
||||||
|
String token,
|
||||||
|
) async {
|
||||||
acceptCalls += 1;
|
acceptCalls += 1;
|
||||||
_mark(shareId, ShareInboxStatus.accepted);
|
_mark(shareId, ShareInboxStatus.accepted);
|
||||||
return RemoteSyncedItem(
|
return [
|
||||||
resourceType: SyncResourceType.program,
|
RemoteSyncedItem(
|
||||||
clientId: 'shared-program-1',
|
resourceType: SyncResourceType.program,
|
||||||
serverId: 'server-program-1',
|
clientId: 'shared-program-1',
|
||||||
schemaVersion: 1,
|
serverId: 'server-program-1',
|
||||||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
schemaVersion: 1,
|
||||||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
deletedAt: null,
|
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
payload: const {'name': 'Programme tirs'},
|
deletedAt: null,
|
||||||
);
|
payload: const {'name': 'Programme tirs'},
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1942,6 +1942,12 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('8'), findsWidgets);
|
expect(find.text('8'), findsWidgets);
|
||||||
|
expect(find.byType(SingleChildScrollView), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.widgetWithText(OutlinedButton, 'Passer l’étape'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsOneWidget);
|
||||||
await tester.tap(find.text('Étape suivante'));
|
await tester.tap(find.text('Étape suivante'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
|||||||
@ -1272,17 +1272,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
|||||||
List<String> lastRecipientEmails = const [];
|
List<String> lastRecipientEmails = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
Future<List<RemoteSyncedItem>> acceptShare(
|
||||||
return RemoteSyncedItem(
|
String shareId,
|
||||||
resourceType: SyncResourceType.workoutTemplate,
|
String token,
|
||||||
clientId: 'template-remote',
|
) async {
|
||||||
serverId: 'template-server',
|
return [
|
||||||
schemaVersion: 1,
|
RemoteSyncedItem(
|
||||||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
resourceType: SyncResourceType.workoutTemplate,
|
||||||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
clientId: 'template-remote',
|
||||||
deletedAt: null,
|
serverId: 'template-server',
|
||||||
payload: const {'name': 'Séance partagée'},
|
schemaVersion: 1,
|
||||||
);
|
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
deletedAt: null,
|
||||||
|
payload: const {'name': 'Séance partagée'},
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BODY_SENSORS"
|
android:name="android.permission.BODY_SENSORS"
|
||||||
android:maxSdkVersion="35" />
|
android:maxSdkVersion="35" />
|
||||||
@ -36,7 +37,7 @@
|
|||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
@ -47,6 +48,10 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.gametime.watch.OPEN_ACTIVE_SESSION" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.gametime.watch
|
package com.gametime.watch
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.wear.ambient.AmbientModeSupport
|
import androidx.wear.ambient.AmbientModeSupport
|
||||||
import com.gametime.watch.bridge.WatchBridgePlugin
|
import com.gametime.watch.bridge.WatchBridgePlugin
|
||||||
@ -13,6 +14,18 @@ class MainActivity :
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
AmbientModeSupport.attach(this)
|
AmbientModeSupport.attach(this)
|
||||||
WatchBridgePlugin.attachActivity(this)
|
WatchBridgePlugin.attachActivity(this)
|
||||||
|
WatchBridgePlugin.handleActivityReentry(this, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
WatchBridgePlugin.handleActivityReentry(this, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
WatchBridgePlugin.handleActivityReentry(this, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ object WatchBridgePlugin {
|
|||||||
const val ACK_PATH = "/gametime/phone/ack"
|
const val ACK_PATH = "/gametime/phone/ack"
|
||||||
const val STATE_PATH = "/gametime/phone/projection"
|
const val STATE_PATH = "/gametime/phone/projection"
|
||||||
const val PHONE_CAPABILITY = "gametime_phone_companion"
|
const val PHONE_CAPABILITY = "gametime_phone_companion"
|
||||||
|
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
|
||||||
private const val SENSOR_PERMISSION_REQUEST = 4106
|
private const val SENSOR_PERMISSION_REQUEST = 4106
|
||||||
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
|
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
|
||||||
private const val READ_HEART_RATE_PERMISSION =
|
private const val READ_HEART_RATE_PERMISSION =
|
||||||
@ -56,6 +57,9 @@ object WatchBridgePlugin {
|
|||||||
private var lastSensorPermissionRequestEpochMs = 0L
|
private var lastSensorPermissionRequestEpochMs = 0L
|
||||||
private var pendingSensorPermissionRequest = false
|
private var pendingSensorPermissionRequest = false
|
||||||
private var lastSensorProjection: Map<String, Any?>? = null
|
private var lastSensorProjection: Map<String, Any?>? = null
|
||||||
|
private var lastActiveProjection: Map<String, Any?>? = null
|
||||||
|
private var lastActiveProjectionReceivedAtEpochMs = 0L
|
||||||
|
private var activeProjectionExpiryRunnable: Runnable? = null
|
||||||
|
|
||||||
fun attachApplicationContext(context: Context) {
|
fun attachApplicationContext(context: Context) {
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
@ -123,6 +127,16 @@ object WatchBridgePlugin {
|
|||||||
requestPendingSensorPermissionIfPossible()
|
requestPendingSensorPermissionIfPossible()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleActivityReentry(activity: Activity, intent: android.content.Intent?) {
|
||||||
|
attachActivity(activity)
|
||||||
|
val context = activity.applicationContext
|
||||||
|
requestCapabilityRefresh(context)
|
||||||
|
requestLatestProjection(context)
|
||||||
|
if (intent?.action == ACTION_OPEN_ACTIVE_SESSION && hasFreshActiveProjection()) {
|
||||||
|
emitProjection(lastActiveProjection ?: return)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun detachActivity(activity: Activity) {
|
fun detachActivity(activity: Activity) {
|
||||||
if (this.activity === activity) {
|
if (this.activity === activity) {
|
||||||
this.activity = null
|
this.activity = null
|
||||||
@ -159,7 +173,9 @@ object WatchBridgePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun emitProjection(payload: Map<String, Any?>): Boolean {
|
fun emitProjection(payload: Map<String, Any?>): Boolean {
|
||||||
|
rememberActiveProjection(payload)
|
||||||
appContext?.let {
|
appContext?.let {
|
||||||
|
scheduleActiveProjectionExpiry(it, payload)
|
||||||
WatchOngoingActivityController.update(it, payload, activity)
|
WatchOngoingActivityController.update(it, payload, activity)
|
||||||
updateHeartRateCollection(it, payload)
|
updateHeartRateCollection(it, payload)
|
||||||
}
|
}
|
||||||
@ -229,10 +245,24 @@ object WatchBridgePlugin {
|
|||||||
requestCapabilityRefresh(context)
|
requestCapabilityRefresh(context)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"invalidateActiveProjection" -> {
|
||||||
|
invalidateActiveProjection(context)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun invalidateActiveProjection(context: Context) {
|
||||||
|
lastActiveProjection = null
|
||||||
|
lastActiveProjectionReceivedAtEpochMs = 0L
|
||||||
|
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
activeProjectionExpiryRunnable = null
|
||||||
|
WatchOngoingActivityController.cancel(context)
|
||||||
|
WatchHeartRateForegroundService.stop(context)
|
||||||
|
heartRateCollector.finishCurrentSession(context)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendCommand(
|
private fun sendCommand(
|
||||||
context: Context,
|
context: Context,
|
||||||
arguments: Any?,
|
arguments: Any?,
|
||||||
@ -318,6 +348,63 @@ object WatchBridgePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openActiveSessionIntent(context: Context): android.content.Intent {
|
||||||
|
return android.content.Intent(context, com.gametime.watch.MainActivity::class.java).apply {
|
||||||
|
action = ACTION_OPEN_ACTIVE_SESSION
|
||||||
|
flags = android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||||
|
android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rememberActiveProjection(projection: Map<String, Any?>) {
|
||||||
|
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||||
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
|
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||||
|
lastActiveProjection = null
|
||||||
|
lastActiveProjectionReceivedAtEpochMs = 0L
|
||||||
|
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
activeProjectionExpiryRunnable = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastActiveProjection = projection
|
||||||
|
lastActiveProjectionReceivedAtEpochMs = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleActiveProjectionExpiry(context: Context, projection: Map<String, Any?>) {
|
||||||
|
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||||
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
|
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||||
|
activeProjectionExpiryRunnable = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
|
||||||
|
val delayMs = if (expiresAt > 0L) {
|
||||||
|
(expiresAt - now).coerceAtLeast(0L)
|
||||||
|
} else {
|
||||||
|
12000L
|
||||||
|
}
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
activeProjectionExpiryRunnable = Runnable {
|
||||||
|
if (!hasFreshActiveProjection()) {
|
||||||
|
invalidateActiveProjection(appContext)
|
||||||
|
}
|
||||||
|
}.also { runnable ->
|
||||||
|
mainHandler.postDelayed(runnable, delayMs + 250L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasFreshActiveProjection(): Boolean {
|
||||||
|
val projection = lastActiveProjection ?: return false
|
||||||
|
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (expiresAt > 0L) {
|
||||||
|
return now < expiresAt
|
||||||
|
}
|
||||||
|
return now - lastActiveProjectionReceivedAtEpochMs <= 12000L
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
|
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
|
||||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
@ -416,6 +503,7 @@ object WatchBridgePlugin {
|
|||||||
return listOf(
|
return listOf(
|
||||||
heartRatePermission,
|
heartRatePermission,
|
||||||
android.Manifest.permission.ACTIVITY_RECOGNITION,
|
android.Manifest.permission.ACTIVITY_RECOGNITION,
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package com.gametime.watch.bridge
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.health.services.client.ExerciseClient
|
||||||
|
import androidx.health.services.client.ExerciseUpdateCallback
|
||||||
import androidx.health.services.client.HealthServices
|
import androidx.health.services.client.HealthServices
|
||||||
import androidx.health.services.client.MeasureClient
|
import androidx.health.services.client.MeasureClient
|
||||||
import androidx.health.services.client.MeasureCallback
|
import androidx.health.services.client.MeasureCallback
|
||||||
@ -9,6 +11,10 @@ import androidx.health.services.client.data.Availability
|
|||||||
import androidx.health.services.client.data.DataPointContainer
|
import androidx.health.services.client.data.DataPointContainer
|
||||||
import androidx.health.services.client.data.DataType
|
import androidx.health.services.client.data.DataType
|
||||||
import androidx.health.services.client.data.DeltaDataType
|
import androidx.health.services.client.data.DeltaDataType
|
||||||
|
import androidx.health.services.client.data.ExerciseConfig
|
||||||
|
import androidx.health.services.client.data.ExerciseEvent
|
||||||
|
import androidx.health.services.client.data.ExerciseLapSummary
|
||||||
|
import androidx.health.services.client.data.ExerciseType
|
||||||
import com.google.android.gms.wearable.CapabilityClient
|
import com.google.android.gms.wearable.CapabilityClient
|
||||||
import com.google.android.gms.wearable.Wearable
|
import com.google.android.gms.wearable.Wearable
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@ -35,10 +41,12 @@ internal class WatchHeartRateCollector(
|
|||||||
private var sampleSequence = 0
|
private var sampleSequence = 0
|
||||||
private var executionContext: Map<String, Any?> = emptyMap()
|
private var executionContext: Map<String, Any?> = emptyMap()
|
||||||
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
|
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
|
||||||
|
private var exerciseMetricsStarted = false
|
||||||
|
private var exerciseMetricsStartInFlight = false
|
||||||
private var shouldAggregate = false
|
private var shouldAggregate = false
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
|
|
||||||
private val callback = object : MeasureCallback {
|
private val measureCallback = object : MeasureCallback {
|
||||||
override fun onAvailabilityChanged(
|
override fun onAvailabilityChanged(
|
||||||
dataType: DeltaDataType<*, *>,
|
dataType: DeltaDataType<*, *>,
|
||||||
availability: Availability,
|
availability: Availability,
|
||||||
@ -54,21 +62,11 @@ internal class WatchHeartRateCollector(
|
|||||||
for (point in data.getData(DataType.HEART_RATE_BPM)) {
|
for (point in data.getData(DataType.HEART_RATE_BPM)) {
|
||||||
latestHeartRateBpm = recordHeartRate(point.value)
|
latestHeartRateBpm = recordHeartRate(point.value)
|
||||||
}
|
}
|
||||||
var updatedDistance = false
|
if (latestHeartRateBpm != null) {
|
||||||
for (point in data.getData(DataType.DISTANCE)) {
|
Log.d(
|
||||||
if (point.value > 0) {
|
TAG,
|
||||||
distanceMeters = (distanceMeters ?: 0.0) + point.value
|
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
|
||||||
updatedDistance = true
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
var updatedCalories = false
|
|
||||||
for (point in data.getData(DataType.CALORIES)) {
|
|
||||||
if (point.value > 0) {
|
|
||||||
caloriesKcal = (caloriesKcal ?: 0.0) + point.value
|
|
||||||
updatedCalories = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (latestHeartRateBpm != null || updatedDistance || updatedCalories) {
|
|
||||||
sendSample(latestHeartRateBpm)
|
sendSample(latestHeartRateBpm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,6 +76,57 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val exerciseCallback = object : ExerciseUpdateCallback {
|
||||||
|
override fun onRegistered() {
|
||||||
|
Log.d(TAG, "exercise update callback registered sessionId=$sessionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRegistrationFailed(throwable: Throwable) {
|
||||||
|
Log.w(TAG, "exercise update callback registration failed", throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExerciseUpdateReceived(update: androidx.health.services.client.data.ExerciseUpdate) {
|
||||||
|
if (!shouldAggregate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updated = false
|
||||||
|
for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
|
||||||
|
val value = point.value
|
||||||
|
if (value > 0) {
|
||||||
|
distanceMeters = (distanceMeters ?: 0.0) + value
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (point in update.latestMetrics.getData(DataType.CALORIES)) {
|
||||||
|
val value = point.value
|
||||||
|
if (value > 0) {
|
||||||
|
caloriesKcal = (caloriesKcal ?: 0.0) + value
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal",
|
||||||
|
)
|
||||||
|
sendSample(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) {}
|
||||||
|
|
||||||
|
override fun onAvailabilityChanged(
|
||||||
|
dataType: androidx.health.services.client.data.DataType<*, *>,
|
||||||
|
availability: Availability,
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "exercise availability dataType=$dataType availability=$availability")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExerciseEventReceived(event: ExerciseEvent) {
|
||||||
|
Log.d(TAG, "exercise event sessionId=$sessionId event=$event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun noteActiveSession(
|
fun noteActiveSession(
|
||||||
nextSessionId: String,
|
nextSessionId: String,
|
||||||
shouldAggregate: Boolean,
|
shouldAggregate: Boolean,
|
||||||
@ -103,17 +152,18 @@ internal class WatchHeartRateCollector(
|
|||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
val measureClient = HealthServices.getClient(context).measureClient
|
val measureClient = HealthServices.getClient(context).measureClient
|
||||||
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
|
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
|
||||||
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
|
startExerciseMetrics(context)
|
||||||
registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(context: Context) {
|
fun pause(context: Context) {
|
||||||
shouldAggregate = false
|
shouldAggregate = false
|
||||||
unregister(context)
|
unregister(context)
|
||||||
|
stopExerciseMetrics(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finishCurrentSession(context: Context) {
|
fun finishCurrentSession(context: Context) {
|
||||||
unregister(context)
|
unregister(context)
|
||||||
|
stopExerciseMetrics(context)
|
||||||
val completedSessionId = sessionId
|
val completedSessionId = sessionId
|
||||||
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
|
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
|
||||||
sendSummary(context, completedSessionId)
|
sendSummary(context, completedSessionId)
|
||||||
@ -188,6 +238,8 @@ internal class WatchHeartRateCollector(
|
|||||||
"minHeartRateBpm" to min,
|
"minHeartRateBpm" to min,
|
||||||
"averageHeartRateBpm" to sampleSum / sampleCount,
|
"averageHeartRateBpm" to sampleSum / sampleCount,
|
||||||
"maxHeartRateBpm" to max,
|
"maxHeartRateBpm" to max,
|
||||||
|
"distanceMeters" to distanceMeters,
|
||||||
|
"caloriesKcal" to caloriesKcal,
|
||||||
),
|
),
|
||||||
).toString().toByteArray(StandardCharsets.UTF_8)
|
).toString().toByteArray(StandardCharsets.UTF_8)
|
||||||
Wearable.getCapabilityClient(context)
|
Wearable.getCapabilityClient(context)
|
||||||
@ -213,10 +265,9 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
val measureClient = HealthServices.getClient(context).measureClient
|
val measureClient = HealthServices.getClient(context).measureClient
|
||||||
for (dataType in registeredDataTypes.toList()) {
|
for (dataType in registeredDataTypes.toList()) {
|
||||||
measureClient.unregisterMeasureCallbackAsync(dataType, callback)
|
measureClient.unregisterMeasureCallbackAsync(dataType, measureCallback)
|
||||||
}
|
}
|
||||||
registeredDataTypes.clear()
|
registeredDataTypes.clear()
|
||||||
appContext = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerMeasureCallbackIfNeeded(
|
private fun registerMeasureCallbackIfNeeded(
|
||||||
@ -227,7 +278,7 @@ internal class WatchHeartRateCollector(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
measureClient.registerMeasureCallback(dataType, callback)
|
measureClient.registerMeasureCallback(dataType, measureCallback)
|
||||||
registeredDataTypes.add(dataType)
|
registeredDataTypes.add(dataType)
|
||||||
Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId")
|
Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId")
|
||||||
} catch (error: RuntimeException) {
|
} catch (error: RuntimeException) {
|
||||||
@ -235,6 +286,108 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startExerciseMetrics(context: Context) {
|
||||||
|
if (exerciseMetricsStarted || exerciseMetricsStartInFlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val exerciseClient = HealthServices.getClient(context).exerciseClient
|
||||||
|
exerciseMetricsStartInFlight = true
|
||||||
|
val capabilitiesFuture = exerciseClient.getCapabilitiesAsync()
|
||||||
|
capabilitiesFuture.addListener(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
val capabilities = capabilitiesFuture.get()
|
||||||
|
val config = exerciseConfigFromCapabilities(capabilities)
|
||||||
|
if (config == null) {
|
||||||
|
exerciseMetricsStartInFlight = false
|
||||||
|
Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId")
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
|
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
|
||||||
|
val startFuture = exerciseClient.startExerciseAsync(config)
|
||||||
|
startFuture.addListener(
|
||||||
|
{
|
||||||
|
exerciseMetricsStartInFlight = false
|
||||||
|
try {
|
||||||
|
startFuture.get()
|
||||||
|
exerciseMetricsStarted = true
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}",
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "exercise metrics start failed", error)
|
||||||
|
clearExerciseCallback(exerciseClient)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context.mainExecutor,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
exerciseMetricsStartInFlight = false
|
||||||
|
Log.w(TAG, "exercise capabilities lookup failed", error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context.mainExecutor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exerciseConfigFromCapabilities(
|
||||||
|
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
|
||||||
|
): ExerciseConfig? {
|
||||||
|
val requestedTypes = listOf(
|
||||||
|
ExerciseType.WORKOUT,
|
||||||
|
ExerciseType.RUNNING,
|
||||||
|
ExerciseType.WALKING,
|
||||||
|
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
|
||||||
|
)
|
||||||
|
for (exerciseType in requestedTypes) {
|
||||||
|
if (exerciseType !in capabilities.supportedExerciseTypes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val supported = capabilities.getExerciseTypeCapabilities(exerciseType)
|
||||||
|
.supportedDataTypes
|
||||||
|
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
|
||||||
|
if (DataType.DISTANCE !in supported) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"exercise type lacks distance type=$exerciseType supported=$supported",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataTypes.add(DataType.DISTANCE)
|
||||||
|
if (DataType.CALORIES in supported) {
|
||||||
|
dataTypes.add(DataType.CALORIES)
|
||||||
|
}
|
||||||
|
return ExerciseConfig.builder(exerciseType)
|
||||||
|
.setDataTypes(dataTypes)
|
||||||
|
.setIsAutoPauseAndResumeEnabled(false)
|
||||||
|
.setIsGpsEnabled(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopExerciseMetrics(context: Context) {
|
||||||
|
if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val exerciseClient = HealthServices.getClient(context).exerciseClient
|
||||||
|
clearExerciseCallback(exerciseClient)
|
||||||
|
if (exerciseMetricsStarted) {
|
||||||
|
exerciseClient.endExerciseAsync()
|
||||||
|
}
|
||||||
|
exerciseMetricsStarted = false
|
||||||
|
exerciseMetricsStartInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
|
||||||
|
try {
|
||||||
|
exerciseClient.clearUpdateCallbackAsync(exerciseCallback)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
Log.w(TAG, "clear exercise update callback failed", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun reset(nextSessionId: String?) {
|
private fun reset(nextSessionId: String?) {
|
||||||
sessionId = nextSessionId
|
sessionId = nextSessionId
|
||||||
sampleCount = 0
|
sampleCount = 0
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import android.os.IBinder
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.gametime.watch.MainActivity
|
|
||||||
import com.gametime.watch.R
|
import com.gametime.watch.R
|
||||||
|
|
||||||
internal class WatchHeartRateForegroundService : Service() {
|
internal class WatchHeartRateForegroundService : Service() {
|
||||||
@ -71,9 +70,7 @@ internal class WatchHeartRateForegroundService : Service() {
|
|||||||
val touchIntent = PendingIntent.getActivity(
|
val touchIntent = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
Intent(this, MainActivity::class.java).apply {
|
WatchBridgePlugin.openActiveSessionIntent(this),
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
|||||||
@ -6,14 +6,12 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.wear.ongoing.OngoingActivity
|
import androidx.wear.ongoing.OngoingActivity
|
||||||
import androidx.wear.ongoing.Status
|
import androidx.wear.ongoing.Status
|
||||||
import com.gametime.watch.MainActivity
|
|
||||||
import com.gametime.watch.R
|
import com.gametime.watch.R
|
||||||
|
|
||||||
object WatchOngoingActivityController {
|
object WatchOngoingActivityController {
|
||||||
@ -47,9 +45,7 @@ object WatchOngoingActivityController {
|
|||||||
val touchIntent = PendingIntent.getActivity(
|
val touchIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
0,
|
0,
|
||||||
Intent(context, MainActivity::class.java).apply {
|
WatchBridgePlugin.openActiveSessionIntent(context),
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
val exerciseName = (projection["exerciseName"] as? String)
|
val exerciseName = (projection["exerciseName"] as? String)
|
||||||
|
|||||||
@ -128,6 +128,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
Timer? _scoreWaitingTimer;
|
Timer? _scoreWaitingTimer;
|
||||||
Timer? _scoreCommandTimeoutTimer;
|
Timer? _scoreCommandTimeoutTimer;
|
||||||
Timer? _freshnessTimer;
|
Timer? _freshnessTimer;
|
||||||
|
Timer? _projectionExpiryTimer;
|
||||||
Timer? _commandFailureClearTimer;
|
Timer? _commandFailureClearTimer;
|
||||||
WatchCommandEnvelope? _pendingCommand;
|
WatchCommandEnvelope? _pendingCommand;
|
||||||
final _pendingScoreCommandIds = <String>{};
|
final _pendingScoreCommandIds = <String>{};
|
||||||
@ -198,6 +199,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
_scoreWaitingTimer?.cancel();
|
_scoreWaitingTimer?.cancel();
|
||||||
_scoreCommandTimeoutTimer?.cancel();
|
_scoreCommandTimeoutTimer?.cancel();
|
||||||
_freshnessTimer?.cancel();
|
_freshnessTimer?.cancel();
|
||||||
|
_projectionExpiryTimer?.cancel();
|
||||||
_commandFailureClearTimer?.cancel();
|
_commandFailureClearTimer?.cancel();
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in _subscriptions) {
|
||||||
unawaited(subscription.cancel());
|
unawaited(subscription.cancel());
|
||||||
@ -309,6 +311,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
void _handleProjection(WatchSessionProjection projection) {
|
void _handleProjection(WatchSessionProjection projection) {
|
||||||
final previousProjection = value.projection;
|
final previousProjection = value.projection;
|
||||||
_lastProjectionReceivedAt = DateTime.now();
|
_lastProjectionReceivedAt = DateTime.now();
|
||||||
|
_scheduleProjectionExpiry(projection);
|
||||||
_pendingCommand = null;
|
_pendingCommand = null;
|
||||||
_clearCommandTimers();
|
_clearCommandTimers();
|
||||||
_syncScorePendingFromProjection(projection);
|
_syncScorePendingFromProjection(projection);
|
||||||
@ -388,7 +391,23 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
if (receivedAt == null) {
|
if (receivedAt == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final age = DateTime.now().difference(receivedAt);
|
final now = DateTime.now();
|
||||||
|
final expiresAtEpochMs = value.projection.expiresAtEpochMs;
|
||||||
|
final fallbackExpired =
|
||||||
|
expiresAtEpochMs <= 0 &&
|
||||||
|
now.difference(receivedAt) >= const Duration(seconds: 12);
|
||||||
|
final expired =
|
||||||
|
value.projection.deviceSessionId.isNotEmpty &&
|
||||||
|
(fallbackExpired ||
|
||||||
|
(expiresAtEpochMs > 0 &&
|
||||||
|
now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) &&
|
||||||
|
_pendingCommand == null &&
|
||||||
|
_pendingScoreCommandIds.isEmpty;
|
||||||
|
if (expired) {
|
||||||
|
_invalidateExpiredProjection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final age = now.difference(receivedAt);
|
||||||
final stale = age >= _staleProjectionThreshold;
|
final stale = age >= _staleProjectionThreshold;
|
||||||
final lost = age >= _connectionLostThreshold;
|
final lost = age >= _connectionLostThreshold;
|
||||||
if (stale != value.staleProjection || lost != value.connectionLost) {
|
if (stale != value.staleProjection || lost != value.connectionLost) {
|
||||||
@ -396,6 +415,53 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _invalidateExpiredProjection() {
|
||||||
|
_projectionExpiryTimer?.cancel();
|
||||||
|
_projectionExpiryTimer = null;
|
||||||
|
_pendingCommand = null;
|
||||||
|
_pendingScoreCommandIds.clear();
|
||||||
|
_optimisticManualScoreValue = null;
|
||||||
|
_clearCommandTimers();
|
||||||
|
_scoreWaitingTimer?.cancel();
|
||||||
|
_scoreWaitingTimer = null;
|
||||||
|
_scoreCommandTimeoutTimer?.cancel();
|
||||||
|
_scoreCommandTimeoutTimer = null;
|
||||||
|
_lastProjectionReceivedAt = null;
|
||||||
|
value = WatchSessionUiState(
|
||||||
|
projection: _expiredProjection(),
|
||||||
|
connectionLost: true,
|
||||||
|
staleProjection: true,
|
||||||
|
commandFailureMessage: value.commandFailureMessage,
|
||||||
|
commandFailureSerial: value.commandFailureSerial,
|
||||||
|
lastAck: value.lastAck,
|
||||||
|
);
|
||||||
|
unawaited(_nativeClient.invalidateActiveProjection());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleProjectionExpiry(WatchSessionProjection projection) {
|
||||||
|
_projectionExpiryTimer?.cancel();
|
||||||
|
_projectionExpiryTimer = null;
|
||||||
|
if (projection.deviceSessionId.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||||
|
final expiresAtEpochMs = projection.expiresAtEpochMs > 0
|
||||||
|
? projection.expiresAtEpochMs
|
||||||
|
: nowMs + const Duration(seconds: 12).inMilliseconds;
|
||||||
|
final delayMs = expiresAtEpochMs - nowMs;
|
||||||
|
_projectionExpiryTimer = Timer(
|
||||||
|
Duration(milliseconds: delayMs <= 0 ? 0 : delayMs),
|
||||||
|
() {
|
||||||
|
if (value.projection.deviceSessionId.isEmpty ||
|
||||||
|
_pendingCommand != null ||
|
||||||
|
_pendingScoreCommandIds.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_invalidateExpiredProjection();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _clearCommandTimers() {
|
void _clearCommandTimers() {
|
||||||
_waitingTimer?.cancel();
|
_waitingTimer?.cancel();
|
||||||
_waitingTimer = null;
|
_waitingTimer = null;
|
||||||
@ -504,10 +570,29 @@ bool _requiresActiveSession(WatchCommandType type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WatchSessionProjection _initialProjection() {
|
WatchSessionProjection _initialProjection() {
|
||||||
|
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||||
return WatchSessionProjection(
|
return WatchSessionProjection(
|
||||||
deviceSessionId: '',
|
deviceSessionId: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
projectedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
|
projectedAtEpochMs: nowMs,
|
||||||
|
expiresAtEpochMs: nowMs,
|
||||||
|
phase: WatchSessionPhase.noActiveSession,
|
||||||
|
phoneReachable: false,
|
||||||
|
seriesIndex: 0,
|
||||||
|
seriesTotal: 0,
|
||||||
|
exerciseName: '',
|
||||||
|
primaryAction: WatchPrimaryAction.none,
|
||||||
|
statusLabel: 'Téléphone indisponible',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchSessionProjection _expiredProjection() {
|
||||||
|
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||||
|
return WatchSessionProjection(
|
||||||
|
deviceSessionId: '',
|
||||||
|
revision: 0,
|
||||||
|
projectedAtEpochMs: nowMs,
|
||||||
|
expiresAtEpochMs: nowMs,
|
||||||
phase: WatchSessionPhase.noActiveSession,
|
phase: WatchSessionPhase.noActiveSession,
|
||||||
phoneReachable: false,
|
phoneReachable: false,
|
||||||
seriesIndex: 0,
|
seriesIndex: 0,
|
||||||
|
|||||||
@ -41,6 +41,8 @@ abstract interface class NativeWatchBridgeClient {
|
|||||||
Future<void> requestResync();
|
Future<void> requestResync();
|
||||||
|
|
||||||
Future<void> requestCapabilityRefresh();
|
Future<void> requestCapabilityRefresh();
|
||||||
|
|
||||||
|
Future<void> invalidateActiveProjection();
|
||||||
}
|
}
|
||||||
|
|
||||||
final class MethodChannelNativeWatchBridgeClient
|
final class MethodChannelNativeWatchBridgeClient
|
||||||
@ -141,6 +143,11 @@ final class MethodChannelNativeWatchBridgeClient
|
|||||||
Future<void> requestResync() {
|
Future<void> requestResync() {
|
||||||
return _methodChannel.invokeMethod<void>('requestResync');
|
return _methodChannel.invokeMethod<void>('requestResync');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> invalidateActiveProjection() {
|
||||||
|
return _methodChannel.invokeMethod<void>('invalidateActiveProjection');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> _stringObjectMap(Object? value) {
|
Map<String, Object?> _stringObjectMap(Object? value) {
|
||||||
|
|||||||
@ -99,7 +99,11 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return PageView(controller: _pageController, children: pages);
|
return PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
physics: const _WatchPageScrollPhysics(),
|
||||||
|
children: pages,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -220,7 +224,21 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
!_completionHapticTimerKeys.add(key)) {
|
!_completionHapticTimerKeys.add(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_triggerTimerCompletionHaptic();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _triggerTimerCompletionHaptic() {
|
||||||
unawaited(HapticFeedback.heavyImpact());
|
unawaited(HapticFeedback.heavyImpact());
|
||||||
|
unawaited(
|
||||||
|
Future<void>.delayed(const Duration(milliseconds: 140), () {
|
||||||
|
return HapticFeedback.heavyImpact();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
Future<void>.delayed(const Duration(milliseconds: 320), () {
|
||||||
|
return HapticFeedback.heavyImpact();
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _confirm({
|
Future<bool> _confirm({
|
||||||
@ -242,6 +260,47 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _WatchPageScrollPhysics extends PageScrollPhysics {
|
||||||
|
const _WatchPageScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_WatchPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return _WatchPageScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Simulation? createBallisticSimulation(
|
||||||
|
ScrollMetrics position,
|
||||||
|
double velocity,
|
||||||
|
) {
|
||||||
|
if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
|
||||||
|
(velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
|
||||||
|
return super.createBallisticSimulation(position, velocity);
|
||||||
|
}
|
||||||
|
final viewport = position is PageMetrics
|
||||||
|
? position.viewportDimension * position.viewportFraction
|
||||||
|
: position.viewportDimension;
|
||||||
|
if (viewport <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final target = (position.pixels / viewport).roundToDouble() * viewport;
|
||||||
|
final clampedTarget = target.clamp(
|
||||||
|
position.minScrollExtent,
|
||||||
|
position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
if (clampedTarget == position.pixels) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ScrollSpringSimulation(
|
||||||
|
spring,
|
||||||
|
position.pixels,
|
||||||
|
clampedTarget,
|
||||||
|
velocity,
|
||||||
|
tolerance: toleranceFor(position),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class _RoundScaffold extends StatelessWidget {
|
final class _RoundScaffold extends StatelessWidget {
|
||||||
const _RoundScaffold({required this.child, this.notice, super.key});
|
const _RoundScaffold({required this.child, this.notice, super.key});
|
||||||
|
|
||||||
@ -927,17 +986,21 @@ final class _ManualScoreContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
final target = projection.manualScoreTargetValue;
|
final target = projection.manualScoreTargetValue;
|
||||||
final targetLabel = projection.manualScoreTargetLabel;
|
final targetLabel = projection.manualScoreTargetLabel;
|
||||||
|
final captionSegments = [
|
||||||
|
if (projection.manualScoreRepsTargetValue != null)
|
||||||
|
'Répétitions : ${projection.manualScoreRepsTargetValue}',
|
||||||
|
if (target != null && targetLabel != null && targetLabel.isNotEmpty)
|
||||||
|
'$targetLabel : ${_scoreText(target)}',
|
||||||
|
];
|
||||||
return _ScaledContent(
|
return _ScaledContent(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_ExerciseName(projection.exerciseName),
|
_ExerciseName(projection.exerciseName),
|
||||||
_StepNameBand(projection.stepName),
|
_StepNameBand(projection.stepName),
|
||||||
if (target != null &&
|
if (captionSegments.isNotEmpty) ...[
|
||||||
targetLabel != null &&
|
|
||||||
targetLabel.isNotEmpty) ...[
|
|
||||||
Text(
|
Text(
|
||||||
'$targetLabel : ${_scoreText(target)}',
|
captionSegments.join(' · '),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|||||||
@ -87,6 +87,36 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('shows reps target on step manual score content', (tester) async {
|
||||||
|
final client = _FakeNativeWatchBridgeClient();
|
||||||
|
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||||
|
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = const Size(192, 192);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: watchTheme(),
|
||||||
|
home: WatchSessionScreen(viewModel: viewModel),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emitProjection(_manualScoreProjectionWithRepsTarget());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(find.text('Répétitions : 10 · Cible : 8'), findsOneWidget);
|
||||||
|
expect(find.text('SCORE'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Ajouter'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Valider l’étape'), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
viewModel.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('hides set timer even when it is projected as dominant', (
|
testWidgets('hides set timer even when it is projected as dominant', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -683,22 +713,63 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('vibrates once when a countdown timer reaches zero', (
|
testWidgets(
|
||||||
|
'uses a strong pulse sequence when a countdown timer reaches zero',
|
||||||
|
(tester) async {
|
||||||
|
final hapticCalls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
if (call.method == 'HapticFeedback.vibrate') {
|
||||||
|
hapticCalls.add(call);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = _FakeNativeWatchBridgeClient();
|
||||||
|
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||||
|
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = const Size(192, 192);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: watchTheme(),
|
||||||
|
home: WatchSessionScreen(viewModel: viewModel),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emitProjection(_countdownProjection(accumulatedMs: 29000));
|
||||||
|
await tester.pump();
|
||||||
|
expect(hapticCalls, isEmpty);
|
||||||
|
|
||||||
|
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
expect(hapticCalls, hasLength(3));
|
||||||
|
expect(
|
||||||
|
hapticCalls.map((call) => call.arguments),
|
||||||
|
everyElement('HapticFeedbackType.heavyImpact'),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||||
|
await tester.pump();
|
||||||
|
expect(hapticCalls, hasLength(3));
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
viewModel.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('expires an orphaned active projection after its TTL', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final hapticCalls = <MethodCall>[];
|
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
||||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
|
||||||
if (call.method == 'HapticFeedback.vibrate') {
|
|
||||||
hapticCalls.add(call);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
addTearDown(() {
|
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
||||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
final client = _FakeNativeWatchBridgeClient();
|
final client = _FakeNativeWatchBridgeClient();
|
||||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||||
|
|
||||||
@ -714,20 +785,18 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
client.emitProjection(_countdownProjection(accumulatedMs: 29000));
|
client.emitProjection(
|
||||||
|
_expiringProjection(expiresIn: const Duration(seconds: 1)),
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(hapticCalls, isEmpty);
|
expect(find.text('Squat jump'), findsOneWidget);
|
||||||
|
|
||||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
await tester.pump(const Duration(seconds: 2));
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(hapticCalls, hasLength(1));
|
expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession);
|
||||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact');
|
expect(viewModel.value.connectionLost, isTrue);
|
||||||
|
expect(client.invalidatedProjectionCount, 1);
|
||||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
expect(find.text('Téléphone indisponible'), findsOneWidget);
|
||||||
await tester.pump();
|
|
||||||
expect(hapticCalls, hasLength(1));
|
|
||||||
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
viewModel.dispose();
|
viewModel.dispose();
|
||||||
@ -745,6 +814,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
|||||||
|
|
||||||
var resyncRequests = 0;
|
var resyncRequests = 0;
|
||||||
var capabilityRefreshRequests = 0;
|
var capabilityRefreshRequests = 0;
|
||||||
|
var invalidatedProjectionCount = 0;
|
||||||
final sentCommands = <WatchCommandEnvelope>[];
|
final sentCommands = <WatchCommandEnvelope>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -782,6 +852,11 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
|||||||
capabilityRefreshRequests += 1;
|
capabilityRefreshRequests += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> invalidateActiveProjection() async {
|
||||||
|
invalidatedProjectionCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> requestResync() async {
|
Future<void> requestResync() async {
|
||||||
resyncRequests += 1;
|
resyncRequests += 1;
|
||||||
@ -794,10 +869,12 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WatchSessionProjection _runningProjection() {
|
WatchSessionProjection _runningProjection() {
|
||||||
|
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
|
||||||
return WatchSessionProjection(
|
return WatchSessionProjection(
|
||||||
deviceSessionId: 'session-1',
|
deviceSessionId: 'session-1',
|
||||||
revision: 1,
|
revision: 1,
|
||||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
projectedAtEpochMs: projectedAt,
|
||||||
|
expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds,
|
||||||
phase: WatchSessionPhase.running,
|
phase: WatchSessionPhase.running,
|
||||||
phoneReachable: true,
|
phoneReachable: true,
|
||||||
seriesIndex: 2,
|
seriesIndex: 2,
|
||||||
@ -820,6 +897,23 @@ WatchSessionProjection _runningProjection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WatchSessionProjection _expiringProjection({required Duration expiresIn}) {
|
||||||
|
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||||
|
return WatchSessionProjection(
|
||||||
|
deviceSessionId: 'session-1',
|
||||||
|
revision: 99,
|
||||||
|
projectedAtEpochMs: nowMs,
|
||||||
|
expiresAtEpochMs: nowMs + expiresIn.inMilliseconds,
|
||||||
|
phase: WatchSessionPhase.running,
|
||||||
|
phoneReachable: true,
|
||||||
|
seriesIndex: 1,
|
||||||
|
seriesTotal: 3,
|
||||||
|
exerciseName: 'Squat jump',
|
||||||
|
statusLabel: 'Chrono étape',
|
||||||
|
primaryAction: WatchPrimaryAction.pauseSession,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
|
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
|
||||||
return WatchSessionProjection(
|
return WatchSessionProjection(
|
||||||
deviceSessionId: '',
|
deviceSessionId: '',
|
||||||
@ -980,6 +1074,29 @@ WatchSessionProjection _manualScoreProjectionWithTimer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WatchSessionProjection _manualScoreProjectionWithRepsTarget() {
|
||||||
|
return WatchSessionProjection(
|
||||||
|
deviceSessionId: 'session-1',
|
||||||
|
revision: 4,
|
||||||
|
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||||
|
phase: WatchSessionPhase.running,
|
||||||
|
phoneReachable: true,
|
||||||
|
seriesIndex: 1,
|
||||||
|
seriesTotal: 3,
|
||||||
|
exerciseName: 'Pompes tempo',
|
||||||
|
stepName: 'Score libre',
|
||||||
|
statusLabel: 'Score manuel',
|
||||||
|
primaryAction: WatchPrimaryAction.pauseSession,
|
||||||
|
hasManualScore: true,
|
||||||
|
currentManualScoreValue: 3,
|
||||||
|
canDecrementScore: true,
|
||||||
|
manualScoreTargetValue: 8,
|
||||||
|
manualScoreTargetLabel: 'Cible',
|
||||||
|
manualScoreRepsTargetValue: 10,
|
||||||
|
manualScoreScope: WatchManualScoreScope.step,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
WatchSessionProjection _restProjection() {
|
WatchSessionProjection _restProjection() {
|
||||||
return WatchSessionProjection(
|
return WatchSessionProjection(
|
||||||
deviceSessionId: 'session-1',
|
deviceSessionId: 'session-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user