feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre

Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 05:56:12 +02:00
parent c65a5a76a9
commit 65d43b9768
80 changed files with 12292 additions and 892 deletions

View File

@ -62,6 +62,140 @@ void main() {
]);
expect(repository.items.single.clientId, 'exercise-1');
});
test('pull returns synced items and filters them with since query', () async {
final repository = _FakeSyncedResourceRepository()
..items.addAll([
SyncedResource(
serverId: 'resource-1',
ownerUserId: 'user-1',
resourceType: SyncedResourceType.exercise,
clientId: 'exercise-1',
payloadJson: {'name': 'Squat'},
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
),
SyncedResource(
serverId: 'resource-2',
ownerUserId: 'user-1',
resourceType: SyncedResourceType.program,
clientId: 'program-1',
payloadJson: {'name': 'Programme A'},
schemaVersion: 2,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 12, 30),
),
]);
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
final response = await handler(
Request(
'GET',
Uri.parse(
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final items = body['items'] as List;
expect(response.statusCode, 200);
expect(body['serverCursor'], '2026-07-19T12:30:00.000Z');
expect(items, hasLength(1));
expect((items.single as Map)['clientId'], 'program-1');
expect((items.single as Map)['resourceType'], 'program');
});
test('pull returns 400 for invalid since query parameter', () async {
final handler = buildApiHandler(syncApi: _syncApi());
final response = await handler(
Request(
'GET',
Uri.parse('http://localhost/sync/pull?since=not-a-date'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Invalid date format'},
);
});
test('exchange returns push results followed by pulled items', () async {
final repository = _FakeSyncedResourceRepository()
..items.add(
SyncedResource(
serverId: 'existing-1',
ownerUserId: 'user-1',
resourceType: SyncedResourceType.workoutHistory,
clientId: 'history-1',
payloadJson: {'score': 12},
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 8),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 9),
),
);
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/sync/exchange'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'deviceId': 'device-1',
'since': '2026-07-19T08:30:00Z',
'items': [
{
'resourceType': 'exercise',
'clientId': 'exercise-2',
'schemaVersion': 1,
'clientUpdatedAt': '2026-07-19T10:00:00Z',
'payload': {'name': 'Lunge'},
},
],
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final pushResults = body['pushResults'] as List;
final items = body['items'] as List;
expect(response.statusCode, 200);
expect(pushResults, hasLength(1));
expect((pushResults.single as Map)['status'], 'accepted');
expect(items, hasLength(2));
expect(
items.map((item) => (item as Map)['clientId']),
containsAll(['history-1', 'exercise-2']),
);
});
test('exchange returns 400 when items is not a json array', () async {
final handler = buildApiHandler(syncApi: _syncApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/sync/exchange'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({'deviceId': 'device-1', 'items': 'not-a-list'}),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'items must be a JSON array.'},
);
});
}
SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {