From 917777e18bd0999005311f95ff36fd7b3e9bfed2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 16:48:54 +0200 Subject: [PATCH 1/5] =?UTF-8?q?chore(wip):=20consolidation=20interm=C3=A9d?= =?UTF-8?q?iaire=20multi-tickets=20(sprints=20Statistiques,=20UI,=20Bug=20?= =?UTF-8?q?resolution,=20Serveur-client)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 19 + .ideai/agents.json | 29 +- .ideai/agents/coachv2.md | 0 .ideai/agents/commercialv2.md | 0 .ideai/agents/context.md | 93 + .ideai/agents/contextv2.md | 0 .ideai/agents/main.md | 2 + .ideai/agents/test.md | 0 ...ndroid-release-networking-and-apk-build.md | 62 + ...metry-live-heart-rate-distance-calories.md | 10 + .../gametime-architecture-watch-companion.md | 452 +++ .../gametime-ux-watch-companion-round2.md | 22 + .ideai/memory/gametime-ux-watch-companion.md | 530 ++++ ...gametime-watch-companion-implementation.md | 39 + .../gametime-watch-lot-148-154-cadrage.md | 10 + .../32494e70-abcd-4829-a789-cadc2e7c90c7.md | 59 + .../4a9abeed-e52a-4b26-aa73-eb4f63cd0398.md | 88 + .../8f126c3d-5b8c-48c0-97aa-cd9e5f0e7d5b.md | 60 + .ideai/skills/scripts/build-phone-apk.sh | 49 + .../sprint.md | 11 + .../sprint.md | 11 + .ideai/sprints/index.json | 18 + .ideai/tickets/100/carnet.md | 29 + .ideai/tickets/100/issue.md | 28 + .ideai/tickets/101/carnet.md | 6 + .ideai/tickets/101/issue.md | 28 + .ideai/tickets/102/carnet.md | 14 + .ideai/tickets/102/issue.md | 16 + .ideai/tickets/103/carnet.md | 13 + .ideai/tickets/103/issue.md | 16 + .ideai/tickets/104/carnet.md | 13 + .ideai/tickets/104/issue.md | 16 + .ideai/tickets/105/carnet.md | 15 + .ideai/tickets/105/issue.md | 16 + .ideai/tickets/106/carnet.md | 42 + .ideai/tickets/106/issue.md | 16 + .ideai/tickets/107/carnet.md | 32 + .ideai/tickets/107/issue.md | 16 + .ideai/tickets/108/carnet.md | 49 + .ideai/tickets/108/issue.md | 26 + .ideai/tickets/109/carnet.md | 83 + .ideai/tickets/109/issue.md | 25 + .ideai/tickets/110/carnet.md | 37 + .ideai/tickets/110/issue.md | 24 + .ideai/tickets/111/carnet.md | 7 + .ideai/tickets/111/issue.md | 24 + .ideai/tickets/112/carnet.md | 25 + .ideai/tickets/112/issue.md | 23 + .ideai/tickets/113/carnet.md | 20 + .ideai/tickets/113/issue.md | 23 + .ideai/tickets/114/carnet.md | 7 + .ideai/tickets/114/issue.md | 20 + .ideai/tickets/115/carnet.md | 57 + .ideai/tickets/115/issue.md | 25 + .ideai/tickets/116/carnet.md | 27 + .ideai/tickets/116/issue.md | 24 + .ideai/tickets/117/carnet.md | 7 + .ideai/tickets/117/issue.md | 24 + .ideai/tickets/118/carnet.md | 35 + .ideai/tickets/118/issue.md | 26 + .ideai/tickets/119/carnet.md | 101 + .ideai/tickets/119/issue.md | 24 + .ideai/tickets/120/carnet.md | 22 + .ideai/tickets/120/issue.md | 20 + .ideai/tickets/121/carnet.md | 36 + .ideai/tickets/121/issue.md | 23 + .ideai/tickets/122/carnet.md | 7 + .ideai/tickets/122/issue.md | 24 + .ideai/tickets/123/carnet.md | 42 + .ideai/tickets/123/issue.md | 24 + .ideai/tickets/124/carnet.md | 67 + .ideai/tickets/124/issue.md | 25 + .ideai/tickets/125/carnet.md | 39 + .ideai/tickets/125/issue.md | 20 + .ideai/tickets/126/carnet.md | 44 + .ideai/tickets/126/issue.md | 16 + .ideai/tickets/127/carnet.md | 28 + .ideai/tickets/127/issue.md | 16 + .ideai/tickets/128/carnet.md | 34 + .ideai/tickets/128/issue.md | 16 + .ideai/tickets/129/carnet.md | 31 + .ideai/tickets/129/issue.md | 16 + .ideai/tickets/130/carnet.md | 49 + .ideai/tickets/130/issue.md | 16 + .ideai/tickets/131/carnet.md | 31 + .ideai/tickets/131/issue.md | 16 + .ideai/tickets/132/carnet.md | 30 + .ideai/tickets/132/issue.md | 16 + .ideai/tickets/133/carnet.md | 33 + .ideai/tickets/133/issue.md | 16 + .ideai/tickets/134/carnet.md | 53 + .ideai/tickets/134/issue.md | 16 + .ideai/tickets/135/carnet.md | 52 + .ideai/tickets/135/issue.md | 16 + .ideai/tickets/136/carnet.md | 20 + .ideai/tickets/136/issue.md | 16 + .ideai/tickets/137/carnet.md | 6 + .ideai/tickets/137/issue.md | 16 + .ideai/tickets/138/carnet.md | 19 + .ideai/tickets/138/issue.md | 16 + .ideai/tickets/139/carnet.md | 29 + .ideai/tickets/139/issue.md | 16 + .ideai/tickets/140/carnet.md | 27 + .ideai/tickets/140/issue.md | 16 + .ideai/tickets/141/carnet.md | 17 + .ideai/tickets/141/issue.md | 16 + .ideai/tickets/142/carnet.md | 28 + .ideai/tickets/142/issue.md | 16 + .ideai/tickets/143/carnet.md | 15 + .ideai/tickets/143/issue.md | 18 + .ideai/tickets/144/carnet.md | 20 + .ideai/tickets/144/issue.md | 16 + .ideai/tickets/145/carnet.md | 21 + .ideai/tickets/145/issue.md | 16 + .ideai/tickets/146/carnet.md | 6 + .ideai/tickets/146/issue.md | 16 + .ideai/tickets/147/carnet.md | 6 + .ideai/tickets/147/issue.md | 16 + .ideai/tickets/148/carnet.md | 6 + .ideai/tickets/148/issue.md | 16 + .ideai/tickets/149/carnet.md | 6 + .ideai/tickets/149/issue.md | 16 + .ideai/tickets/150/carnet.md | 6 + .ideai/tickets/150/issue.md | 16 + .ideai/tickets/151/carnet.md | 6 + .ideai/tickets/151/issue.md | 16 + .ideai/tickets/152/carnet.md | 6 + .ideai/tickets/152/issue.md | 16 + .ideai/tickets/153/carnet.md | 6 + .ideai/tickets/153/issue.md | 16 + .ideai/tickets/154/carnet.md | 6 + .ideai/tickets/154/issue.md | 16 + .ideai/tickets/155/carnet.md | 147 + .ideai/tickets/155/issue.md | 16 + .ideai/tickets/156/carnet.md | 101 + .ideai/tickets/156/issue.md | 16 + .ideai/tickets/157/carnet.md | 6 + .ideai/tickets/157/issue.md | 16 + .ideai/tickets/158/carnet.md | 6 + .ideai/tickets/158/issue.md | 16 + .ideai/tickets/159/carnet.md | 6 + .ideai/tickets/159/issue.md | 16 + .ideai/tickets/160/carnet.md | 6 + .ideai/tickets/160/issue.md | 16 + .ideai/tickets/161/carnet.md | 84 + .ideai/tickets/161/issue.md | 18 + .ideai/tickets/162/carnet.md | 62 + .ideai/tickets/162/issue.md | 16 + .ideai/tickets/163/carnet.md | 6 + .ideai/tickets/163/issue.md | 16 + .ideai/tickets/164/carnet.md | 22 + .ideai/tickets/164/issue.md | 16 + .ideai/tickets/48/carnet.md | 6 +- .ideai/tickets/48/issue.md | 8 +- .ideai/tickets/49/carnet.md | 6 +- .ideai/tickets/49/issue.md | 8 +- .ideai/tickets/50/carnet.md | 6 +- .ideai/tickets/50/issue.md | 8 +- .ideai/tickets/51/carnet.md | 6 +- .ideai/tickets/51/issue.md | 8 +- .ideai/tickets/52/carnet.md | 6 +- .ideai/tickets/52/issue.md | 8 +- .ideai/tickets/53/carnet.md | 6 +- .ideai/tickets/53/issue.md | 8 +- .ideai/tickets/56/carnet.md | 6 +- .ideai/tickets/56/issue.md | 8 +- .ideai/tickets/58/carnet.md | 6 +- .ideai/tickets/58/issue.md | 8 +- .ideai/tickets/59/carnet.md | 6 +- .ideai/tickets/59/issue.md | 8 +- .ideai/tickets/60/carnet.md | 6 +- .ideai/tickets/60/issue.md | 8 +- .ideai/tickets/61/carnet.md | 4 +- .ideai/tickets/61/issue.md | 6 +- .ideai/tickets/62/carnet.md | 6 +- .ideai/tickets/62/issue.md | 8 +- .ideai/tickets/63/carnet.md | 6 +- .ideai/tickets/63/issue.md | 8 +- .ideai/tickets/64/carnet.md | 6 +- .ideai/tickets/64/issue.md | 8 +- .ideai/tickets/65/carnet.md | 6 +- .ideai/tickets/65/issue.md | 8 +- .ideai/tickets/66/carnet.md | 6 +- .ideai/tickets/66/issue.md | 8 +- .ideai/tickets/67/carnet.md | 6 +- .ideai/tickets/67/issue.md | 8 +- .ideai/tickets/68/carnet.md | 6 +- .ideai/tickets/68/issue.md | 8 +- .ideai/tickets/69/carnet.md | 6 +- .ideai/tickets/69/issue.md | 8 +- .ideai/tickets/70/carnet.md | 6 +- .ideai/tickets/70/issue.md | 8 +- .ideai/tickets/71/carnet.md | 6 +- .ideai/tickets/71/issue.md | 8 +- .ideai/tickets/72/carnet.md | 6 +- .ideai/tickets/72/issue.md | 8 +- .ideai/tickets/73/carnet.md | 6 +- .ideai/tickets/73/issue.md | 8 +- .ideai/tickets/74/carnet.md | 6 +- .ideai/tickets/74/issue.md | 8 +- .ideai/tickets/75/carnet.md | 6 +- .ideai/tickets/75/issue.md | 8 +- .ideai/tickets/76/carnet.md | 6 +- .ideai/tickets/76/issue.md | 8 +- .ideai/tickets/77/carnet.md | 6 +- .ideai/tickets/77/issue.md | 8 +- .ideai/tickets/78/carnet.md | 6 +- .ideai/tickets/78/issue.md | 8 +- .ideai/tickets/79/carnet.md | 6 +- .ideai/tickets/79/issue.md | 8 +- .ideai/tickets/85/carnet.md | 38 +- .ideai/tickets/85/issue.md | 8 +- .ideai/tickets/88/carnet.md | 4 +- .ideai/tickets/88/issue.md | 6 +- .ideai/tickets/90/carnet.md | 4 +- .ideai/tickets/90/issue.md | 6 +- .ideai/tickets/91/assets/build_mockup.py | 174 ++ .../tickets/91/assets/watch_screen_mockup.png | Bin 0 -> 88400 bytes .../tickets/91/assets/watch_screen_mockup.svg | 64 + .ideai/tickets/91/carnet.md | 4 +- .ideai/tickets/91/issue.md | 6 +- .ideai/tickets/94/carnet.md | 142 + .ideai/tickets/94/issue.md | 24 + .ideai/tickets/95/carnet.md | 6 + .ideai/tickets/95/issue.md | 27 + .ideai/tickets/96/carnet.md | 6 + .ideai/tickets/96/issue.md | 22 + .ideai/tickets/97/carnet.md | 6 + .ideai/tickets/97/issue.md | 24 + .ideai/tickets/98/carnet.md | 6 + .ideai/tickets/98/issue.md | 26 + .ideai/tickets/99/carnet.md | 6 + .ideai/tickets/99/issue.md | 24 + .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 890 +++++- lib/application/app_bootstrap.dart | 13 + lib/application/ports.dart | 23 + lib/application/use_cases.dart | 397 ++- lib/domain/entities.dart | 148 +- lib/infrastructure/local/app_database.dart | 150 +- lib/infrastructure/local/app_database.g.dart | 2782 ++++++++++++++++- .../local/drift_repositories.dart | 253 +- lib/infrastructure/local/tables.dart | 84 +- lib/infrastructure/remote/share_api.dart | 2 + .../watch_bridge/wear_data_layer_adapter.dart | 10 +- lib/presentation/exercise_step_audio.dart | 13 +- lib/presentation/profile_screen.dart | 54 +- lib/presentation/share_inbox_screen.dart | 201 +- lib/presentation/share_screen.dart | 159 + .../workout_execution_screen.dart | 366 ++- server/bin/server.dart | 7 +- server/lib/api/share_api.dart | 61 +- server/lib/application/ports.dart | 6 + server/lib/application/share_use_cases.dart | 209 +- server/lib/domain/entities.dart | 56 +- .../postgres/share_repository.dart | 135 +- server/migrations/0002_share_packs.sql | 42 + server/openapi.yaml | 53 +- server/test/postgres_migrations_test.dart | 12 + server/test/share_api_test.dart | 315 +- server/test/share_use_cases_test.dart | 119 +- test/application/use_cases_test.dart | 401 ++- .../watch_companion_command_handler_test.dart | 7 + .../watch_companion_projection_test.dart | 30 +- .../drift_repositories_test.dart | 285 +- .../wear_data_layer_adapter_test.dart | 130 +- test/presentation/history_screen_test.dart | 6 + test/presentation/home_screen_test.dart | 51 + test/presentation/profile_screen_test.dart | 3 + test/presentation/program_screen_test.dart | 7 + .../presentation/progression_screen_test.dart | 6 + .../presentation/share_inbox_screen_test.dart | 186 +- .../workout_execution_screen_test.dart | 232 ++ .../workout_template_screen_test.dart | 10 + .../bridge/WatchBridgeListenerService.kt | 4 + .../watch/bridge/WatchBridgePlugin.kt | 6 +- .../watch/bridge/WatchHeartRateCollector.kt | 33 + .../presentation/watch_session_screen.dart | 300 +- .../watch_session_screen_test.dart | 239 +- 279 files changed, 13546 insertions(+), 674 deletions(-) create mode 100644 .ideai/agents/coachv2.md create mode 100644 .ideai/agents/commercialv2.md create mode 100644 .ideai/agents/context.md create mode 100644 .ideai/agents/contextv2.md create mode 100644 .ideai/agents/test.md create mode 100644 .ideai/memory/gametime-android-release-networking-and-apk-build.md create mode 100644 .ideai/memory/gametime-architecture-telemetry-live-heart-rate-distance-calories.md create mode 100644 .ideai/memory/gametime-architecture-watch-companion.md create mode 100644 .ideai/memory/gametime-ux-watch-companion-round2.md create mode 100644 .ideai/memory/gametime-ux-watch-companion.md create mode 100644 .ideai/memory/gametime-watch-companion-implementation.md create mode 100644 .ideai/memory/gametime-watch-lot-148-154-cadrage.md create mode 100644 .ideai/skills/md/32494e70-abcd-4829-a789-cadc2e7c90c7.md create mode 100644 .ideai/skills/md/4a9abeed-e52a-4b26-aa73-eb4f63cd0398.md create mode 100644 .ideai/skills/md/8f126c3d-5b8c-48c0-97aa-cd9e5f0e7d5b.md create mode 100755 .ideai/skills/scripts/build-phone-apk.sh create mode 100644 .ideai/sprints/2c0dd1ea-8809-49ff-adc1-aa8820815ee7/sprint.md create mode 100644 .ideai/sprints/d5c18b44-0eec-46db-b8ab-506cfee0bfea/sprint.md create mode 100644 .ideai/tickets/100/carnet.md create mode 100644 .ideai/tickets/100/issue.md create mode 100644 .ideai/tickets/101/carnet.md create mode 100644 .ideai/tickets/101/issue.md create mode 100644 .ideai/tickets/102/carnet.md create mode 100644 .ideai/tickets/102/issue.md create mode 100644 .ideai/tickets/103/carnet.md create mode 100644 .ideai/tickets/103/issue.md create mode 100644 .ideai/tickets/104/carnet.md create mode 100644 .ideai/tickets/104/issue.md create mode 100644 .ideai/tickets/105/carnet.md create mode 100644 .ideai/tickets/105/issue.md create mode 100644 .ideai/tickets/106/carnet.md create mode 100644 .ideai/tickets/106/issue.md create mode 100644 .ideai/tickets/107/carnet.md create mode 100644 .ideai/tickets/107/issue.md create mode 100644 .ideai/tickets/108/carnet.md create mode 100644 .ideai/tickets/108/issue.md create mode 100644 .ideai/tickets/109/carnet.md create mode 100644 .ideai/tickets/109/issue.md create mode 100644 .ideai/tickets/110/carnet.md create mode 100644 .ideai/tickets/110/issue.md create mode 100644 .ideai/tickets/111/carnet.md create mode 100644 .ideai/tickets/111/issue.md create mode 100644 .ideai/tickets/112/carnet.md create mode 100644 .ideai/tickets/112/issue.md create mode 100644 .ideai/tickets/113/carnet.md create mode 100644 .ideai/tickets/113/issue.md create mode 100644 .ideai/tickets/114/carnet.md create mode 100644 .ideai/tickets/114/issue.md create mode 100644 .ideai/tickets/115/carnet.md create mode 100644 .ideai/tickets/115/issue.md create mode 100644 .ideai/tickets/116/carnet.md create mode 100644 .ideai/tickets/116/issue.md create mode 100644 .ideai/tickets/117/carnet.md create mode 100644 .ideai/tickets/117/issue.md create mode 100644 .ideai/tickets/118/carnet.md create mode 100644 .ideai/tickets/118/issue.md create mode 100644 .ideai/tickets/119/carnet.md create mode 100644 .ideai/tickets/119/issue.md create mode 100644 .ideai/tickets/120/carnet.md create mode 100644 .ideai/tickets/120/issue.md create mode 100644 .ideai/tickets/121/carnet.md create mode 100644 .ideai/tickets/121/issue.md create mode 100644 .ideai/tickets/122/carnet.md create mode 100644 .ideai/tickets/122/issue.md create mode 100644 .ideai/tickets/123/carnet.md create mode 100644 .ideai/tickets/123/issue.md create mode 100644 .ideai/tickets/124/carnet.md create mode 100644 .ideai/tickets/124/issue.md create mode 100644 .ideai/tickets/125/carnet.md create mode 100644 .ideai/tickets/125/issue.md create mode 100644 .ideai/tickets/126/carnet.md create mode 100644 .ideai/tickets/126/issue.md create mode 100644 .ideai/tickets/127/carnet.md create mode 100644 .ideai/tickets/127/issue.md create mode 100644 .ideai/tickets/128/carnet.md create mode 100644 .ideai/tickets/128/issue.md create mode 100644 .ideai/tickets/129/carnet.md create mode 100644 .ideai/tickets/129/issue.md create mode 100644 .ideai/tickets/130/carnet.md create mode 100644 .ideai/tickets/130/issue.md create mode 100644 .ideai/tickets/131/carnet.md create mode 100644 .ideai/tickets/131/issue.md create mode 100644 .ideai/tickets/132/carnet.md create mode 100644 .ideai/tickets/132/issue.md create mode 100644 .ideai/tickets/133/carnet.md create mode 100644 .ideai/tickets/133/issue.md create mode 100644 .ideai/tickets/134/carnet.md create mode 100644 .ideai/tickets/134/issue.md create mode 100644 .ideai/tickets/135/carnet.md create mode 100644 .ideai/tickets/135/issue.md create mode 100644 .ideai/tickets/136/carnet.md create mode 100644 .ideai/tickets/136/issue.md create mode 100644 .ideai/tickets/137/carnet.md create mode 100644 .ideai/tickets/137/issue.md create mode 100644 .ideai/tickets/138/carnet.md create mode 100644 .ideai/tickets/138/issue.md create mode 100644 .ideai/tickets/139/carnet.md create mode 100644 .ideai/tickets/139/issue.md create mode 100644 .ideai/tickets/140/carnet.md create mode 100644 .ideai/tickets/140/issue.md create mode 100644 .ideai/tickets/141/carnet.md create mode 100644 .ideai/tickets/141/issue.md create mode 100644 .ideai/tickets/142/carnet.md create mode 100644 .ideai/tickets/142/issue.md create mode 100644 .ideai/tickets/143/carnet.md create mode 100644 .ideai/tickets/143/issue.md create mode 100644 .ideai/tickets/144/carnet.md create mode 100644 .ideai/tickets/144/issue.md create mode 100644 .ideai/tickets/145/carnet.md create mode 100644 .ideai/tickets/145/issue.md create mode 100644 .ideai/tickets/146/carnet.md create mode 100644 .ideai/tickets/146/issue.md create mode 100644 .ideai/tickets/147/carnet.md create mode 100644 .ideai/tickets/147/issue.md create mode 100644 .ideai/tickets/148/carnet.md create mode 100644 .ideai/tickets/148/issue.md create mode 100644 .ideai/tickets/149/carnet.md create mode 100644 .ideai/tickets/149/issue.md create mode 100644 .ideai/tickets/150/carnet.md create mode 100644 .ideai/tickets/150/issue.md create mode 100644 .ideai/tickets/151/carnet.md create mode 100644 .ideai/tickets/151/issue.md create mode 100644 .ideai/tickets/152/carnet.md create mode 100644 .ideai/tickets/152/issue.md create mode 100644 .ideai/tickets/153/carnet.md create mode 100644 .ideai/tickets/153/issue.md create mode 100644 .ideai/tickets/154/carnet.md create mode 100644 .ideai/tickets/154/issue.md create mode 100644 .ideai/tickets/155/carnet.md create mode 100644 .ideai/tickets/155/issue.md create mode 100644 .ideai/tickets/156/carnet.md create mode 100644 .ideai/tickets/156/issue.md create mode 100644 .ideai/tickets/157/carnet.md create mode 100644 .ideai/tickets/157/issue.md create mode 100644 .ideai/tickets/158/carnet.md create mode 100644 .ideai/tickets/158/issue.md create mode 100644 .ideai/tickets/159/carnet.md create mode 100644 .ideai/tickets/159/issue.md create mode 100644 .ideai/tickets/160/carnet.md create mode 100644 .ideai/tickets/160/issue.md create mode 100644 .ideai/tickets/161/carnet.md create mode 100644 .ideai/tickets/161/issue.md create mode 100644 .ideai/tickets/162/carnet.md create mode 100644 .ideai/tickets/162/issue.md create mode 100644 .ideai/tickets/163/carnet.md create mode 100644 .ideai/tickets/163/issue.md create mode 100644 .ideai/tickets/164/carnet.md create mode 100644 .ideai/tickets/164/issue.md create mode 100644 .ideai/tickets/91/assets/build_mockup.py create mode 100644 .ideai/tickets/91/assets/watch_screen_mockup.png create mode 100644 .ideai/tickets/91/assets/watch_screen_mockup.svg create mode 100644 .ideai/tickets/94/carnet.md create mode 100644 .ideai/tickets/94/issue.md create mode 100644 .ideai/tickets/95/carnet.md create mode 100644 .ideai/tickets/95/issue.md create mode 100644 .ideai/tickets/96/carnet.md create mode 100644 .ideai/tickets/96/issue.md create mode 100644 .ideai/tickets/97/carnet.md create mode 100644 .ideai/tickets/97/issue.md create mode 100644 .ideai/tickets/98/carnet.md create mode 100644 .ideai/tickets/98/issue.md create mode 100644 .ideai/tickets/99/carnet.md create mode 100644 .ideai/tickets/99/issue.md create mode 100644 server/migrations/0002_share_packs.sql diff --git a/.gitignore b/.gitignore index f8a3f24..1b81a9a 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,22 @@ coverage/ .ideai/live-state.json .ideai/layouts.json .ideai/permissions.json +.ideai/system-permissions.json +.ideai/build-env/ + +# Environnements locaux de build (SDK/Gradle/XDG/pub-cache par worktree) — jamais du code applicatif +.android-local/ +.android-oldflow/ +.gradle-local/ +.gradle-oldflow/ +.home-oldflow/ +.home/ +.pub-cache-oldflow/ +.qa-gradle-watch/ +.qa-home-watch/ +.xdg-oldflow/ +.xdg/ +.tmp/ + +# Fichiers de heap dump parasites (JVM/Android crash) +*.hprof diff --git a/.ideai/agents.json b/.ideai/agents.json index 3718998..1dee6a1 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -5,7 +5,7 @@ "agentId": "8f065f64-ef6e-4a00-af9c-d00be079e3cc", "name": "Git", "mdPath": "agents/git.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "9dd61642-f10d-4fc2-a8f1-992d7e1c6940", "templateId": "07670754-878b-4a37-8e18-5b061c51cd9b", "synchronized": true, "syncedTemplateVersion": 1 @@ -14,16 +14,16 @@ "agentId": "57695b92-24d0-4876-837c-76116e70a6ae", "name": "Main", "mdPath": "agents/main.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "64be4281-545b-48d6-9b35-d8ae0fb3bb72", "templateId": "84716ac6-ee09-4d5e-8790-3194148bedea", "synchronized": true, - "syncedTemplateVersion": 1 + "syncedTemplateVersion": 2 }, { "agentId": "10ee045b-1c41-479e-ba03-dceed9edd495", "name": "DevBackend", "mdPath": "agents/devbackend.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "templateId": "b92be0a9-d8c6-4373-bac4-032950d62dc1", "synchronized": true, "syncedTemplateVersion": 1 @@ -32,7 +32,7 @@ "agentId": "9933c93a-b8a1-4164-a3bb-7063fdad747d", "name": "DevFrontend", "mdPath": "agents/devfrontend.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "templateId": "c42c2c2c-b0ae-4d7d-9041-35e0647e9175", "synchronized": true, "syncedTemplateVersion": 1 @@ -41,7 +41,7 @@ "agentId": "f3408f5d-469c-4f64-9485-d8b218f3ff26", "name": "UX", "mdPath": "agents/ux.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "9dd61642-f10d-4fc2-a8f1-992d7e1c6940", "templateId": "6d577198-b737-4ff5-aa93-ee20ae4d29ec", "synchronized": true, "syncedTemplateVersion": 1 @@ -50,7 +50,7 @@ "agentId": "f8f40941-ecf7-4830-b9de-8818a099f448", "name": "Architect", "mdPath": "agents/architect.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "9dd61642-f10d-4fc2-a8f1-992d7e1c6940", "templateId": "5a167cad-565a-4058-8efb-8144b433944e", "synchronized": true, "syncedTemplateVersion": 1 @@ -59,7 +59,7 @@ "agentId": "7efa512f-3b3a-47b5-ade0-a2dd13073055", "name": "QA", "mdPath": "agents/qa.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "64be4281-545b-48d6-9b35-d8ae0fb3bb72", "templateId": "2629157e-21c6-4c4c-93ef-10dde69480b0", "synchronized": true, "syncedTemplateVersion": 1 @@ -68,15 +68,24 @@ "agentId": "a5da242f-e5f4-49b5-a29f-e0d4b7b49169", "name": "Commercial", "mdPath": "agents/commercial.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "98708986-0b01-4768-b44c-1974c7e2576e", "synchronized": false }, { "agentId": "aa1ae4f2-c78e-4481-8f96-64f77391d09a", "name": "Coach", "mdPath": "agents/coach.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "98708986-0b01-4768-b44c-1974c7e2576e", "synchronized": false + }, + { + "agentId": "4e440156-ae5d-466b-8cf0-62e41ccfb3a5", + "name": "Context", + "mdPath": "agents/context.md", + "profileId": "98708986-0b01-4768-b44c-1974c7e2576e", + "templateId": "9b854d15-de14-494f-a154-22ad06285d87", + "synchronized": true, + "syncedTemplateVersion": 1 } ] } diff --git a/.ideai/agents/coachv2.md b/.ideai/agents/coachv2.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/agents/commercialv2.md b/.ideai/agents/commercialv2.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/agents/context.md b/.ideai/agents/context.md new file mode 100644 index 0000000..38d84ad --- /dev/null +++ b/.ideai/agents/context.md @@ -0,0 +1,93 @@ +# Context — Agent d'assistance légère à faible coût + +> Tu es l'**agent Context**. Tu tournes sur un **LLM local, peu puissant**. Ta seule +> raison d'être : **absorber les tâches mécaniques et coûteuses en tokens** que les +> autres agents du cycle devraient sinon faire eux-mêmes, pour qu'ils atteignent +> **moins souvent leur limite de tokens** — **sans dégrader la qualité de leurs +> décisions**. + +Tu n'es **pas** un agent de décision. Tu ne remplaces aucun rôle du cycle. Tu prépares, +tu extrais, tu résumes — l'agent qui t'a sollicité garde la responsabilité et le dernier +mot sur tout ce qui compte. + +--- + +## 1. Ce que tu fais + +Tu réponds à des demandes ponctuelles d'un autre agent (jamais directement de +l'utilisateur, sauf sollicitation explicite). Ton périmètre : + +- **Recherche de symboles** : localiser où une fonction/classe/type est définie, où elle + est utilisée, sans que l'agent appelant ait à lire tout l'arbre de fichiers. +- **Résumé de fichier(s)** : compresser un fichier long ou un ensemble de fichiers en un + résumé factuel (structure, responsabilités, points d'entrée) — pas une interprétation + architecturale. +- **Classification d'erreurs de compilation** : trier une sortie de build brute par + catégorie (type, fichier, ligne, nature de l'erreur) pour que l'agent appelant lise un + tableau plutôt que des milliers de lignes de log. +- **Extraction des tests en échec** : à partir d'une sortie de test brute, lister les + tests KO avec leur message d'erreur, sans le bruit des tests verts. +- **Résumé de diff** : condenser un `git diff` volumineux en une liste factuelle de + fichiers touchés et de la nature du changement (ajout, suppression, renommage, + ampleur). +- **Repérage de fichiers probablement concernés par un ticket/une demande** : à partir + d'un texte de ticket et d'une recherche dans l'arbre du projet, proposer une liste de + fichiers candidats — une piste de départ, pas une garantie. + +Tout ce qui engage une décision (proposer un message de commit, générer un test +unitaire, mettre à jour une documentation qui fera foi) est **hors périmètre** : ce sont +des artefacts que l'agent propriétaire du domaine doit produire ou valider lui-même. Un +modèle local peu puissant qui les produit directement fait courir un risque de qualité +que la vérification par l'agent fort annulerait de toute façon le gain de tokens visé. +Si on te demande l'un de ces artefacts, tu peux produire un **brouillon explicitement +marqué comme tel**, jamais un livrable. + +--- + +## 2. Ce que tu ne fais jamais + +- Tu ne **décides** rien : pas d'architecture, pas de contrat, pas de branche, pas de + verdict de test, pas de conception UI. +- Tu ne **corriges pas de code de production**. +- Tu ne **remplaces pas la vérification** : si une sortie doit être prouvée vraie (tests, + verdict de build), c'est l'agent propriétaire qui l'exécute et la lit, pas toi. +- Tu ne **inventes pas** quand l'information te manque. Un modèle local faible qui + complète par extrapolation produit un faux gain : ça coûte plus cher en correction + après coup qu'en tokens économisés avant. **Dis "non trouvé" ou "incertain" + explicitement plutôt que de deviner.** +- Tu ne gères aucun ticket, tu n'appelles pas d'outil de remise de résultat : tu réponds + normalement en fin de tour, la réponse finale est capturée par l'orchestration. + +--- + +## 3. Comment produire une réponse utile (vu ta faiblesse de modèle) + +Ta valeur vient de la **compression fiable**, pas de l'intelligence. Pour rester fiable : + +- **Cite ce que tu as effectivement vu** (chemins de fichiers, numéros de ligne, extraits + courts) plutôt que de reformuler de mémoire. Une réponse vérifiable vaut mieux qu'une + réponse fluide. +- **Format court, structuré, sans prose.** Liste à puces, tableau, ou bloc de citations — + jamais un paragraphe d'analyse. L'agent qui te lit doit pouvoir consommer ta réponse en + quelques secondes. +- **Un scope explicite dans chaque réponse** : dis ce que tu as couvert (quels fichiers, + quelle partie du log) et ce que tu n'as pas couvert, si la demande dépassait ce que tu + as pu lire. +- **N'ajoute pas de jugement de valeur ni de recommandation** — un avis sur la qualité + d'un fichier ou la gravité d'une erreur n'appartient pas à ton rôle et ta faiblesse de + modèle ne te permet pas de le fonder correctement. +- **Reste bref.** Le but est d'économiser des tokens à l'agent appelant, pas de produire + un rapport plus long que le log d'origine. + +--- + +## 4. Délégation & collaboration + +- Tu es sollicité par l'orchestrateur ou par un autre agent. Traite la demande et + termine ton tour avec ta réponse normale — pas de protocole de ticket. +- Si la demande sort clairement de ton périmètre (une décision, un arbitrage, un + jugement de qualité), dis-le et renvoie vers l'agent propriétaire au lieu d'improviser + une réponse hors sujet. +- Si tu n'es pas sûr d'un résultat (symbole non trouvé, fichier candidat incertain), dis + la limite plutôt que de la masquer — un agent fort qui reçoit une fausse certitude perd + plus de tokens à la détecter que si tu avais été transparent. diff --git a/.ideai/agents/contextv2.md b/.ideai/agents/contextv2.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/agents/main.md b/.ideai/agents/main.md index 1e2e045..bc90943 100644 --- a/.ideai/agents/main.md +++ b/.ideai/agents/main.md @@ -97,3 +97,5 @@ applique le cycle. Si le contexte d'un agent manque une consigne qui relève de son rôle, **mets à jour ce contexte** au lieu de gonfler le tien. Ton contexte reste celui du pilotage ; les détails d'architecture, de conception et de test appartiennent aux agents propriétaires. + +Si une réflexion est récurrente et aboutit toujours à la même solution, créés en un skill réutilisable \ No newline at end of file diff --git a/.ideai/agents/test.md b/.ideai/agents/test.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/memory/gametime-android-release-networking-and-apk-build.md b/.ideai/memory/gametime-android-release-networking-and-apk-build.md new file mode 100644 index 0000000..682cb66 --- /dev/null +++ b/.ideai/memory/gametime-android-release-networking-and-apk-build.md @@ -0,0 +1,62 @@ +--- +name: gametime-android-release-networking-and-apk-build +description: memory note gametime-android-release-networking-and-apk-build +metadata: + type: project +--- +# GameTime — Réseau Android release, cleartext et build APK + +Capitalise le bug de création de compte sur APK release (« Création impossible pour le moment. Réessaie plus tard ») et la procédure de build/distribution APK. + +## Causes du bug (toutes nécessaires, stacking) + +L'erreur UI générique `Création impossible pour le moment` (`lib/presentation/profile_screen.dart`, `_registerErrorMessage`) est retournée pour `RemoteAuthFailure.network` **ou** tout échec non `emailAlreadyUsed`. Trois causes indépendantes cumulées sur le release : + +1. **Permission `INTERNET` absente du manifest release.** Elle n'était déclarée que dans `android/app/src/debug/AndroidManifest.xml` et `.../profile/...`. Le release (merge main+release, sans manifest release dédié) n'en héritait pas → aucun socket réseau possible → `http.ClientException` → `RemoteAuthFailure.network`. + - Fix définitif : `INTERNET` déclaré dans `android/app/src/main/AndroidManifest.xml` (racine ``), hérité par tous les build types. + +2. **Trafic cleartext bloqué.** Serveur de dev/LAN en `http://` (pas de TLS ; reverse proxy HTTPS externe). Sur Android 9+ (API 28+) le cleartext est bloqué par défaut. + - Fix : `android:usesCleartextTraffic="true"` sur `` dans `src/main/AndroidManifest.xml`. Acceptable tant qu'il n'y a pas de TLS bout-en-bout ; à remplacer par une `network_security_config` ciblée quand la prod passe en HTTPS. + +3. **Port serveur.** Le conteneur API publie le port interne 8080 sur le port hôte **8090** (`server/.env` : `API_BIND_ADDRESS=192.168.1.75`, `API_PORT=8090` ; `server/docker-compose.yaml` `${API_BIND_ADDRESS}:${API_PORT}:8080`). Donc l'URL joignable est `http://192.168.1.75:8090`, **pas** 8080. + +## Contrat d'injection d'URL (ne pas coder en dur) + +`HttpApiClient.defaultBaseUrl` (`lib/infrastructure/remote/http_api_client.dart`) reste `http://localhost:8080` par défaut (fallback de dev local). L'URL réelle est injectée au build via `--dart-define=GAMETIME_API_BASE_URL=...`. Ne pas hardcoder d'IP/port serveur dans le code (validé par Architect). Le défaut `localhost:8080` est volontairement non fonctionnel sur device physique distant. + +## Build APK release (env Main) + +Pré-requis machine projet : Flutter 3.44.6 (`/usr/bin/flutter`), Android SDK `/opt/android-sdk`, **JDK 21 obligatoire** (`/usr/lib/jvm/java-21-openjdk`) — le JDK système par défaut (java-26) casse Gradle. + +```bash +export JAVA_HOME=/usr/lib/jvm/java-21-openjdk +export ANDROID_HOME=/opt/android-sdk +export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" +cd /home/anthony/Documents/Projects/GameTime +flutter build apk --release --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090 +``` + +Sortie : `build/app/outputs/flutter-apk/app-release.apk` (+ `.sha1`). Signé avec les clés debug (`android/app/build.gradle.kts` : `signingConfig = debug`) → installable sans keystore prod. + +Note : les sandboxes agents (QA, codex runner) échouent à lancer Gradle (« Could not determine a usable wildcard IP for this machine ») ; le build release se fait depuis l'environnement Main uniquement. + +## Vérification du manifest mergé d'un APK + +```bash +AAPT=/opt/android-sdk/build-tools/34.0.0/aapt2 +$AAPT dump permissions # doit lister android.permission.INTERNET +$AAPT dump xmltree --file AndroidManifest.xml | grep -iE 'INTERNET|usesCleartextTraffic' +``` + +Pour un release GameTime sain : `INTERNET` présent ET `usesCleartextTraffic=true`. + +## Santé serveur (pre-flight) + +```bash +curl http://192.168.1.75:8090/health # attendu {"status":"ok"} +curl -X POST http://192.168.1.75:8090/auth/register \ + -H 'content-type: application/json' \ + -d '{"email":"probe@example.com","password":"probe12345"}' # attendu HTTP 201 +``` + +`docker ps` : `server-api-1` expose `192.168.1.75:8090->8080/tcp`, `server-postgres-1` healthy. \ No newline at end of file diff --git a/.ideai/memory/gametime-architecture-telemetry-live-heart-rate-distance-calories.md b/.ideai/memory/gametime-architecture-telemetry-live-heart-rate-distance-calories.md new file mode 100644 index 0000000..f2cb816 --- /dev/null +++ b/.ideai/memory/gametime-architecture-telemetry-live-heart-rate-distance-calories.md @@ -0,0 +1,10 @@ +--- +name: gametime-architecture-telemetry-live-heart-rate-distance-calories +description: > +metadata: + type: project +--- +- Ne pas mélanger telemetry et projection de commande. +- Ne pas réutiliser une estimation de calories comme mesure réelle. +- Ne pas compter l'ordre d'arrivée réseau pour les agrégats. +- Les données manquantes restent silencieuses. \ No newline at end of file diff --git a/.ideai/memory/gametime-architecture-watch-companion.md b/.ideai/memory/gametime-architecture-watch-companion.md new file mode 100644 index 0000000..0b10e3a --- /dev/null +++ b/.ideai/memory/gametime-architecture-watch-companion.md @@ -0,0 +1,452 @@ +--- +name: gametime-architecture-watch-companion +description: memory note gametime-architecture-watch-companion +metadata: + type: project +--- +# GameTime — Architecture interface montre synchronisée + +Décision d'architecture pour la feature #91, basée sur `gametime-ux-watch-companion`, `gametime-architecture-initial-stack-data-model`, `gametime-session-execution-timer-refactor`, `gametime-architecture-step-chaining-override` et `gametime-architecture-exercise-steps`. + +## Décision structurante + +La montre est un **companion stateless côté domaine** : +- le **téléphone** reste l'unique source de vérité d'exécution ; +- la **montre** n'exécute aucune logique métier de séance, ne persiste aucun état métier et ne calcule aucun enchaînement ; +- toute action montre est une **commande adressée au téléphone** ; +- la montre ne se recale que sur l'**état confirmé** renvoyé par le téléphone. + +Conséquence non négociable : le cas clé "première étape chrono + exercice chrono = démarrage commun" reste implémenté uniquement dans `ActiveWorkoutSessionUseCases.startCurrentExerciseTimers(...)`. La montre invoque ce même point métier ; elle ne recompose jamais ce démarrage elle-même. + +## Structure projet retenue + +Option retenue : **app Wear OS Flutter dédiée dans le mono-dépôt**, pas un module compagnon embarqué dans l'app téléphone. + +Structure recommandée : + +```text +/ + lib/ // app téléphone existante + android/ // app téléphone existante + watch_app/ // nouvelle app Flutter Wear OS dédiée + lib/ + android/ + pubspec.yaml + packages/ + watch_bridge_contract/ // package Dart pur partagé (DTO + enums + codecs) +``` + +Justification : +- l'app téléphone actuelle est un package Flutter unique déjà câblé pour Android/iOS ; y greffer une surface Wear OS dans le même target Android mélangerait trop la config mobile phone, la config watch et le bridge natif ; +- une app Wear OS dédiée isole les manifestes, permissions, icônes, navigation et cadence de release montre sans polluer l'app téléphone ; +- le mono-dépôt reste simple : un second package Flutter avec dépendance locale vers un package Dart partagé suffit ; pas besoin d'introduire un nouveau runtime, ni de dupliquer le domaine ; +- l'architecture hexagonale reste propre : le package partagé ne contient que des **contrats de transport**, jamais du métier. + +Option écartée : module compagnon dans l'app téléphone. +- elle réduit légèrement le nombre de packages, mais couple trop fort les couches Android et rend plus fragile la maintenance du bridge Wearable/Data Layer et des variantes téléphone/montre. + +## Canal téléphone ↔ montre retenu + +Canal retenu : **Wearable Data Layer natif Android** exposé à Flutter via un adapter d'infrastructure fin. + +Répartition : +- **MessageClient** pour les **commandes montre -> téléphone** et les **acks**. +- **DataClient** pour la **projection d'état téléphone -> montre** sous forme de "latest state". +- **CapabilityClient** pour la **découverte de nœud** et la reprise de connexion. + +Décision d'implémentation : +- ne pas rendre le domaine/application dépendants d'un plugin tiers ; +- encapsuler le Data Layer dans un adapter Android dédié (`infrastructure/watch_bridge`) exposé à Flutter par `MethodChannel`/`EventChannel` ou `Pigeon`. + +Justification : +- fonctionne **offline/local** via le lien téléphone-montre existant, sans cloud ; +- `MessageClient` est adapté aux intentions impératives basse latence ; +- `DataClient` est adapté au **dernier état compact** à rejouer après reconnexion, sans devoir rejouer un historique d'événements ; +- le couple Message/Data est plus robuste qu'un flux message-only : la montre peut toujours se réaligner sur le dernier snapshot autoritaire. + +## Architecture hexagonale cible + +### Côté téléphone + +Ajouter une façade applicative dédiée, additive et non invasive : + +`WatchCompanionUseCases` + +Responsabilités : +- recevoir une `WatchCommandEnvelope` depuis l'adapter Wear ; +- sérialiser l'exécution des commandes montre ; +- router chaque commande vers les **use cases existants** (`ActiveWorkoutSessionUseCases`, `ActiveExerciseStepUseCases` et lecture repository) ; +- construire une `WatchSessionProjection` compacte à partir de l'état persistant téléphone ; +- publier cette projection à chaque mutation d'exécution pertinente. + +Ports recommandés côté application : +- `WatchCommandIngress` + - `Future dispatch(WatchCommandEnvelope command)` +- `WatchProjectionPublisher` + - `Future publish(WatchSessionProjection projection)` +- `WatchProjectionSource` + - `Future currentProjection()` + +Important : +- `WatchCompanionUseCases` est une **façade d'orchestration**, pas une seconde logique métier ; +- les règles d'exécution restent dans les use cases existants ; +- les adapters Wear n'appellent jamais directement Drift ni la présentation Flutter téléphone. + +### Côté montre + +L'app Wear OS a trois couches : +- `presentation/` : écrans UX montre, état local de connexion/pending ; +- `application/` : interprétation minimale des DTO et orchestration UI ; +- `infrastructure/` : adapter Data Layer. + +La montre peut persister seulement : +- préférences UI locales ; +- dernier état reçu pour reprise visuelle courte durée si l'app montre est recréée. + +Elle ne persiste jamais : +- session métier ; +- timers métier ; +- résultats ; +- historique de commandes comme source de vérité. + +## Sémantique des flux + +## 1. Montre -> téléphone : contrat de commande + +### Enveloppe + +```dart +enum WatchCommandType { + startCurrentExercise, + pauseSession, + resumeSession, + startPreparedTimedStep, + skipCurrentStep, + skipCurrentPassage, + finishCurrentSet, + skipCurrentSet, + skipCurrentRest, +} + +final class WatchCommandEnvelope { + final int schemaVersion; + final String commandId; + final WatchCommandType type; + final String sessionId; + final int expectedRevision; + final int sentAtEpochMs; +} +``` + +Décisions : +- `commandId` : UUID généré côté montre, unique par tentative utilisateur. +- `sessionId` : session téléphone visée ; empêche l'application d'une commande à une autre séance après reconnexion. +- `expectedRevision` : révision de projection sur laquelle l'utilisateur a agi. +- pas de payload métier supplémentaire en v1 : toutes les commandes portent implicitement sur la **position courante** de la séance active. + +### Mapping métier obligatoire + +- `startCurrentExercise` + - appelle la même commande applicative que le téléphone pour `Démarrer l'exercice`. + - route vers `ActiveWorkoutSessionUseCases.startCurrentExerciseTimers(...)`. + - couvre explicitement le démarrage commun timer de série + première étape chrono + score chrono. + +- `pauseSession` + - route vers `ActiveWorkoutSessionUseCases.pause(...)`. + +- `resumeSession` + - route vers `ActiveWorkoutSessionUseCases.resume(...)`. + +- `startPreparedTimedStep` + - route vers `ActiveExerciseStepUseCases.startTimer(...)`. + - réservé au cas `Chrono suivant prêt`. + +- `skipCurrentStep` + - route vers `ActiveExerciseStepUseCases.skipCurrentStep(...)`. + +- `skipCurrentPassage` + - route vers `ActiveExerciseStepUseCases.skipCurrentPassage(...)`. + +- `finishCurrentSet` + - route vers le même enchaînement applicatif que le bouton téléphone `Terminer la série` : + - arrêt/enregistrement des chronos actifs via les use cases existants ; + - création/mise à jour du résultat de série ; + - démarrage éventuel du repos ; + - progression de curseur. + +- `skipCurrentSet` + - route vers le même enchaînement applicatif que `Passer la série`, avec skip des chronos/séquence et progression. + +- `skipCurrentRest` + - route vers `ActiveWorkoutSessionUseCases.skipRest(...)`. + +### Ack + +```dart +enum WatchCommandAckStatus { + accepted, + acceptedNoOp, + rejectedStaleRevision, + rejectedNotApplicable, + rejectedNoActiveSession, + rejectedSessionMismatch, + rejectedPhoneBusy, +} + +final class WatchCommandAck { + final int schemaVersion; + final String commandId; + final WatchCommandAckStatus status; + final String sessionId; + final int revisionAtAck; + final int ackedAtEpochMs; + final String? reasonCode; +} +``` + +Règles : +- `accepted` : la commande a été appliquée ; une projection mise à jour doit suivre immédiatement. +- `acceptedNoOp` : la commande était déjà satisfaite ou doublonnée sans effet métier. +- `rejectedStaleRevision` : l'état téléphone a avancé depuis `expectedRevision` ; la commande n'est pas rejouée sur le nouvel état. La montre doit attendre le snapshot courant et se recaler. +- `rejectedNotApplicable` : action impossible dans l'état courant. +- `rejectedPhoneBusy` : réservé au cas exceptionnel où le téléphone n'a pas pu sérialiser immédiatement ; la montre ne rejoue pas en boucle sans nouvel état. + +### Garantie d'ordre et d'idempotence + +Décision : +- les commandes montre sont traitées **séquentiellement** côté téléphone, via une file mono-consommateur dans `WatchCompanionUseCases` ; +- le téléphone incrémente une `revision` entière de projection à chaque mutation visible montre ; +- une commande n'est appliquée que si `expectedRevision == currentRevision` ; +- après succès, la nouvelle projection porte `revision + 1` ; +- un duplicate/retry avec ancien `expectedRevision` est rejeté `rejectedStaleRevision` et ne peut donc pas skipper une étape supplémentaire par accident. + +Conséquence : +- **pas besoin** d'une persistance métier de reçus de commandes sur la montre ; +- la combinaison `sessionId + expectedRevision + commandId` suffit pour obtenir un comportement effectivement idempotent côté UX ; +- l'ordre réel retenu est toujours celui du téléphone, jamais celui reconstruit par la montre. + +## 2. Téléphone -> montre : DTO de projection d'état + +### DTO racine + +```dart +enum WatchSessionPhase { + noActiveSession, + ready, + running, + paused, + nextTimerReady, + restRunning, + restPaused, + betweenSetsReady, +} + +enum WatchPrimaryAction { + none, + startCurrentExercise, + pauseSession, + resumeSession, + startPreparedTimedStep, + skipCurrentRest, +} + +enum WatchSecondaryAction { + skipCurrentStep, + skipCurrentPassage, + finishCurrentSet, + skipCurrentSet, + skipCurrentRest, +} + +final class WatchSessionProjection { + final int schemaVersion; + final String deviceSessionId; + final int revision; + final int projectedAtEpochMs; + final WatchSessionPhase phase; + final bool phoneReachable; + final int seriesIndex; + final int seriesTotal; + final String exerciseName; + final int? passageIndex; + final int? passageTotal; + final int? stepIndex; + final int? stepTotal; + final String? stepName; + final WatchTimerProjection? dominantTimer; + final List secondaryTimers; + final WatchPrimaryAction primaryAction; + final List secondaryActions; + final String? nextExerciseName; + final String? statusLabel; +} +``` + +### DTO timer + +```dart +enum WatchTimerKind { + rest, + step, + scoreStopwatch, + setTimer, +} + +enum WatchTimerDisplayMode { + countdown, + elapsed, +} + +enum WatchTimerRunState { + stopped, + running, + paused, +} + +final class WatchTimerProjection { + final WatchTimerKind kind; + final String label; + final WatchTimerDisplayMode displayMode; + final WatchTimerRunState runState; + final int referenceEpochMs; + final int accumulatedMs; + final int? startedAtEpochMs; + final int? targetMs; +} +``` + +### Règles de calcul + +- `seriesIndex` / `seriesTotal` sont **1-based** pour éviter toute logique de mapping montre. +- `passageIndex`, `stepIndex` et leurs totals sont omis si non applicables. +- `dominantTimer` suit strictement la priorité UX : + 1. repos ; + 2. étape temps ou `nextTimerReady` ; + 3. score chrono ; + 4. temps de série. +- `secondaryTimers` contient seulement les autres chronos utiles à l'affichage compact, ordonnés. +- `nextExerciseName` n'est renseigné que pendant `restRunning` / `restPaused`. +- `statusLabel` sert aux libellés compacts type `Chrono étape`, `Séance en pause`, `Prêt pour la série suivante`. + +### Interpolation locale du chrono + +Décision : +- la montre **interpole localement l'affichage du chrono** à partir de `referenceEpochMs`, `startedAtEpochMs`, `accumulatedMs` et `targetMs` ; +- le téléphone envoie une projection immédiatement à chaque transition métier et un **heartbeat de resynchronisation léger toutes les 5 secondes** tant qu'au moins un chrono est `running`. + +Justification : +- réduit fortement le trafic et la batterie par rapport à un push haute fréquence ; +- exploite le modèle téléphone déjà persistant par horodatages ; +- garde la montre lisible même avec une brève latence ; +- la montre n'utilise cette interpolation que pour **l'affichage**, jamais pour décider d'un changement métier. + +## États de connexion, latence et resync + +Décision de seuils v1 : +- après tap sur la montre : état local `Envoi...` immédiat ; +- si pas d'ack après **500 ms** : afficher `En attente du téléphone` ; +- si pas d'ack après **2 s** : commande considérée en timeout UX ; +- si aucun ack ni projection fraîche depuis **10 s** : état `Connexion perdue`, actions désactivées ; +- si une projection reçue date de plus de **6 s** pendant une séance active, la montre la marque `dernier état reçu` mais garde encore l'écran. + +Stratégie de reprise : +- à reconnexion d'un nœud téléphone, la montre demande un `resync` ; +- le téléphone republie la `WatchSessionProjection` complète courante via `DataClient` ; +- la projection complète remplace toujours l'état montre en entier, jamais patch par patch. + +## Service premier plan téléphone + +Décision : quand une séance est active côté téléphone (`running`, `paused` ou repos actif), le bridge montre doit vivre dans un **foreground service Android** dédié au companion. + +Responsabilités du service : +- garder le process téléphone vivant pendant la séance ; +- écouter les commandes Wear Data Layer ; +- invoquer `WatchCompanionUseCases` ; +- publier les projections et heartbeats ; +- exposer une notification persistante `Séance en cours`. + +Contraintes : +- le service ne porte **aucune logique métier** ; il orchestre uniquement le bridge et les use cases existants ; +- il doit redémarrer à partir de l'état persistant téléphone si Android recrée le process pendant une séance ; +- type Android recommandé : `connectedDevice`, avec complément `dataSync` seulement si requis par l'implémentation exacte du bridge ; +- arrêt du service quand la séance passe en `completed`, `abandoned` ou `savedExit` et qu'aucune synchronisation montre n'est encore en vol. + +## Conflits et source de vérité + +Confirmation de la règle UX : +- une action montre n'est **jamais appliquée localement** sur la montre ; +- la montre ne fait qu'afficher un pending local puis attend `ack + projection` ; +- si téléphone et montre agissent presque simultanément, l'ordre retenu est celui appliqué par le téléphone ; +- une commande fondée sur une révision périmée est rejetée `rejectedStaleRevision`, puis remplacée visuellement par l'état réel courant. + +## Haptiques + +Décision : +- déclenchement **côté montre**, à réception d'un `ack` ou d'une projection franchissant un jalon ; +- jamais côté téléphone pour la montre ; +- aucun son requis au MVP ; +- si un son est ajouté plus tard, il doit être configuré sans prise de focus audio. + +Mapping v1 : +- `accepted` / `acceptedNoOp` pour start/pause/reprise : impulsion courte ; +- projection entrant en `nextTimerReady`, fin de repos ou fin de chrono visible : double impulsion ; +- perte de connexion après action : impulsion lourde unique optionnelle. + +Invariant #92 : +- aucune API haptique/son montre ne doit prendre le focus audio ni interrompre la musique du téléphone. + +## Invariants à préserver + +- le domaine d'exécution reste centralisé sur le téléphone ; +- aucune logique d'enchaînement d'étapes, de repos ou de timers n'est dupliquée sur la montre ; +- toute commande montre passe par les mêmes use cases applicatifs que l'UI téléphone ; +- la projection montre reste compacte et dérivée, jamais source de vérité ; +- la reconnexion remplace intégralement l'état montre par le dernier snapshot téléphone ; +- aucune nouvelle base métier n'est introduite sur la montre. + +## Sous-tickets recommandés + +- #91-A `[DevBackend] Contrats watch bridge partagés + façade applicative WatchCompanionUseCases` + - créer le package `packages/watch_bridge_contract` + - définir `WatchCommandEnvelope`, `WatchCommandAck`, `WatchSessionProjection` + - créer la façade applicative téléphone et la file séquentielle + - dépendances : aucune + +- #91-B `[DevBackend] Projection compacte d'exécution téléphone -> montre` + - dériver `WatchSessionProjection` depuis l'état persistant d'exécution + - gérer `revision`, priorisation du chrono dominant, actions autorisées + - dépend de `#91-A` + +- #91-C `[DevBackend] Routing des commandes montre vers les use cases d'exécution existants` + - mapper toutes les commandes watch vers `ActiveWorkoutSessionUseCases` / `ActiveExerciseStepUseCases` + - appliquer contrôle `sessionId + expectedRevision` + - produire `WatchCommandAck` + - dépend de `#91-A` et `#91-B` + +- #91-D `[DevBackend] Adapter Android Wear Data Layer + foreground service téléphone` + - implémenter l'adapter natif MessageClient/DataClient/CapabilityClient + - brancher le service premier plan, réception commandes, publication projections/heartbeats + - dépend de `#91-B` et `#91-C` + +- #91-E `[DevFrontend] App Wear OS Flutter dédiée + navigation UX montre` + - créer `watch_app/` + - implémenter écrans `pas de séance`, `séance active`, `actions`, `repos`, `connexion perdue` + - dépend de `#91-A` + +- #91-F `[DevFrontend] Client watch bridge + états pending/latence/reconnexion + haptiques` + - consommer `ack` et `WatchSessionProjection` + - gérer interpolation locale, timeouts UX, désactivation actions, resync complet + - déclencher haptiques montre + - dépend de `#91-D` et `#91-E` + +- #91-G `[QA] Validation companion watch offline/local` + - vérifier ordre/idempotence, rejet de révision périmée, reconnexion, écran verrouillé/téléphone en arrière-plan, absence d'interruption audio + - dépend de `#91-F` + +Ordre recommandé : +- `#91-A` +- `#91-B` et `#91-E` en parallèle +- `#91-C` +- `#91-D` +- `#91-F` +- `#91-G` diff --git a/.ideai/memory/gametime-ux-watch-companion-round2.md b/.ideai/memory/gametime-ux-watch-companion-round2.md new file mode 100644 index 0000000..96a21b8 --- /dev/null +++ b/.ideai/memory/gametime-ux-watch-companion-round2.md @@ -0,0 +1,22 @@ +--- +name: gametime-ux-watch-companion-round2 +description: memory note gametime-ux-watch-companion-round2 +metadata: + type: project +--- +# GameTime — Cadrage UX watch round 2 (notification, icône, refonte UI, score, stats) — 2026-07-26 + +Cadrages UX consignés dans les carnets de #108, #112, #115, #118, #123 (parents #102-#106). Résumé pour référence rapide inter-agent — le détail exploitable est dans chaque carnet ticket, pas dupliqué ici. + +## Décisions structurantes transverses +- **La montre reste un satellite d'affichage/commande, jamais une source de vérité** ni une source d'initiative (pas de démarrage de séance, pas de pause/next depuis la montre dans ce MVP — seul le score +/- est une commande montre→téléphone). Cf. [[gametime-watch-companion-implementation]]. +- **Notification Android (#108)** : résumé d'accès rapide façon Heavy, priorité d'affichage chrono > reps/score > étape, aucune action bouton en MVP, tap = ouvrir l'app. Retirée immédiatement en fin de séance. +- **Icône montre (#112)** : réutilisation stricte du logo "GT" Court Blazer existant, simple export au gabarit rond Wear OS, aucun redesign. +- **Refonte UI montre (#115)** : DA Court Blazer en thème sombre uniquement (pas de thème clair sur montre), une donnée dominante par écran, 5 variantes spécifiées (no-session, séance active, repos, actions/confirmation, connexion perdue — dernière valeur connue assombrie, jamais d'écran vide). +- **Score +/- montre (#118)** : feedback optimiste immédiat + état visuel "en attente" jusqu'à confirmation téléphone, recalage silencieux en cas d'échec, jamais de mention "hors ligne"/"mode local". +- **Stats montre (#123)** : **cadrage seulement, implémentation non ouverte** — dépend du retour Architect #124 (faisabilité Health Services, permissions, persistance). Périmètre MVP proposé : fréquence cardiaque moyenne/max de séance uniquement, affichée dans résumé fin de séance + historique, silence total (aucun placeholder) si donnée absente. + +## Cohérence de vocabulaire à respecter par les devs +- "Série X/Y" en Anton or = pattern déjà établi ([[gametime-ux-series-counter]]), réutilisé identique sur montre et notification. +- Score chrono vs score libre = mode existant ([[gametime-ux-score-chrono]]), pas de nouveau concept introduit par ces tickets. +- Philosophie silence/non-blocage réseau = [[gametime-online-layer-philosophy]], appliquée à la latence de sync montre et à l'absence de stats capteur. \ No newline at end of file diff --git a/.ideai/memory/gametime-ux-watch-companion.md b/.ideai/memory/gametime-ux-watch-companion.md new file mode 100644 index 0000000..55f8a07 --- /dev/null +++ b/.ideai/memory/gametime-ux-watch-companion.md @@ -0,0 +1,530 @@ +--- +name: gametime-ux-watch-companion +description: memory note gametime-ux-watch-companion +metadata: + type: project +--- +# GameTime — UX interface montre synchronisée (ticket #91, UX 2026-07-25) + +Conception UX d'une app compagnon Wear OS synchronisée avec l'app téléphone pour piloter une séance en cours depuis le poignet. + +Références à respecter : +- `gametime-session-execution-timer-refactor` +- `gametime-ux-step-chaining-override` +- `gametime-architecture-step-chaining-override` +- `gametime-ux-score-chrono` +- `gametime-online-layer-philosophy` + +## Décision produit structurante + +La montre est une **surface compagnon de pilotage**, pas une deuxième app autonome de séance. + +- **Téléphone = source de vérité de l'exécution**. +- **Montre = télécommande + miroir d'état**. +- Toute action lancée sur la montre est une **intention utilisateur** envoyée au téléphone, puis confirmée par le retour d'état. +- La montre ne doit jamais exposer un modèle d'exécution différent de celui du téléphone. + +Justification : +- la logique de séance, des chronos, des overrides d'étapes et du repos existe déjà côté téléphone ; +- cela évite les divergences de calcul entre deux appareils ; +- cela rend la cohérence UX compréhensible : un seul état réel, visible sur deux surfaces. + +## Principe de surface + +Sur montre, la hiérarchie doit être radicale : + +1. **où j'en suis** : série, exercice, étape/passage si nécessaire ; +2. **ce qui se passe maintenant** : chrono dominant ou état dominant ; +3. **l'action unique la plus probable** ; +4. **les actions de contournement** dans une surface secondaire. + +La montre ne doit pas tenter de reproduire toute l'interface téléphone. Pas de médias, pas d'édition, pas de paramètres, pas de détails historiques. + +## Surfaces montre + +## 1. État sans séance active + +Écran affiché si aucune séance n'est en cours côté téléphone. + +Contenu : + +```text +Aucune séance en cours + +Lance une séance sur le téléphone. +``` + +CTA optionnel si le système le permet : + +```text +[Actualiser] +``` + +Règles : +- aucun contrôle d'exécution affiché ; +- pas de faux bouton `Démarrer` ; +- si la montre n'est pas connectée au téléphone, le message devient : + +```text +Téléphone indisponible + +Rouvre GameTime sur le téléphone. +``` + +## 2. Écran principal `Séance active` + +Écran par défaut dès qu'une séance en cours existe. C'est la surface centrale de la feature. + +Structure recommandée sur écran rond : + +```text +SÉRIE 2 / 5 +Pompes tempo +Passage 1 / 3 · Étape 2 / 4 + +00:18 +Chrono étape + +Série 01:42 · Score 00:51 + +[Pause] +``` + +### Hiérarchie d'information + +L'ordre visuel doit être fixe : + +1. `SÉRIE X / Y` toujours en haut. +2. Nom de l'exercice sur 1 à 2 lignes max. +3. Ligne contextuelle compacte : + - `Passage A / B` seulement si l'exercice en a ; + - `Étape C / D` seulement si l'exercice en a ; + - si les deux existent, les concaténer sur une seule ligne. +4. **Chrono dominant** en très grand. +5. Libellé du chrono dominant ou de l'état. +6. Ligne compacte des autres chronos simultanés, si utile. +7. Bouton principal pleine largeur. + +### Règle du chrono dominant + +La montre ne doit afficher qu'un seul chrono en grand. Ordre de priorité UX : + +1. `Repos` si un repos est en cours. +2. `Étape` si une étape temps est active ou en état `Chrono suivant prêt`. +3. `Score chrono` s'il est actif et qu'aucune étape temps n'est prioritaire. +4. `Temps de série` si actif. +5. Sinon, l'état dominant remplace le chrono. + +Les autres chronos actifs restent en secondaire sur une seule ligne compacte, par exemple : + +```text +Série 03:12 · Score 01:08 +``` + +But : rester lisible pendant l'effort tout en respectant le modèle multi-chronos existant. + +### Bouton principal contextuel + +Le bas de l'écran porte **une seule action primaire** dont le libellé dépend de l'état : + +- `Démarrer l'exercice` +- `Pause` +- `Reprendre` +- `Démarrer le chrono` +- `Passer le repos` + +Cette action unique est le raccourci du cas d'usage le plus probable à cet instant. + +## 3. Écran `Actions` + +Surface secondaire accessible depuis `Séance active` par swipe horizontal ou tap sur un affordance discret `Actions`. + +Cette surface contient les actions moins fréquentes, sous forme de gros boutons verticaux scrollables. + +Ordre des actions : + +```text +[Passer l'étape] // seulement si applicable +[Passer le passage] // seulement si applicable +[Terminer la série] +[Passer la série] +[Passer le repos] // seulement pendant le repos +``` + +Règles : +- n'afficher que les actions réellement applicables à l'état courant ; +- ne pas afficher d'action impossible ou déjà satisfaite ; +- pendant un chrono en cours, `Passer la série` doit demander une confirmation ; +- pendant un repos, `Terminer la série` disparaît car la série est déjà terminée. + +### Confirmations montre + +Deux confirmations seulement, pour éviter les erreurs grossières pendant l'effort : + +1. `Passer la série ?` + `Le chrono en cours sera ignoré.` + `[Annuler] [Passer]` + +2. `Passer le passage ?` + `L'étape en cours sera ignorée.` + `[Annuler] [Passer]` + +`Passer l'étape` peut être immédiat : coût faible, fréquence plus élevée. + +## 4. Écran `Repos` + +Quand le repos est en cours, l'écran principal change de nature et devient un écran repos, pas un simple bandeau. + +Structure : + +```text +REPOS +Après série 2 / 5 + +00:37 +Repos en cours + +Exercice suivant +Fentes sautées + +[Pause] +``` + +Actions secondaires sur l'écran `Actions` : + +```text +[Passer le repos] +``` + +Si le repos est en pause : + +```text +REPOS +00:37 +Repos en pause + +[Reprendre] +``` + +## États à couvrir + +## 1. Séance pas encore lancée / début de série + +Cas : la série existe, mais aucun chrono qui démarre au début de série n'a encore été lancé. + +Affichage : + +```text +SÉRIE 1 / 4 +Burpees +Étape 1 / 3 + +00:20 +Prêt à démarrer + +[Démarrer l'exercice] +``` + +Règle obligatoire : +- si l'exercice est sous chrono **et** que la première étape est une étape `Temps`, le bouton reste **unique** : + +```text +[Démarrer l'exercice] +``` + +Cette action démarre à la fois : +- le timer de série si `timeEnabled` ; +- le chrono de la première étape ; +- le score chrono si `scoreInputMode == stopwatch`. + +La montre ne doit jamais afficher deux boutons concurrents du type `Démarrer l'exercice` et `Démarrer l'étape`. + +## 2. Chrono running + +Cas nominal pendant l'effort. + +Affichage : +- chrono dominant animé visuellement ; +- libellé `En cours` ou libellé du chrono (`Chrono étape`, `Score chrono`, `Temps de série`) ; +- bouton principal `Pause`. + +Effet du bouton : +- met la séance en pause ; +- la pause suspend tous les chronos running, y compris le repos. + +## 3. Séance en pause + +Affichage : + +```text +SÉRIE 2 / 5 +Pompes tempo + +00:18 +Séance en pause + +[Reprendre] +``` + +Règles : +- l'état doit être explicite ; +- aucun chrono ne doit sembler continuer ; +- si l'utilisateur reprend depuis le téléphone, la montre revient automatiquement à l'état running. + +## 4. `Chrono suivant prêt` + +Cas imposé par `autoStartNextTimedStep = false` après une étape temps suivie d'une autre étape temps. + +Affichage : + +```text +SÉRIE 2 / 5 +Pompes tempo +Passage 1 / 3 · Étape 3 / 4 + +00:15 +Chrono suivant prêt + +[Démarrer le chrono] +``` + +Règles : +- ne pas réutiliser `Démarrer l'exercice` ; +- ne pas auto-démarrer ; +- conserver l'état après pause/reprise ; +- l'écran doit rendre évident qu'on est déjà avancé dans la séquence, mais en attente d'un lancement manuel. + +## 5. Entre les séries + +Deux cas distincts : + +1. repos configuré et lancé ; +2. pas de repos en cours, prochaine série prête. + +Si pas de repos : + +```text +SÉRIE 3 / 5 +Pompes tempo + +Prêt pour la série suivante + +[Démarrer l'exercice] +``` + +L'utilisateur comprend qu'il redémarre une nouvelle série, pas la séance depuis zéro. + +## 6. Repos running / repos en pause + +Voir surface `Repos`. + +Le repos doit être traité comme un état de premier rang, car c'est souvent le seul moment où l'utilisateur regarde la montre entre deux séries. + +## 7. Pas de séance active + +Voir surface `État sans séance active`. + +## 8. Téléphone temporairement indisponible + +Cas spécifique à la cohabitation téléphone/montre. + +La montre peut afficher le dernier état connu, mais il doit être clairement marqué comme potentiellement obsolète : + +```text +Connexion perdue +Dernier état reçu il y a quelques secondes + +[Réessayer] +``` + +Règles : +- désactiver les actions de pilotage tant que la connexion n'est pas rétablie ; +- ne pas laisser croire qu'un tap local a réellement modifié la séance sans confirmation ; +- au retour de connexion, la montre remplace entièrement son affichage par l'état confirmé du téléphone. + +## Mapping des contrôles + +## Gestes retenus + +- **Tap** sur le bouton principal : action primaire contextuelle. +- **Swipe horizontal** : bascule entre `Séance active` et `Actions`. +- **Scroll vertical / couronne** : parcours des actions si la liste dépasse. +- **Bouton système / retour système** : navigation système uniquement. + +Décision UX : ne pas attribuer de commande métier obligatoire à un bouton physique matériel. Les montres Wear OS n'offrent pas toutes la même ergonomie matérielle. + +## Mapping détaillé + +### Sur `Séance active` + +- `Démarrer l'exercice` + - déclenche tous les chronos de début de série applicables ; + - couvre explicitement le cas exercice chrono + première étape chrono. + +- `Pause` + - met la séance en pause ; + - suspend série, étape, score chrono, repos. + +- `Reprendre` + - reprend exactement l'état suspendu. + +- `Démarrer le chrono` + - démarre l'étape temps prête après un enchaînement manuel. + +- `Passer le repos` + - raccourci contextuel possible si UX test montre que c'est plus utile que `Pause` pendant le repos ; + - sinon garder `Pause` en primaire et déplacer `Passer le repos` dans `Actions`. + +Décision recommandée pour v1 : +- pendant le repos, **bouton principal = `Pause`**, pour garder la cohérence avec le reste de la séance ; +- `Passer le repos` reste en action secondaire. + +### Sur `Actions` + +- `Passer l'étape` + - disponible seulement si une étape courante existe. + +- `Passer le passage` + - disponible seulement si l'exercice a plusieurs passages/séquences et si le passage courant n'est pas déjà terminé. + +- `Terminer la série` + - finalise la série et arrête/enregistre les chronos actifs selon les règles existantes. + +- `Passer la série` + - ignore la série courante avec confirmation si un chrono ou une séquence est en cours. + +- `Passer le repos` + - disponible seulement si repos en cours. + +## Cohérence téléphone ↔ montre + +## Source de vérité + +Décision UX ferme : + +- l'état autoritaire de séance vit sur le téléphone ; +- la montre affiche une **projection compacte** de cet état ; +- la montre n'invente jamais un état final seule. + +## Modèle d'interaction + +Cycle UX attendu pour une action montre : + +1. l'utilisateur tape sur la montre ; +2. la montre passe brièvement le bouton en état `Envoi...` ; +3. le téléphone applique la commande ; +4. la montre reçoit l'état confirmé et remplace l'affichage. + +Si l'état confirmé diffère de l'intention initiale parce que le téléphone a déjà changé entre-temps, c'est **le dernier état confirmé** qui gagne visuellement. + +Exemple : +- l'utilisateur tape `Pause` sur la montre ; +- presque au même moment, il a déjà repris sur le téléphone ; +- la montre ne doit pas essayer de "corriger" localement ; elle se recale sur le dernier snapshot confirmé. + +## Règle de conflit UX + +En cas d'actions quasi simultanées téléphone/montre : + +- le système applique un ordre réel côté source de vérité ; +- la montre n'affiche jamais un dialogue de conflit ; +- elle se contente d'afficher le résultat réel le plus récent. + +Autrement dit : **pas de résolution de conflit visible par l'utilisateur**, seulement un réalignement rapide de l'UI. + +## Règle de latence + +La montre doit distinguer trois cas UX : + +1. **latence courte** + - simple état `Envoi...` sur le bouton. + +2. **latence perceptible** + - texte discret `En attente du téléphone`. + +3. **absence de réponse** + - état `Connexion perdue` et actions désactivées. + +Les seuils temporels exacts sont laissés à Architect, mais la surface doit prévoir ces trois états distincts. + +## Signaux sonores et haptiques + +## Principe + +Pour respecter #92, la montre doit privilégier **l'haptique**. L'audio montre n'est pas requis pour le MVP. + +Décisions UX : +- par défaut, **pas de son obligatoire côté montre** ; +- retour principal = vibration ; +- si un son est ajouté plus tard, il doit être bref, non intrusif, et ne jamais prendre le focus audio au détriment de la musique de fond. + +## Patterns recommandés + +- démarrage/pause/reprise confirmé : **impulsion courte** ; +- fin d'un chrono ou fin de repos : **double impulsion** ; +- état `Chrono suivant prêt` : **double impulsion** au moment où l'état est atteint, puis silence ; +- perte de connexion après action utilisateur : **impulsion lourde unique** optionnelle. + +Règles : +- pas de bip de décompte 3-2-1 imposé sur la montre au MVP ; +- pas de répétition haptique continue ; +- éviter la duplication agressive téléphone + montre sur le même événement. + +## Exigences de donnée pour Architect + +La montre a besoin d'un view model compact, orienté exécution, pas de tout le snapshot séance brut. + +Minimum UX requis : + +- présence ou absence d'une séance active ; +- statut de connexion téléphone↔montre ; +- `seriesIndex`, `seriesTotal` ; +- `exerciseName` ; +- contexte séquence : + - `sequenceIndex?`, `sequenceTotal?` + - `stepIndex?`, `stepTotal?` + - `stepName?` +- type et valeur du **chrono dominant** ; +- liste compacte des autres chronos actifs visibles ; +- état global : + - `ready` + - `running` + - `paused` + - `nextTimerReady` + - `restRunning` + - `restPaused` + - `noActiveSession` + - `phoneUnavailable` +- libellé de l'action primaire autorisée ; +- liste des actions secondaires autorisées ; +- indicateur `commandPending`. + +## Hypothèses techniques laissées à Architect + +Points explicitement non tranchés par UX, à valider techniquement : + +1. faisabilité et coût d'une app Wear OS Flutter dédiée ou module compagnon séparé ; +2. capacité à exposer côté téléphone un flux d'état d'exécution suffisamment compact et fréquent pour la montre ; +3. stratégie de mise à jour du chrono affiché sur la montre : + - push fréquent depuis le téléphone ; + - ou interpolation locale à partir d'horodatages autoritaires ; +4. transport exact téléphone↔montre et garanties d'acknowledgement pour les commandes ; +5. comportement si le téléphone est verrouillé, en arrière-plan, ou si le process principal est suspendu ; +6. possibilité de déclencher des vibrations montre sans prise de focus audio ; +7. capacité à rendre idempotentes les commandes utilisateur (`pause`, `resume`, `skipStep`, `finishSet`, etc.) ; +8. découpage des use cases côté téléphone pour exposer uniquement les commandes compatibles montre ; +9. gestion exacte des timeouts et des seuils de latence visibles dans l'UI ; +10. politique de duplication ou non des signaux entre téléphone et montre sur un même événement. + +## Synthèse décisionnelle pour la suite + +La montre doit rester une surface extrêmement simple : + +- un écran principal `Séance active` ; +- un écran secondaire `Actions` ; +- un écran dédié `Repos` quand le repos est l'état dominant ; +- des états explicites pour pause, `Chrono suivant prêt`, absence de séance et perte de connexion ; +- une seule action primaire contextuelle à la fois ; +- téléphone autoritaire, montre suiveuse interactive. + +Cette forme couvre l'objectif utilisateur réel : piloter entièrement la séance sans sortir le téléphone, tout en conservant la logique d'exécution déjà stabilisée dans GameTime. diff --git a/.ideai/memory/gametime-watch-companion-implementation.md b/.ideai/memory/gametime-watch-companion-implementation.md new file mode 100644 index 0000000..2c1b56e --- /dev/null +++ b/.ideai/memory/gametime-watch-companion-implementation.md @@ -0,0 +1,39 @@ +--- +name: gametime-watch-companion-implementation +description: memory note gametime-watch-companion-implementation +metadata: + type: project +--- +# GameTime — Implémentation companion watch (#91) et contraintes sandbox + +## État (2026-07-25) +Feature #91 « Interface montre synchronisée » entièrement codée, validée verte en sandbox, committée sur `feature/ticket91-wear-os-watch-sync`. + +Sous-tickets : #91-A contrats (`packages/watch_bridge_contract/`), #91-B projection téléphone, #91-C routing commandes, #91-D adapter Android Wear Data Layer + foreground service, #91-E app Wear OS (`watch_app/`), #91-F client montre + latence/haptiques. + +Commits : cf68a72 (#91-A), d1c6076 (#91-B), 6c177de (#91-C), 68a87d1 (#91-D), c65a5a7 (#91-E/#91-F). + 40a2d5e `feat(android): local server networking (INTERNET + cleartext)` isolé du watch (pré-existant au serveur, séparé à la demande utilisateur). + +## Validation sandbox (verte, par Main) +- Contrat A : 8 `dart test`. +- Projection B (12), command handler C (11), adapter D (6) : `flutter test` all passed. Cas clés : idempotence (retry/doublon n'avance pas deux fois), rejets stale/non-applicable/missing/mismatch, resync reconnexion, heartbeat, ordre séquentiel. +- `flutter analyze` app téléphone : 0 problème watch (24 `info` pré-existants hors #91). `flutter analyze` watch_app : No issues found. +- Revue code Main : invariant source-de-vérité respecté (la montre n'applique JAMAIS une commande localement, recalage sur projection confirmée) ; haptiques sans focus audio (#92) ; démarrage commun exercice+étape via use cases existants (pas de logique montre). + +## NON validé en sandbox → on-device utilisateur +- Build gradle app téléphone (`flutter build apk`) avec D (plugin Kotlin, `play-services-wearable`, services watch). +- Build watch_app (`cd watch_app && flutter build apk`). +- Pairing Wearable téléphone↔montre ; commande/projection réelles ; foreground service (verrouillé/background) ; latence/interpolation/resync réelles. + +## Contraintes sandbox IdeA (durable, important pour les futures sessions) +Les **agents** (Git, DevBackend, DevFrontend, QA) ne peuvent **pas** écrire `.git` (read-only), ni lancer `flutter` (cache engine read-only), ni accéder au réseau (build hook sqlite3 → SocketException). **Main (orchestrateur) le peut** : `.git` inscriptible, Flutter 3.44.6 OK, réseau OK. + +Conséquences pratiques : +- Validation exécutable (`flutter test`/`analyze`) = **Main**, pas les agents. +- Commits/branches = **Main** (agents bloqués). +- Build natif gradle + on-device = **utilisateur** (hors sandbox). +- Messagerie inter-agent instable (« no final text » / « Tool execution aborted ») : le travail atterrit souvent dans le working tree même si la réponse est perdue → vérifier le working tree directement après chaque délégation. +- Identité git repo-local : `Blomios ` (réutilisée de l'historique). + +## Décisions de cadrage #85 / #86 (report Main) +- #85 (packs partageables) : reporté — gâté sur retour d'usage du partage ciblé (encore en QA). +- #86 (analytics basket avancées) : reporté, périmètre v1 gelé (réussite par exercice de tir + charge hebdo estimée), à reprendre après #91 si usage justifie. \ No newline at end of file diff --git a/.ideai/memory/gametime-watch-lot-148-154-cadrage.md b/.ideai/memory/gametime-watch-lot-148-154-cadrage.md new file mode 100644 index 0000000..4ea6dd5 --- /dev/null +++ b/.ideai/memory/gametime-watch-lot-148-154-cadrage.md @@ -0,0 +1,10 @@ +--- +name: gametime-watch-lot-148-154-cadrage +description: > +metadata: + type: project +--- +- No-session = état informatif, jamais de lancement. +- `startLastWorkoutTemplate` doit être retiré du flux actif montre. +- Le score d’étape indépendant doit rester séparé du score de série. +- `stepName` suffit comme donnée, la forme est maintenant figée par UX. \ No newline at end of file diff --git a/.ideai/skills/md/32494e70-abcd-4829-a789-cadc2e7c90c7.md b/.ideai/skills/md/32494e70-abcd-4829-a789-cadc2e7c90c7.md new file mode 100644 index 0000000..c784675 --- /dev/null +++ b/.ideai/skills/md/32494e70-abcd-4829-a789-cadc2e7c90c7.md @@ -0,0 +1,59 @@ +## Objectif + +Builder l'APK Android du **téléphone** GameTime de manière déterministe, sans +recalculer l'environnement de build à chaque fois. + +## Usage obligatoire + +Toujours lancer le script dédié depuis le project root : + +```bash +bash .ideai/skills/scripts/build-phone-apk.sh debug +``` + +ou pour un build optimisé : + +```bash +bash .ideai/skills/scripts/build-phone-apk.sh release +``` + +Par défaut, si aucun mode n'est donné, le script construit `debug`. + +## Ce que le script fixe automatiquement + +- copie un SDK Flutter writable dans `.ideai/build-env/flutter-sdk` si absent ; +- isole `HOME`, `XDG_*` et `GRADLE_USER_HOME` dans `.ideai/build-env` ; +- force **Java 21** via `/usr/lib/jvm/java-21-openjdk` ; +- configure `ANDROID_HOME=/opt/android-sdk` et le `PATH` Android/Java ; +- lance `flutter pub get` puis `flutter build apk`. + +Ne pas utiliser `/usr/bin/flutter` ou `/opt/flutter/bin/flutter` directement tant +que l'environnement a les mêmes contraintes de sandbox/cache : le wrapper et les +caches système peuvent écrire dans des emplacements non utilisables. + +## Sorties + +- Debug : `build/app/outputs/flutter-apk/app-debug.apk` +- Release : `build/app/outputs/flutter-apk/app-release.apk` + +Le script affiche le chemin final de l'APK. + +## Quand l'utiliser + +- Après tout changement mobile Android/Flutter côté téléphone. +- Avant une installation ADB sur le téléphone. +- Avant de livrer un APK au user. + +## Diagnostic rapide + +- `Unsupported class file major version 70` : + `JAVA_HOME` n'est pas sur Java 21. +- `No space left on device` : + manque d'espace dans le volume qui porte `.ideai/build-env`. +- erreurs de dépendances Flutter : + relancer le même script, ne pas improviser une autre séquence. + +## Règle d'exécution + +Pour builder l'APK téléphone, ne pas réfléchir à la recette : exécuter le script +ci-dessus avec `debug` ou `release`, puis vérifier l'APK dans `build/app/outputs/flutter-apk/`. diff --git a/.ideai/skills/md/4a9abeed-e52a-4b26-aa73-eb4f63cd0398.md b/.ideai/skills/md/4a9abeed-e52a-4b26-aa73-eb4f63cd0398.md new file mode 100644 index 0000000..b13a9f4 --- /dev/null +++ b/.ideai/skills/md/4a9abeed-e52a-4b26-aa73-eb4f63cd0398.md @@ -0,0 +1,88 @@ +## Objectif + +Builder et lancer le serveur GameTime (`server/`, package Dart `gametime_server`) avec sa +base PostgreSQL, pour : +- valider fonctionnellement les routes API (health, auth, sync, shares) sur HTTP ; +- fournir une cible vivante aux tests fonctionnels de routes de QA ; +- développer/débugger côté serveur. + +Skill de pilotage — exécutable par **Main**, support pour **QA** (tests fonctionnels de +routes). Le contrat des routes est dans `server/openapi.yaml`. + +## Mode 1 — Docker Compose (recommandé, stack complète) + +Depuis `server/`, créer/éditer `.env` (modèle `server/.env.example`) : +``` +API_BIND_ADDRESS=0.0.0.0 # interface hôte publiée par Docker. 0.0.0.0 = LAN + loopback ; une IP précise restreint +API_PORT=8080 # port hôte publié +MIGRATE_ON_STARTUP=true # applique les migrations SQL au démarrage du conteneur api +DATABASE_HOST=postgres # nom du service Compose (interne au réseau Docker) +DATABASE_PORT=5432 +DATABASE_NAME=gametime +DATABASE_USER=gametime +DATABASE_PASSWORD=change-me +POSTGRES_DB=gametime +POSTGRES_USER=gametime +POSTGRES_PASSWORD=change-me +``` + +Lancer : +```bash +cd server +docker compose up -d --build +``` + +Le service `api` attend le healthcheck Postgres, exécute `/app/bin/migrate`, puis +`/app/bin/server`. Le conteneur écoute en interne sur `0.0.0.0:8080` (log trompeur) ; +l'adresse réellement joignable côté hôte est **`${API_BIND_ADDRESS}:${API_PORT}`**. + +Vérifier : +```bash +curl http://${API_BIND_ADDRESS}:${API_PORT}/health # attendu : {"status":"ok"} +``` + +Commandes utiles : +```bash +docker compose logs -f api # logs serveur temps réel +docker compose config # config Compose interpolée (vérifier les ports publiés) +docker compose restart api # relancer l'API sans toucher Postgres +docker compose down # arrêter la stack (volume Postgres conservé) +``` + +### Piège LAN (accès depuis le téléphone) +- L'adresse publiée doit être l'IP LAN de l'hôte (ex. `192.168.1.75`), pas `localhost`. +- Ouvrir le firewall hôte sur `API_PORT`. +- L'app mobile pointe par défaut sur `http://localhost:8080` (codé au build via + `GAMETIME_API_BASE_URL`) → surcharger au build de l'APK avec + `--dart-define=GAMETIME_API_BASE_URL=http://:` et autoriser le HTTP en + clair côté Android (`usesCleartextTraffic` dans `AndroidManifest.xml`). + +## Mode 2 — Local Dart (sans Docker) + +Nécessite un PostgreSQL joignable (hors Compose, ou le conteneur `postgres` exposé). +```bash +cd server +dart pub get +DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_NAME=gametime \ +DATABASE_USER=gametime DATABASE_PASSWORD=gametime \ +dart run bin/migrate.dart # migrations lues depuis server/migrations/ +DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_NAME=gametime \ +DATABASE_USER=gametime DATABASE_PASSWORD=gametime \ +PORT=8080 dart run bin/server.dart # écoute sur 0.0.0.0:$PORT +``` + +## Lancer les tests (support QA) + +Depuis `server/` : +```bash +dart test # unitaires + handlers en isolation +TEST_DATABASE_URL=postgres://gametime:gametime@localhost:5432/gametime dart test # + intégration Postgres (sinon skippées) +``` +Limite : les sandbox agents n'ont pas d'accès réseau à pub.dev → si `dart pub get` est +requis, Main le passe avant ; `dart test` sur deps résolus reste jouable par QA. + +## État courant connu (machine projet, 2026-07-25) + +- Stack Docker validée : `GET /health` → 200, `POST /auth/register` → 201. +- IP LAN hôte : `192.168.1.75` (interface `enp4s0`). +- `.env` courant publie sur `192.168.1.75:8090`. diff --git a/.ideai/skills/md/8f126c3d-5b8c-48c0-97aa-cd9e5f0e7d5b.md b/.ideai/skills/md/8f126c3d-5b8c-48c0-97aa-cd9e5f0e7d5b.md new file mode 100644 index 0000000..9fbcf59 --- /dev/null +++ b/.ideai/skills/md/8f126c3d-5b8c-48c0-97aa-cd9e5f0e7d5b.md @@ -0,0 +1,60 @@ +## Objectif + +Builder l'APK Android de la **montre Wear OS** GameTime de manière déterministe, +sans recalculer l'environnement de build à chaque fois. + +## Usage obligatoire + +Toujours lancer le script dédié depuis le project root : + +```bash +bash .ideai/skills/scripts/build-watch-apk.sh debug +``` + +ou pour un build optimisé : + +```bash +bash .ideai/skills/scripts/build-watch-apk.sh release +``` + +Par défaut, si aucun mode n'est donné, le script construit `debug`. + +## Ce que le script fixe automatiquement + +- réutilise le SDK Flutter writable dans `.ideai/build-env/flutter-sdk` ; +- isole `HOME`, `XDG_*` et `GRADLE_USER_HOME` dans `.ideai/build-env` ; +- force **Java 21** via `/usr/lib/jvm/java-21-openjdk` ; +- configure `ANDROID_HOME=/opt/android-sdk` et le `PATH` Android/Java ; +- se place dans `watch_app/` ; +- lance `flutter pub get` puis `flutter build apk`. + +Ne pas builder la montre depuis le root téléphone, et ne pas improviser une autre +sortie de build : l'app montre a son propre projet Flutter sous `watch_app/`. + +## Sorties + +- Debug : `watch_app/build/watch_app/app/outputs/flutter-apk/app-debug.apk` +- Release : `watch_app/build/watch_app/app/outputs/flutter-apk/app-release.apk` + +Le script affiche le chemin final de l'APK. + +## Quand l'utiliser + +- Après tout changement Flutter/Android dans `watch_app/`. +- Avant une installation ADB sur la montre. +- Avant une vérification de connexion téléphone/montre. + +## Diagnostic rapide + +- `Unsupported class file major version 70` : + `JAVA_HOME` n'est pas sur Java 21. +- `No space left on device` : + manque d'espace dans le volume qui porte `.ideai/build-env`. +- erreur `adb: more than one device/emulator` : + le build est bon, le problème est au moment de l'installation ADB, pas du build. + +## Règle d'exécution + +Pour builder l'APK montre, ne pas réfléchir à la recette : exécuter le script +ci-dessus avec `debug` ou `release`, puis vérifier l'APK dans +`watch_app/build/watch_app/app/outputs/flutter-apk/`. diff --git a/.ideai/skills/scripts/build-phone-apk.sh b/.ideai/skills/scripts/build-phone-apk.sh new file mode 100755 index 0000000..7daa552 --- /dev/null +++ b/.ideai/skills/scripts/build-phone-apk.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-debug}" + +if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then + echo "Usage: $0 [debug|release]" >&2 + exit 2 +fi + +PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime" +BUILD_ENV_ROOT="$PROJECT_ROOT/.ideai/build-env" +SDK_COPY="$BUILD_ENV_ROOT/flutter-sdk" +HOME_DIR="$BUILD_ENV_ROOT/home" +GRADLE_DIR="$BUILD_ENV_ROOT/gradle" +PUB_CACHE_DIR="$BUILD_ENV_ROOT/pub-cache" +TMP_DIR="$BUILD_ENV_ROOT/tmp" +ANDROID_HOME="/opt/android-sdk" +JAVA_HOME="/usr/lib/jvm/java-21-openjdk" +FLUTTER_BIN="$SDK_COPY/bin/flutter" + +mkdir -p "$BUILD_ENV_ROOT" "$HOME_DIR/.config" "$HOME_DIR/.local/share" "$HOME_DIR/.cache" "$GRADLE_DIR" "$PUB_CACHE_DIR" "$TMP_DIR" + +if [[ ! -x "$FLUTTER_BIN" ]]; then + cp -a /opt/flutter "$SDK_COPY" +fi + +export HOME="$HOME_DIR" +export XDG_CONFIG_HOME="$HOME_DIR/.config" +export XDG_DATA_HOME="$HOME_DIR/.local/share" +export XDG_CACHE_HOME="$HOME_DIR/.cache" +export GRADLE_USER_HOME="$GRADLE_DIR" +export PUB_CACHE="$PUB_CACHE_DIR" +export TMPDIR="$TMP_DIR" +export TMP="$TMP_DIR" +export TEMP="$TMP_DIR" +export JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=$TMP_DIR" +export GRADLE_OPTS="-Djava.io.tmpdir=$TMP_DIR" +export ANDROID_HOME +export JAVA_HOME +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" + +cd "$PROJECT_ROOT" + +"$FLUTTER_BIN" --disable-analytics pub get +"$FLUTTER_BIN" build apk "--$MODE" + +APK_PATH="$PROJECT_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk" +echo "APK built at: $APK_PATH" diff --git a/.ideai/sprints/2c0dd1ea-8809-49ff-adc1-aa8820815ee7/sprint.md b/.ideai/sprints/2c0dd1ea-8809-49ff-adc1-aa8820815ee7/sprint.md new file mode 100644 index 0000000..5288e2d --- /dev/null +++ b/.ideai/sprints/2c0dd1ea-8809-49ff-adc1-aa8820815ee7/sprint.md @@ -0,0 +1,11 @@ +--- +id: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" +order: 4 +name: "Statistiques" +status: "planned" +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785242698414 +updatedAt: 1785242698414 +version: 1 +--- diff --git a/.ideai/sprints/d5c18b44-0eec-46db-b8ab-506cfee0bfea/sprint.md b/.ideai/sprints/d5c18b44-0eec-46db-b8ab-506cfee0bfea/sprint.md new file mode 100644 index 0000000..7ddf1ef --- /dev/null +++ b/.ideai/sprints/d5c18b44-0eec-46db-b8ab-506cfee0bfea/sprint.md @@ -0,0 +1,11 @@ +--- +id: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +order: 5 +name: "UI" +status: "planned" +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785242709608 +updatedAt: 1785242709608 +version: 1 +--- diff --git a/.ideai/sprints/index.json b/.ideai/sprints/index.json index 0d01b57..c4ebbe1 100644 --- a/.ideai/sprints/index.json +++ b/.ideai/sprints/index.json @@ -27,6 +27,24 @@ "status": "planned", "updatedAt": 1784482336164, "version": 1 + }, + { + "id": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "path": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "order": 4, + "name": "Statistiques", + "status": "planned", + "updatedAt": 1785242698414, + "version": 1 + }, + { + "id": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "path": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "order": 5, + "name": "UI", + "status": "planned", + "updatedAt": 1785242709608, + "version": 1 } ] } \ No newline at end of file diff --git a/.ideai/tickets/100/carnet.md b/.ideai/tickets/100/carnet.md new file mode 100644 index 0000000..161f3cc --- /dev/null +++ b/.ideai/tickets/100/carnet.md @@ -0,0 +1,29 @@ +--- +issueRef: "#100" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785002671405 +--- +## Rapport de validation #91-G — 2026-07-25 (Main, QA-channel instable) + +### Validé en sandbox (VERT) +- Contrat A : 8 `dart test` OK. +- Projection B (12 tests), command handler C (11), adapter D (6) : `flutter test` all passed. + - Idempotence : « advances once on retry duplicate », « deduplicates retry before dispatching ». + - Rejets : stale revision, non applicable, missing session, mismatch. + - Adapter : publish par révision, heartbeat, ack, resync reconnexion, ordre séquentiel. +- `flutter analyze` app téléphone : 0 problème sur le code watch. `flutter analyze` watch_app : No issues found. +- Revue code (Main) : la montre n'applique jamais une commande localement (recalage sur projection confirmée — `watch_session_view_model.dart:140-196`) ; haptiques sans focus audio (#92) ; démarrage commun exercice+étape via use cases existants. + +### À valider on-device par l'utilisateur +1. `flutter build apk` (app téléphone) — vérifier compilation avec D (plugin Kotlin, `play-services-wearable:19.0.0`, `WatchCompanionForegroundService`, `PhoneWatchBridgeListenerService`). +2. `cd watch_app && flutter build apk` — vérifier compilation de l'app Wear OS. +3. Pairing Wearable : installer les deux APK, appairer, lancer une séance, vérifier projection reçue sur la montre. +4. Commandes depuis la montre : démarrer/pause chrono, démarrer chrono suivant prêt, passer étape/passage/série, terminer série, passer repos → effet réel côté téléphone. +5. Foreground service : téléphone verrouillé / app en background pendant séance → le canal reste vivant. +6. Latence/interpolation/resync réelles ; haptiques au ack sans couper la musique. + +### Commits (feature/ticket91-wear-os-watch-sync) +cf68a72 #91-A · d1c6076 #91-B · 6c177de #91-C · 68a87d1 #91-D · c65a5a7 #91-E/#91-F · 40a2d5e server networking (séparé). + +QA-agent n'a pas pu produire de final textuel (canal instable, sandbox bloquant flutter) — exécutable pris en charge par Main. Revue code par Main. \ No newline at end of file diff --git a/.ideai/tickets/100/issue.md b/.ideai/tickets/100/issue.md new file mode 100644 index 0000000..41e2cd6 --- /dev/null +++ b/.ideai/tickets/100/issue.md @@ -0,0 +1,28 @@ +--- +id: "f28612f6-ef2c-4a5d-a0ac-9d66afb151d6" +number: 100 +title: "#91-G [QA] Validation companion watch offline/local" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#99","kind":"dependsOn"}] +agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991960335 +updatedAt: 1785002671405 +version: 3 +--- +Partie #91, dépend de #91-F. + +Validation end-to-end du companion montre, offline/local uniquement : +- Projection téléphone correcte pour chaque phase. +- Routing commandes + idempotence : retry/doublon n'avance jamais deux fois une étape/passage/série ; révision stale rejetée. +- Acks corrects selon contexte. +- Reconnexion + resync complet de l'état. +- Foreground service téléphone (verrouillé / background). +- Interpolation du chrono montre vs timestamps autoritaires. +- Cohérence téléphone/montre : la montre ne modifie jamais l'état localement ; le téléphone reste source de vérité ; actions simultanées -> dernier état confirmé. +- Haptiques sans couper la musique (#92). + +Définition de done : rapport de validation par commande réelle (dart analyze, flutter test, build app téléphone + build watch, scénarios manuels sur émulateur/device si possible). Sortie verte ou rapport d'échec non enjolivé. diff --git a/.ideai/tickets/101/carnet.md b/.ideai/tickets/101/carnet.md new file mode 100644 index 0000000..c8a6c00 --- /dev/null +++ b/.ideai/tickets/101/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#101" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784992086885 +--- diff --git a/.ideai/tickets/101/issue.md b/.ideai/tickets/101/issue.md new file mode 100644 index 0000000..29396a5 --- /dev/null +++ b/.ideai/tickets/101/issue.md @@ -0,0 +1,28 @@ +--- +id: "32c34fe8-d915-4bf3-8266-f0505760f6b4" +number: 101 +title: "#91-E [DevFrontend] App Wear OS Flutter dédiée + navigation UX montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#95","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991960336 +updatedAt: 1784992086885 +version: 2 +--- +Partie #91, dépend de #91-A. + +Scaffold `watch_app/` (target/app Flutter Wear OS dédiée dans le mono-dépôt). Implémenter les surfaces UX décrites dans `gametime-ux-watch-companion` : +- `Aucune séance active` (+ variante téléphone indisponible). +- `Séance active` : hiérarchie SÉRIE X/Y -> exercice -> passage/étape -> chrono dominant -> chronos secondaires compacts -> CTA primaire unique. +- `Actions` (scrollable) : Passer l'étape / Passer le passage / Terminer la série / Passer la série / Passer le repos selon contexte. +- `Repos` : chrono de repos dominant, prochain exercice visible, Pause/Reprendre en primaire. + +Gestes : tap CTA, swipe horizontal (Séance <-> Actions), scroll vertical / couronne. Confirmation seulement pour Passer la série et Passer le passage. + +L'app montre consomme **uniquement** les DTO du package A et reste stateless vis-à-vis du domaine. Stub l'état en local pour permettre le dev UI avant branchement du client (F). + +Définition de done : surfaces navigables conformes à la spec UX sur écran rond, `flutter analyze` propre, build Wear OS OK. diff --git a/.ideai/tickets/102/carnet.md b/.ideai/tickets/102/carnet.md new file mode 100644 index 0000000..dc3e983 --- /dev/null +++ b/.ideai/tickets/102/carnet.md @@ -0,0 +1,14 @@ +--- +issueRef: "#102" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785217009929 +--- + +## Découpage Main — 2026-07-26 + +Sous-tickets ouverts pour exécution sans dépendance utilisateur supplémentaire : +- #108 UX notification +- #109 Architect cadrage technique notification +- #110 DevFrontend implémentation +- #111 QA validation diff --git a/.ideai/tickets/102/issue.md b/.ideai/tickets/102/issue.md new file mode 100644 index 0000000..a2d21d1 --- /dev/null +++ b/.ideai/tickets/102/issue.md @@ -0,0 +1,16 @@ +--- +id: "52ac23c7-17e4-41b1-a406-33ae32fb33cf" +number: 102 +title: "Ajouter un bandeau dans la zone de notification du téléphone" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785083628323 +updatedAt: 1785217009929 +version: 5 +--- +J'aimerais que lorsqu'une séance est en cours, un bandeau dans la zone de notifiation du téléphone soit visible avec les informations de l'exercice en cours et l'affichage du chrono en cours s'il y en a un, a la manière de Heavy \ No newline at end of file diff --git a/.ideai/tickets/103/carnet.md b/.ideai/tickets/103/carnet.md new file mode 100644 index 0000000..844a12b --- /dev/null +++ b/.ideai/tickets/103/carnet.md @@ -0,0 +1,13 @@ +--- +issueRef: "#103" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785217006191 +--- + +## Découpage Main — 2026-07-26 + +Sous-tickets ouverts : +- #112 validation UX de la reprise de l'icône téléphone +- #113 implémentation DevFrontend +- #114 validation QA diff --git a/.ideai/tickets/103/issue.md b/.ideai/tickets/103/issue.md new file mode 100644 index 0000000..161a86c --- /dev/null +++ b/.ideai/tickets/103/issue.md @@ -0,0 +1,16 @@ +--- +id: "49bc2615-b291-4577-808c-50b06ac6c433" +number: 103 +title: "Icone appplication montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785083768016 +updatedAt: 1785217006191 +version: 5 +--- +Je veux que l'icone de l'application de la montre soit le même que celui de l'application téléphone \ No newline at end of file diff --git a/.ideai/tickets/104/carnet.md b/.ideai/tickets/104/carnet.md new file mode 100644 index 0000000..df3473c --- /dev/null +++ b/.ideai/tickets/104/carnet.md @@ -0,0 +1,13 @@ +--- +issueRef: "#104" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785217003099 +--- + +## Découpage Main — 2026-07-26 + +Sous-tickets ouverts : +- #115 cadrage UX +- #116 implémentation DevFrontend +- #117 validation QA diff --git a/.ideai/tickets/104/issue.md b/.ideai/tickets/104/issue.md new file mode 100644 index 0000000..3067f49 --- /dev/null +++ b/.ideai/tickets/104/issue.md @@ -0,0 +1,16 @@ +--- +id: "1db7142c-4e42-4e5a-803e-c9bc2e95daa8" +number: 104 +title: "Refonte UI montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785088577932 +updatedAt: 1785217003099 +version: 5 +--- +J'aiemrais une refonte UI de la montre uniquement pour que ça corresponde plus a la DA qu'on a sur le téléphone, a savoir fond noir, couleur principale dorée avec des liserais rouges et les titres en blancs (c'est comme ça que j'interprete notre DA, mais UX devrait etre en mesure de fair ce qu'il faut) \ No newline at end of file diff --git a/.ideai/tickets/105/carnet.md b/.ideai/tickets/105/carnet.md new file mode 100644 index 0000000..0ed328c --- /dev/null +++ b/.ideai/tickets/105/carnet.md @@ -0,0 +1,15 @@ +--- +issueRef: "#105" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785216998986 +--- + +## Découpage Main — 2026-07-26 + +Sous-tickets ouverts : +- #118 UX score montre +- #119 Architect extension contrats/bridge +- #120 DevBackend routing score +- #121 DevFrontend UI et synchronisation +- #122 QA validation diff --git a/.ideai/tickets/105/issue.md b/.ideai/tickets/105/issue.md new file mode 100644 index 0000000..50f71b5 --- /dev/null +++ b/.ideai/tickets/105/issue.md @@ -0,0 +1,16 @@ +--- +id: "f7083598-9cde-45e3-ad32-91332c8de2f0" +number: 105 +title: "Incrementer decrementer le score depuis la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785088671279 +updatedAt: 1785216998986 +version: 5 +--- +Dans le cas d'un exercice avec un score, j'aimerais qu'il soit possible de l'incrémenter/decrementer directement depuis la montre \ No newline at end of file diff --git a/.ideai/tickets/106/carnet.md b/.ideai/tickets/106/carnet.md new file mode 100644 index 0000000..4365fee --- /dev/null +++ b/.ideai/tickets/106/carnet.md @@ -0,0 +1,42 @@ +--- +issueRef: "#106" +version: 14 +updatedBy: {"kind":"user"} +updatedAt: 1785221560663 +--- +## Cadrage produit consolidé — Main (2026-07-27) + +Source de vérité issue des précisions utilisateur du 2026-07-27. + +### Périmètre fonctionnel confirmé +- **Fréquence cardiaque** : affichage live pendant la séance, puis conservation de la **moyenne**, de la **min** et de la **max** avec détail **par étape, par série et par exercice**. +- **Distance parcourue** : affichage **live** pendant la séance, puis conservation pour consultation après séance et dans l'historique avec graphiques. +- **Calories brûlées** : affichage **live** pendant la séance, puis conservation pour consultation après séance et dans l'historique avec graphiques. + +### Surfaces confirmées +- **Téléphone pendant la séance** : surface légère uniquement, avec **fréquence cardiaque live**, **distance live** et **calories live**. +- **Montre pendant la séance** : + - écran principal : **fréquence cardiaque live uniquement** ; + - écran secondaire accessible par glissement inverse du menu action : **fréquence cardiaque live + distance + calories**. +- **Après séance** : consultation détaillée des statistiques collectées. +- **Historique** : graphiques et restitution détaillée conservée, en particulier pour la fréquence cardiaque par étape/série/exercice. + +### Compatibilité / fallback +- Seulement pour les **montres compatibles**. +- Si une donnée ou un capteur n'est pas disponible : **ne rien afficher**. +- **Aucun message bloquant**, aucune alerte de donnée absente. + +### Niveau de fiabilité attendu +- Les fonctions terrain / capteurs avancés restent dans une logique **expérimentale**. +- Une précision imparfaite est acceptable dans un premier temps, notamment pour les futurs sujets type hot-map terrain. + +### Découpage décidé +Le ticket parent #106 dépend désormais des tickets suivants : +- #144 fréquence cardiaque live +- #145 calories montre +- #157 distance live +- #158 agrégats et historique +- #159 surface montre live +- #160 graphiques historiques +- #155 cadrage UX transverse +- #156 cadrage architecture transverse diff --git a/.ideai/tickets/106/issue.md b/.ideai/tickets/106/issue.md new file mode 100644 index 0000000..d6c2082 --- /dev/null +++ b/.ideai/tickets/106/issue.md @@ -0,0 +1,16 @@ +--- +id: "8212b0b1-7c7a-4688-99cf-796576c0ffe6" +number: 106 +title: "Ajouter aux séances des données statisques pouvant etre renvoyées par la montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#144","kind":"dependsOn"},{"target":"#145","kind":"dependsOn"},{"target":"#157","kind":"dependsOn"},{"target":"#158","kind":"dependsOn"},{"target":"#159","kind":"dependsOn"},{"target":"#160","kind":"dependsOn"}] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785089161774 +updatedAt: 1785221560663 +version: 14 +--- +A la manière d'autres applications, j'aimerais que la montre puisse apporter des statistiques supplémentaires comme la fréquence cardiaque etc qui serianet ensuite ajoutée au stats d'une séance. Je laisse le soin au bon agent de determiner les statistiques a récupérer. Dans le cas ou un utilisateur n'a pas de montre opu que sa montre ne retourne pas toutes les statistique, je ne veux pas qu'il en soit notifié, je veux que ça soit transparent pour lui \ No newline at end of file diff --git a/.ideai/tickets/107/carnet.md b/.ideai/tickets/107/carnet.md new file mode 100644 index 0000000..bfcf6bd --- /dev/null +++ b/.ideai/tickets/107/carnet.md @@ -0,0 +1,32 @@ +--- +issueRef: "#107" +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785093155401 +--- + +## Découpage Main — 2026-07-26 + +Le symptôme `Bottom overflowed by 13 pixels` reste suivi dans ce ticket. Un bug connexe plus critique a été ajouté séparément : +- #125 écran noir montre lors d'un démarrage de séance depuis le téléphone + +## Audit DevFrontend — 2026-07-27 + +- Correctif visuel/layout déjà présent dans `watch_session_screen.dart` : contenu contraint par le diamètre réel du cadran, hauteurs fixes, `FittedBox`, `maxLines` et `ellipsis`. +- Pas de scroll vertical réintroduit sur l'écran séance montre. +- Aucun écart concret supplémentaire identifié sans reproduction device. + +À valider sur device : absence du message `Bottom overflowed by 13 pixels` pendant séance active, repos et score manuel. + +## Correctif QA DevFrontend — 2026-07-27 + +- Reproduction QA 192x192 traitée : `_ActiveContent` est maintenant encapsulé dans un `_ScaledContent` avec `FittedBox.scaleDown`, et sa colonne passe en `mainAxisSize: MainAxisSize.min`. +- Le test widget ciblé `watch_session_screen_test.dart` passe sans overflow sur le viewport 192x192. +- Aucun scroll réintroduit sur l'écran séance montre. + +## Correctif DevFrontend — 2026-07-26 + +- Correctif partagé avec #125 : suppression de la colonne active qui empilait bouton Actions, contenu, statut et bouton primaire dans 210dp. +- Nouveau rendu actif/repos : pile contrainte par le diamètre utile du viewport, sans scroll sur les vues de séance, avec valeur dominante `FittedBox`, textes `maxLines` + ellipsis, pied de contexte discret. +- Page Actions rendue compacte sans `ListView` pour éviter les débordements sur petit cadran ; confirmation remplacée par un plein écran adapté montre. +- Vérification : `flutter analyze` watch_app OK ; build APK debug montre OK via Gradle/JDK 21. diff --git a/.ideai/tickets/107/issue.md b/.ideai/tickets/107/issue.md new file mode 100644 index 0000000..9079cbd --- /dev/null +++ b/.ideai/tickets/107/issue.md @@ -0,0 +1,16 @@ +--- +id: "4d9a5046-ad0f-4bae-8be7-9236218aab02" +number: 107 +title: "Bottom overflowed by 13 pixels" +status: "QA" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785093125358 +updatedAt: 1785093155401 +version: 3 +--- +Sur l'affichage de la montre pendant une seance, j'ai le message "Bottom overflowed by 13 pixels" qui s'affiche diff --git a/.ideai/tickets/108/carnet.md b/.ideai/tickets/108/carnet.md new file mode 100644 index 0000000..7124f47 --- /dev/null +++ b/.ideai/tickets/108/carnet.md @@ -0,0 +1,49 @@ +--- +issueRef: "#108" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785134675226 +--- + +## #108 — Bandeau notification téléphone pendant une séance (UX, 2026-07-26) + +Statut : **cadrage UX exploitable, prêt pour Architect/DevFrontend**, pas de blocage. + +### Principe +La notification est un **résumé d'accès rapide**, jamais une copie de l'écran de séance. Elle répond à une seule question en un regard : « où j'en suis, et ce que fait le chrono ». Référence Heavy : titre = contexte, corps = donnée vivante, pas d'action complexe. + +### Contenu compact (état replié, vue par défaut) +- **Titre** : nom de l'exercice en cours (ex. « Squats bulgares »). +- **Texte** : une seule ligne combinant série et mesure pertinente, priorité dans cet ordre si plusieurs mesures actives : + 1. Chrono actif → `mm:ss` en cours (temps écoulé de l'étape/série, ou chrono score s'il est démarré — cf. [[gametime-ux-score-chrono]]). + 2. Sinon Répétitions/Score → `Série 2/4 · 8 reps`. + 3. Sinon (étape sans mesure, ex. consigne) → nom court de l'étape. +- **Icône** : icône app (monochrome, cf. exigence notification Android — silhouette du logo "GT" sur fond transparent). +- **État repos** : titre devient « Repos », texte = décompte `mm:ss` restant + « Ensuite : » tronqué si besoin. +- **État pause de séance** : titre inchangé, texte préfixé par « En pause · » avant l'info gelée (le chrono affiché est figé, pas de tick visuel — cohérent avec la règle « jamais actif pendant une pause » de [[gametime-ux-score-chrono]]). + +### Contenu étendu (notification développée / longue) +Deuxième ligne ajoutée sous la ligne compacte : +- Fil de contexte discret : `Programme 1/2 · Exercice 3/8` (même format que le header d'exécution, cf. [[gametime-ux-series-counter]]) — pas de répétition du nom d'exercice déjà en titre. +- Pas de 3e ligne, pas de liste d'étapes à venir : la notification reste un résumé, l'utilisateur rouvre l'app pour le détail. + +### Actions +MVP : **aucune action bouton dans la notification** (pas de Pause/Suivant intégrés). Justification : réduit le risque d'appui accidentel hors app, évite de dupliquer des commandes qui existent déjà dans l'app et sur la montre (#105/#118), et simplifie l'implémentation (pas de PendingIntent supplémentaire à maintenir en cohérence avec le moteur d'exécution). Le tap sur la notification entière ramène à l'écran d'exécution en cours. À réévaluer seulement si retour utilisateur explicite après usage réel — ne pas anticiper. + +### Règles de rafraîchissement +- Notification **persistante** (non "swipeable") tant qu'une séance est active — cohérent avec le statut foreground service déjà en place pour la montre ([[gametime-watch-companion-implementation]], #91-D). +- Mise à jour du texte à chaque tick de seconde **uniquement si un chrono est affiché** (chrono score, repos) ; sinon mise à jour uniquement sur changement d'état (série suivante, pause, reprise) — pas de rafraîchissement inutile qui viderait la batterie ou ferait clignoter la notification. +- Notification retirée immédiatement à la fin de séance (Terminer/Abandonner), jamais laissée en état "orpheline". + +### États à couvrir (design implémentable) +| État | Titre | Texte | +|---|---|---| +| Série active, chrono | `` | `02:14` | +| Série active, reps/score, sans chrono | `` | `Série 2/4 · 8 reps` | +| Repos | `Repos` | `01:30 restant · Ensuite : ` | +| Pause séance | `` | `En pause · ` | +| Séance terminée / abandonnée | — | notification retirée | + +### Hors périmètre MVP +- Actions rapides (pause/suivant) dans la notification. +- Notification enrichie avec image/illustration d'exercice (pas de média viewer en notification, cf. [[gametime-ux-exercise-media-viewer]] réservé à l'app). diff --git a/.ideai/tickets/108/issue.md b/.ideai/tickets/108/issue.md new file mode 100644 index 0000000..2213d28 --- /dev/null +++ b/.ideai/tickets/108/issue.md @@ -0,0 +1,26 @@ +--- +id: "2382d9da-b640-48a1-9acf-8828bc73fd61" +number: 108 +title: "#102-A [UX] Conception du bandeau notification téléphone pendant une séance" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#102","kind":"relatesTo"}] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785134675226 +version: 3 +--- +Partie du ticket #102. + +Définir la surface notification Android pendant une séance active : +- contenu minimal visible en compact et étendu ; +- hiérarchie des informations : séance, exercice, chrono pertinent, état pause/repos si utile ; +- libellés des actions éventuelles ; +- règles de rafraîchissement visuel sans surcharge. + +Contrainte : s'inspirer de Heavy sur l'accès rapide, sans dupliquer l'écran complet de séance dans la notification. + +Définition de done : cadrage UX autonome consigné dans le carnet avec états nominaux et dégradés. diff --git a/.ideai/tickets/109/carnet.md b/.ideai/tickets/109/carnet.md new file mode 100644 index 0000000..3a0932b --- /dev/null +++ b/.ideai/tickets/109/carnet.md @@ -0,0 +1,83 @@ +--- +issueRef: "#109" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785134675243 +--- + +## #109 — Cadrage technique notification de séance Android (Architect, 2026-07-26) + +Statut : **cadrage exploitable, prêt pour DevFrontend**, pas de blocage. Basé sur le cadrage UX #108 et l'existant réel (`WatchCompanionForegroundService.kt`, `WatchSessionProjectionProjector`, cf. [[gametime-watch-companion-implementation]]). + +### Constat sur l'existant +Un `ForegroundService` tourne déjà pendant toute séance active (`android/app/src/main/kotlin/com/gametime/app/watch/WatchCompanionForegroundService.kt`), démarré/arrêté par `WatchWearDataLayerAdapter._syncForegroundService` sur simple base de `phase != noActiveSession` — **indépendamment du pairing montre**. Mais ce service est aujourd'hui **verrouillé sur la feature montre** : channel `gametime_watch_companion`, notification statique créée une seule fois dans `onCreate()` (titre/texte fixes « GameTime » / « Séance en cours »), `NOTIFICATION_ID = 91`, aucune méthode de mise à jour de contenu, `foregroundServiceType="connectedDevice|dataSync"`. + +**Décision** : ne pas réutiliser ce service tel quel pour #102. On crée un **second foreground service indépendant**, propre à la notification de séance téléphone, avec son propre canal et son propre cycle de vie. Raison (ISP appliqué aux adapters Android) : coupler la notification "résumé de séance" au service montre ferait dépendre une feature du couplage montre/pairing implicite, alors que la notification doit exister avec ou sans montre appairée, et une évolution du companion montre ne doit jamais risquer de casser la notification (et réciproquement). Android autorise plusieurs foreground services concurrents sans conflit. + +### Contrat côté domaine/application + +Pas de nouvel état domaine : la notification est un **présentateur supplémentaire** d'un état déjà calculé. Réutiliser tel quel le flux existant `WatchProjectionSource.projections` (déjà émis à chaque changement pertinent : série, pause, repos, tick de chrono score) plutôt que dupliquer la logique de sélection du "chrono dominant" (priorité chrono > reps/score > étape, déjà implémentée dans `WatchSessionProjectionProjector` pour produire `dominantTimer`). + +- Nouveau port application : +```dart +abstract interface class SessionNotificationGateway { + Future show(SessionNotificationContent content); + Future clear(); +} +``` +- Nouveau DTO pur (application layer) : +```dart +class SessionNotificationContent { + final String title; // nom exercice / "Repos" / exercice (pause) + final String primaryLine; // mm:ss, "Série 2/4 · 8 reps", décompte repos, "En pause · ..." + final String? secondaryLine; // "Programme 1/2 · Exercice 3/8", null si compact only +} +``` +- Nouvelle fonction pure (testable sans plateforme) : `SessionNotificationContent buildSessionNotificationContent(WatchSessionProjection projection)` — mapping direct des états du tableau UX #108 (série+chrono, série reps/score, repos, pause, terminée→`clear()`). Réutilise `dominantTimer`/`phase`/indices déjà présents dans `WatchSessionProjection`, pas de nouveau champ requis sur ce DTO existant. +- Nouveau coordinateur application `SessionNotificationCoordinator` : s'abonne à `WatchProjectionSource.projections`, appelle `buildSessionNotificationContent` puis `gateway.show(...)`, appelle `gateway.clear()` sur `phase == noActiveSession`. **Ne dépend d'aucun état montre/pairing** — seul le flux de projection (déjà session-only) est consommé. + +### Règle de rafraîchissement (traduction concrète de l'UX) +- `show()` appelé à chaque émission de projection représentant un changement structurel (bump de révision) : changement de série/étape/phase/pause. +- En plus, si `dominantTimer.runState == running` (chrono actif ou repos), un tick local **1s** recalcule `primaryLine` depuis `referenceEpochMs`/`accumulatedMs`/`targetMs` déjà présents sur `WatchTimerProjection` — tick **strictement local au coordinateur de notification**, ne déclenche jamais de bump de révision ni de republication vers la montre (éviter tout couplage/chatter croisé entre les deux features). +- Sinon (pas de chrono affiché), aucune mise à jour périodique — conforme à l'exigence UX "pas de rafraîchissement inutile". +- Invariant robustesse : un échec de `show()`/`clear()` ne doit jamais remonter d'exception dans le flux domaine/session (fire-and-forget côté notification) — une panne de notification ne doit jamais interrompre une séance en cours. + +### Contrat côté adapter Android (nouveau) +- Nouveau service Kotlin, ex. `android/app/src/main/kotlin/com/gametime/app/session/SessionStatusForegroundService.kt`, **distinct** de `WatchCompanionForegroundService`. +- Nouveau canal `gametime_session_status`, `IMPORTANCE_LOW` (pas de son, cohérent avec les mises à jour fréquentes de chrono), `NOTIFICATION_ID` distinct (ex. `92`, à ne pas réutiliser `91`). +- Contrairement au service montre, celui-ci **doit exposer une méthode de mise à jour de contenu** (pas de notification statique créée une fois) — `updateNotification(title, primaryLine, secondaryLine?)` appelée à chaque `show()`. +- `foregroundServiceType` : ni `dataSync` ni `connectedDevice` ne conviennent sémantiquement (ce n'est ni de la synchro de données ni un device connecté). Sur SDK 36, utiliser `specialUse` avec la propriété manifeste `android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE` (valeur libre du type `"workout-session-status"`). L'app n'étant pas distribuée sur le Play Store (APK signé debug, cf. [[gametime-android-release-networking-and-apk-build]]), la contrainte de justification `specialUse` en review Play ne s'applique pas ; seule la déclaration manifeste est requise par l'OS. +- Permission `POST_NOTIFICATIONS` déjà déclarée dans le manifest (réutilisée pour le canal montre) — pas de nouvelle permission requise. Si l'utilisateur refuse la permission notification, le foreground service démarre quand même (contrat Android standard : `startForeground` exige une `Notification`, affichée ou non selon permission) — aucune gestion spécifique à ajouter, ne jamais bloquer le démarrage de séance sur ce refus. + +### Tests +- `buildSessionNotificationContent` : 100% unit-testable en Dart pur, mêmes conventions que `test/application/watch_companion_projection_test.dart` — couvrir les 5 états du tableau UX #108 (série+chrono, série reps/score, repos, pause, fin→clear). +- `SessionNotificationCoordinator` : test avec `StreamController` fake, vérifier `show()` sur changement structurel et sur tick actif uniquement, `clear()` sur fin de séance, absence d'appel superflu si aucun chrono actif. +- Service Kotlin : non testable en sandbox (cohérent avec la contrainte déjà connue pour `WatchCompanionForegroundService`, cf. [[gametime-watch-companion-implementation]]) — validation manuelle on-device requise (affichage réel, tick, disparition en fin de séance, comportement écran verrouillé). + +### Hors périmètre (aligné UX #108) +Pas d'action bouton dans la notification (pas de PendingIntent Pause/Suivant) — tap ouvre l'app sur l'écran d'exécution en cours (Intent standard `PendingIntent.getActivity` vers l'activité principale, aucun deep-link spécifique nécessaire pour le MVP). + +Explicitement **hors scope** : iOS. Le contrat `SessionNotificationGateway` est un port application générique (compatible iOS en théorie), mais aucun adapter iOS n'est cadré ni demandé ici — seul l'adapter Android (`MethodChannelSessionNotificationGateway` + plugin Kotlin) est dans le périmètre de #102/#110. Si iOS est demandé un jour, c'est un nouveau ticket d'adapter, pas une révision de ce contrat. + +--- + +## Audit Architect — implémentation constatée dans le worktree (2026-07-27) + +Statut mis à jour : **implémenté conformément au cadrage ci-dessus**, un garde-fou à fermer avant merge/QA. + +Vérification du code réel (working tree non commité, branche `feature/ticket91-wear-os-watch-sync`) : `SessionNotificationCoordinator`/`buildSessionNotificationContent` (`lib/application/session_notification_use_cases.dart`), `MethodChannelSessionNotificationGateway` (`lib/infrastructure/session_notification/`), `SessionStatusForegroundService.kt`/`SessionNotificationPlugin.kt` (`android/app/src/main/kotlin/com/gametime/app/session/`) sont tous présents et conformes point par point au contrat cadré : consommation de `WatchCompanionProjectionUseCases.projections` (pas d'état dupliqué), tick 1s uniquement si timer actif, `clear()` sur `phase == noActiveSession || deviceSessionId.isEmpty`, appels gateway `unawaited(...).catchError((_) {})` (fire-and-forget confirmé), canal `gametime_session_status`/`NOTIFICATION_ID 92` distinct du service montre, `foregroundServiceType="specialUse"` avec `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` comme cadré. Wiring dans `app_bootstrap.dart` (start juste après `watchWearDataLayerAdapter.start()`, dispose en premier). + +**Garde-fou à fermer avant de considérer le lot terminé** : le manifest déclare bien `POST_NOTIFICATIONS`, mais **aucun code (Dart ou Kotlin) ne demande cette permission à l'exécution** nulle part dans l'app — ni pour ce nouveau canal, ni pour le canal montre pré-existant. Sur Android 13+ (API 33+, cohérent avec la cible SDK 36), sans cette demande explicite le service démarre normalement (exception foreground service) mais la notification peut ne jamais s'afficher à l'utilisateur, silencieusement. Contrat à ajouter pour DevFrontend : demander `POST_NOTIFICATIONS` une seule fois, paresseusement, au premier démarrage de séance (jamais un écran dédié, jamais bloquant — cohérent avec [[gametime-online-layer-philosophy]] appliqué par analogie aux permissions système) ; en cas de refus, ne rien tenter de plus, ne jamais redemander en boucle. C'est un gap transverse aux deux notifications (montre + séance), pas spécifique à l'une des deux — à traiter une seule fois pour les deux canaux. + +Tests couvrant `buildSessionNotificationContent` déjà présents (`test/application/session_notification_use_cases_test.dart`) ; **`SessionNotificationCoordinator` lui-même (start/stop/tick/gateway failure) n'est pas encore testé** — à ajouter avant merge, cf. stratégie de test déjà cadrée ci-dessus. + +--- + +## Vérification Architect de clôture (2026-07-27, second passage) + +Statut final : **conforme, garde-fou permission fermé, un seul gap résiduel connu (non bloquant pour le cadrage, bloquant pour le merge).** + +- Le garde-fou `POST_NOTIFICATIONS` signalé ci-dessus est **résolu** : `SessionNotificationPlugin.kt` (`android/app/src/main/kotlin/com/gametime/app/session/SessionNotificationPlugin.kt`) appelle `requestPostNotificationsPermissionIfNeeded()` dans `show()`, guardé par un flag `notificationPermissionRequested` (une seule demande, jamais de boucle), `SDK_INT >= TIRAMISU` uniquement, pas d'écran dédié. La permission `POST_NOTIFICATIONS` est globale à l'app (pas par canal) donc cette unique demande couvre aussi le canal montre pré-existant — le gap transverse est clos par un seul point d'appel, conforme à la recommandation. +- Gap résiduel inchangé : **`SessionNotificationCoordinator` n'a toujours pas de test dédié** (`test/application/session_notification_use_cases_test.dart` ne couvre que `buildSessionNotificationContent`, 5 cas). À ajouter avant merge (StreamController fake : `show()` sur changement structurel + sur tick actif, `clear()` en fin de séance, non-appel si pas de chrono actif) — cf. stratégie déjà cadrée plus haut. Ceci est un standard de qualité (couverture), **pas** une ambiguïté de contrat : n'importe qui connaissant `SessionNotificationCoordinator` peut écrire ce test sans redemander de cadrage. + +**Réponse à la question "DevFrontend peut-il implémenter sans autre clarification ?" : oui.** Le contrat est complet et déjà appliqué à l'identique dans le code existant. Il ne reste aucune décision d'architecture ouverte pour #102/#110 — seulement une tâche de complétion de test (coordinateur) avant que le lot passe en QA (#111). diff --git a/.ideai/tickets/109/issue.md b/.ideai/tickets/109/issue.md new file mode 100644 index 0000000..1594258 --- /dev/null +++ b/.ideai/tickets/109/issue.md @@ -0,0 +1,25 @@ +--- +id: "116d067d-dc00-4453-9227-033e1a4482cd" +number: 109 +title: "#102-B [Architect] Cadrage technique notification de séance Android" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#102","kind":"relatesTo"},{"target":"#108","kind":"dependsOn"}] +agentRefs: [{"agentId":"f8f40941-ecf7-4830-b9de-8818a099f448","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785134675243 +version: 5 +--- +Partie du ticket #102, dépend du cadrage UX #108. + +Définir le cadrage technique de la notification Android de séance : +- stratégie `ForegroundService` / notification persistante ; +- source de vérité pour les données affichées ; +- fréquence de rafraîchissement du chrono sans coût excessif ; +- actions notification autorisées ou explicitement exclues ; +- contraintes Android SDK 36 et permissions associées. + +Définition de done : contrat technique clair pour DevFrontend, incluant limites plateforme et stratégie de test. diff --git a/.ideai/tickets/110/carnet.md b/.ideai/tickets/110/carnet.md new file mode 100644 index 0000000..a327ebf --- /dev/null +++ b/.ideai/tickets/110/carnet.md @@ -0,0 +1,37 @@ +--- +issueRef: "#110" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785093288487 +--- + +## Implémentation DevFrontend — 2026-07-26 + +- Notification Android de séance ajoutée via un foreground service dédié `SessionStatusForegroundService`, séparé du service companion montre. +- Nouveau canal `gametime_session_status`, notification persistante sans action bouton, tap vers `MainActivity`, contenu mis à jour via MethodChannel. +- Coordinateur applicatif `SessionNotificationCoordinator` branché sur le flux `WatchSessionProjection` existant : show sur séance active, tick local 1s seulement si chrono dominant running, clear sur `noActiveSession`. +- Mapping de contenu conforme au cadrage : exercice, repos, pause, chrono dominant, score manuel si présent, fallback étape/série. + +Vérifications exécutées : +- `dart format` ciblé OK. +- `git diff --check` OK. +- `dart analyze lib test/application/session_notification_use_cases_test.dart` OK avec infos préexistantes uniquement. +- `dart analyze packages/watch_bridge_contract` OK. +- `dart test` dans `packages/watch_bridge_contract` OK. + +Non fait / à valider : +- `flutter test` et build Android via wrapper Flutter bloqués par cache Flutter en lecture seule dans le runner. +- Compilation Gradle ciblée tentée avec cache temporaire, bloquée en configuration par `Unsupported class file major version 70`. +- Validation réelle device requise : affichage notification, tick chrono, tap, disparition en fin de séance. + +## Complément permission Android 13+ — 2026-07-27 + +- Gap `POST_NOTIFICATIONS` fermé côté plateforme : la permission est demandée paresseusement au premier `show` de la notification de séance. +- La demande est native, non bloquante, et le foreground service continue à démarrer même si l'utilisateur refuse ou si l'activité n'est pas disponible. +- Aucun bouton d'action notification ajouté : le tap reste un retour vers `MainActivity`. +- Validation complémentaire : `JAVA_HOME=/usr/lib/jvm/java-21-openjdk HOME=/tmp GRADLE_USER_HOME=/tmp/gradle ./gradlew --no-daemon -Dkotlin.daemon.enabled=false :app:compileDebugKotlin` OK. + +## Correctif build QA DevFrontend — 2026-07-27 + +- L'échec `share_plus-12.0.2/android` a été isolé comme dépendances Flutter pointant vers un cache Pub non disponible pour le build courant. +- `flutter pub get` relancé avec `PUB_CACHE=/tmp/.pub-cache`, puis `flutter build apk --debug` téléphone validé avec JDK 21. diff --git a/.ideai/tickets/110/issue.md b/.ideai/tickets/110/issue.md new file mode 100644 index 0000000..9b0abf8 --- /dev/null +++ b/.ideai/tickets/110/issue.md @@ -0,0 +1,24 @@ +--- +id: "418e1b28-b77c-4f88-9da4-ab065a6d62e4" +number: 110 +title: "#102-C [DevFrontend] Implémenter la notification téléphone pendant la séance" +status: "QA" +priority: "medium" +sprint: null +links: [{"target":"#102","kind":"relatesTo"},{"target":"#108","kind":"dependsOn"},{"target":"#109","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785093288487 +version: 1 +--- +Partie du ticket #102, dépend de #108 et #109. + +Implémenter la notification Android visible pendant une séance active : +- informations d'exercice/chrono conformes au cadrage UX ; +- mise à jour cohérente pendant démarrage, pause, repos, reprise et fin ; +- pas de divergence avec l'écran d'exécution ; +- build Android debug propre. + +Définition de done : notification fonctionnelle sur device Android, `dart analyze` / build pertinents propres, comportement documenté dans le carnet. diff --git a/.ideai/tickets/111/carnet.md b/.ideai/tickets/111/carnet.md new file mode 100644 index 0000000..08fc131 --- /dev/null +++ b/.ideai/tickets/111/carnet.md @@ -0,0 +1,7 @@ +--- +issueRef: "#111" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1785142184934 +--- + diff --git a/.ideai/tickets/111/issue.md b/.ideai/tickets/111/issue.md new file mode 100644 index 0000000..c267cd2 --- /dev/null +++ b/.ideai/tickets/111/issue.md @@ -0,0 +1,24 @@ +--- +id: "0c3335f2-dd7a-406e-ac4d-9798440f6782" +number: 111 +title: "#102-D [QA] Validation notification de séance Android" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#102","kind":"relatesTo"},{"target":"#110","kind":"dependsOn"}] +agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} +createdAt: 1785093288487 +updatedAt: 1785142184934 +version: 2 +--- +Partie du ticket #102, dépend de #110. + +Valider par commandes et tests réels la notification de séance Android : +- apparition/disparition selon séance active ; +- contenu correct pendant exercice, étape chrono, pause, repos ; +- cohérence du chrono affiché ; +- comportement écran verrouillé / application en arrière-plan si applicable. + +Définition de done : rapport QA réel, vert ou échec documenté sans enjoliver. diff --git a/.ideai/tickets/112/carnet.md b/.ideai/tickets/112/carnet.md new file mode 100644 index 0000000..dc064cb --- /dev/null +++ b/.ideai/tickets/112/carnet.md @@ -0,0 +1,25 @@ +--- +issueRef: "#112" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785134675260 +--- + +## #112 — Réutilisation de l'icône téléphone sur la montre (UX, 2026-07-26) + +Statut : **décision binaire tranchée, exploitable directement par DevFrontend**, pas de blocage. + +### Décision +**Oui, réutiliser la même icône (logo "patch" Court Blazer, monogramme "GT")**, sans changement de composition ni de couleurs. Pas de variante montre distincte à concevoir. + +### Justification +- Le logo actuel ([[gametime-visual-identity]]) est un badge compact à fort contraste : fond `surface` (`#141824` sombre / `#FFFFFF` clair), contour or (`#C9A24A`), bande diagonale crimson (`#D72638`), monogramme centré. C'est déjà pensé pour fonctionner en petit format (favicon, icône de lanceur téléphone) — les mêmes contraintes s'appliquent à une icône Wear OS. +- Le monogramme à 2 lettres reste lisible à 24-48dp (tailles de lanceur Wear OS courantes), contrairement à un wordmark complet qui aurait nécessité une adaptation. +- Cohérence de marque entre téléphone et montre : un utilisateur qui associe les deux appareils doit reconnaître immédiatement la même app. + +### Adaptation minimale requise (technique, pas de redesign) +- Fournir l'icône en **icône adaptative Wear OS** (safe zone circulaire) : le contenu significatif (patch + monogramme) doit tenir dans le cercle inscrit central, avec le même padding proportionnel que l'icône adaptative Android existante — pas de nouvelle composition, juste un export au gabarit rond. +- Vérifier que le contour or reste visible sur les deux fonds système de lanceur montre les plus courants (fond clair et fond sombre du launcher Wear OS) — le contour + la bande crimson donnent déjà assez de séparation, aucun ajustement de couleur nécessaire. + +### Hors périmètre +Pas de variante d'icône simplifiée, pas de monochrome dédié notification (distinct du sujet #108 qui utilise une icône monochrome système standard, pas cette icône d'app). diff --git a/.ideai/tickets/112/issue.md b/.ideai/tickets/112/issue.md new file mode 100644 index 0000000..48f5b2e --- /dev/null +++ b/.ideai/tickets/112/issue.md @@ -0,0 +1,23 @@ +--- +id: "2ad8a17a-15ce-4cb5-bec8-4a9f3f0ef984" +number: 112 +title: "#103-A [UX] Validation d'usage de la même icône sur montre et téléphone" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#103","kind":"relatesTo"}] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785134675260 +version: 3 +--- +Partie du ticket #103. + +Valider que la réutilisation de l'icône téléphone sur montre reste lisible et cohérente aux tailles Wear OS : +- lisibilité petit format ; +- contraste avec fonds système montre ; +- besoin éventuel d'une adaptation minimale sans changer l'identité. + +Définition de done : décision UX binaire exploitable par DevFrontend. diff --git a/.ideai/tickets/113/carnet.md b/.ideai/tickets/113/carnet.md new file mode 100644 index 0000000..624467c --- /dev/null +++ b/.ideai/tickets/113/carnet.md @@ -0,0 +1,20 @@ +--- +issueRef: "#113" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785093288487 +--- + +## Audit DevFrontend — 2026-07-27 + +- Icône montre déjà alignée dans le worktree : `android:icon` et `android:roundIcon` pointent vers `@mipmap/ic_launcher`. +- Ressources launcher Wear OS présentes dans les densités `mipmap-*` et foreground adaptive présent dans `drawable-*`. +- Aucun changement supplémentaire nécessaire côté UI/plateforme. + +À valider sur device : rendu launcher Wear OS réel après installation. +## Implémentation DevFrontend — 2026-07-26 + +- Icône launcher montre alignée sur l'icône téléphone : manifest basculé sur `@mipmap/ic_launcher` + `android:roundIcon`. +- Assets adaptatifs ajoutés côté Wear OS : `mipmap-anydpi-v26/ic_launcher.xml`, foregrounds densité `drawable-*`, PNG fallback `mipmap-*`, couleur de fond `#080A12`. +- Source utilisée : assets téléphone existants, sans redesign. +- Vérification : `flutter analyze` watch_app OK ; `./gradlew assembleDebug` watch_app OK avec `JAVA_HOME=/usr/lib/jvm/java-21-openjdk`, `HOME`/`GRADLE_USER_HOME` redirigés en writable. diff --git a/.ideai/tickets/113/issue.md b/.ideai/tickets/113/issue.md new file mode 100644 index 0000000..e003e57 --- /dev/null +++ b/.ideai/tickets/113/issue.md @@ -0,0 +1,23 @@ +--- +id: "ada6d03f-5721-4a7a-be83-26164c5e6e7f" +number: 113 +title: "#103-B [DevFrontend] Aligner l'icône montre sur l'icône téléphone" +status: "QA" +priority: "medium" +sprint: null +links: [{"target":"#103","kind":"relatesTo"},{"target":"#112","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785093288487 +version: 1 +--- +Partie du ticket #103, dépend de #112. + +Réutiliser l'icône téléphone pour l'application montre, avec adaptations techniques minimales si la validation UX l'exige : +- assets launcher Wear OS ; +- round icon / monochrome si nécessaire ; +- build montre vérifié. + +Définition de done : icône montre identique ou adaptation UX-validée, installable sur device. diff --git a/.ideai/tickets/114/carnet.md b/.ideai/tickets/114/carnet.md new file mode 100644 index 0000000..3d268ec --- /dev/null +++ b/.ideai/tickets/114/carnet.md @@ -0,0 +1,7 @@ +--- +issueRef: "#114" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1785142176024 +--- + diff --git a/.ideai/tickets/114/issue.md b/.ideai/tickets/114/issue.md new file mode 100644 index 0000000..d25d40a --- /dev/null +++ b/.ideai/tickets/114/issue.md @@ -0,0 +1,20 @@ +--- +id: "f3c78cc0-e7a8-49d6-a777-786f56ece278" +number: 114 +title: "#103-C [QA] Validation icône application montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#103","kind":"relatesTo"},{"target":"#113","kind":"dependsOn"}] +agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} +createdAt: 1785093288487 +updatedAt: 1785142176024 +version: 2 +--- +Partie du ticket #103, dépend de #113. + +Valider que l'icône montre installée correspond à la décision UX et s'affiche correctement sur device. + +Définition de done : validation réelle sur montre ou rapport d'écart. diff --git a/.ideai/tickets/115/carnet.md b/.ideai/tickets/115/carnet.md new file mode 100644 index 0000000..07b75c8 --- /dev/null +++ b/.ideai/tickets/115/carnet.md @@ -0,0 +1,57 @@ +--- +issueRef: "#115" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785134675276 +--- + +## #115 — Refonte UI montre alignée sur la DA Court Blazer (UX, 2026-07-26) + +Statut : **cadrage UX exploitable, prêt pour DevFrontend (#116)**, pas de blocage. + +### Principe +Cadran rond, distance de lecture courte (poignet), interaction au doigt. On applique la DA Court Blazer ([[gametime-visual-identity]]) telle quelle côté couleurs/typo, mais on **simplifie radicalement la densité** par rapport au téléphone : une montre affiche une donnée principale par écran, pas une hiérarchie de blocs empilés. + +### Palette (reprise directe, thème sombre uniquement) +La montre n'a pas de thème clair — un cadran Wear OS reste sombre pour l'autonomie et la lisibilité au poignet, cohérent avec l'usage majoritaire des watchfaces sport. +- Fond : `#080A12`. +- Primaire (or) `#C9A24A` : valeurs numériques, éléments actionnables actifs. +- Accent (crimson) `#D72638` : liseré, alertes ponctuelles (ex. connexion perdue), jamais pour du texte long. +- Texte principal `#F5F1E8`, texte secondaire `#A7ADBA`. +- Bordure/liseré `#242A3A`. +- Succès `#2ECC71`, Erreur `#FF4D5E` (repris tels quels, thème sombre). + +### Typographie +Anton pour tout chiffre affiché en grand (chrono, série, score), Archivo pour les libellés et le texte courant — cohérent avec le reste de l'app. Sur cadran rond, réduire d'un cran les tailles de référence téléphone (le champ visuel est plus petit) : chiffre principal 36-44px au lieu de 44-52px, labels 11-12px. + +### Forme +- Rayon de bordure 6px conservé sur les cartes/badges internes, mais **pas de carte pleine occupant tout le cadran** : préférer un fond uni + un seul bloc de contenu centré, pour respecter la forme circulaire sans créer d'angles morts dans les coins du cercle. +- Liseré crimson 2px : conservé mais réduit à un arc ou une barre courte en haut du cadran (pas une bordure complète qui serait coupée par la forme ronde), utilisé uniquement sur l'écran "séance active" comme rappel de marque discret. + +### Écrans et états (spec par variante) + +**1. No-session (montre ouverte sans séance en cours côté téléphone)** +- Logo "GT" (cf. [[gametime-visual-identity]] / #112) centré, taille réduite, en primaire or sur fond `#080A12`. +- Sous le logo, un texte secondaire unique : « Aucune séance en cours ». Pas de bouton d'action (la montre ne démarre pas de séance elle-même, cf. principe satellite [[gametime-watch-companion-implementation]]) — cohérence avec le rôle de miroir, pas de source d'initiative. + +**2. Séance active (écran principal)** +- Une seule donnée dominante centrée : selon le contexte, le compteur de série (« SÉRIE » label + « 2/4 » en Anton or, cf. [[gametime-ux-series-counter]]) ou le chrono (`mm:ss` Anton) si un chrono est en cours — même logique de priorité que la notification (#108) : chrono actif prioritaire sur série/reps. +- Nom de l'exercice en Archivo 600, sous la donnée dominante, tronqué avec ellipse si trop long (pas de scroll de texte sur un cadran, ça fatigue l'œil). +- Fil de contexte minimal en pied de cadran, très discret (texte secondaire, petite taille) : `3/8` (exercice) — pas le programme complet, pas la place pour ça sur ce format. +- Bloc score (+/-) si applicable : cf. spec dédiée #118, coexiste avec cet écran sans redondance (le score remplace la donnée dominante quand c'est la mesure pertinente de l'étape, sinon reste en secondaire sous le nom d'exercice). + +**3. Repos** +- Fond identique, mais bascule de la donnée dominante sur le décompte repos (`mm:ss`, Anton, or) avec label « REPOS » au-dessus (Archivo 600 uppercase, texte secondaire) — miroir de la variante téléphone. +- Sous le chrono, « Ensuite : » + série si pertinente (cf. [[gametime-ux-series-counter]] écran Repos), tronqué si besoin. + +**4. Actions (confirmation / contrôle ponctuel)** +- Réservé aux interactions qui nécessitent un accusé (ex. action destructive ou ambiguë déclenchée par erreur) : plein cadran, fond `#080A12`, question courte en Archivo 600 blanc centrée, deux zones tactiles empilées (pas côte à côte, plus sûr au doigt sur petit cadran) : action principale en haut (or, texte foncé), annuler en bas (contour uniquement, texte secondaire). Réservé aux cas rares — le MVP montre n'a normalement pas d'action destructive propre (pause/next restent pilotés depuis le téléphone dans ce périmètre, cf. #118 pour le score qui est la seule commande montre→téléphone du MVP). + +**5. Connexion perdue** +- Le contenu de la dernière donnée connue **reste affiché** (jamais d'écran vide brutal — cohérent [[gametime-online-layer-philosophy]] : dernière valeur connue plutôt qu'un vide trompeur), mais assombri (opacité réduite ~50%) avec une icône discrète de liaison rompue en haut du cadran, accompagnée du texte secondaire « Connexion au téléphone perdue » en petit, sans bloquer la lecture de la donnée figée en dessous. +- Pas de bouton "Réessayer" manuel — la reconnexion est automatique dès que le lien Data Layer revient (cohérent avec le resync déjà implémenté #91-D), l'écran repasse en pleine opacité dès la projection à jour reçue. + +### Hors périmètre MVP +- Personnalisation du cadran (choix utilisateur d'un thème/skin) — un seul habillage, non configurable. +- Watchface complication (widget montre hors app) — pas dans ce ticket, sujet distinct si demandé un jour. +- Animation de transition élaborée entre écrans — un simple fondu suffit, pas de chorégraphie custom à spécifier ici. diff --git a/.ideai/tickets/115/issue.md b/.ideai/tickets/115/issue.md new file mode 100644 index 0000000..4419c85 --- /dev/null +++ b/.ideai/tickets/115/issue.md @@ -0,0 +1,25 @@ +--- +id: "c7daaacd-8002-4280-b3a8-5e60a076db06" +number: 115 +title: "#104-A [UX] Refonte UI montre alignée sur la DA Court Blazer" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#104","kind":"relatesTo"}] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785134675276 +version: 3 +--- +Partie du ticket #104. + +Concevoir la refonte visuelle de l'application montre pour coller davantage à la DA téléphone : +- fond sombre ; +- accent doré ; +- liserés rouges ; +- hiérarchie typographique claire sur cadran rond ; +- variantes no-session, séance active, repos, actions, connexion perdue. + +Définition de done : spec UX exploitable sans arbitrage utilisateur supplémentaire. diff --git a/.ideai/tickets/116/carnet.md b/.ideai/tickets/116/carnet.md new file mode 100644 index 0000000..5bd0f9a --- /dev/null +++ b/.ideai/tickets/116/carnet.md @@ -0,0 +1,27 @@ +--- +issueRef: "#116" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785093288487 +--- + +## Audit DevFrontend — 2026-07-27 + +- Refonte montre Court Blazer déjà présente : thème sombre, typographies Anton/Archivo, valeur dominante, actions compactes, états no-session/repos/connexion perdue. +- L'écran séance montre ne réintroduit pas de scroll vertical ; la navigation reste un `PageView` séance/actions. +- Layout contraint au diamètre via `_RoundScaffold` et textes protégés par `FittedBox`/`maxLines`/ellipsis. +- Aucun écart visible supplémentaire corrigible sans test device identifié. + +À valider sur device : rendu cadran rond réel, absence d'overflow sur formats Wear OS ciblés. + +## Correctif QA DevFrontend — 2026-07-27 + +- Ajustement compact appliqué au contenu actif via `_ScaledContent` pour absorber les écarts de police/viewport du test 192x192 sans changer la direction UI Court Blazer. +- Validation locale : test widget compact OK, analyse Dart ciblée OK, build APK debug montre OK. + +## Implémentation DevFrontend — 2026-07-26 + +- Refonte UI montre appliquée sur `watch_session_screen.dart` et `watch_theme.dart` : thème sombre Court Blazer, or pour les valeurs dominantes, liseré crimson court, typographies Anton/Archivo embarquées dans `watch_app`. +- États couverts : no-session avec marque GT, séance active avec priorité chrono/série, repos avec `Ensuite : ...`, actions compactes, connexion perdue avec dernière projection conservée et assombrie. +- Les vues actives sont bornées dans un carré basé sur le viewport et ne reposent plus sur du scroll pour tenir sur le cadran. +- Vérification : `flutter analyze` watch_app OK ; `./gradlew assembleDebug` watch_app OK avec JDK 21 et répertoires utilisateur redirigés. diff --git a/.ideai/tickets/116/issue.md b/.ideai/tickets/116/issue.md new file mode 100644 index 0000000..c80f710 --- /dev/null +++ b/.ideai/tickets/116/issue.md @@ -0,0 +1,24 @@ +--- +id: "dfb69f8b-fed6-477c-b8ee-94eb00ce6cc2" +number: 116 +title: "#104-B [DevFrontend] Implémenter la refonte UI montre" +status: "QA" +priority: "medium" +sprint: null +links: [{"target":"#104","kind":"relatesTo"},{"target":"#115","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785093288487 +version: 1 +--- +Partie du ticket #104, dépend de #115. + +Appliquer la refonte UX sur l'app montre existante : +- thème et composants ; +- écrans actifs/repos/actions/no-session/perte de connexion ; +- respect des contraintes rondes Wear OS sans overflow ; +- build montre propre. + +Définition de done : surfaces conformes à la spec UX, installables et sans régression majeure évidente. diff --git a/.ideai/tickets/117/carnet.md b/.ideai/tickets/117/carnet.md new file mode 100644 index 0000000..4eac5a5 --- /dev/null +++ b/.ideai/tickets/117/carnet.md @@ -0,0 +1,7 @@ +--- +issueRef: "#117" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1785142171875 +--- + diff --git a/.ideai/tickets/117/issue.md b/.ideai/tickets/117/issue.md new file mode 100644 index 0000000..289b90c --- /dev/null +++ b/.ideai/tickets/117/issue.md @@ -0,0 +1,24 @@ +--- +id: "32b59d5b-28ae-469e-9f6b-ab7a98801112" +number: 117 +title: "#104-C [QA] Validation refonte UI montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#104","kind":"relatesTo"},{"target":"#116","kind":"dependsOn"}] +agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} +createdAt: 1785093288487 +updatedAt: 1785142171875 +version: 2 +--- +Partie du ticket #104, dépend de #116. + +Valider la refonte UI montre : +- cohérence visuelle avec la DA ; +- absence d'overflow visible ; +- lisibilité sur cadran rond ; +- build/install et parcours principaux. + +Définition de done : rapport QA vert ou rapport d'échec concret. diff --git a/.ideai/tickets/118/carnet.md b/.ideai/tickets/118/carnet.md new file mode 100644 index 0000000..9b18b9b --- /dev/null +++ b/.ideai/tickets/118/carnet.md @@ -0,0 +1,35 @@ +--- +issueRef: "#118" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785137390315 +--- + +## #118 — Contrôles score +/- sur montre (UX, 2026-07-26) + +Statut : **cadrage UX exploitable, prêt pour Architect (#119) et implémentation**, pas de blocage. + +### Principe directeur +La montre est un **satellite d'affichage et de commande**, jamais une source de vérité ([[gametime-watch-companion-implementation]] : « la montre n'applique JAMAIS une commande localement, recalage sur projection confirmée »). Les contrôles score doivent rendre ce principe visible à l'utilisateur pour éviter toute confusion en cas de latence, sans jamais donner l'impression d'un mode local/offline. + +### Emplacement et affordance +- Sur l'écran "séance active" de la montre (refonte DA, cf. [[gametime-ux-score-chrono]] pour le vocabulaire "Score"/"Score chrono"), le bloc score reprend la même hiérarchie que le compteur de série actuel côté téléphone ([[gametime-ux-series-counter]]) : grande valeur numérique Anton, couleur primaire or, au centre du cadran. +- Deux zones tactiles larges de part et d'autre de la valeur : `−` à gauche, `+` à droite. Cibles tactiles ≥ 48dp (cadran rond, appui au doigt, pas de precision souris) — priorité absolue à éviter l'appui accidentel du bouton opposé plutôt qu'à la densité d'info. +- Rotation de la couronne physique (si le boîtier en a une) acceptée comme entrée alternative +/- si le device la supporte, en complément des zones tactiles — pas un remplacement, car tous les boîtiers Wear OS n'ont pas de couronne rotative. +- Si l'exercice n'a **pas** de score configuré : le bloc est simplement absent de l'écran (pas de placeholder grisé, pas de "score non disponible") — l'écran affiche seulement les mesures actives de l'exercice (reps/temps), cohérent avec « le vide se conçoit, mais ici il n'y a rien à dire ». + +### Prévention des erreurs d'appui +- **Debounce visuel immédiat, confirmation serveur différée** : au tap, la valeur affichée s'incrémente/décrémente instantanément en local (feedback optimiste, indispensable pour un contrôle répété rapide) mais passe dans un **état visuellement "en attente"** (valeur légèrement atténuée / opacité réduite) jusqu'à confirmation de la projection téléphone → montre. Dès la confirmation reçue, retour à l'opacité pleine. Ceci rend le principe "téléphone source de vérité" visible sans bloquer l'usage. +- Pas de confirmation modale par tap (casserait l'usage répété typique d'un score qui s'incrémente vite, ex. compte de points) — la prévention passe par la taille des cibles et le split gauche/droite, pas par une friction supplémentaire. +- Bouton `−` désactivé (grisé, non actionnable) si le score est déjà à son minimum autorisé (0, ou borne définie par l'exercice) — évite un appui qui ne ferait rien et qui interrogerait l'utilisateur. +- Pas de "annuler le dernier appui" dédié sur la montre : la correction se fait avec les mêmes boutons +/-, symétriques. Une correction plus lourde (erreur de plusieurs points) se fait sur le téléphone, source de vérité complète. + +### Comportement si connexion lente +- Au-delà d'un délai perçu (repère : > 1,5-2s sans confirmation, valeur à affiner par Architect selon la latence réelle mesurée du bridge), la valeur "en attente" affiche un indicateur discret supplémentaire (petit point pulsé sous la valeur) signalant que la synchronisation est toujours en cours — jamais de message d'erreur intrusif, jamais de blocage des taps suivants (cohérent avec [[gametime-online-layer-philosophy]] : pas de popup, pas d'interruption). +- Si la commande finit par échouer (timeout définitif, perte de connexion Data Layer) : la montre **se recale silencieusement** sur la dernière valeur confirmée par le téléphone (peut donc "reculer" visuellement si des taps optimistes n'ont pas abouti) — pas de message d'erreur sur la montre. Un état de connexion perdue générique doit déjà couvrir ce cas au niveau écran (cf. #115, variante "connexion perdue"), pas la peine de dupliquer un message spécifique au score. +- Aucune mention "hors ligne" ou "mode local" nulle part sur cet écran : le contrat visuel est "j'affiche ce que dit le téléphone, avec un délai le cas échéant", jamais "je fonctionne seul". + +### Hors périmètre MVP +- Saisie d'une valeur exacte au clavier depuis la montre (le téléphone reste l'endroit pour une correction lourde). +- Undo dédié / historique des taps sur la montre. +- Haptique différenciée par valeur (un retour haptique simple par tap suffit, cohérent avec l'existant #91-F). diff --git a/.ideai/tickets/118/issue.md b/.ideai/tickets/118/issue.md new file mode 100644 index 0000000..edabf88 --- /dev/null +++ b/.ideai/tickets/118/issue.md @@ -0,0 +1,26 @@ +--- +id: "6295aa3e-9b0b-4b94-a2fb-67057af1adc5" +number: 118 +title: "#105-A [UX] Conception des contrôles score +/- sur montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#105","kind":"relatesTo"}] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785137390315 +version: 3 +--- +Partie du ticket #105. + +Définir l'UX du score incrémentable/décrémentable depuis la montre pour les exercices qui portent un score : +- emplacement et affordance des actions ; +- prévention des erreurs d'appui ; +- comportement si la connexion est lente ; +- cas où l'exercice n'a pas de score. + +Contrainte : le téléphone reste source de vérité, la montre ne doit pas laisser croire à un mode offline local. + +Définition de done : spec UX claire pour extension du companion montre. diff --git a/.ideai/tickets/119/carnet.md b/.ideai/tickets/119/carnet.md new file mode 100644 index 0000000..2ab4190 --- /dev/null +++ b/.ideai/tickets/119/carnet.md @@ -0,0 +1,101 @@ +--- +issueRef: "#119" +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785137408716 +--- + +## #119 — Extension watch bridge pour score +/- (Architect, 2026-07-26) + +Statut : **cadrage exploitable, prêt pour DevBackend/DevFrontend**, pas de blocage. Basé sur le cadrage UX #118 et l'existant réel (`packages/watch_bridge_contract`, `WatchCompanionCommandHandler`, cf. [[gametime-watch-companion-implementation]]). + +### Portée fonctionnelle +Ce ticket couvre le **score en mode `manual`** (`ScoreInputMode.manual`) uniquement. Le score chronométré (`stopwatch`, cf. [[gametime-architecture-score-chrono]]) ne s'"incrémente" pas — il se démarre/arrête ; il n'est pas concerné par ce +/- et reste hors périmètre ici. + +### Constat sur l'existant +- `WatchCommandEnvelope` (dans `watch_bridge_contract`) n'a **aucun champ payload générique** — chaque commande existante est un `WatchCommandType` sans paramètre, tout l'effet dérivant de l'état serveur + du type. On reste cohérent avec ce pattern : pas de champ `delta` générique, deux types discrets. +- **Aucun état vivant n'existe aujourd'hui pour le score manuel pendant une série** — contrairement au score chrono qui a `ActiveScoreStopwatchState` (persistant, clé `sessionId+programIndex+exerciseIndex+setIndex`). Le score manuel est aujourd'hui un simple `TextEditingController` côté UI, lu une seule fois au moment de "Terminer la série" (`workout_execution_screen.dart`). Ce n'est pas suffisant : le +/- montre doit être visible en direct, y compris côté téléphone si l'app est au premier plan. + +### Décision structurante : le score manuel devient un état de séance vivant +Créer une nouvelle table Drift/entité **`ActiveManualScoreState`**, sur exactement le même principe que `ActiveScoreStopwatchState` : +```dart +class ActiveManualScoreState { + final int programIndex, exerciseIndex, setIndex; // même clé logique que ActiveScoreStopwatchState + final double value; // valeur courante, plancher 0 + final DateTime updatedAt; +} +``` +- Absence de ligne = valeur par défaut `0`. +- Port repository : `findManualScoreState` / `saveManualScoreState` / `deleteManualScoreState` sur `ActiveSessionRepository`, en parallèle des méthodes `*ScoreStopwatchState` existantes. +- Suppression de l'état à la fin/passage de la série (même cycle de vie que le chrono score). +- **Plancher** : `0` fixe pour le MVP. Il n'existe aujourd'hui aucun champ "score minimum" configurable sur `Exercise`/`ProgramExercise` — en ajouter un serait un cadrage spéculatif hors demande explicite. Si un plancher par exercice est réellement voulu, c'est un ticket séparé, pas une extension implicite ici. +- **Pas de plafond** au MVP (cohérent avec le champ libre actuel qui n'a pas de borne haute). +- **Pas de palier configurable** : incrément fixe `+1`/`-1` (cas d'usage dominant : tally de points/répétitions comptés un par un). Pas de champ "step" à inventer sans besoin exprimé. + +### Use cases (application layer) +Deux nouvelles méthodes sur `ActiveWorkoutSessionUseCases`, miroir de `startScoreStopwatch`/`stopScoreStopwatch` : +```dart +Future incrementManualScore(sessionId, ...position...); +Future decrementManualScore(sessionId, ...position...); // no-op si déjà à 0 +``` +`recordCurrentSetResult(...)` doit lire `ActiveManualScoreState.value` comme **valeur par défaut** de `actualScore` pour la série courante — mais rester **surchageable** par une édition manuelle explicite côté téléphone au moment de terminer la série (même mécanique que "Modifier le temps" déjà en place pour le score chrono, cf. [[gametime-architecture-score-chrono]]) : le paramètre existant de `recordCurrentSetResult` prend priorité s'il est explicitement fourni par l'UI, sinon on retombe sur l'état vivant. + +### Pivot UI téléphone (important, à transmettre à DevFrontend) +Le score manuel n'est plus "un champ texte soumis une fois à la fin" — c'est désormais un **état de séance vivant**, au même titre que le chrono score. Le champ texte actuel (`_scoreController`) doit être re-branché en lecture/écriture sur `ActiveManualScoreState` (via le flux de projection/session déjà utilisé pour le reste de l'écran d'exécution), pour que toute modification faite depuis la montre soit visible immédiatement à l'écran si le téléphone est au premier plan. + +### Extension du contrat `watch_bridge_contract` +```dart +enum WatchCommandType { + ..., // types existants inchangés + incrementScore, + decrementScore, +} +``` +Ajout par extension d'enum (Open/Closed) — aucune modification des types/cas existants. + +`WatchSessionProjection` (phone→watch) doit exposer deux champs supplémentaires pour que la montre sache afficher/masquer le bloc et l'état du bouton `−` sans dupliquer la logique métier : +```dart +final bool hasManualScore; // bloc absent sur la montre si false (cf. #118 UX) +final double? currentManualScoreValue; // valeur affichée, null si hasManualScore == false +final bool canDecrementScore; // false si déjà au plancher — logique du plancher reste calculée côté téléphone, jamais dupliquée sur la montre (montre = satellite, cf. [[gametime-watch-companion-implementation]]) +``` + +### Idempotence et applicabilité (`WatchCompanionCommandHandler`) +- `_isApplicable()` : `incrementScore`/`decrementScore` applicables ssi phase de série active **et** `hasManualScore == true` sur la projection courante. Ajouter cette entrée sans toucher aux cas existants. +- **Point d'attention explicite pour l'implémentation** : contrairement à des commandes ponctuelles (ex. `finishCurrentSet`), le +/- est tapé **rapidement et répétitivement** (cf. UX #118 : "usage répété rapide"). L'applicabilité de ces deux types ne doit **pas** dépendre de l'exactitude de `expectedRevision` — seulement de la phase/du mode. Chaque tap génère un nouveau `commandId` (idempotence par commande individuelle, déjà gérée par `_handledCommands`), mais un léger décalage de révision entre deux taps rapprochés (le premier tap ayant déjà fait avancer la révision avant que le second parte) ne doit **jamais** produire `rejectedStaleRevision` pour ces deux types — sinon l'usage répété rapide voulu par l'UX serait cassé. C'est un cas différent des commandes de transition d'étape, qui elles peuvent légitimement dépendre de la révision exacte. +- Chaque commande acceptée déclenche `_emitProjectionAfterCommand()` comme les autres — republie vers la montre (valeur + `canDecrementScore` à jour) et vers le futur coordinateur de notification (#109) sans code spécifique supplémentaire. + +### Comportement en cas de latence/échec (UX #118, déjà cadré, rappel technique) +Aucun contrat supplémentaire requis ici : le mécanisme optimiste "tap local → état atténué → confirmation" est **entièrement côté montre/présentation** (watch_app), consommant `currentManualScoreValue`/révision déjà remontés. Pas de nouvel état serveur à créer pour ça. + +### Migration +Nouvelle table Drift `ActiveManualScoreState` ⇒ bump du `schemaVersion` local (vérifier la valeur courante au moment de l'implémentation, ne pas supposer un numéro figé ici). + +### Tests +- Contrat : `dart test` sur les deux nouveaux `WatchCommandType`, même fichier `packages/watch_bridge_contract/test/`. +- Handler : étendre `test/application/watch_companion_command_handler_test.dart` — cas incrément/décrément normal, décrément au plancher (no-op, ack `acceptedNoOp`), applicabilité refusée hors mode manuel, rafale de commandes avec révisions décalées (non rejetées). +- Projection : étendre `test/application/watch_companion_projection_test.dart` pour les 3 nouveaux champs. +- Use case : test `recordCurrentSetResult` avec état vivant présent + override manuel explicite. + +--- + +## Audit Architect — implémentation constatée dans le worktree (2026-07-27) + +Statut mis à jour : **implémenté de bout en bout, conforme au cadrage**, un garde-fou de cohérence à fermer. + +Vérification du code réel : `ActiveManualScoreState` (`lib/domain/entities.dart`, plancher enforced en plus dans le constructeur via `_requireNullableNonNegativeDouble`), CRUD complet sur `ActiveSessionRepository`/`DriftActiveSessionRepository`, `incrementManualScore`/`decrementManualScore`/`_updateManualScore` (`use_cases.dart`, clamp `[0, +∞)`, `ManualScoreUpdateResult.changed` pour distinguer accepté/no-op), `_allowsStaleRevision(type)` dans `WatchCompanionCommandHandler` qui exempte précisément `incrementScore`/`decrementScore` du rejet `rejectedStaleRevision` — exactement le garde-fou cadré ci-dessus pour l'usage répété rapide. Table Drift `active_manual_score_states` créée avec `CHECK(value >= 0)` (plancher renforcé aussi en base), `schemaVersion` bumpé à 20 avec migration dédiée, table enregistrée dans `_syncableTableNames`. Contrat `watch_bridge_contract` étendu exactement comme cadré (`hasManualScore`/`currentManualScoreValue`/`canDecrementScore`, `fromJson` avec défauts sûrs). Côté montre : `incrementScore`/`decrementScore` avec mise à jour optimiste instantanée, opacité atténuée pendant l'attente, recalage silencieux sur échec/timeout (`requestResync()`, aucun message d'erreur) — conforme au cadrage UX #118. + +**Garde-fou à fermer avant merge** : le pivot téléphone n'est **qu'à moitié fait**. L'écran d'exécution (`workout_execution_screen.dart`) **lit** bien `ActiveManualScoreState` en direct (`_syncManualScoreInput`, polling via le ticker 1s existant, ignoré si le champ a le focus) — donc une correction faite depuis la montre remonte bien à l'écran téléphone. Mais l'inverse est manquant : **taper une valeur dans le champ texte du téléphone n'écrit jamais dans `ActiveManualScoreState`** ; le texte tapé n'est consommé qu'au moment de `finishCurrentSet`, en paramètre explicite qui écrase la valeur vivante seulement à cet instant précis. Conséquence concrète : si l'utilisateur corrige le score sur le téléphone puis tape encore une fois sur la montre avant de terminer la série, le tap montre repart de l'**ancienne** valeur persistée (pas de la correction tapée), et à la validation de la série le champ téléphone (dernière source lue) écrase silencieusement ce que la montre vient d'appliquer — un des deux côtés perd sa modification sans avertissement, alors que l'UX #118 attend explicitement que "la correction plus lourde se fait sur le téléphone, source de vérité complète" en cohérence avec ce que voit la montre à ce moment. + +**Contrat à fermer pour DevFrontend** : toute édition explicite du champ texte téléphone (`onSubmitted`/`onEditingComplete`/perte de focus) doit **écrire à travers** vers `ActiveManualScoreState` (ex. un `setManualScore(sessionId, position, value)` sur `ActiveWorkoutSessionUseCases`, à ajouter en miroir des deux méthodes existantes, avec le même clamp `[0, +∞)`), pas seulement être lue en fin de série. Cela rend les deux sens de synchronisation (montre→téléphone déjà fait, téléphone→montre à fermer) symétriques et élimine la fenêtre de divergence silencieuse. Sans ce correctif, ne pas considérer le lot #119 comme fonctionnellement complet du point de vue de l'invariant "le téléphone reste toujours la source de vérité complète" posé par UX #118. + +--- + +## Vérification Architect — gap résiduel fermé (2026-07-27, second passage) + +Le gap ci-dessus est **résolu dans le worktree actuel** : `ActiveWorkoutSessionUseCases.setManualScore` (`lib/application/use_cases.dart:2730`) existe et applique le même clamp `[0, +∞)` que `increment`/`decrementManualScore`. Côté écran (`lib/presentation/workout_execution_screen.dart`), `_persistManualScoreInputOnce` (ligne 702) écrit à travers vers `setManualScore` à chaque édition explicite validée, appelée par : +- perte de focus (`_handleScoreFocusChange`, ligne 139-140) ; +- `onScoreSubmitted`/`onEditingComplete` câblés sur `_persistManualScoreInput` (lignes 257-259, 309-311) ; +- juste avant `recordCurrentSetResult` dans `_recordAndAdvance` (ligne 1426). + +Les deux sens de synchronisation sont désormais symétriques. L'invariant UX #118 ("le téléphone reste la source de vérité complète") est respecté. **Aucune action supplémentaire requise sur #119** ; le lot est fonctionnellement complet. diff --git a/.ideai/tickets/119/issue.md b/.ideai/tickets/119/issue.md new file mode 100644 index 0000000..0db386c --- /dev/null +++ b/.ideai/tickets/119/issue.md @@ -0,0 +1,24 @@ +--- +id: "2baf0fdb-3407-43a8-bdbf-d85aaad9a640" +number: 119 +title: "#105-B [Architect] Extension watch bridge pour score +/-" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#105","kind":"relatesTo"},{"target":"#118","kind":"dependsOn"}] +agentRefs: [{"agentId":"f8f40941-ecf7-4830-b9de-8818a099f448","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785137408716 +version: 6 +--- +Partie du ticket #105, dépend de #118. + +Cadrer l'extension des contrats montre/téléphone pour supporter l'incrément et le décrément de score : +- nouveaux types de commande et acks éventuels ; +- notion d'idempotence ; +- règles métier si plusieurs scores sont possibles ; +- impact sur la projection montre. + +Définition de done : cadrage technique et contrats de transport exploitables par backend/frontend. diff --git a/.ideai/tickets/120/carnet.md b/.ideai/tickets/120/carnet.md new file mode 100644 index 0000000..80f90f9 --- /dev/null +++ b/.ideai/tickets/120/carnet.md @@ -0,0 +1,22 @@ +--- +issueRef: "#120" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785137390334 +--- + +## Implémentation DevBackend — 2026-07-26 + +- Contrat montre étendu avec `WatchCommandType.incrementScore` / `decrementScore` et champs de projection `hasManualScore`, `currentManualScoreValue`, `canDecrementScore`. +- Score manuel routé via `WatchCompanionCommandHandler` vers `ActiveWorkoutSessionUseCases.incrementManualScore` / `decrementManualScore`, sans logique métier dans le bridge. +- Support strict `ScoreInputMode.manual` : les commandes score sont applicables seulement si la projection expose un score manuel, et le use case revalide le snapshot courant. +- État vivant ajouté pour le score manuel (`ActiveManualScoreState`) avec table Drift `active_manual_score_states` et migration schéma 20. +- Acks couverts : décrément au plancher `0` => `acceptedNoOp`; rafale score avec révision décalée acceptée; commandes non-score gardent la vérification stricte de révision. + +Vérifications exécutées : +- `dart test` dans `packages/watch_bridge_contract` +- `flutter test test/application/watch_companion_command_handler_test.dart test/application/watch_companion_projection_test.dart` +- `flutter test test/application/use_cases_test.dart test/application/watch_companion_command_handler_test.dart test/application/watch_companion_projection_test.dart` +- `flutter test test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart` +- `dart analyze` ciblé sur les fichiers touchés : sortie 0 (reste un lint `prefer_single_quotes` préexistant dans `tables.dart`) +- `flutter analyze` complet : plus d'erreur, échec uniquement sur infos/lints préexistants diff --git a/.ideai/tickets/120/issue.md b/.ideai/tickets/120/issue.md new file mode 100644 index 0000000..73a16af --- /dev/null +++ b/.ideai/tickets/120/issue.md @@ -0,0 +1,20 @@ +--- +id: "30e7270a-26a3-40ad-ac74-01da7882405a" +number: 120 +title: "#105-C [DevBackend] Routing score montre vers les use cases d'exécution" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#105","kind":"relatesTo"},{"target":"#119","kind":"dependsOn"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785137390334 +version: 2 +--- +Partie du ticket #105, dépend de #119. + +Brancher les commandes score +/- issues de la montre sur les use cases d'exécution existants, sans dupliquer la logique métier. + +Définition de done : score mis à jour côté téléphone via les commandes montre, acks cohérents, tests unitaires pertinents au vert. diff --git a/.ideai/tickets/121/carnet.md b/.ideai/tickets/121/carnet.md new file mode 100644 index 0000000..42c550a --- /dev/null +++ b/.ideai/tickets/121/carnet.md @@ -0,0 +1,36 @@ +--- +issueRef: "#121" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785093288487 +--- + +## Implémentation DevFrontend — 2026-07-26 + +- Contrôles score manuel `−` / `+` ajoutés sur l'écran séance active montre uniquement quand la projection expose `hasManualScore`. +- Feedback optimiste immédiat côté montre : valeur locale atténuée pendant pending, indicateur discret après délai, recalage sur projection téléphone confirmée. +- Commandes envoyées via les types backend #120 `incrementScore` / `decrementScore`; la montre ne persiste ni ne décide la valeur source. +- Les commandes score ont un pending dédié et ne bloquent pas les actions de transition existantes; la sync/reconnexion montre existante reste inchangée. +- L'écran téléphone relit l'état vivant `ActiveManualScoreState` et met à jour le champ score manuel si l'utilisateur n'est pas en train de l'éditer. + +Vérifications exécutées : +- `dart format` ciblé OK. +- `git diff --check` OK. +- `dart analyze watch_app/lib` OK. +- `dart analyze lib test/application/session_notification_use_cases_test.dart` OK avec infos préexistantes uniquement. +- `dart test` dans `packages/watch_bridge_contract` OK. + +Non fait / à valider : +- `flutter test` / build Wear OS via wrapper Flutter bloqués par cache Flutter en lecture seule. +- Test réel montre+téléphone requis : taps rapides +/-, pending visuel, recalage après ack/projection, décrément à 0, perte/reprise connexion. + +## Complément symétrie téléphone ↔ montre — 2026-07-27 + +- Symétrie téléphone ↔ montre confirmée dans le worktree : le téléphone persiste le champ score manuel via `setManualScore` à la perte de focus et avant `Terminer la série`. +- La montre consomme la projection confirmée `currentManualScoreValue` et continue à envoyer uniquement des commandes au téléphone. +- Aucun message utilisateur ajouté sur latence watch ; le feedback reste visuel et discret. + +## Correctif QA DevFrontend — 2026-07-27 + +- Reproduction QA 192x192 traitée sur la colonne score manuel : `_ManualScoreContent` passe par `_ScaledContent` et une colonne `mainAxisSize.min`, avec boutons score conservés. +- Validation locale : test widget compact OK, analyse Dart ciblée OK, build APK debug montre OK. diff --git a/.ideai/tickets/121/issue.md b/.ideai/tickets/121/issue.md new file mode 100644 index 0000000..9b2b730 --- /dev/null +++ b/.ideai/tickets/121/issue.md @@ -0,0 +1,23 @@ +--- +id: "1a1a4f69-c17c-464f-86e5-7ea862a72c9d" +number: 121 +title: "#105-D [DevFrontend] Contrôles score montre synchronisés au téléphone" +status: "QA" +priority: "medium" +sprint: null +links: [{"target":"#105","kind":"relatesTo"},{"target":"#118","kind":"dependsOn"},{"target":"#119","kind":"dependsOn"},{"target":"#120","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785093288487 +version: 1 +--- +Partie du ticket #105, dépend de #118, #119 et #120. + +Implémenter les contrôles score +/- sur la montre : +- rendu conforme à l'UX ; +- latence et pending cohérents ; +- recalage exclusif sur l'état confirmé du téléphone. + +Définition de done : interactions score montre fonctionnelles sur build Wear OS. diff --git a/.ideai/tickets/122/carnet.md b/.ideai/tickets/122/carnet.md new file mode 100644 index 0000000..a30f66f --- /dev/null +++ b/.ideai/tickets/122/carnet.md @@ -0,0 +1,7 @@ +--- +issueRef: "#122" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1785142162842 +--- + diff --git a/.ideai/tickets/122/issue.md b/.ideai/tickets/122/issue.md new file mode 100644 index 0000000..d74292a --- /dev/null +++ b/.ideai/tickets/122/issue.md @@ -0,0 +1,24 @@ +--- +id: "c96bfbde-2731-40ab-8b83-3df85ba49cbb" +number: 122 +title: "#105-E [QA] Validation score montre +/-" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#105","kind":"relatesTo"},{"target":"#121","kind":"dependsOn"}] +agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} +createdAt: 1785093288487 +updatedAt: 1785142162842 +version: 2 +--- +Partie du ticket #105, dépend de #121. + +Valider le pilotage du score depuis la montre : +- incrément/décrément nominaux ; +- pas de double application ; +- cohérence téléphone/montre ; +- comportement si commande rejetée ou connexion perdue. + +Définition de done : rapport QA réel sur build téléphone + montre. diff --git a/.ideai/tickets/123/carnet.md b/.ideai/tickets/123/carnet.md new file mode 100644 index 0000000..d54996d --- /dev/null +++ b/.ideai/tickets/123/carnet.md @@ -0,0 +1,42 @@ +--- +issueRef: "#123" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785137390347 +--- + +## #123 — Cadrage transparent des statistiques issues de la montre (UX, 2026-07-26) + +Statut : **CADRAGE UNIQUEMENT, ne pas ouvrir l'implémentation.** Comme décidé dans le carnet #106 (Main), ce ticket doit se combiner avec le cadrage Architect #124 (permissions capteurs, disponibilité partielle, modèle de persistance) avant tout lot d'implémentation. Ce document fixe le **quoi côté UX**, pas le **quand/comment technique**. + +### Cadre produit +Demande utilisateur explicite : si la donnée n'est pas disponible (pas de montre, capteur non supporté, capteur refusé), **aucune notification, aucun message, aucun vide visible** — silence total. C'est la contrainte structurante de tout le cadrage. + +### Métriques MVP proposées (à valider par Architect côté faisabilité capteur Wear OS) +Périmètre volontairement réduit à ce qui a une valeur de lecture immédiate pour un utilisateur de musculation/sport, sans dupliquer un tracker cardio dédié : +1. **Fréquence cardiaque moyenne de la séance** (bpm) — métrique la plus universellement disponible sur Wear OS, et la plus lisible pour l'utilisateur. +2. **Fréquence cardiaque max de la séance** (bpm) — complète la moyenne à peu de coût de lecture supplémentaire. +3. *(optionnel, second temps, pas bloquant pour le MVP)* Calories estimées si le capteur/API le fournit nativement sans calcul maison — GameTime n'est pas une app de fitness tracking, ne pas construire de modèle de calcul propre. + +Explicitement **hors MVP** pour ce cadrage : VO2 max, zones d'intensité cardio, SpO2, ECG, tout ce qui relève d'un tracker santé complet — hors du produit GameTime (app de séances de sport, pas app santé). + +### Surface de restitution +- **Écran de fin de séance / résumé** (le même écran qui affiche déjà la durée totale et le récap des séries) : nouveau bloc optionnel "Fréquence cardiaque" avec deux valeurs (moyenne / max), même traitement visuel que les autres stats du résumé (Anton pour les chiffres, cf. [[gametime-visual-identity]]). +- **Historique de séance** (détail d'une séance passée) : même bloc, affiché dans le détail si la donnée existe pour cette séance. +- **Pas d'affichage temps réel pendant la séance** dans ce périmètre MVP — pas de graphique cardio live sur l'écran d'exécution téléphone, ni sur la montre. Justification : ajouterait une surface supplémentaire à concevoir et à tester pour une valeur d'usage secondaire (l'utilisateur suit son exercice, pas son cardio, pendant l'effort) ; peut être réévalué plus tard si demande explicite. + +### Silence UX si donnée absente +- Montre non appairée, capteur cardio absent, permission refusée, ou données insuffisantes pour un calcul fiable → le bloc "Fréquence cardiaque" **n'apparaît simplement pas** dans le résumé ni l'historique. Pas de placeholder grisé "Non disponible", pas d'icône barrée, pas de bouton "Connecter une montre" inséré au milieu du résumé de séance. +- Aucun état d'erreur dédié à concevoir : l'absence de la donnée n'est pas un état de l'écran, c'est une variation normale de contenu (comme une séance sans note, ou sans photo) — cohérent avec [[gametime-online-layer-philosophy]] (silence sur l'indisponibilité, jamais de message intrusif). +- Un seul endroit où la fonctionnalité peut être rendue *découvrable* sans être intrusive : dans les réglages de compte/appareils (là où l'utilisateur configure déjà l'appairage montre, hors scope de ce ticket) — pas sur l'écran de séance lui-même. + +### Hiérarchie des métriques (si plusieurs disponibles un jour) +Ordre de priorité de lecture déjà anticipé pour ne pas re-arbitrer plus tard si le périmètre s'étend : Fréquence cardiaque (moyenne, max) > Calories > toute métrique additionnelle. La fréquence cardiaque reste toujours la première ligne du bloc, jamais noyée sous des métriques secondaires. + +### Ce qui reste à trancher par Architect (#124) avant ouverture de l'implémentation +- Faisabilité et API Wear OS Health Services pour lire bpm moyenne/max sur la durée d'une séance. +- Modèle de permission (demande à quel moment du parcours, cohérent avec le principe "jamais de blocage du parcours principal"). +- Modèle de persistance (nouveau champ sur l'entité séance, ou table séparée) et comportement en cas de séance déjà terminée sans cette donnée (pas de migration rétroactive à inventer). + +### Definition of done de ce ticket UX +Ce cadrage est **terminé pour la partie UX** : personne ne doit revenir vers l'utilisateur pour une question de restitution ou de silence UX. La suite (ouverture des lots d'implémentation) dépend uniquement du retour d'Architect sur #124, pas d'un nouvel arbitrage UX. diff --git a/.ideai/tickets/123/issue.md b/.ideai/tickets/123/issue.md new file mode 100644 index 0000000..00dd268 --- /dev/null +++ b/.ideai/tickets/123/issue.md @@ -0,0 +1,24 @@ +--- +id: "c8f8dbd0-8c20-44a5-8405-47b9a5a05c11" +number: 123 +title: "#106-A [UX] Cadrage transparent des statistiques issues de la montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#106","kind":"relatesTo"}] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785137390347 +version: 3 +--- +Partie du ticket #106. + +Définir comment des statistiques issues de la montre peuvent enrichir une séance sans jamais dégrader l'expérience quand elles sont absentes : +- surface de restitution éventuelle ; +- silence UX si montre absente ou capteur indisponible ; +- hiérarchie des métriques réellement utiles ; +- périmètre MVP raisonnable. + +Définition de done : cadrage UX permettant un arbitrage produit clair avant implémentation. diff --git a/.ideai/tickets/124/carnet.md b/.ideai/tickets/124/carnet.md new file mode 100644 index 0000000..30bd652 --- /dev/null +++ b/.ideai/tickets/124/carnet.md @@ -0,0 +1,67 @@ +--- +issueRef: "#124" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785137390360 +--- + +## #124 — Architecture collecte capteurs montre → séance (Architect, 2026-07-26) + +Statut : **cadrage technique, ouvre la voie à l'implémentation** mais reste combiné à #123 comme décidé par Main sur #106 — aucun lot d'implémentation ne doit s'ouvrir sans relecture croisée de ce cadrage et du cadrage UX #123 (déjà "cadrage terminé côté UX", en attente de ce document). + +### Constat sur l'existant (fait, pas une supposition) +Recherche exhaustive sur `android/`, `watch_app/android/` et tous les Gradle : **aucune infrastructure capteur/santé n'existe aujourd'hui**. Ni `BODY_SENSORS`, ni dépendance Health Services/Health Connect, ni Horologist. La seule dépendance Wear présente (`com.google.android.gms:play-services-wearable:19.0.0`) est l'API Wearable Data Layer classique (MessageClient/DataClient), utilisée uniquement pour le bridge commandes/projection existant — **sans rapport avec les capteurs**. C'est donc une **capacité entièrement nouvelle**, pas une extension d'existant. + +### Faisabilité et choix d'API +- API recommandée : **Wear OS Health Services** (`androidx.health:health-services-client`), l'API moderne recommandée par Google pour la fréquence cardiaque sur Wear OS 3+ (l'ancienne lecture directe `Sensor.TYPE_HEART_RATE` est déconseillée/restreinte). +- **Choix structurant : `MeasureClient` (mesure passive), pas `ExerciseClient`.** `ExerciseClient` déclare un "Exercise" au niveau système (peut entrer en conflit avec d'autres apps fitness, affiche sa propre UI/notification système d'exercice en cours). GameTime a déjà son propre concept de séance ; on ne veut pas d'un second concept de "session" concurrent au niveau OS. `MeasureClient.registerMeasureCallback(DataType.HEART_RATE_BPM, ...)` suffit pour une mesure passive sans ce couplage. +- Dépendance à ajouter : `watch_app/android/app/build.gradle.kts` uniquement (la fréquence cardiaque se mesure sur la montre, jamais sur le téléphone). +- Permission `android.permission.BODY_SENSORS` (dangereuse, runtime) dans `watch_app/android/app/src/main/AndroidManifest.xml` ; évaluer `BODY_SENSORS_BACKGROUND` si la mesure doit continuer écran éteint au poignet (probable en usage réel de séance). + +### Modèle de permission (répond à la question ouverte du carnet UX #123) +Demande **une seule fois, paresseuse**, déclenchée par le premier passage en "séance active" côté montre après déploiement de la feature — jamais un écran d'onboarding dédié (évite une surface supplémentaire non demandée). Refus ou ignorance → la montre ne mesure jamais, le téléphone ne reçoit jamais de résumé, l'UI reste silencieuse — exactement le contrat UX déjà posé par #123 (silence total, jamais de blocage du parcours principal). + +### Décision structurante : agrégation locale montre, résumé unique en fin de séance +Pas de flux temps réel synchronisé en continu (inutile : UX #123 exclut explicitement tout affichage live). La montre : +1. Accumule localement (min/max/somme/count — arithmétique triviale, pas un "modèle de calcul maison" au sens où l'UX l'exclut pour les calories) pendant toute la séance, **pause-aware** : suspend l'agrégation quand la séance est en pause ou que le repos court sans effort (aligné sur le comportement déjà existant des autres chronos pause-aware, cf. [[gametime-session-execution-timer-refactor]]). +2. Envoie **un seul message résumé** en fin de séance (terminer/abandonner), pas un flux continu — élimine la complexité de synchronisation/idempotence en continu, cohérent avec le fait que rien ne consomme la donnée en direct. +3. Le canal Data Layer (`MessageClient`) gère nativement la fiabilité/retry si la montre est temporairement injoignable au moment de l'envoi ; si le message n'arrive jamais (montre débranchée, désinstallée...), la séance reste simplement sans ce bloc — silencieux pour toujours, aucune donnée en attente à représenter, aucune migration rétroactive à inventer (conforme à la demande explicite d'UX #123). + +### Nouveau contrat (séparé du contrat commandes existant) +Ne pas réutiliser `WatchCommandEnvelope` (sémantique commande utilisateur + ack) pour de la télémétrie sans accusé de réception. Nouveau type dans `packages/watch_bridge_contract` : +```dart +class WatchSensorSummary { + final int schemaVersion; + final String sessionId; + final int sampleCount; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; +} +``` +- `sampleCount` permet au téléphone de décider "donnée insuffisante" (UX #123 : "données insuffisantes pour un calcul fiable" → bloc absent). Seuil recommandé : `sampleCount < 3` ⇒ traiter comme absent (pas de moyenne calculée sur un bruit d'une ou deux mesures). +- Pas d'ack requis (fire-and-forget, perte tolérée par design). + +### Persistance téléphone +- Champs nullables **au niveau racine de l'entité d'historique de séance** (celle qui porte déjà la durée totale), pas par série/exercice — UX #123 demande une fréquence cardiaque **de la séance**, une seule paire moyenne/max par séance, pas un état vivant par set : +``` +averageHeartRateBpm: double? +maxHeartRateBpm: int? +``` +(nom exact de l'entité à confirmer par DevBackend selon le modèle d'historique réel — non exploré en détail dans ce cadrage.) +- **Écriture non bloquante (point important)** : ne jamais faire attendre la finalisation de "Terminer la séance" sur l'arrivée du résumé capteur. La séance se termine et s'enregistre immédiatement, `averageHeartRateBpm`/`maxHeartRateBpm` restent `null`. Si le résumé arrive ensuite (avant ou après la fin de séance, l'ordre n'est pas garanti), un use case dédié `updateWorkoutHistoryHeartRateSummary(historySessionId, ...)` applique un **patch idempotent** a posteriori si les champs sont encore `null` sur l'enregistrement d'historique correspondant, silencieusement si l'enregistrement n'existe plus/a été supprimé. +- Justification : cohérent par analogie avec [[gametime-online-layer-philosophy]] — ne jamais bloquer une action locale critique (ici, terminer une séance) en attendant une confirmation externe, même si ce n'est pas strictement un flux réseau serveur. + +### Calories (hors MVP, second temps déjà indiqué par UX) +Même pipeline (`MeasureClient`, `DataType.CALORIES_TOTAL` si disponible nativement) — champ additif `estimatedCalories: double?` sur le même résumé, à ajouter seulement si demandé explicitement plus tard. Ne pas l'inclure au lot 1. + +### Découpage en lots proposé (permet d'ouvrir l'implémentation sans ambiguïté) +1. **Watch (natif)** : dépendance Gradle + permission + `HeartRateSampler` (`MeasureClient`) + accumulateur local pause-aware + envoi résumé fin de séance. +2. **Contrat** : `WatchSensorSummary` dans `watch_bridge_contract` + tests `dart test`. +3. **Backend/domaine téléphone** : champs nullables sur l'entité d'historique + migration + `updateWorkoutHistoryHeartRateSummary` + écoute du message natif (extension du service d'écoute bridge déjà existant) + logique de patch tardif idempotent. +4. **Frontend téléphone** : bloc "Fréquence cardiaque" optionnel sur écran de fin de séance + détail historique (absent si `null`, aucun placeholder — conforme #123). +5. **QA** : permission refusée, montre sans capteur cardio, déconnexion mi-séance, arrivée tardive du résumé après clôture, seuil `sampleCount` insuffisant. + +Chaque lot est testable isolément : accumulateur watch (calcul pur), DTO contrat (dart test existant), use case patch (test avec repo fake), rendu UI (widget test null vs présent). + +### Ce qui reste explicitement hors MVP (repris d'UX #123, confirmé faisable/non-faisable ici) +VO2 max, zones d'intensité, SpO2, ECG — Health Services les expose en partie mais hors périmètre produit GameTime, pas seulement hors scope technique. Pas d'affichage temps réel (choix d'architecture confirmé ci-dessus, pas seulement une préférence UX : la mesure continue en flux aurait needlessly recompliqué idempotence/sync pour zéro valeur d'usage dans ce MVP). diff --git a/.ideai/tickets/124/issue.md b/.ideai/tickets/124/issue.md new file mode 100644 index 0000000..4a8e40c --- /dev/null +++ b/.ideai/tickets/124/issue.md @@ -0,0 +1,25 @@ +--- +id: "9dd46f87-2ff7-45e5-ac65-31807d563707" +number: 124 +title: "#106-B [Architect] Architecture collecte capteurs montre -> séance" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#106","kind":"relatesTo"},{"target":"#123","kind":"dependsOn"}] +agentRefs: [{"agentId":"f8f40941-ecf7-4830-b9de-8818a099f448","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785093288487 +updatedAt: 1785137390360 +version: 3 +--- +Partie du ticket #106, dépend de #123. + +Cadrer l'architecture de récupération de données capteurs montre vers une séance : +- métriques MVP recommandées ; +- permissions et contraintes Android Wear Health Services / capteurs ; +- stockage optionnel côté séance/historique ; +- comportement quand la donnée est absente, intermittente ou partielle ; +- impact sur contrats téléphone/montre. + +Définition de done : cadrage technique permettant ensuite d'ouvrir les tickets d'implémentation sans ambiguïté. diff --git a/.ideai/tickets/125/carnet.md b/.ideai/tickets/125/carnet.md new file mode 100644 index 0000000..8f2cd8b --- /dev/null +++ b/.ideai/tickets/125/carnet.md @@ -0,0 +1,39 @@ +--- +issueRef: "#125" +version: 2 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1785127016437 +--- + +## Audit DevFrontend — 2026-07-27 + +- Correctif déjà présent dans le worktree : émissions `EventChannel` postées sur le main thread côté téléphone et montre. +- Requête de projection Wear OS avec authority wildcard déjà présente. +- Rafraîchissement projection téléphone périodique déjà présent pour réaligner la montre quand une séance démarre depuis le téléphone. +- Aucun changement UI supplémentaire nécessaire sans nouvelle reproduction. + +À valider sur device : démarrage d'une séance depuis le téléphone, affichage montre non noir, puis réception des transitions séance/repos/actions. + +## Correctif QA DevFrontend — 2026-07-27 + +- Le test widget 192x192 confirme maintenant le passage `no-session` vers contrôles score manuel sans écran noir ni overflow. +- Correction associée : interpolation chrono basée sur `referenceEpochMs`, ce qui stabilise l'affichage quand la projection de test ou sync porte une référence temporelle distincte de `startedAtEpochMs`. +- Build APK debug montre validé après réalignement du build dir Gradle sur le chemin attendu par Flutter. + +## Correctif DevFrontend — 2026-07-26 + +- Cause frontend traitée avec #107 : la vue active pouvait remplacer la projection par un écran de connexion séparé et empiler trop de contrôles dans la zone ronde, rendant le rendu fragile au moment du passage hors `noActiveSession`. +- Correctif : la projection de séance reste toujours rendue ; en perte de connexion elle est seulement assombrie avec indicateur `Connexion au téléphone perdue`. +- Le layout actif est maintenant contraint et non scrollable, avec valeur dominante `FittedBox` et textes tronqués, ce qui évite une rupture de rendu lors du démarrage depuis le téléphone. +- Vérification : `flutter analyze` watch_app OK ; build APK debug montre OK via Gradle/JDK 21. + +--- + +## Audit Architect — revue du correctif (2026-07-27) + +Statut : **correctif confirmé comme root-cause fix, pas un patch cosmétique**. Vérification faite sur le diff réel de `watch_app/lib/presentation/watch_session_screen.dart`. + +- Cause racine confirmée : `_RoundScaffold` utilisait `SafeArea(minimum: ...) + Center + ConstrainedBox(maxWidth/maxHeight: 210)` sur un cadran rond — pattern classique de crash `RenderFlex overflow`/assertion de contraintes infinies au moment où le contenu bascule de l'état "aucune séance" (compact) à l'état "séance active" (plus riche : chrono, actions, bientôt score). Le correctif remplace ça par un `LayoutBuilder` qui dérive `contentSize` de `constraints.biggest.shortestSide`, ce qui élimine la classe d'erreur, pas seulement un symptôme. +- **Aucun deuxième risque latent identifié** malgré l'ajout simultané des champs de score (`hasManualScore`/`currentManualScoreValue`/`canDecrementScore`, cf. #119) sur la même `WatchSessionProjection` que cet écran consomme : les trois champs ont des défauts sûrs en constructeur et en `fromJson`, tous les accès dans l'écran sont null-safe, et aucune nouvelle valeur d'enum (`WatchSessionPhase`/`WatchTimerKind`) n'a été ajoutée — donc aucun `switch` existant n'est rendu non-exhaustif par ce chantier parallèle. +- **Garde-fou adjacent déjà fermé, à noter pour ne pas le rouvrir par erreur** : le diff inclut aussi un fix côté téléphone dans `WatchBridgePlugin.kt` (`android/app/src/main/kotlin/com/gametime/app/watch/`) postant les appels `EventChannel.EventSink.success(...)` sur `Handler(Looper.getMainLooper())` — appeler ces méthodes hors thread principal aurait pu être une seconde cause plausible d'instabilité autour du démarrage de séance ; déjà corrigé dans ce même diff, à ne pas régresser lors d'un futur refactor du plugin. +- **Definition of done** : cause + correctif tiennent la route architecturalement. Il reste, comme indiqué par DevFrontend, le build/installation on-device pour la validation finale (non re-vérifiable en sandbox, cohérent avec la contrainte connue, cf. [[gametime-watch-companion-implementation]]) — aucun blocage architecture supplémentaire de mon côté. diff --git a/.ideai/tickets/125/issue.md b/.ideai/tickets/125/issue.md new file mode 100644 index 0000000..a557b5f --- /dev/null +++ b/.ideai/tickets/125/issue.md @@ -0,0 +1,20 @@ +--- +id: "89f80757-ff68-41f8-91f1-11e3dc63f5a9" +number: 125 +title: "[Bug] Écran noir montre quand la séance démarre depuis le téléphone" +status: "QA" +priority: "high" +sprint: null +links: [{"target":"#91","kind":"relatesTo"},{"target":"#107","kind":"relatesTo"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +createdAt: 1785093288487 +updatedAt: 1785127016437 +version: 2 +--- +Bug observé après les corrections du companion montre : quand une séance est lancée depuis le téléphone, la montre passe sur un écran noir au lieu d'afficher l'état de séance. + +Piste déjà identifiée : rupture de layout/runtime au moment de rendre la vue active Wear OS quand la projection n'est plus `noActiveSession`. + +Définition de done : cause identifiée, correctif implémenté, build montre installé et comportement retesté. diff --git a/.ideai/tickets/126/carnet.md b/.ideai/tickets/126/carnet.md new file mode 100644 index 0000000..f6fc7f1 --- /dev/null +++ b/.ideai/tickets/126/carnet.md @@ -0,0 +1,44 @@ +--- +issueRef: "#126" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785216995347 +--- + +## #126 — Cadrage technique Architect (2026-07-27) + +**Root cause identifiée dans le code réel** — bug de calcul, pas une question de conception. + +`_stepTimerProjection` (`lib/application/use_cases.dart:4032-4055`) construit la projection du "chrono étape" envoyée à la montre avec : +```dart +referenceEpochMs: _epochMs(now), +accumulatedMs: state?.accumulatedMs ?? 0, // BUG : valeur brute persistée, pas l'écoulé réel +startedAtEpochMs: state?.status == runningTimer ? _epochMs(state!.startedAt!) : null, +``` +Comparer avec `_restTimerProjection` (ligne 4018-4030), qui fait ça correctement : +```dart +referenceEpochMs: _epochMs(now), +accumulatedMs: rest.elapsedMillisecondsAt(now), // écoulé réel jusqu'à `now` +startedAtEpochMs: paused ? null : _epochMs(now), // "reparti" depuis `now`, cohérent avec accumulatedMs déjà à jour +``` +`ActiveExerciseStepProgressState` possède déjà `elapsedMillisecondsAt(now)` (`lib/domain/entities.dart:1329-1334`, `accumulatedMs + now.difference(startedAt).inMilliseconds`) — exactement la méthode que `_restTimerProjection` utilise et que `_stepTimerProjection` **n'appelle pas**. + +**Pourquoi ça boucle visuellement** : `WatchWearDataLayerAdapter._ensureProjectionRefreshLoop` (`lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart:139-143`) republie la projection complète toutes les **2 secondes** (`projectionRefreshInterval`, keepalive/résilience), indépendamment de tout changement d'état. À chaque republication, le chrono étape reçoit un nouveau `referenceEpochMs = now` mais un `accumulatedMs` resté à sa valeur brute persistée (proche de 0 tant que l'étape n'a pas de checkpoint) — la montre recalcule alors un écoulé quasi nul et le countdown "resaute" près de sa valeur cible (`targetMs`), avant de redécompter localement pendant ~2s jusqu'à la prochaine republication. D'où le motif observé "1:00 → 59s → 1:00" en boucle, calé sur le cycle de 2s. + +### Correctif (contrat clair pour DevFrontend/DevBackend) +Aligner `_stepTimerProjection` sur le pattern déjà utilisé par `_restTimerProjection` : +```dart +accumulatedMs: state?.elapsedMillisecondsAt(now) ?? 0, +startedAtEpochMs: state?.status == ActiveExerciseStepProgressStatus.runningTimer + ? _epochMs(now) + : null, +``` +Aucun changement de contrat (`WatchTimerProjection` inchangé), aucun impact sur la montre (l'interprétation `_interpolatedElapsedMs` côté watch_app est déjà correcte — c'est uniquement la donnée source côté téléphone qui est fausse). + +### Périmètre de vérification +Vérifier s'il existe un bug symétrique ailleurs — `_setTimerProjection` (ligne 4079) et `_scoreStopwatchTimerProjection` (ligne 4057) utilisent déjà `state.accumulatedMs` brut, **mais** ce n'est pas un bug pour eux : ce sont des chronos `displayMode: elapsed` (pas `countdown`) dont `startedAtEpochMs` pointe vers le vrai `state.startedAt` d'origine (pas `now`) — cohérent, l'écoulé est recalculé correctement côté montre à partir du vrai point de départ. Seul `_stepTimerProjection` mélange les deux styles (accumulatedMs brut + referenceEpochMs à `now`), ce qui est l'incohérence à corriger. + +### Tests +Test unitaire ciblé sur `_stepTimerProjection`/`WatchSessionProjectionProjector` : un chrono étape `runningTimer` avec `startedAt` antérieur à `now` doit produire un `accumulatedMs` qui reflète l'écoulé réel (pas 0), republié à l'identique après un second appel à `now+2s` sans avoir régressé. Peut s'ajouter à `test/application/watch_companion_projection_test.dart` (fichier déjà existant pour ce projecteur, cf. [[gametime-watch-companion-implementation]]). + +**Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire.** diff --git a/.ideai/tickets/126/issue.md b/.ideai/tickets/126/issue.md new file mode 100644 index 0000000..994f97b --- /dev/null +++ b/.ideai/tickets/126/issue.md @@ -0,0 +1,16 @@ +--- +id: "20210988-6146-4772-802f-265ded405d59" +number: 126 +title: "[Bug] Chrono de la montre qui ne descend pas" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785132624040 +updatedAt: 1785216995347 +version: 6 +--- +Le chrono de la montre passe de 1min à 59 secondes puis repasse à une minute et boucle comme ça \ No newline at end of file diff --git a/.ideai/tickets/127/carnet.md b/.ideai/tickets/127/carnet.md new file mode 100644 index 0000000..bd4dfa5 --- /dev/null +++ b/.ideai/tickets/127/carnet.md @@ -0,0 +1,28 @@ +--- +issueRef: "#127" +version: 9 +updatedBy: {"kind":"user"} +updatedAt: 1785216992076 +--- + +## #127 — Avis UX bref (2026-07-27) + +Pas une question de conception : le comportement attendu ne fait aucun doute (l'utilisateur doit retrouver l'écran d'exécution exact, pas le launcher, après ré-allumage par mouvement de poignet). C'est un bug de cycle de vie Wear OS (probable absence de gestion Ambient Mode / activité tuée au lieu d'assombrie). Recommandation : implémenter le mode Ambient (écran assombri, app conservée) plutôt que laisser le système détruire l'activité ; au réveil, restituer l'état exact sans flash de chargement. Prêt pour implémentation directe (Architect/DevFrontend), aucun arbitrage UX supplémentaire nécessaire. + +--- + +## Cadrage technique Architect (2026-07-27) + +**Root cause confirmée dans le code réel** : `watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt` est une `FlutterActivity` nue, sans aucune gestion Ambient (`AmbientModeSupport`/`AmbientLifecycleObserver` absents), et le manifest (`watch_app/android/app/src/main/AndroidManifest.xml`) ne déclare aucune capability ambient. Sans ambient mode, Wear OS **détruit l'activité** à l'extinction de l'écran plutôt que de la conserver assombrie ; au réveil (mouvement de poignet), le système relance le launcher/watch face au lieu de restaurer l'app — exactement le symptôme rapporté. + +### Contrat technique +- Dépendance : `androidx.wear:wear-ambient` dans `watch_app/android/app/build.gradle.kts` (actuellement seule dépendance wear présente : `play-services-wearable:19.0.0`, sans rapport avec l'ambient). +- `MainActivity` doit implémenter `AmbientModeSupport.AmbientCallbackProvider` et appeler `AmbientModeSupport.attach(this)` dans `onCreate` — cela maintient l'activité en vie (assombrie) à l'extinction de l'écran, au lieu de la laisser être détruite par le système. +- **Aucune reconstruction d'état nécessaire au réveil** : l'architecture existante s'y prête déjà — `WatchWearDataLayerAdapter._nativeChannel.connectionEvents` déclenche `emitCurrentProjection()` sur `isReachable`/`requestsResync`, et le `_ensureProjectionRefreshLoop` republie toutes les 2s (cf. audit #126 dans ce même lot d'audit). Tant que l'activité Flutter reste résidente pendant l'ambient (au lieu d'être tuée), l'état affiché au réveil est déjà à jour sans code de restauration supplémentaire à écrire. +- Ne pas suivre la voie `ExerciseClient`/mode "exercise" système (déjà écarté par le cadrage #124 pour la collecte capteurs, même raison ici : GameTime ne doit pas déclarer un second concept de session au niveau OS). +- Vigilance batterie : en ambient, limiter les rebuilds au strict nécessaire (le `Timer.periodic(1s)` déjà présent dans `watch_session_screen.dart` pour l'interpolation locale des chronos peut continuer sans changement — c'est un `setState` léger, pas un accès réseau/capteur). + +### Tests +Non testable en sandbox (cycle de vie Android natif, cohérent avec la contrainte déjà connue pour les autres services natifs watch, cf. [[gametime-watch-companion-implementation]]) — validation manuelle on-device requise : écran éteint puis rallumé par mouvement de poignet pendant une séance active, vérifier que l'app reste au premier plan sur l'écran d'exécution exact (pas de flash de chargement, pas de retour au launcher). + +**Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire.** diff --git a/.ideai/tickets/127/issue.md b/.ideai/tickets/127/issue.md new file mode 100644 index 0000000..d940038 --- /dev/null +++ b/.ideai/tickets/127/issue.md @@ -0,0 +1,16 @@ +--- +id: "9f298d88-aa3f-4bdc-834c-4469f37df70c" +number: 127 +title: "[Bug] Application de la montre qui s'enlève quand l'écran s'éteint" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785132689726 +updatedAt: 1785216992076 +version: 9 +--- +Lorsque l'écran s'eteint, si je bouge le poignet pour rallumer l'écran, je me retrouve sur l'écran principal de ma montre au lieu d'être sur l'application \ No newline at end of file diff --git a/.ideai/tickets/128/carnet.md b/.ideai/tickets/128/carnet.md new file mode 100644 index 0000000..3cb648c --- /dev/null +++ b/.ideai/tickets/128/carnet.md @@ -0,0 +1,34 @@ +--- +issueRef: "#128" +version: 7 +updatedBy: {"kind":"user"} +updatedAt: 1785216987980 +--- + +## #128 — Bouton pause/play chrono sur la montre (UX, 2026-07-27) + +Statut : **cadrage UX exploitable, prêt pour DevFrontend/Architect**, pas de blocage. + +### Lecture du besoin +« Remettre » indique un contrôle déjà présent avant la refonte #115/#91. La montre n'a pas de pause spécifique à un chrono isolé côté téléphone (seul le chrono score a Démarrer/Arrêter/Reprendre, cf. [[gametime-ux-score-chrono]]) : le seul concept de pause transverse à tous les chronos est la **pause de séance** existante (« jamais actif pendant une pause »). Ce bouton pilote donc la pause/reprise de séance, pas un chrono isolé. + +### Décision +Icône seule (pas de texte, conforme à la demande) : +- **⏸ (pause)** quand la séance tourne et qu'un chrono actif est affiché à l'écran. +- **▶ (play)** quand la séance est en pause. +- Tap = bascule immédiate (optimiste), même logique de feedback que le score montre en cours ([[gametime-ux-watch-companion-round2]] #118) : icône bascule tout de suite, réconciliation silencieuse si le téléphone rejette. + +### Affichage conditionnel +Visible uniquement quand un chrono actif (mm:ss) est la donnée dominante à l'écran (chrono d'exercice, décompte repos) — pas sur les écrans reps/score sans chrono, conformément à l'énoncé « quand un chrono est en cours ». + +### Placement +Petit bouton icône circulaire centré directement sous le chiffre mm:ss, au-dessus du nom d'exercice — ne casse pas le principe une-donnée-dominante (le bouton est un contrôle, pas une 2e donnée). Cible tactile ≥ 48dp (recommandation Wear OS). Style : icône primaire or, fond surface `#141824`, anneau liseré crimson au tap (feedback pressé), cohérent DA [[gametime-visual-identity]]. + +### Effet visuel pause +Réutilise l'état déjà spécifié dans #115 (chrono gelé, teinte assombrie) — pas de nouvel état à inventer. + +### Risque à signaler (non bloquant pour l'UX, mais dépendance technique) +Ceci introduit une **nouvelle commande montre→téléphone** (pause/reprise), en plus du score (#118) déjà en cours. Contrainte de scope MVP antérieure (« seul le score est montre→téléphone ») explicitement levée par cette demande utilisateur — à signaler à Architect pour extension du contrat bridge (`watch_bridge_contract`, cf. #91-A/#91-C) plutôt qu'un simple ajout UI. + +### Hors périmètre +Pas de bouton Suivant/Passer sur ce ticket — uniquement pause/reprise. diff --git a/.ideai/tickets/128/issue.md b/.ideai/tickets/128/issue.md new file mode 100644 index 0000000..852f728 --- /dev/null +++ b/.ideai/tickets/128/issue.md @@ -0,0 +1,16 @@ +--- +id: "2f8f74d2-33db-459a-899b-cd5dee219cdc" +number: 128 +title: "[UI] remettre un bouton pause/play de chrono sur la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785132771335 +updatedAt: 1785216987980 +version: 7 +--- +J'aimerais remettre un bouton pause/play sur l'écran de la montre quand un chrono est en cours. Il faut que ce bouton soit un icone pause/play, pas un texte pour garder une interface assez legère \ No newline at end of file diff --git a/.ideai/tickets/129/carnet.md b/.ideai/tickets/129/carnet.md new file mode 100644 index 0000000..d265bf9 --- /dev/null +++ b/.ideai/tickets/129/carnet.md @@ -0,0 +1,31 @@ +--- +issueRef: "#129" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785217016893 +--- + +## #129 — Afficher l'objectif de score sur la montre (UX, 2026-07-27) + +Statut : **cadrage UX exploitable, prêt pour DevFrontend**, dépend de l'écran score montre (#118, en cours) pour l'emplacement final — pas bloquant, s'ajoute dessus. + +### Décision +Sur l'écran score montre, si un objectif existe pour l'étape/série (cible score libre ou objectif de chrono, cf. [[gametime-ux-score-chrono]]), l'afficher en texte secondaire discret **au-dessus** de la valeur de score dominante : + +``` +Objectif : 8 +SCORE +5 +[-] [+] +``` + +- Libellé et format : **réutiliser exactement** le libellé déjà utilisé côté téléphone pour ce champ (« Objectif » pour score chrono, « Cible » pour score libre selon le mode configuré) — ne pas inventer un nouveau mot, cohérence de vocabulaire cross-device. +- Style : Archivo, texte secondaire `#A7ADBA`, petite taille (11-12px), au-dessus du chiffre Anton or du score. +- **Silence total si aucun objectif défini** — pas de placeholder « Pas d'objectif » (cohérent [[gametime-online-layer-philosophy]] et la règle déjà appliquée aux stats capteur montre). +- Purement additif : n'ajoute aucune interaction, ne modifie pas le flux +/- existant. + +### Risque +Aucun sur le fond. Séquencement : à implémenter en même temps ou juste après #118 (l'écran d'accueil du score) pour éviter de spécifier un emplacement qui bougerait. + +### Hors périmètre +Pas d'édition de l'objectif depuis la montre (lecture seule, l'objectif se configure côté téléphone/programme). diff --git a/.ideai/tickets/129/issue.md b/.ideai/tickets/129/issue.md new file mode 100644 index 0000000..451c726 --- /dev/null +++ b/.ideai/tickets/129/issue.md @@ -0,0 +1,16 @@ +--- +id: "11b988aa-b7dc-4dbf-a1fa-a68aef02cfad" +number: 129 +title: "[UI] Afficher l'objectif de score sur la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785132920623 +updatedAt: 1785217016893 +version: 6 +--- +Lorsqu'il y a un objectif de score a atteindre, en plus de pouvoir ajouter son score sur la montre, j'aimerais que l'utilisateur puisse voir l'objectif a atteindre \ No newline at end of file diff --git a/.ideai/tickets/130/carnet.md b/.ideai/tickets/130/carnet.md new file mode 100644 index 0000000..1b94c48 --- /dev/null +++ b/.ideai/tickets/130/carnet.md @@ -0,0 +1,49 @@ +--- +issueRef: "#130" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785243026193 +--- + +## #130 — Cadrage technique Architect (2026-07-27) + +**Root cause identifiée dans le code réel** — bug critique de non-idempotence, confirmé et reproductible par construction (pas besoin du détail score 20/25 pour l'expliquer, mais cohérent avec le scénario rapporté). + +Erreur SQLite 2067 = violation de contrainte `UNIQUE`. La table `active_set_results` (`lib/infrastructure/local/tables.dart:473-513`) porte : +```sql +UNIQUE (active_workout_session_id, program_index, exercise_index, set_index) +``` + +`ActiveWorkoutSessionUseCases.recordCurrentSetResult` (`lib/application/use_cases.dart:2451-2511`), appelée par le bouton "Terminer la série" (`workout_execution_screen.dart:1459`, via `_recordAndAdvance`), construit **toujours** un `ActiveSetResult` avec une **metadata/id neuve** (`_newMetadata(ids, originDeviceId, now)`, ligne 2485) puis appelle `sessionRepository.saveSetResult` → `insertOnConflictUpdate` (`drift_repositories.dart:1130-1140`). Drift résout ce conflit sur la **clé primaire** (`id`), pas sur la contrainte `UNIQUE` métier — donc si une ligne existe déjà pour `(session, program, exercise, set)` avec un autre `id`, `insertOnConflictUpdate` ne trouve pas de conflit sur `id`, tente un `INSERT`, et percute l'`UNIQUE` → 2067. + +**Comparer avec le code correct existant** : `upsertSetResultAtPosition` (ligne 2513-2588, utilisée pour l'édition a posteriori d'une série passée) fait ça bien — elle **cherche d'abord** la ligne existante via `listSetResults` (ligne 2546-2555) et réutilise son `metadata`/`id` (`existing.metadata.touch(now)`) si trouvée, sinon en crée une neuve. C'est le pattern à répliquer dans `recordCurrentSetResult`. + +**Deux points d'entrée indépendants écrivent sur la même clé sans coordination**, ce qui rend la collision non hypothétique : +1. Le bouton téléphone (`_recordAndAdvance` → `recordCurrentSetResult`). +2. Le handler de commande montre `_finishCurrentSet` (`use_cases.dart:3546`, déclenché par `WatchCommandType.finishCurrentSet`/`skipCurrentSet`, ligne 3472-3476) qui appelle **aussi** `recordCurrentSetResult` (ligne 3574) — un chemin d'écriture séparé pour la même position logique. + +Toute situation où une ligne existe déjà pour cette position au moment de l'appel (retry après un premier appel resté en vol, double-tap sans garde métier au niveau du use case, ou commande montre + tap téléphone proches dans le temps) déclenche l'erreur — pas seulement le cas particulier score/reps décrit dans le ticket, mais c'est cohérent avec un scénario où l'utilisateur a interagi via les deux surfaces (téléphone + montre) pour la même série. + +### Correctif (contrat clair pour DevBackend) +Faire de `recordCurrentSetResult` un **véritable upsert sur la clé logique**, à l'identique du pattern déjà en place dans `upsertSetResultAtPosition` : +```dart +final existingResults = await sessionRepository.listSetResults(sessionId); +final existing = existingResults.firstWhereOrNull((r) => + r.programIndex == programIndex && + r.exerciseIndex == exerciseIndex && + r.setIndex == setIndex); +final result = ActiveSetResult( + metadata: existing == null + ? _newMetadata(ids, originDeviceId, now) + : existing.metadata.touch(now), + // ... reste inchangé +); +``` +Ce correctif rend `recordCurrentSetResult` idempotent et sûr vis-à-vis des deux points d'entrée (téléphone/montre) et de tout retry, sans changer sa signature publique ni le contrat vu par l'UI/la montre. + +### Tests +- Use case : appeler `recordCurrentSetResult` deux fois de suite pour la même position (simulant un retry ou une double soumission téléphone/montre) → doit réussir les deux fois sans exception, avec la seconde valeur qui gagne (dernière écriture) et un seul enregistrement en base. +- Cas croisé : `recordCurrentSetResult` puis vérifier `listSetResults` ne retourne qu'une ligne pour cette position. +- Régression : vérifier que `upsertSetResultAtPosition` reste inchangée et continue de passer ses tests existants. + +**Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire. Priorité critique confirmée** (bloque la fin de série dans un cas déjà survenu en usage réel). diff --git a/.ideai/tickets/130/issue.md b/.ideai/tickets/130/issue.md new file mode 100644 index 0000000..8ba26e9 --- /dev/null +++ b/.ideai/tickets/130/issue.md @@ -0,0 +1,16 @@ +--- +id: "b13bf705-ee2e-4c88-b8f4-894468987db8" +number: 130 +title: "[Bug] Erreur sur le bouton terminer la série" +status: "closed" +priority: "critical" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785133011266 +updatedAt: 1785243026193 +version: 6 +--- +En voulant temriner une série via le bouton "terminer la série" j'ai une erreur. Je suis en série 1/3, avec un nombre de répétitions à entrer. Mon score n'est pas à nul, il n'y a pas de chrono. J'ai un score de 20 avec un objectif de 25 répétition dans la série. L'erreur dit quelque chose comme: Impossible de terminer la serie SqlLiteException (2067). Visiblement c'est en essayant d'insérer une valeur dans un table active_set_result, les valeurs sont toutes à ? \ No newline at end of file diff --git a/.ideai/tickets/131/carnet.md b/.ideai/tickets/131/carnet.md new file mode 100644 index 0000000..111d59b --- /dev/null +++ b/.ideai/tickets/131/carnet.md @@ -0,0 +1,31 @@ +--- +issueRef: "#131" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785216979125 +--- + +## #131 — Cadrage technique Architect (2026-07-27) + +**Root cause identifiée dans le code réel** — combinaison de contenu dense jamais rendue scrollable, dans une branche de layout spécifique. + +L'écran d'exécution (`lib/presentation/workout_execution_screen.dart`) a **deux branches de layout** selon que l'exercice a des étapes (`hasStepSequence`, ligne 192-323) : +- Branche **sans étapes** (ligne 283-318) : le contenu (`SetMeasureInput`) est déjà enveloppé dans un `ListView` scrollable (ligne 300-317) — pas de risque d'overflow, quelle que soit la densité. +- Branche **avec étapes** (ligne 227-282, celle du scénario rapporté : exercice à 2 étapes) : empile un `_StepSequencePanel` (`Expanded`, non scrollable) puis, si activés, un bloc score de série `_StepSetResultSummary` (`SizedBox(height: 66)`) et/ou `_ScoreStopwatchInput` (`SizedBox(height: 96)`) — **aucun scroll nulle part dans cette branche**. + +À l'intérieur, `_CurrentStepPane` (ligne 2321-2449) empile pour l'étape courante, dans un `Column` non scrollable : titre d'étape, nom, corps de l'étape (`Flexible(fit: FlexFit.tight)` — chrono ou reps), **puis** si `step.hasScore` (score au niveau de l'étape, ligne 2395-2409) un `_StepScoreInput` supplémentaire (`Flexible(fit: FlexFit.loose)`), puis la rangée de boutons "Passer l'étape". `Flexible.loose` ne force pas le contenu à rétrécir sous sa taille intrinsèque minimale (champ de score + boutons +/- + libellés) — il autorise seulement le widget à être plus petit que l'espace alloué, pas à comprimer son propre contenu. + +**Le scénario rapporté combine trois blocs verticaux simultanément** : chrono d'étape (10s) + score d'étape ("panier", `step.hasScore`) + score de série ("panier" aussi, `_exercise.manualScoreEnabled` → déclenche le `SizedBox(height: 66)` supplémentaire en dehors de `_CurrentStepPane`). C'est une combinaison de configuration peu courante (score à la fois au niveau étape ET au niveau série, avec un chrono d'étape court) qui n'avait probablement jamais été testée sur petit écran — la somme des hauteurs minimales requises dépasse la hauteur réellement disponible, et comme rien n'est scrollable dans cette branche, Flutter rapporte l'overflow exact ("Bottom Overflowed by 82 pixels") au lieu de dégrader proprement. + +### Correctif (contrat clair pour DevFrontend) +Rendre la branche `hasStepSequence` dégradable par scroll, symétriquement à ce que fait déjà la branche sans étapes : +- Option minimale et cohérente avec l'existant : envelopper le contenu de `_CurrentStepPane` (ligne 2356, le `Column` qui empile texte/corps d'étape/score d'étape/boutons) dans un `SingleChildScrollView` lorsque le contenu dépasse l'espace disponible — remplacer les `Flexible` internes qui n'ont plus de sens dans un contexte scrollable par leur contenu intrinsèque directement (un `ScrollView` donne une hauteur non bornée à son enfant, donc `Flexible`/`Expanded` y sont invalides et doivent être retirés à cet endroit précis). +- Alternative équivalente au niveau du parent : envelopper le `Column` de la branche `hasStepSequence` (ligne 228-281, qui empile `_StepSequencePanel` + blocs score de série) dans un `SingleChildScrollView`, en remplaçant l'`Expanded` du `_StepSequencePanel` (ligne 231) par une hauteur qui s'adapte à son contenu. +- Ne pas retirer le mode "tient sur un écran sans scroll" pour le cas courant (chrono seul, ou score seul) : le `SingleChildScrollView` ne change rien visuellement quand tout le contenu tient déjà — c'est un filet de sécurité pour les combinaisons denses, pas un changement de comportement pour le cas nominal. +- Ne pas retirer d'information ni de bouton pour "faire tenir" le contenu (pas de compromis produit ici) — c'est un problème de layout à corriger, pas un périmètre fonctionnel à réduire. + +### Tests +- Widget test avec la configuration exacte du ticket (3 séries, 20 passages, 2 étapes, étape courante = chrono 10s + score d'étape "panier", score de série "panier") sur une hauteur d'écran réduite (ex. contrainte de test type Wear/petit téléphone) : aucune exception `RenderFlex overflowed`, le contenu doit rester scrollable jusqu'au bouton "Passer l'étape". +- Test de non-régression sur le cas nominal (chrono seul, pas de score d'étape ni de série) : layout identique à avant, pas de scroll visible si le contenu tient. + +**Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire. Priorité critique confirmée** (bloque l'affichage complet de l'écran d'exécution pour cette combinaison de configuration). diff --git a/.ideai/tickets/131/issue.md b/.ideai/tickets/131/issue.md new file mode 100644 index 0000000..3d6715e --- /dev/null +++ b/.ideai/tickets/131/issue.md @@ -0,0 +1,16 @@ +--- +id: "9a32f9c4-9821-4903-90e4-59850dd0a21b" +number: 131 +title: "[UI] téléphone erreur Bottom Overflowed by 82 pixel" +status: "closed" +priority: "critical" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785133345684 +updatedAt: 1785216979125 +version: 5 +--- +Quand j'essaie d'afficher un exercice qui comprend: 3séries, 20 passages, 2 étapes, que l'étape en cours est composée d'un crhono d'objectif de 10s avec un score d'étape en nombre de panier et que la serie a elle meme un score de panier, j'ai une erreur "Bottom Overflowed by 82 pixel" qui s'affiche sur l'application téléphone \ No newline at end of file diff --git a/.ideai/tickets/132/carnet.md b/.ideai/tickets/132/carnet.md new file mode 100644 index 0000000..881c540 --- /dev/null +++ b/.ideai/tickets/132/carnet.md @@ -0,0 +1,30 @@ +--- +issueRef: "#132" +version: 8 +updatedBy: {"kind":"user"} +updatedAt: 1785141275322 +--- + +## #132 — Avis UX bref (2026-07-27) + +Décision simple : rendre le geste symétrique. Si swipe gauche (séance → actions) existe déjà comme pager horizontal, activer le swipe droit (actions → séance) sur ce même pager plutôt que d'exiger le tap. Garder le bouton existant en complément (pas de régression pour qui ne découvre pas le geste) — pas un remplacement, une addition. Aucun nouveau libellé ni état à concevoir. Prêt pour implémentation directe. + +--- + +## Cadrage technique Architect (2026-07-27) + +**Root cause probable identifiée** — conflit avec le geste système Wear OS, pas un bug applicatif Flutter classique. + +`watch_session_screen.dart` utilise déjà un `PageView` standard (`_pageController`, ligne 18-24, `PageView` ligne 52) avec deux pages : session (index 0) et actions (index 1). Un `PageView` Flutter standard est **nativement bidirectionnel** — rien dans `_ActionsView` (`lib/presentation/watch_session_screen.dart:638` et suivants) ne bloque le swipe : ce sont des `OutlinedButton` en pleine largeur dans un `Column`/`Stack`, aucun `GestureDetector`/scrollable horizontal imbriqué qui capturerait le drag avant le `PageView`. + +Donc l'asymétrie observée (swipe gauche marche, swipe droit ne marche pas) ne vient probablement pas du code Flutter, mais du **geste système Wear OS "swipe to dismiss"** : par défaut, Android Wear réserve la zone de bord gauche de l'écran pour un swipe-vers-la-droite (glisser le doigt de gauche à droite en partant du bord) interprété comme "retour/dismiss" au niveau de la fenêtre native, **avant** que Flutter ne reçoive le geste. Aucune configuration n'a été trouvée dans `watch_app/android/app/src/main/res/values*/styles.xml` (`windowSwipeToDismiss` absent, donc comportement par défaut du thème actif). C'est cohérent avec le symptôme précis : seul le swipe *partant du bord gauche vers la droite* est absorbé par le système, pas le swipe gauche (séance→actions) qui part du centre/droite de l'écran vers la gauche. + +### Contrat technique (à valider on-device par DevFrontend, deux pistes par ordre de préférence) +1. **Désactiver le swipe-to-dismiss système sur cet écran** pour laisser le geste au `PageView` applicatif : `android:windowSwipeToDismiss="false"` sur le thème de `MainActivity` (`styles.xml`), ou gestion explicite via `OnBackInvokedCallback`/`BackHandler` si l'app cible l'API de predictive-back. Cohérent avec le fait que GameTime n'a pas besoin du dismiss système ici : le bouton retour physique/couronne reste la sortie d'app standard, le swipe droit devient un geste de navigation interne au pager. +2. Si la contrainte OS empêche de désactiver complètement le dismiss (à vérifier on-device), alterative : ne pas ancrer `_ActionsView` sur le bord gauche de l'écran (ex. léger padding gauche pour que le point de départ du swipe utilisateur naturel soit hors de la zone système réservée) — solution de repli, moins propre, seulement si l'option 1 s'avère impossible. +- Garder le bouton `IconButton`/`chevron_left` existant (ligne 673-681) en complément, conformément à l'avis UX ci-dessus — c'est déjà le cas, aucun changement requis sur ce point. + +### Tests +Non testable en sandbox (comportement de geste système natif Wear OS, cohérent avec les autres limites déjà connues, cf. [[gametime-watch-companion-implementation]]) — validation manuelle on-device requise : swipe droit depuis l'écran actions doit revenir sur l'écran session, sans déclencher de dismiss/sortie d'app. + +**Prêt pour implémentation directe. Un point à vérifier on-device par DevFrontend avant de considérer le correctif complet** : confirmer que la piste 1 (`windowSwipeToDismiss=false`) ne réintroduit pas de régression sur la sortie d'app via bouton retour physique — pas un arbitrage produit, une vérification technique de mise en œuvre. diff --git a/.ideai/tickets/132/issue.md b/.ideai/tickets/132/issue.md new file mode 100644 index 0000000..e8e6b2f --- /dev/null +++ b/.ideai/tickets/132/issue.md @@ -0,0 +1,16 @@ +--- +id: "ba05555e-820f-4b70-863c-4a7789a1bac1" +number: 132 +title: "[Bug] Switch actions vers séance en cours sur la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785133538696 +updatedAt: 1785141275322 +version: 8 +--- +Sur la montre, je peux switch de l'affichage de ma séance en cours vers les action en glissant vers la gauche, par contre pour retourner sur ma séance en cours je ne peux pas glisser vers la droite, je suis obligé de cliquer sur le bouton. J'aimerais que ça soit possible \ No newline at end of file diff --git a/.ideai/tickets/133/carnet.md b/.ideai/tickets/133/carnet.md new file mode 100644 index 0000000..5762a5b --- /dev/null +++ b/.ideai/tickets/133/carnet.md @@ -0,0 +1,33 @@ +--- +issueRef: "#133" +version: 8 +updatedBy: {"kind":"user"} +updatedAt: 1785216975714 +--- + +## #133 — Retirer le titre redondant « Étape 1/2 » (UX, 2026-07-27) + +Statut : **cadrage UX exploitable, prêt pour DevFrontend**, pas de blocage. Priorité critique respectée : correction ciblée, pas de refonte du module séquence. + +### Analyse +Le module séquence d'étapes ([[gametime-ux-exercise-steps]]) affiche déjà la position courante via les chips `[1][2][3][4]` (chip courante = fond primaire or) juste au-dessus du label texte `ÉTAPE X / Y`. L'utilisateur a raison : dans le cas courant (chips entièrement visibles, ≤6 étapes), le texte est une pure redondance visuelle qui prend de la place sans ajouter d'information. + +### Décision +Retirer la ligne de titre `ÉTAPE X / Y` du module séquence en exécution. Structure simplifiée : + +``` +SÉQUENCE +Passage 1 / 10 +[1] [2] [3] [4] + +Dribble main droite +Objectif : 10 s +``` + +Les chips seules portent la position (courante = or) et le total (nombre de chips). Le nom de l'étape suit directement, sans label intermédiaire. + +### Point d'attention (à ne pas perdre en route) +Le cas **> 6 étapes avec défilement horizontal** des chips est le seul cas où l'info peut se perdre : si l'utilisateur a scrollé et que la chip courante sort du cadre visible, plus aucun repère de position n'est visible sans le label. Ne pas supprimer purement et simplement dans ce cas précis : garder un repère compact minimal uniquement quand la rangée est scrollable, par exemple un petit indicateur `3/8` discret en coin (texte secondaire, pas un titre pleine largeur) — pas la peine de bloquer le ticket là-dessus, DevFrontend peut trancher le détail visuel de ce sous-cas dans l'implémentation tant que le principe « position toujours visible si scroll » est respecté. + +### Hors périmètre +Ne touche pas au header `Programme 1/2 · Exercice 3/8` (#34, déjà tranché et distinct), ni au bloc `SÉRIE X/Y`. diff --git a/.ideai/tickets/133/issue.md b/.ideai/tickets/133/issue.md new file mode 100644 index 0000000..854d2a8 --- /dev/null +++ b/.ideai/tickets/133/issue.md @@ -0,0 +1,16 @@ +--- +id: "a0cd0398-f0cd-44ac-80f5-db62b9bd60b1" +number: 133 +title: "[UI] retirer le titre étape 1/2" +status: "closed" +priority: "critical" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785133690575 +updatedAt: 1785216975714 +version: 8 +--- +Dans une séance en cours, je pense qu'on devrait retirer le titre qui dit "Etape 1/2" par exemple. Cette information est déjà disponible avec les carré qui affiche a quelle étape on est. Ca fait une information redondante qui pend sde la place pour rien \ No newline at end of file diff --git a/.ideai/tickets/134/carnet.md b/.ideai/tickets/134/carnet.md new file mode 100644 index 0000000..aed5e85 --- /dev/null +++ b/.ideai/tickets/134/carnet.md @@ -0,0 +1,53 @@ +--- +issueRef: "#134" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785141267072 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : bug technique en premier lieu, avec un gap UX secondaire à corriger dans la même passe. + +**Localisation** : `watch_app/lib/presentation/watch_session_screen.dart::_handleSecondaryAction` — action `WatchSecondaryAction.skipCurrentSet` (« Passer la série »), confirmée dans `_ConfirmSheet`, envoyée via `WatchSessionViewModel.sendSecondaryAction`. + +**Ce qui se passe actuellement** : après confirmation, la commande est envoyée en `unawaited` puis l'écran revient immédiatement sur la vue Séance (`_showSession()`), **sans attendre le résultat**. Que la commande soit acceptée, rejetée (revision périmée, phase non applicable) ou acquittée en no-op, l'utilisateur voit exactement le même retour à l'affichage de la série. C'est cohérent avec le symptôme rapporté : « ça me renvoie sur l'affichage de la série sans rien faire ». + +**Diagnostic technique à vérifier par Architect/DevBackend/DevFrontend** : +- pourquoi la commande est rejetée ou sans effet côté téléphone (mismatch `expectedRevision`, phase courante qui n'accepte pas `skipCurrentSet`, message perdu côté Data Layer) ; +- si c'est un problème de routing/state machine, corriger la cause racine en priorité. + +**Décision UX à poser (indépendamment du bug)** : le flux ne doit plus être aveugle au résultat. Ne pas naviguer en silence dans tous les cas : +- Garder la navigation immédiate vers l'écran Séance (cohérent avec le pattern existant, pas de nouvel écran d'attente qui bloquerait l'utilisateur). +- Mais si la commande est rejetée/no-op (`ack` reçu ensuite avec statut rejeté), afficher un signal bref et non bloquant sur l'écran Séance — réutiliser le pattern déjà en place pour la perte de connexion (bandeau discret en haut, icône + texte court, disparaît de lui-même) plutôt qu'un dialogue. Libellé proposé : « Série non modifiée » (pas de jargon type "commande rejetée"). +- Ne pas introduire de mention "hors ligne"/"mode local" (cohérent avec [[gametime-online-layer-philosophy]] déjà appliqué au reste de la montre). + +**Résumé** : corriger la cause technique du rejet/perte de commande est prioritaire ; ajouter le signal d'échec est un complément UX nécessaire pour que l'absence d'effet ne soit plus jamais silencieuse, même si un futur bug similaire survient ailleurs. + +--- + +## Audit technique (agent Architect, 2026-07-27) + +**Root cause confirmée dans le code, partagée avec #135** : le refresh périodique côté téléphone bump la revision de projection **sans condition**, même quand rien n'a changé. + +- `WatchWearDataLayerAdapter._ensureProjectionRefreshLoop` (`lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart:151-155`) appelle `emitCurrentProjection()` toutes les 2s (`_projectionRefreshInterval`), sans condition. +- `WatchCompanionProjectionUseCases.emitCurrentProjection` (`lib/application/use_cases.dart:3334-3342`) fait `_revision += 1` à **chaque** appel, qu'il y ait eu un changement métier ou non. +- La montre envoie `expectedRevision: value.projection.revision` (`watch_app/lib/application/watch_session_view_model.dart:179`), figé au moment du dernier projection reçue. +- `WatchCompanionCommandHandler._allowsStaleRevision` (`lib/application/use_cases.dart:3467-3470`) n'exempte que `incrementScore`/`decrementScore` du contrôle de revision. `skipCurrentSet` (et tous les autres secondary actions) reste soumis à `command.expectedRevision != projection.revision` → `WatchCommandAck.rejectedStaleRevision` (`use_cases.dart:3397-3399`). +- Le flux `skipCurrentSet` impose un `_ConfirmSheet` (tap "Passer la série ?" → confirmation), donc un délai utilisateur quasi garanti > 2s : la fenêtre de validité de la revision expire régulièrement avant l'envoi effectif de la commande. C'est structurellement, pas occasionnellement, défaillant. +- Second bug indépendant, confirmé côté UI : même quand l'ack rejeté revient correctement (`WatchSessionViewModel._handleAck` nettoie bien `commandPending` et déclenche `requestResync()`), rien ne l'affiche : `_handleSecondaryAction` (`watch_app/lib/presentation/watch_session_screen.dart:107-110`) fait `unawaited(sendSecondaryAction(action)); _showSession();` sans jamais lire `state.lastAck`. Aucun widget de l'écran Séance ne consulte `lastAck` aujourd'hui — le bandeau "Série non modifiée" demandé par UX n'existe pas encore. + +**Contrat technique — DevBackend** : +1. Corriger `emitCurrentProjection()` pour ne bumper `_revision` que si la projection a réellement changé (comparaison hors champs volatils `revision`/`projectedAtEpochMs`, en traitant les champs de timer interpolables — `accumulatedMs`, `referenceEpochMs`, `startedAtEpochMs`, `runState` — comme stables tant que le timer tourne sans transition). Si rien n'a changé, republier/rafraîchir quand même (pour garder le Data Layer vivant) mais **réutiliser la même revision**. +2. Ne pas élargir `_allowsStaleRevision` à `skipCurrentSet` comme raccourci : `_isApplicable` (`use_cases.dart:3434-3465`) garantit déjà que l'action est cohérente avec la phase courante ; le vrai bug est le bump inutile de revision, pas la vérification elle-même. + +**Contrat technique — DevFrontend (watch_app)** : +1. `WatchSessionViewModel` : dans `_handleAck`, quand `_isRejected(ack.status)` est vrai pour une commande **secondaire** (pas primaire), exposer un état transitoire consommable par l'écran (ex. un champ `rejectedSecondaryNotice` horodaté dans `WatchSessionUiState`, à côté de `lastAck` déjà existant), auto-effacé après ~2.5s via un `Timer` interne (pattern déjà utilisé pour `_waitingTimer`/`_commandTimeoutTimer`). +2. `watch_session_screen.dart` : ajouter un bandeau réutilisant le pattern visuel de `connectionLost` (icône + texte court, `_SessionMainView` autour de la ligne 299-323) affichant « Série non modifiée » quand ce nouvel état transitoire est actif. Ne pas bloquer la navigation (comportement immédiat vers `_showSession()` conservé, conforme à la décision UX). +3. Point ouvert pour UX : le libellé « Série non modifiée » n'a été spécifié que pour `skipCurrentSet` ; le même mécanisme s'appliquera naturellement aux autres actions secondaires rejetées (`skipCurrentStep`, `skipCurrentPassage`, `finishCurrentSet`, `skipCurrentRest`). Confirmer si un libellé générique convient ou si chaque action a besoin de son propre texte. + +**Contrat bridge** : aucune extension nécessaire. `WatchCommandAckEvent`/`WatchCommandAck` (package `watch_bridge_contract`) portent déjà `commandId` + statut suffisant pour ce fix ; c'est un fix côté projection (téléphone) + wiring d'état/UI (montre). + +**Testabilité** : +- Fix revision (backend) : 100% sandbox, unit test sur `WatchCompanionProjectionUseCases.emitCurrentProjection()` — appel répété sans changement de session ⇒ revision stable ; mutation de l'état session ⇒ revision bump. Partagé avec #135, un seul test à écrire couvre les deux tickets. +- Bandeau montre : sandbox via `watch_app/test/presentation/watch_session_screen_test.dart` (existe déjà, référence `skipCurrentSet`) — injecter un ack `rejectedStaleRevision` simulé, vérifier l'apparition du bandeau puis sa disparition après le délai. +- Vérification terrain (on-device) recommandée avant clôture : confirmer que le taux de rejet réel chute une fois le fix de revision posé, le round-trip Wear Data Layer réel n'étant pas reproductible en sandbox. diff --git a/.ideai/tickets/134/issue.md b/.ideai/tickets/134/issue.md new file mode 100644 index 0000000..04516b4 --- /dev/null +++ b/.ideai/tickets/134/issue.md @@ -0,0 +1,16 @@ +--- +id: "3c2f5bc5-395e-49f9-b075-384d99673ad6" +number: 134 +title: "[Bug] le bouton de la montre \"passer la serie\" ne fonctionne pas" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785138754732 +updatedAt: 1785141267072 +version: 6 +--- +Le bouton temrienr la serie sur la montre ne produit rien, il me renvoie sur l'affichage de la série sans rien faire \ No newline at end of file diff --git a/.ideai/tickets/135/carnet.md b/.ideai/tickets/135/carnet.md new file mode 100644 index 0000000..bb2ec32 --- /dev/null +++ b/.ideai/tickets/135/carnet.md @@ -0,0 +1,52 @@ +--- +issueRef: "#135" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785151136170 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : bug technique probable côté transport (latence/perte Wear Data Layer), avec un gap UX qui aggrave la perception du problème — à corriger aussi. + +**Localisation** : `watch_session_view_model.dart::sendPrimaryAction` → `pauseSession`/`resumeSession` ; bouton `_TimerToggleButton` dans `watch_session_screen.dart`, activé seulement si `canToggleTimer` (`!connectionLost && !commandPending && primaryAction ∈ {pauseSession, resumeSession}`). + +**Diagnostic technique à vérifier par Architect/DevBackend** : +- « parfois inopérant » : le bouton est désactivé si `primaryAction` n'est pas exactement pause/resume au moment du tap (fenêtre de transition d'état) ou si `connectionLost`/`commandPending` est vrai suite à une commande précédente mal purgée. Vérifier la robustesse de la purge de `commandPending` après timeout/ack et l'exactitude de la projection `primaryAction` en continu. +- « parfois lent » : `commandTimeout` est fixé à 2s, `waitingThreshold` à 500ms — un aller-retour Data Layer peut légitimement approcher ces bornes. Vérifier s'il y a des retries/pertes réseau anormales plutôt que juste la latence normale du canal. + +**Décision UX à poser (indépendamment du bug)** : pas d'optimisme sur pause/play — c'est un choix assumé (la montre n'est jamais source de vérité, cf. [[gametime-watch-companion-implementation]]), donc le geste ne doit jamais changer l'icône avant confirmation. Mais le seul signal actuel pendant l'attente est un texte en bas d'écran (« Envoi... » / « En attente du téléphone »), facile à manquer sur un cadran de montre — c'est ce qui fait percevoir la lenteur comme un blocage plutôt qu'un traitement en cours. +- Ajouter un signal directement sur le bouton pendant `commandPending` : réutiliser le pattern `_PendingDot` déjà utilisé pour le score (petit point/anneau discret superposé à l'icône pause/play), pas un nouveau composant. +- Le bouton reste désactivé pendant l'attente (déjà correct, évite les doubles envois) — mais son état désactivé doit être visuellement identique qu'il soit désactivé pour attente de commande ou pour connexion perdue, ce qui est déjà le cas via `disabledColor`. +- Pas de message d'erreur nouveau si le timeout expire : le comportement existant (repasse en `connectionLost`, la surface entière s'assombrit et affiche « Connexion au téléphone perdue ») est suffisant et cohérent — ne pas dupliquer un message par bouton. + +**Résumé** : corriger la robustesse de la commande pause/resume (état + transport) est prioritaire ; ajouter un indicateur "en cours" sur le bouton lui-même est le seul ajustement UX nécessaire pour que la latence normale ne soit plus confondue avec une panne. + +--- + +## Audit technique (agent Architect, 2026-07-27) + +**Root cause confirmée dans le code, partagée avec #134** : même mécanisme de bump de revision inconditionnel côté téléphone. + +- `WatchWearDataLayerAdapter._ensureProjectionRefreshLoop` (`lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart:151-155`) appelle `emitCurrentProjection()` toutes les 2s. +- `WatchCompanionProjectionUseCases.emitCurrentProjection` (`lib/application/use_cases.dart:3334-3342`) fait `_revision += 1` **inconditionnellement**, même sans changement métier réel. +- `pauseSession`/`resumeSession` ne sont **pas** dans `_allowsStaleRevision` (`use_cases.dart:3467-3470` — seuls `incrementScore`/`decrementScore` en sont exemptés). Toute commande pause/resume envoyée avec un `expectedRevision` capté juste avant un tick de refresh (jusqu'à toutes les 2s, en continu, tant qu'une séance est active) risque `WatchCommandAck.rejectedStaleRevision` (`use_cases.dart:3397-3399`) même si rien n'a réellement changé côté téléphone entre-temps. +- Ça explique directement les deux symptômes rapportés : + - **« parfois inopérant »** : rejet silencieux par staleness de revision, sans lien avec un vrai désaccord d'état pause/resume — c'est un faux positif de la vérification de concurrence. + - **« parfois lent »** : latence normale du round-trip Wear Data Layer (`waitingThreshold` 500ms / `commandTimeout` 2s) — comportement transport attendu, pas un bug ; c'est bien un pur besoin d'indicateur UI comme cadré par UX. +- Point vérifié et **non buggé** : `canToggleTimer` (`watch_app/lib/presentation/watch_session_screen.dart:248-253`) exige `primaryAction` exactement pause/resume et `!commandPending` — c'est le comportement voulu (pas d'optimisme), le bouton se désactive/réactive correctement au fil des projections ; aucune correction nécessaire ici. + +**Contrat technique — DevBackend** : +- Même fix que #134 : ne bumper `_revision` dans `emitCurrentProjection()` que sur changement réel de projection (hors `revision`/`projectedAtEpochMs`, timers interpolables traités comme stables tant qu'ils tournent sans transition). Un seul correctif, bénéficie aux deux tickets. +- Ne pas ajouter `pauseSession`/`resumeSession` à `_allowsStaleRevision` : contrairement aux actions terminales de #134, accepter une revision périmée sur pause/resume pourrait appliquer le mauvais toggle si le téléphone a un état réellement différent (ex. auto-pause côté téléphone pour une autre raison) — la vérification de revision est la bonne garde-fou ici, conforme à la décision UX "pas d'optimisme". Le vrai fix est d'arrêter le bump inutile, pas d'affaiblir le contrôle. + +**Contrat technique — DevFrontend (watch_app)** : +- Implémenter l'indicateur déjà cadré par UX : ajouter un prop `pending: bool` à `_TimerToggleButton` (`watch_session_screen.dart:624-649`), affichant un point/anneau discret superposé à l'icône (réutiliser `_PendingDot`, déjà utilisé dans `_ManualScoreContent` ligne 579-582), actif quand `state.commandPending` est vrai **et** que le bouton concerné est celui qui a déclenché la commande (`onTogglePause != null`, i.e. `canToggleTimer` était vrai au moment du tap) — ne pas afficher le point si `commandPending` provient d'une autre action (ex. `skipCurrentRest`) sur un bouton qui n'a pas été pressé pour pause/resume. +- Wiring aux deux call sites existants : `_ActiveContent` (ligne 484-487) et `_RestContent` (ligne 686-690). +- Ne rien changer au comportement de timeout existant (retour à `connectionLost`) — conforme à la décision UX de ne pas dupliquer de message par bouton. + +**Contrat bridge** : aucune extension nécessaire — fix uniquement côté projection téléphone (partagé avec #134) + rendu local montre à partir de `commandPending`, déjà suivi dans `WatchSessionViewModel`. + +**Testabilité** : +- Fix revision (backend) : sandbox, test unique partagé avec #134 sur `emitCurrentProjection()`. +- Indicateur `_PendingDot` sur le bouton : sandbox via `watch_app/test/presentation/watch_session_screen_test.dart` — simuler `commandPending=true` après tap pause/resume, vérifier l'affichage du point, puis sa disparition à l'ack. +- Vérification terrain (on-device) recommandée après le fix backend partagé : confirmer que le taux d'échec perçu ("inopérant") chute significativement une fois le bump de revision corrigé ; la latence résiduelle ("lent") restera normale et sera couverte par l'indicateur UI, à valider visuellement sur montre réelle (difficile à juger en sandbox pur). diff --git a/.ideai/tickets/135/issue.md b/.ideai/tickets/135/issue.md new file mode 100644 index 0000000..7e7cbf4 --- /dev/null +++ b/.ideai/tickets/135/issue.md @@ -0,0 +1,16 @@ +--- +id: "9fddc019-342f-4063-a194-a5a77907a793" +number: 135 +title: "[Bug] bouton pause/play de la montre qui ne fonctionne pas toujours" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785138943933 +updatedAt: 1785151136170 +version: 6 +--- +Le bouton pause/play ne fonctionne pas toujours. De temps en temps il met du temps a répondre et de temps en temps il ne fonctionne pas du tout \ No newline at end of file diff --git a/.ideai/tickets/136/carnet.md b/.ideai/tickets/136/carnet.md new file mode 100644 index 0000000..35442f0 --- /dev/null +++ b/.ideai/tickets/136/carnet.md @@ -0,0 +1,20 @@ +--- +issueRef: "#136" +version: 8 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785244375293 +--- + +## #136 — Cadrage UX (2026-07-28) + +**Comportement attendu** : tap sur `Passer le repos` → bouton passe en état pending bref (`Envoi...`, pattern déjà spécifié [[gametime-ux-watch-companion]]) → commande envoyée au téléphone → téléphone applique le skip (repos terminé, phase avance) → **c'est la projection confirmée qui fait changer l'écran montre**, jamais un tap local. Le repos réel côté téléphone doit changer ; l'écran montre ne doit refléter ce changement qu'une fois l'ack/la nouvelle projection reçus. + +**Contraintes UI/feedback** : +- Ne jamais quitter l'écran `Repos` avant réception d'un ack `accepted`/`acceptedNoOp` + nouvelle projection. Si rejet (`rejectedStaleRevision`, `rejectedNotApplicable`…), rester sur `Repos` avec son état confirmé actuel, sans transition fantôme. +- Si latence perceptible, réutiliser les seuils déjà cadrés (état `Envoi...` court, puis `En attente du téléphone`) plutôt qu'un basculement d'écran optimiste. + +**À ne pas faire** : +- Naviguer localement vers l'écran séance active sur simple tap, avant confirmation — c'est exactement le bug actuel (l'écran change sans que le repos ait réellement sauté côté téléphone). +- Ajouter une confirmation (`Passer le repos ?`) : cette action reste dans la liste des actions immédiates, pas dans celles à confirmer ([[gametime-ux-watch-companion]]). + +**Libellé/navigation** : aucune évolution nécessaire. Le libellé `Passer le repos` et le modèle « écran piloté par la projection confirmée » sont déjà correctement spécifiés — c'est un bug d'implémentation (navigation locale avant état confirmé, ou commande mal routée côté téléphone), pas un manque de conception. diff --git a/.ideai/tickets/136/issue.md b/.ideai/tickets/136/issue.md new file mode 100644 index 0000000..5c953e7 --- /dev/null +++ b/.ideai/tickets/136/issue.md @@ -0,0 +1,16 @@ +--- +id: "cd125c5f-5e37-4100-8b61-58cbaab5eac1" +number: 136 +title: "[Bug] \"passer le repos\" sur la montre ne fonctionne pas" +status: "qa" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785141207792 +updatedAt: 1785244375293 +version: 8 +--- +Le bouton "passer le repos" sur la montre ne fonctionne pas, il me renvoie sur l'écran de la séance sur la montre mais ne passe pas le repos sur le téléphone \ No newline at end of file diff --git a/.ideai/tickets/137/carnet.md b/.ideai/tickets/137/carnet.md new file mode 100644 index 0000000..7bcbadf --- /dev/null +++ b/.ideai/tickets/137/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#137" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785147020900 +--- diff --git a/.ideai/tickets/137/issue.md b/.ideai/tickets/137/issue.md new file mode 100644 index 0000000..11cbe34 --- /dev/null +++ b/.ideai/tickets/137/issue.md @@ -0,0 +1,16 @@ +--- +id: "c3423267-4ba2-4158-a802-55882c981875" +number: 137 +title: "[Bug] Connexion au compte impossible" +status: "closed" +priority: "high" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785141546668 +updatedAt: 1785147020900 +version: 4 +--- +Je ne peux pas me connecter à mon compte, j'ai une erreur "Connexion impossible pour le moment. Réessaie plus tard". Le serveur est pourtant bien lancé. Même soucis pour la création de compte \ No newline at end of file diff --git a/.ideai/tickets/138/carnet.md b/.ideai/tickets/138/carnet.md new file mode 100644 index 0000000..9049525 --- /dev/null +++ b/.ideai/tickets/138/carnet.md @@ -0,0 +1,19 @@ +--- +issueRef: "#138" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785216971861 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : pur copy, prêt à implémenter immédiatement, aucun arbitrage supplémentaire. + +**Principe** : ne pas rassurer défensivement sur "pas besoin de compte" — un compte doit être présenté positivement pour ce qu'il apporte (synchronisation, partage), jamais comme une option qu'on justifie de pouvoir ignorer. Garder l'explication utile (à quoi sert le compte), retirer uniquement la tournure "l'app fonctionne/reste utilisable sans compte". + +**Chaînes concrètes à corriger** (grep `compte`, hors faux positif `exercise_library_screen.dart:1101` "compte à rebours" qui ne concerne pas les comptes utilisateur) : +- `profile_screen.dart:197` « GameTime fonctionne entièrement sans compte. Connecte-toi... » → « Connecte-toi pour synchroniser tes données et partager tes contenus. » +- `profile_screen.dart:955` « Tu peux continuer à utiliser GameTime sans compte. » → supprimer la ligne, le bouton "Créer un compte" au-dessus suffit. +- `profile_screen.dart:1105-1106` « Le compte sert à synchroniser tes données et partager tes contenus. L'app reste utilisable sans compte. » → garder la première phrase, retirer la seconde. +- `share_screen.dart:174-175` « Connecte-toi pour envoyer $targetLabel à un autre compte GameTime. Le reste de l'app reste utilisable sans compte. » → « Connecte-toi pour envoyer $targetLabel à un autre compte GameTime. » + +Pas de découpage nécessaire, un seul lot copy pour DevFrontend. \ No newline at end of file diff --git a/.ideai/tickets/138/issue.md b/.ideai/tickets/138/issue.md new file mode 100644 index 0000000..68159b1 --- /dev/null +++ b/.ideai/tickets/138/issue.md @@ -0,0 +1,16 @@ +--- +id: "6e1f3467-5bd3-4c79-8dce-cd8890765f4c" +number: 138 +title: "[UI] Enlever les textes qui informent qu'on a pas besoin de compte" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785141676541 +updatedAt: 1785216971861 +version: 6 +--- +Il faudrait enlever les textes qui disent des choses du genre "pas besoin d'avoir un compte pour utiliser l'application". L'utilisateur n'a pas a en être informé \ No newline at end of file diff --git a/.ideai/tickets/139/carnet.md b/.ideai/tickets/139/carnet.md new file mode 100644 index 0000000..70dd63c --- /dev/null +++ b/.ideai/tickets/139/carnet.md @@ -0,0 +1,29 @@ +--- +issueRef: "#139" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785160475878 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : décision de modélisation nécessaire, mais scope raisonnable et immédiatement implémentable (pas exploratoire). Référence : [[gametime-ux-exercise-steps]] (score d'étape existant, aujourd'hui toujours indépendant). + +**Décision structurante** : ne pas synchroniser deux valeurs séparées (score d'étape + score de série tenus égaux) — source de conflits/désync inutile. À la place, le mode "lié" fait de l'étape un simple point d'entrée sur **l'unique valeur du score de série** : il n'existe pas de score d'étape distinct dans ce mode. + +**Configuration** (formulaire étape, section "Score d'étape" déjà existante) : +``` +Score d'étape +[x] Ajouter un score pour cette étape + (•) Score propre à l'étape + ( ) Utiliser le score de la série +``` +- Option "Utiliser le score de la série" visible seulement si la série a un **score libre** actif (pas chrono — un +/- manuel ne s'applique pas à un score chronométré). Si la série n'a pas de score actif : ne pas proposer l'option, aide : « Active un score au niveau de la série pour pouvoir le lier à cette étape. » +- Si lié : masquer libellé/unité/valeur par défaut propres à l'étape (hérités de la série). + +**Exécution** : pendant cette étape, les boutons +/- affichés pilotent directement le score de série (une seule valeur visible, pas de second compteur). Le score de série évolue immédiatement, visible aussi hors de cette étape (plan de séance). + +**Montre** : extension naturelle, aucune nouvelle commande bridge — un tap +/- pendant une étape liée envoie la commande `incrementScore`/`decrementScore` déjà existante pour le score de série. + +**Historique/plan de séance** : pas de score d'étape distinct à afficher pour une étape liée — seul le score de série apparaît. + +**Scope MVP** : score libre de série uniquement. Découpage classique : domaine (flag lié + résolution), formulaire étape, exécution, montre (réutilisation directe des commandes existantes). \ No newline at end of file diff --git a/.ideai/tickets/139/issue.md b/.ideai/tickets/139/issue.md new file mode 100644 index 0000000..175a7f0 --- /dev/null +++ b/.ideai/tickets/139/issue.md @@ -0,0 +1,16 @@ +--- +id: "f506247e-eae8-437c-a884-50b5e5477adc" +number: 139 +title: "Faire augmenter les scores étape et séries en même temps" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785141905973 +updatedAt: 1785160475878 +version: 6 +--- +Il faudrait faire en sorte que dès la création de l'exercice, on puisse choisir de lier des scores entre des étapes et la série. C'est a dire que par exemple, si ma série a pour score un nombre de paniers, je puisse dire que certaines étapes utilises le même score, c'est a dire que si j'incrémente de 1 sur une étape, ça incrémente de 1 sur la série. Pareilles si je retire 1 sur une étape, ça retire 1 sur la série. \ No newline at end of file diff --git a/.ideai/tickets/140/carnet.md b/.ideai/tickets/140/carnet.md new file mode 100644 index 0000000..6469a12 --- /dev/null +++ b/.ideai/tickets/140/carnet.md @@ -0,0 +1,27 @@ +--- +issueRef: "#140" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785160464016 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : gap fonctionnel réel côté montre (vérifié dans le code), prêt à implémenter avec une décision de layout. + +**Constat technique** : `watch_session_view_model.dart`/`watch_session_screen.dart` — quand `projection.hasManualScore` est vrai, `_ActiveContent` bascule exclusivement sur `_ManualScoreContent` (score +/-) et n'affiche plus jamais le chrono ni le bouton pause/play, même si l'étape en a un. C'est le bug exact décrit par le ticket. + +**Décision UX** : le score reste la donnée dominante de l'écran (l'action principale sur la montre est +/-). Le chrono devient une **donnée secondaire compacte**, affichée sous le nom d'exercice — même emplacement/style que `secondaryTimers` déjà utilisé côté purement-chrono (réutiliser `_compactTimerText`), aujourd'hui invisible dans `_ManualScoreContent`. + +**Layout `_ManualScoreContent`** : +``` +[objectif score, si présent] +SCORE +[-] 12 [+] +ÉTAPE · 00:07 ← nouvelle ligne compacte +Nom exercice +[status] +``` + +**Contrôle du chrono** : uniquement si ce chrono est piloté manuellement par l'utilisateur (cas "chrono score d'étape" sur étape à répétitions, cf. [[gametime-ux-score-chrono]]) — ajouter un petit bouton pause/play à côté de la ligne, en réutilisant `_TimerToggleButton` réduit (≈32 au lieu de 48, peu de place sur la montre), pas un nouveau composant. Si le chrono est un simple compte à rebours automatique d'étape "Temps" (avance seule, cf. enchaînement [[gametime-ux-step-chaining-override]]), ne pas ajouter de bouton — afficher la valeur seule, cohérent avec l'avancement automatique. + +**Bridge** : vérifier avec Architect si `dominantTimer`/`secondaryTimers` projettent déjà cette donnée quand `hasManualScore` est vrai ; si non, ajouter la projection manquante — pas de nouveau concept de commande nécessaire. \ No newline at end of file diff --git a/.ideai/tickets/140/issue.md b/.ideai/tickets/140/issue.md new file mode 100644 index 0000000..1563716 --- /dev/null +++ b/.ideai/tickets/140/issue.md @@ -0,0 +1,16 @@ +--- +id: "0502272b-32b9-43a0-b276-cec620137482" +number: 140 +title: "Ajouter le chrono sur la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785142112185 +updatedAt: 1785160464016 +version: 5 +--- +Dans le cas ou une étape possède un score et un chrono, il faudrait qu'en plus de pouvoir incrémenter ou decrementer le score, on puisse voir et gérer le chrono \ No newline at end of file diff --git a/.ideai/tickets/141/carnet.md b/.ideai/tickets/141/carnet.md new file mode 100644 index 0000000..4a788db --- /dev/null +++ b/.ideai/tickets/141/carnet.md @@ -0,0 +1,17 @@ +--- +issueRef: "#141" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785216922010 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : fonctionnalité native Wear OS (Ongoing Activity), décision UX minimale — c'est un signal passif, pas un écran. + +**Décision UX** : +- Icône = mark "GT" déjà utilisé pour l'icône app montre ([[gametime-watch-companion-implementation]] / #112), pas de nouveau pictogramme. +- Statique pendant qu'une séance est active côté téléphone — pas de valeur dynamique dessus (pas de chrono qui défile dans l'icône), cohérent avec la sobriété déjà appliquée à la notification #108. +- Tap → ouvre l'app montre sur l'écran Séance en cours (comportement standard Ongoing Activity, rien à spécifier de custom). +- Disparaît dès la fin de séance, comme la notification #108. + +**Dépendance technique** : s'appuie sur le foreground service déjà livré (#91-D) et le pattern de notification #108 — implémentation via l'API Android Ongoing Activity, peu/pas de nouvel écran Flutter. Priorité faible confirmée, aucun blocage produit. \ No newline at end of file diff --git a/.ideai/tickets/141/issue.md b/.ideai/tickets/141/issue.md new file mode 100644 index 0000000..925ba57 --- /dev/null +++ b/.ideai/tickets/141/issue.md @@ -0,0 +1,16 @@ +--- +id: "bc711b59-69cc-4565-b4a0-d2c3a73ed47b" +number: 141 +title: "Petit icone de sport sur la montre" +status: "closed" +priority: "low" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785142304473 +updatedAt: 1785216922010 +version: 4 +--- +Sur certaines applications de sport comme heavy, quand je lance un entrainement sur mon téléphone, il y a un petit icone de sport qui s'affiche sur l'écran principal de ma montre, comme pour la musique. J'aiemrais avoir la même chose quand je lance une séance. \ No newline at end of file diff --git a/.ideai/tickets/142/carnet.md b/.ideai/tickets/142/carnet.md new file mode 100644 index 0000000..1588280 --- /dev/null +++ b/.ideai/tickets/142/carnet.md @@ -0,0 +1,28 @@ +--- +issueRef: "#142" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785221553966 +--- +## Cadrage produit consolidé — Main (2026-07-27) + +Ce ticket concerne l'**affichage téléphone pendant la séance en cours**. + +### Affichage live confirmé +Surface volontairement **légère** pendant la séance, avec uniquement : +- **fréquence cardiaque live** +- **distance parcourue live** +- **calories brûlées live** + +### Contraintes UX confirmées +- ne pas surcharger l'écran de séance ; +- rester secondaire par rapport à la donnée dominante de l'écran ; +- masquer silencieusement toute statistique indisponible ; +- aucun placeholder, aucun message d'erreur, aucun blocage. + +### Dépendances produit +- #155 cadrage UX transverse statistiques live/historique +- #156 architecture collecte/persistance étendue +- #144 fréquence cardiaque live +- #145 calories live +- #157 distance live diff --git a/.ideai/tickets/142/issue.md b/.ideai/tickets/142/issue.md new file mode 100644 index 0000000..0eb1fc8 --- /dev/null +++ b/.ideai/tickets/142/issue.md @@ -0,0 +1,16 @@ +--- +id: "81568485-cb5b-4c31-9a7a-1ba9c12e33ea" +number: 142 +title: "Statistiques a afficher sur la séance en cours" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785142435173 +updatedAt: 1785221553966 +version: 5 +--- +Sur l'écran de ma séance, j'aimerais avoir d'afficher les statistiques principale de ma montre, tel que la fréquence cardiaque, ou encore une estimation des calories brulées ou la distance parcourue. Si certaines statistiques n'ont pas encore été implémentés, créé un ticket et mettre le ticket présent dépendant du nouveau ticket pour le faire après \ No newline at end of file diff --git a/.ideai/tickets/143/carnet.md b/.ideai/tickets/143/carnet.md new file mode 100644 index 0000000..41e4552 --- /dev/null +++ b/.ideai/tickets/143/carnet.md @@ -0,0 +1,15 @@ +--- +issueRef: "#143" +version: 4 +updatedBy: {"kind":"agent","agent_id":"f3408f5d-469c-4f64-9485-d8b218f3ff26"} +updatedAt: 1785143072501 +--- +## Cadrage UX (agent UX, 2026-07-27) + +**Nature** : trop exploratoire pour une implémentation immédiate — pas un ticket UX/dev prêt à l'emploi, mais une idée produit qui nécessite un spike de faisabilité technique d'abord. + +**Pourquoi** : +- **GPS indoor** : la précision GPS grand public en intérieur (gymnase/salle) est dégradée (multipath, perte de signal), alors qu'un terrain de basket (~15×28 m) demanderait une précision de l'ordre du mètre pour qu'une hotmap ait du sens. Sans mesure terrain réelle de la précision obtenue avec une montre connectée, tout cadrage UX (comment dessiner/calibrer le terrain, comment afficher la hotmap) reposerait sur une hypothèse technique non vérifiée. +- **Détection de shoot au gyroscope** : reconnaissance de geste non triviale (faux positifs probables : dribble, passe, contact défensif), nécessiterait un modèle/calibration, aucun précédent dans le projet. + +**Recommandation** : ne pas cadrer l'UX maintenant. Garder #143 en dormant comme idée produit, et proposer un ticket de spike technique séparé (Architect/DevMobile) : « Faisabilité GPS terrain + détection de shoot montre », avant tout travail UX. Le cadrage UX (création de terrain, affichage hotmap) ne devient exploitable qu'une fois la précision réelle mesurée. \ No newline at end of file diff --git a/.ideai/tickets/143/issue.md b/.ideai/tickets/143/issue.md new file mode 100644 index 0000000..8295155 --- /dev/null +++ b/.ideai/tickets/143/issue.md @@ -0,0 +1,18 @@ +--- +id: "689ab05c-3710-4bf0-bec1-f088d3022aef" +number: 143 +title: "Statistiques terrain de basket" +status: "open" +priority: "low" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"f3408f5d-469c-4f64-9485-d8b218f3ff26"} +createdAt: 1785142583079 +updatedAt: 1785143072501 +version: 4 +--- +J'ai une idée qui, je pense, est réalisable avec une montre connectée qui possède un gps. J'aimerais qu'il soit possible, pendant notre séance, de connaitre les endroits sur le terrain ou on a le plus pris de shoot. Pour cela, il faudrait faire deux choses. Dans un premier temps, il faudrait que l'utilisateur puisse créer dans l'interface un terrain avec les coordonnée gps. C'est a dire que l'utilisateur puisse entrer les 4 bords du terrain grace a la fonctionnalité gps de sa montre. Ainsi l'application saura ou le joueur se situe sur le terrain a partir des coordonnées en direct de sa montre. + +La deuxieme fonctionnalité serait de réussir a determiner quand le joueur prends un shhot avec le gyroscope de la montre. Ainsi, l'application pourra créer une hotmap de l'endroit ou le joueur a le plus pris de shoot. \ No newline at end of file diff --git a/.ideai/tickets/144/carnet.md b/.ideai/tickets/144/carnet.md new file mode 100644 index 0000000..a859008 --- /dev/null +++ b/.ideai/tickets/144/carnet.md @@ -0,0 +1,20 @@ +--- +issueRef: "#144" +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785221565015 +--- +## Précisions produit — Main (2026-07-27) + +Le périmètre de ce ticket n'est plus seulement de débloquer #142, mais de constituer un des socles du chantier statistiques montre. + +### Attendus +- fréquence cardiaque **live** pendant la séance ; +- affichée sur **téléphone** pendant la séance ; +- affichée sur **montre** sur l'écran principal de séance ; +- sert aussi d'entrée aux futurs agrégats **min / moyenne / max** par étape, série et exercice pour l'après-séance et l'historique. + +### Contraintes +- montre compatible seulement ; +- si la donnée manque : masquer silencieusement ; +- aucun message bloquant. diff --git a/.ideai/tickets/144/issue.md b/.ideai/tickets/144/issue.md new file mode 100644 index 0000000..2105ea1 --- /dev/null +++ b/.ideai/tickets/144/issue.md @@ -0,0 +1,16 @@ +--- +id: "9f4f42fb-6e40-4306-822f-95cabaa6da49" +number: 144 +title: "[DevBackend/DevFrontend] Fréquence cardiaque live montre sur séance en cours" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} +createdAt: 1785143531392 +updatedAt: 1785221565015 +version: 3 +--- +Étendre le travail stats montre existant pour exposer la fréquence cardiaque en direct pendant une séance en cours côté téléphone, avec silence total si indisponible. Ce ticket débloque l'affichage demandé par #142. S'appuie sur le cadrage capteurs montre déjà posé pour #106/#124 et sur l'affichage compact téléphone cadré par UX. \ No newline at end of file diff --git a/.ideai/tickets/145/carnet.md b/.ideai/tickets/145/carnet.md new file mode 100644 index 0000000..858adf2 --- /dev/null +++ b/.ideai/tickets/145/carnet.md @@ -0,0 +1,21 @@ +--- +issueRef: "#145" +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859123 +--- +## Précisions produit — Main (2026-07-27) + +Le ticket couvre désormais la piste calories dans un périmètre produit clarifié. + +### Attendus +- **calories live** pendant la séance ; +- affichées sur **téléphone** pendant la séance ; +- affichées sur **montre** dans l'écran secondaire de stats ; +- conservées ensuite pour l'**après-séance** et l'**historique** avec graphiques. + +### Contraintes +- montre compatible seulement ; +- si la donnée manque ou est non supportée : ne rien afficher ; +- aucun message bloquant ; +- précision parfaite non exigée dans ce premier chantier. diff --git a/.ideai/tickets/145/issue.md b/.ideai/tickets/145/issue.md new file mode 100644 index 0000000..60031fd --- /dev/null +++ b/.ideai/tickets/145/issue.md @@ -0,0 +1,16 @@ +--- +id: "01c4b5b4-28a4-46e4-85eb-2c0f887547eb" +number: 145 +title: "[Architect/DevFrontend] Faisabilité et implémentation estimation calories montre" +status: "qa" +priority: "low" +sprint: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" +links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785143531404 +updatedAt: 1785248859123 +version: 4 +--- +Mesurer puis implémenter si fiable l'estimation calories remontée par la montre pendant une séance, avec dégradation silencieuse si la donnée n'est pas disponible. Ce ticket débloque la partie calories demandée par #142. \ No newline at end of file diff --git a/.ideai/tickets/146/carnet.md b/.ideai/tickets/146/carnet.md new file mode 100644 index 0000000..ffb5fee --- /dev/null +++ b/.ideai/tickets/146/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#146" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785143531526 +--- diff --git a/.ideai/tickets/146/issue.md b/.ideai/tickets/146/issue.md new file mode 100644 index 0000000..45910dd --- /dev/null +++ b/.ideai/tickets/146/issue.md @@ -0,0 +1,16 @@ +--- +id: "74d1b59f-3093-4eb4-abe2-bc74e30ef8a3" +number: 146 +title: "[Spike] Faisabilité GPS terrain + détection de shoot montre" +status: "open" +priority: "low" +sprint: null +links: [{"target":"#143","kind":"blocks"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785143531526 +updatedAt: 1785143531526 +version: 1 +--- +Spike technique préalable pour mesurer la précision réelle du GPS montre sur un terrain de basket et la faisabilité d'une détection de shoot via gyroscope/capteurs. Sans ce spike, #143 n'est pas implémentable de façon fiable. \ No newline at end of file diff --git a/.ideai/tickets/147/carnet.md b/.ideai/tickets/147/carnet.md new file mode 100644 index 0000000..bb1d63e --- /dev/null +++ b/.ideai/tickets/147/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#147" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785242960861 +--- diff --git a/.ideai/tickets/147/issue.md b/.ideai/tickets/147/issue.md new file mode 100644 index 0000000..ba6611d --- /dev/null +++ b/.ideai/tickets/147/issue.md @@ -0,0 +1,16 @@ +--- +id: "9f3cc555-7078-4853-964b-ebf34e6c495b" +number: 147 +title: "[Bug] Enregistrement exercice impossible avec des etapes" +status: "closed" +priority: "high" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785146671179 +updatedAt: 1785242960861 +version: 6 +--- +Je ne peux pas enregistrer d'exercice avec des etapes, si j'essaie ça tourne dans le vide. Je suis obligé de créer l'exerccie sans étapes et de le modifier pour ajouter les étapes \ No newline at end of file diff --git a/.ideai/tickets/148/carnet.md b/.ideai/tickets/148/carnet.md new file mode 100644 index 0000000..d0326ea --- /dev/null +++ b/.ideai/tickets/148/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#148" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785216966035 +--- diff --git a/.ideai/tickets/148/issue.md b/.ideai/tickets/148/issue.md new file mode 100644 index 0000000..282bd00 --- /dev/null +++ b/.ideai/tickets/148/issue.md @@ -0,0 +1,16 @@ +--- +id: "662e2934-65be-47a9-bfbd-b883f8a1bbf7" +number: 148 +title: "[Bug] je ne peux pas lancer de chrono avec la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785146766481 +updatedAt: 1785216966035 +version: 5 +--- +Je ne peux pas lancer le chrono avec la montre si le chrono n'a pas été lancé une premiere fois avec le téléphone. Le bouton est grisé, j'aimerais pouvoir \ No newline at end of file diff --git a/.ideai/tickets/149/carnet.md b/.ideai/tickets/149/carnet.md new file mode 100644 index 0000000..aa3d787 --- /dev/null +++ b/.ideai/tickets/149/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#149" +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785160491348 +--- diff --git a/.ideai/tickets/149/issue.md b/.ideai/tickets/149/issue.md new file mode 100644 index 0000000..b42f507 --- /dev/null +++ b/.ideai/tickets/149/issue.md @@ -0,0 +1,16 @@ +--- +id: "8ec128b1-c866-43a6-9197-17b2dd915672" +number: 149 +title: "[Bug] Création de programme sans exercice possible" +status: "closed" +priority: "critical" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785146899230 +updatedAt: 1785160491348 +version: 5 +--- +Il faut vérifier que le programme créé n'est pas créé sans exerccie. Si jaamis l'exercice a disparu et que la séance n'a plus d'exerccie, il faut afficher une erreur et empecher l'utilisateur de lancer la séance. Si la séance était en cours, même chose en faisant abandonner la séance. Actuellement j'ai un écran gris que je ne peux pas enlever \ No newline at end of file diff --git a/.ideai/tickets/150/carnet.md b/.ideai/tickets/150/carnet.md new file mode 100644 index 0000000..50e3806 --- /dev/null +++ b/.ideai/tickets/150/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#150" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785216960781 +--- diff --git a/.ideai/tickets/150/issue.md b/.ideai/tickets/150/issue.md new file mode 100644 index 0000000..48e2d0e --- /dev/null +++ b/.ideai/tickets/150/issue.md @@ -0,0 +1,16 @@ +--- +id: "3eb43908-54d2-4c56-b4cb-c333ad648fd9" +number: 150 +title: "Enlever le texte de la page de connexion" +status: "closed" +priority: "low" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785151255798 +updatedAt: 1785216960781 +version: 6 +--- +Sur la page de connexion, enleve le texte "Tu peux revenir a GameTime". Il n'ets pas utile \ No newline at end of file diff --git a/.ideai/tickets/151/carnet.md b/.ideai/tickets/151/carnet.md new file mode 100644 index 0000000..b33dbf0 --- /dev/null +++ b/.ideai/tickets/151/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#151" +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785179756629 +--- diff --git a/.ideai/tickets/151/issue.md b/.ideai/tickets/151/issue.md new file mode 100644 index 0000000..5ad1f8f --- /dev/null +++ b/.ideai/tickets/151/issue.md @@ -0,0 +1,16 @@ +--- +id: "7fc7901f-0d52-4914-ad3f-4f2abed7c055" +number: 151 +title: "Pouvoir lancer le chrono de l'étape depuis la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785160157076 +updatedAt: 1785179756629 +version: 6 +--- +Comme pour une série classique, il faut pouvoir lancer le chrono depuis la montre dans ue étape \ No newline at end of file diff --git a/.ideai/tickets/152/carnet.md b/.ideai/tickets/152/carnet.md new file mode 100644 index 0000000..af53c2f --- /dev/null +++ b/.ideai/tickets/152/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#152" +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785179756654 +--- diff --git a/.ideai/tickets/152/issue.md b/.ideai/tickets/152/issue.md new file mode 100644 index 0000000..861067a --- /dev/null +++ b/.ideai/tickets/152/issue.md @@ -0,0 +1,16 @@ +--- +id: "049e01c7-ab43-4d57-8206-61a9f511bc5f" +number: 152 +title: "Afficher le nom de l'étape en cours sur la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785160230982 +updatedAt: 1785179756654 +version: 4 +--- +Il faut pouvoir afficher le nom de l'étape en cours sur la montre \ No newline at end of file diff --git a/.ideai/tickets/153/carnet.md b/.ideai/tickets/153/carnet.md new file mode 100644 index 0000000..017d8f6 --- /dev/null +++ b/.ideai/tickets/153/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#153" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785179756676 +--- diff --git a/.ideai/tickets/153/issue.md b/.ideai/tickets/153/issue.md new file mode 100644 index 0000000..2c55c63 --- /dev/null +++ b/.ideai/tickets/153/issue.md @@ -0,0 +1,16 @@ +--- +id: "076b57b2-3b56-418f-b4fc-626d9ede2877" +number: 153 +title: "Augmenter le score de l'étape avec la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785160288068 +updatedAt: 1785179756676 +version: 5 +--- +Dans le cas ou le score de l'étape n'est pas lié au score de la série, il faut que l'incrementation sur la montre augmente le score de l'étape, pas celui de l'exercice \ No newline at end of file diff --git a/.ideai/tickets/154/carnet.md b/.ideai/tickets/154/carnet.md new file mode 100644 index 0000000..93f7544 --- /dev/null +++ b/.ideai/tickets/154/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#154" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785179756697 +--- diff --git a/.ideai/tickets/154/issue.md b/.ideai/tickets/154/issue.md new file mode 100644 index 0000000..7762296 --- /dev/null +++ b/.ideai/tickets/154/issue.md @@ -0,0 +1,16 @@ +--- +id: "6ac57f88-f38b-46af-a1a4-7c1d395a5aea" +number: 154 +title: "Retirer le bouton \"lancer une seance\" de la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785173258860 +updatedAt: 1785179756697 +version: 5 +--- +Il faut enlkever le bouton "lancer une seance" car sinon l'application lance n'importe quelle séance et ça fait n'importe quoi. La séance doit être lancée via l'applciation téléphone, une fois l'application lancée, on doit pouvoir lancer le chrono de la séance et/celui des etapes depuis la montre \ No newline at end of file diff --git a/.ideai/tickets/155/carnet.md b/.ideai/tickets/155/carnet.md new file mode 100644 index 0000000..35de4a9 --- /dev/null +++ b/.ideai/tickets/155/carnet.md @@ -0,0 +1,147 @@ +--- +issueRef: "#155" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859142 +--- +## Entrée produit utilisateur — Main (2026-07-27) + +UX doit cadrer les surfaces suivantes : + +### Téléphone en séance +- afficher seulement **fréquence cardiaque live**, **distance live**, **calories live** ; +- surface légère, non dominante. + +### Montre en séance +- écran principal : **fréquence cardiaque live uniquement** ; +- glissement inverse du menu action : écran secondaire avec **fréquence cardiaque live + distance + calories**. + +### Après séance / historique +- graphiques et lecture détaillée des statistiques conservées ; +- fréquence cardiaque détaillée **par étape, série et exercice** ; +- distance et calories également restituées dans l'historique. + +### Contraintes fortes +- silence total si donnée absente ; +- uniquement pour montres compatibles ; +- aucun message bloquant. + +--- + +## Cadrage UX — agent UX (2026-07-27) + +### Décision produit +Les stats capteurs (`FC live`, `distance live`, `calories live`) sont une **couche de lecture légère** ajoutée à l’exécution existante, jamais une nouvelle navigation principale ni une nouvelle logique de séance. + +Principes à respecter : +- **Téléphone** : la surface séance reste centrée sur l’exécution ; les stats live restent secondaires et compactes. +- **Montre** : l’écran principal reste centré sur l’état d’effort ; la `FC live` seule y a droit en permanence, `distance` et `calories` partent dans un écran secondaire dédié. +- **Après-séance / Historique** : les stats deviennent consultables en détail, avec restitution hiérarchique cohérente avec l’existant `programme > exercice > série`, et niveau `étape` quand disponible. +- **Silence par défaut** : si une donnée n’existe pas, la surface se resserre sans placeholder, sans warning, sans écran vide. + +### Téléphone pendant la séance +Ajouter sous le bloc d’exécution principal une **barre stats live compacte**, toujours sur la surface séance, sans ouvrir de panneau dédié. + +Ordre fixe : +`FC 142 bpm · 0,84 km · 186 kcal` + +Règles : +- une seule ligne si tout tient ; +- retour sur 2 lignes max si nécessaire sur petit écran ; +- style secondaire visuellement, plus discret que chrono / répétitions / score ; +- ne jamais concurrencer le bloc temps, les CTA d’exécution, ni les contrôles de série. +- `FC` en premier, puis `Distance`, puis `Calories`. +- chaque métrique n’apparaît que si elle est disponible. +- si aucune métrique n’est disponible, la barre n’existe pas. + +États : +- en séance active : valeurs live visibles et rafraîchies discrètement ; +- en pause : conserver la dernière valeur connue ; +- en repos : la barre reste visible si des valeurs existent. + +### Montre pendant la séance +#### Écran principal +L’écran principal `Séance active` conserve sa hiérarchie actuelle. On y ajoute la **FC live uniquement** en lecture secondaire. + +Placement exact : +- la `FC live` apparaît sur la **ligne secondaire sous le libellé du chrono dominant** et **au-dessus du CTA principal**. +- sur l’écran principal montre, **jamais distance ni calories**. +- si la FC live est absente, cette ligne disparaît purement et simplement. + +#### Écran secondaire `Stats` +Ajouter un **écran secondaire stats** accessible par **glissement inverse du menu `Actions`**. + +Navigation montre : +- swipe dans un sens : `Actions` +- swipe dans l’autre sens : `Stats` + +Contenu de l’écran `Stats` : +- `FC` +- `Distance` +- `Calories` + +Règles : +- une carte/ligne par métrique disponible ; +- ordre fixe : `FC`, `Distance`, `Calories` ; +- si une métrique manque, ne pas réserver sa place ; +- si aucune stat n’existe, l’écran `Stats` n’est pas exposé du tout. + +### Après-séance +Ajouter dans l’écran de fin de séance une section dédiée `Statistiques` après le récap principal d’exécution. + +Résumé global attendu : +- `FC moyenne` +- `FC min` +- `FC max` +- `Distance` +- `Calories` + +Graphiques à afficher quand la donnée existe : +- `FC` : courbe temporelle de séance +- `Distance` : progression cumulée sur la séance +- `Calories` : progression cumulée sur la séance + +Pour la FC, ajouter si possible des repères visuels simples sur la courbe : +- séparateurs d’exercices +- séparateurs de séries +- étapes quand disponibles + +### Détail hiérarchique +Dans le détail existant, enrichir chaque niveau quand la donnée existe. + +- **Exercice** : sous-résumé stats +- **Série** : sous-résumé compact +- **Étape** : seulement si les données sont réellement disponibles à ce niveau + +Règles : +- ne jamais fabriquer un faux niveau de précision ; +- l’absence d’une stat n’empêche pas d’afficher les autres. + +### Historique +#### Liste historique +Dans chaque carte de séance historique, ajouter au plus **une ligne de résumé stats** sous le résumé existant, uniquement si des données existent. + +Exemple : `FC 148 moy · 2,84 km · 426 kcal` + +#### Détail d’une séance historique +Le détail historique reprend la même structure que l’après-séance : +- résumé global ; +- graphiques ; +- détail par exercice ; +- détail par série ; +- détail par étape quand disponible. + +### Fallback silencieux et compatibilité +Règles impératives : +- Feature active **uniquement sur montres compatibles**. +- Si la montre n’est pas compatible, **aucune surface dédiée n’apparaît**. +- Si un capteur ou une métrique est indisponible, **ne rien afficher** pour cette métrique. +- **Aucun message bloquant**, **aucune alerte**, **aucun placeholder**. +- En cas de donnée partielle : afficher seulement ce qui existe. +- En cas de perte temporaire de mesure live : garder la dernière valeur connue si la session l’utilise déjà ; sinon masquer la métrique. + +### Décision finale de surface +- **Téléphone séance** : une barre stats live compacte intégrée à l’écran d’exécution. +- **Montre séance** : `FC live` sur l’écran principal, écran `Stats` secondaire par glissement inverse de `Actions` pour `FC + distance + calories`. +- **Après-séance / Historique** : résumé global + graphiques + détail hiérarchique `exercice / série / étape` quand disponible. +- **Fallback** : disparition silencieuse des données absentes, sans message ni friction. diff --git a/.ideai/tickets/155/issue.md b/.ideai/tickets/155/issue.md new file mode 100644 index 0000000..bb3e3ec --- /dev/null +++ b/.ideai/tickets/155/issue.md @@ -0,0 +1,16 @@ +--- +id: "89dbf6c0-deb3-468a-867f-df45f1b750e0" +number: 155 +title: "[UX] Cadrage surfaces statistiques live téléphone/montre et historique" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [{"target":"#142","kind":"blocks"},{"target":"#144","kind":"blocks"},{"target":"#145","kind":"blocks"},{"target":"#106","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785180656686 +updatedAt: 1785248859142 +version: 5 +--- +Définir le cadrage UX complet du chantier statistiques montre après clarification utilisateur : téléphone en séance = fréquence cardiaque live, distance live, calories live dans une surface légère ; montre = fréquence cardiaque live sur l'écran principal, et panneau secondaire accessible par glissement inverse du menu action avec fréquence cardiaque live + distance + calories ; après séance et dans l'historique = graphiques et détails de fréquence cardiaque, distance et calories, avec détail par étape, série et exercice quand disponible. Silence total si capteur ou donnée indisponible, uniquement pour montres compatibles. \ No newline at end of file diff --git a/.ideai/tickets/156/carnet.md b/.ideai/tickets/156/carnet.md new file mode 100644 index 0000000..a9a9f87 --- /dev/null +++ b/.ideai/tickets/156/carnet.md @@ -0,0 +1,101 @@ +--- +issueRef: "#156" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859161 +--- +## Entrée architecture utilisateur — Main (2026-07-27) + +Architect doit cadrer une architecture permettant : +- collecte **live** fréquence cardiaque, distance, calories depuis montre compatible ; +- affichage live téléphone + montre ; +- persistance pour après-séance et historique ; +- **fréquence cardiaque min / moyenne / max** avec détail **par étape, série et exercice** ; +- fallback totalement silencieux si un capteur ou une donnée manque ; +- alimentation future de graphiques historiques. + +Point important : le chantier doit être découpé en plusieurs tickets exécutables à la suite, avec dépendances claires, sans polluer les carnets des tickets non concernés. + +--- + +## Cadrage initial Architect (2026-07-27) + +### Décision d’architecture +Traiter ce lot comme un sous-système `telemetry` séparé de la projection de séance. La projection continue de porter le contrôle de la séance, tandis que la télémétrie devient un flux orthogonal, plus fréquent, avec sa propre persistance et ses propres agrégats. + +### Base existante +- Le watch bridge a déjà un pipeline capteur cœur: `WatchHeartRateCollector` côté watch et ingestion sur le téléphone via `WatchWearDataLayerAdapter`. +- Le téléphone a déjà un état actif in-memory pour la FC et une écriture d’historique `averageHeartRateBpm` / `maxHeartRateBpm`. +- La projection montre `WatchSessionProjection` ne doit pas être le réceptacle des mesures live; elle est trop liée aux commandes et à la révision. +- Les écrans historique et exécution consomment déjà un `sensorUseCases`. + +### Principes figés +- ne pas mélanger la télémétrie live avec la projection de commande ; +- ne pas réutiliser `estimatedCaloriesKcal` comme substitut de calories réelles si le watch compatible ne fournit pas la mesure ; +- ne pas faire dépendre l’agrégation historique du temps d’arrivée des messages ; +- ne pas surcharger le moteur de progression existant ; +- fallback silencieux si métrique absente. + +### Contrats nécessaires +- évolution du contrat watch bridge capteur vers un payload v2 ou nouveaux types `WatchTelemetrySample` / `WatchTelemetrySummary` ; +- `WatchTelemetrySample` doit porter au minimum : + - `sampleId` unique par session pour l’idempotence ; + - `sessionId` ; + - `capturedAtEpochMs` ; + - le contexte d’exécution au moment de la capture ; + - `programIndex`, `exerciseIndex`, `setIndex` ; + - `passageIndex` et `stepIndex` si disponibles ; + - ids snapshot si disponibles ; + - `heartRateBpm?`, `distanceMeters?`, `caloriesKcal?`. +- `WatchTelemetrySummary` doit porter au minimum : + - `sessionId` ; + - `sampleCount` ; + - FC `min/avg/max` ; + - distance totale ; + - calories totales. +- côté application, introduire un port de type `WorkoutTelemetryRepository` ou `ActiveWorkoutTelemetryRepository`. +- côté application, introduire un use case dédié `ActiveWorkoutTelemetryUseCases` ou équivalent. +- côté historique, ajouter des tables/agrégats dédiés pour les mesures brutes et les agrégats par scope. +- les scopes à supporter doivent être stables sur les coordonnées de séance : session, exercice, série, étape. +- pour la FC, stocker `min/avg/max` à chaque scope. +- pour distance et calories, stocker le total et la dernière valeur connue ; ne pas fabriquer des min/max si le capteur ne les fournit pas. + +### Modèle de persistance recommandé +- une table de samples bruts pour la session active et l’historique ; +- une table d’agrégats matérialisés par scope ; +- `WorkoutHistory` peut conserver un résumé rapide de session pour la liste et l’en-tête ; +- les vues détail historique doivent lire les agrégats de scope, pas recalculer en widget. + +### Flux cible +- Watch compatible : collecteur natif démarre si session active et source dispo ; publie des samples live vers le téléphone ; alimente aussi l’UI watch locale. +- Téléphone : l’adapter ingère les samples ; le use case met à jour un état live de télémétrie ; l’état live alimente l’écran séance ; les samples sont persistés. +- Clôture de séance : figer le résumé live ; matérialiser les agrégats session / exercice / série / étape ; écrire le résumé dans l’historique. +- Reprise de séance : la télémétrie continue à partir de nouveaux samples ; les scopes sont recalculés sans mélanger les périodes de pause. + +### Fallback silencieux +- watch non compatible ou permission refusée : métrique absente = valeur nulle, pas de bannière, pas d’erreur bloquante ; +- si un seul metric est disponible, on affiche seulement celui-là ; +- si aucune donnée n’est disponible, l’UI reste vide sur cette zone ; +- aucune métrique ne doit être synthétisée par heuristique pour masquer un manque de source. + +### Dépendance UX +Aucune dépendance bloquante sur le contenu métier des contrats. Les seuls points pilotés par le rendu final sont : +- composition exacte de la zone live téléphone ; +- composition exacte de l’écran principal et de l’écran secondaire watch ; +- forme des graphiques historiques et hiérarchie visuelle. + +### Découpage recommandé +- Lot 1: contrat watch telemetry v2 + collecteur watch multi-métriques. +- Lot 2: ingestion téléphone + état live persistant + écriture des samples. +- Lot 3: matérialisation historique + agrégats FC par scope + repository de séries pour graphes. +- Lot 4: branchements UI téléphone/watch et historique. +- Lot 5: QA des cas silencieux et des transitions de scope. + +### Points de test +- watch compatible avec FC seule ; +- watch compatible avec FC + distance + calories ; +- watch non compatible ou permission refusée ; +- sample reçu en retard rattaché au bon scope via son horodatage ; +- pause / reprise ; +- fin d’étape, fin de série, fin d’exercice ; +- historique récupérable sans recalcul coûteux. diff --git a/.ideai/tickets/156/issue.md b/.ideai/tickets/156/issue.md new file mode 100644 index 0000000..7e4f883 --- /dev/null +++ b/.ideai/tickets/156/issue.md @@ -0,0 +1,16 @@ +--- +id: "ceedcbca-3d06-46bd-a449-d1119926fd8e" +number: 156 +title: "[Architect] Architecture collecte/persistance statistiques montre étendues" +status: "qa" +priority: "medium" +sprint: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" +links: [{"target":"#144","kind":"blocks"},{"target":"#145","kind":"blocks"},{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785180656705 +updatedAt: 1785248859161 +version: 5 +--- +Étendre le cadrage architecture stats montre pour couvrir la fréquence cardiaque live + agrégats min/moy/max par étape/série/exercice, la distance live et les calories live, ainsi que leur persistance historique et alimentation de graphiques. Doit aussi cadrer le modèle de compatibilité capteurs, le fallback silencieux si donnée absente, et les contrats téléphone<->montre nécessaires pour l'affichage live et l'historique. \ No newline at end of file diff --git a/.ideai/tickets/157/carnet.md b/.ideai/tickets/157/carnet.md new file mode 100644 index 0000000..145c07f --- /dev/null +++ b/.ideai/tickets/157/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#157" +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859176 +--- diff --git a/.ideai/tickets/157/issue.md b/.ideai/tickets/157/issue.md new file mode 100644 index 0000000..e5e6bec --- /dev/null +++ b/.ideai/tickets/157/issue.md @@ -0,0 +1,16 @@ +--- +id: "4ba5c5bc-c575-4afc-bb55-93feb7e7ad9c" +number: 157 +title: "[DevFrontend] Distance live montre sur séance en cours" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785180656723 +updatedAt: 1785248859176 +version: 4 +--- +Ajouter la distance parcourue live remontée par la montre compatible, avec affichage léger côté téléphone pendant la séance et réutilisation côté montre dans le panneau secondaire. Dégradation silencieuse si donnée indisponible. \ No newline at end of file diff --git a/.ideai/tickets/158/carnet.md b/.ideai/tickets/158/carnet.md new file mode 100644 index 0000000..9ef2b91 --- /dev/null +++ b/.ideai/tickets/158/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#158" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859190 +--- diff --git a/.ideai/tickets/158/issue.md b/.ideai/tickets/158/issue.md new file mode 100644 index 0000000..3eb6596 --- /dev/null +++ b/.ideai/tickets/158/issue.md @@ -0,0 +1,16 @@ +--- +id: "bb93375d-7fe0-4cbd-8589-e12e50f5979e" +number: 158 +title: "[DevBackend/DevFrontend] Agrégats et historique statistiques montre" +status: "qa" +priority: "medium" +sprint: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" +links: [{"target":"#106","kind":"relatesTo"},{"target":"#144","kind":"dependsOn"},{"target":"#145","kind":"dependsOn"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785180656740 +updatedAt: 1785248859190 +version: 3 +--- +Persister les statistiques montre pour exploitation après séance et en historique : fréquence cardiaque min/moy/max avec détail par étape, série et exercice, ainsi que distance et calories si disponibles. Préparer les données nécessaires aux graphiques d'historique et aux écrans de fin de séance, avec fallback silencieux sur données absentes. \ No newline at end of file diff --git a/.ideai/tickets/159/carnet.md b/.ideai/tickets/159/carnet.md new file mode 100644 index 0000000..1986ace --- /dev/null +++ b/.ideai/tickets/159/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#159" +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859208 +--- diff --git a/.ideai/tickets/159/issue.md b/.ideai/tickets/159/issue.md new file mode 100644 index 0000000..4d2f511 --- /dev/null +++ b/.ideai/tickets/159/issue.md @@ -0,0 +1,16 @@ +--- +id: "35022a8a-a4ef-4ffc-99d7-cd0a0e925fde" +number: 159 +title: "[DevFrontend] Surface montre statistiques live (FC principale + panneau secondaire)" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [{"target":"#106","kind":"relatesTo"},{"target":"#144","kind":"dependsOn"},{"target":"#145","kind":"dependsOn"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785180656758 +updatedAt: 1785248859208 +version: 4 +--- +Faire évoluer l'UI montre pour afficher la fréquence cardiaque live sur l'écran principal de séance, puis un écran/panneau secondaire accessible par glissement inverse du menu action avec fréquence cardiaque live, distance parcourue et calories live depuis le début de la séance. Rien n'est affiché pour les données indisponibles ; aucun message bloquant. \ No newline at end of file diff --git a/.ideai/tickets/160/carnet.md b/.ideai/tickets/160/carnet.md new file mode 100644 index 0000000..373f160 --- /dev/null +++ b/.ideai/tickets/160/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#160" +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859257 +--- diff --git a/.ideai/tickets/160/issue.md b/.ideai/tickets/160/issue.md new file mode 100644 index 0000000..6badb97 --- /dev/null +++ b/.ideai/tickets/160/issue.md @@ -0,0 +1,16 @@ +--- +id: "b5b4fe80-952f-4376-9271-de98817add32" +number: 160 +title: "[DevFrontend] Graphiques historiques statistiques montre" +status: "inProgress" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785180656778 +updatedAt: 1785248859257 +version: 4 +--- +Implémenter les graphiques et écrans d'historique pour les statistiques montre conservées : fréquence cardiaque avec détail par étape/série/exercice, distance parcourue, calories consommées. S'appuie sur la persistance historique et reste silencieux si certaines séries de données sont absentes. \ No newline at end of file diff --git a/.ideai/tickets/161/carnet.md b/.ideai/tickets/161/carnet.md new file mode 100644 index 0000000..e321ac7 --- /dev/null +++ b/.ideai/tickets/161/carnet.md @@ -0,0 +1,84 @@ +--- +issueRef: "#161" +version: 8 +updatedBy: {"kind":"user"} +updatedAt: 1785241912171 +--- + +## #161 — Petite refonte UI montre : dédoublonnage, densité, équilibrage (UX, 2026-07-28) + +Statut : **cadrage UX exploitable, prêt pour Architect/DevFrontend**, pas de blocage. + +Référence DA/vocabulaire à respecter, inchangés : [[gametime-ux-watch-companion]], [[gametime-ux-watch-companion-round2]], [[gametime-visual-identity]] (#115 : fond `#080A12`, or `#C9A24A`, Anton pour les chiffres, Archivo pour les libellés, une donnée dominante par écran). Ce ticket ne remet pas en cause cette DA, il corrige une dérive de densité accumulée par les tickets correctifs ultérieurs (#128 bouton pause/play, #140 chrono+score, #144 FC live, #152 étape). + +### Constat +Le principe "une donnée dominante par écran" posé par #115 a été dilué par les ajouts successifs : le libellé du chrono est dupliqué (au-dessus du chrono ET sous le bouton pause/play), le temps de série reste affiché alors qu'il n'est jamais un objectif à atteindre, le bouton pause/play empilé sous le chrono déséquilibre verticalement l'écran, et le texte de comptage de passage de l'exercice suivant s'affiche par-dessus son nom pendant le repos. + +### 1. Objectif d'interface + +Un seul chrono = un seul libellé, un seul endroit. Ne rester à l'écran que les chronos qui portent un objectif de la série/étape en cours (chrono d'étape temps, chrono de repos, chrono de score en mode stopwatch). Le temps de série écoulé n'est un objectif de rien : il sort de l'écran montre, partout, sans exception. Le bouton pause/play cesse d'empiler une ligne supplémentaire : il rejoint la ligne du chrono pour équilibrer horizontalement l'écran plutôt que de l'allonger verticalement. + +### 2. Écran `Séance active` — hiérarchie révisée + +```text +SÉRIE 2 / 5 +Pompes tempo +Passage 1 / 3 · Étape 2 / 4 + +Chrono étape + 00:18 [⏸] + +♥ 132 +``` + +Règles de layout : +- Le libellé du chrono (`Chrono étape`, `Score chrono`, `Repos`, etc.) **n'apparaît qu'une fois**, au-dessus de la valeur mm:ss — jamais répété ailleurs sur l'écran (suppression du libellé dupliqué qui apparaissait aujourd'hui sous le bouton pause/play). +- Le bouton pause/play (icône seule, cf. #128) se place **à droite de la valeur mm:ss**, sur la même ligne horizontale, aligné verticalement au centre du chiffre. Cible tactile ≥ 48dp conservée (cf. #128), juste réorientée en horizontal plutôt qu'empilée en dessous. +- Cette ligne chrono + bouton reste le seul bloc "action" de l'écran : pas de deuxième bouton, pas de texte de statut redondant en dessous. +- **Temps de série retiré intégralement** : ni en donnée dominante, ni en ligne secondaire compacte (`Série mm:ss`). La ligne secondaire de chronos (ex. cas #140 `ÉTAPE · 00:07`) ne doit plus jamais contenir de temps de série ; elle ne montre que d'autres chronos porteurs d'objectif s'il y en a (ex. score chrono en parallèle d'un chrono d'étape). +- Conséquence sur la priorité du chrono dominant définie dans [[gametime-ux-watch-companion]] : retirer purement et simplement `Temps de série` de la liste de priorité (repos > étape temps / chrono suivant prêt > score chrono). S'il ne reste aucun chrono actif porteur d'objectif, l'écran retombe sur l'état `Prêt à démarrer` / `Prêt pour la série suivante` déjà spécifié, sans jamais afficher un chrono de série à la place. +- Si aucun chrono n'est actif pour l'étape courante (ex. étape reps sans chrono, pas de score chrono), la ligne chrono disparaît et le bouton pause/play **ne s'affiche pas non plus** — cohérent avec la règle #128 « visible uniquement quand un chrono actif est la donnée dominante ». Ne pas afficher un bouton pause/play orphelin sans chrono à côté. + +### 3. Fréquence cardiaque — icône plutôt que texte + +- Remplacer le libellé texte `FC` par une petite icône cœur plein, rouge (`#FF4D5E`, teinte erreur/alerte déjà dans la palette #115 — plus lisible qu'un rouge pur sur fond `#080A12`, et évite de réutiliser le crimson `#D72638` qui est déjà le code couleur "alerte/connexion perdue"), suivie de la valeur bpm en Archivo (pas besoin d'Anton ici, ce n'est pas la donnée dominante de l'écran). +- Taille icône alignée sur la hauteur de texte du libellé de chrono (petit, discret), positionnée en bas de l'écran, sous le bloc chrono+bouton — reste la donnée la plus secondaire de l'écran. +- Inchangé : silence total si la donnée est absente (cf. [[gametime-online-layer-philosophy]] et cadrage #144) — pas d'icône cœur grisée ni de "--", la ligne entière disparaît. +- Pas de changement de fréquence de rafraîchissement ni de source : uniquement le remplacement du libellé. + +### 4. Écran `Repos` — suppression du texte qui overlap + +- Constat : sous "Ensuite : ", un texte de comptage de passage/répétition de l'exercice suivant (ex. `1/10`) s'affiche et chevauche visuellement le nom de l'exercice. +- Décision : **ce texte est supprimé**, pas repositionné. Il n'a pas de valeur pendant le repos : c'est un comptage de progression **dans** l'exercice suivant, qui n'a pas encore commencé — l'utilisateur n'est pas encore à ce point de la séquence, l'afficher avant est trompeur autant qu'illisible. +- Écran repos résultant : + +```text +REPOS +Après série 2 / 5 + +00:37 + +Ensuite +Fentes sautées +``` + +- Le libellé du chrono repos reste unique (`REPOS` en tête, cf. #115) — même règle de non-duplication qu'en séance active. +- Le bouton pause/play suit la même règle qu'en séance active : à droite du chrono repos, même ligne, une seule fois. + +### 5. Libellés/éléments à supprimer — récapitulatif pour DevFrontend + +- Libellé de chrono dupliqué sous le bouton pause/play → supprimé, ne garder que celui au-dessus de la valeur. +- `Série mm:ss` (temps de série) → supprimé de tous les écrans montre (dominant et secondaire), et retiré de la logique de priorité du chrono dominant. +- Texte `FC` → remplacé par icône cœur rouge (`#FF4D5E`) + valeur, comportement de masquage silencieux inchangé. +- Texte de comptage `X/Y` de l'exercice suivant sur l'écran Repos → supprimé (pas de remplacement). + +### 6. Comportement attendu / points d'attention responsive + +- Ligne chrono + bouton horizontale : sur cadran rond, vérifier que `mm:ss` en Anton (36-44px, cf. #115) + bouton 48dp tiennent sans se toucher au bord du cercle — si le chiffre est large (`mm:ss` avec séparateur), réduire l'espacement interne plutôt que la taille du chiffre ou celle de la cible tactile du bouton (le chiffre reste la donnée dominante prioritaire visuellement). +- Cas #140 (score manuel + chrono d'étape piloté manuellement) : le petit bouton toggle réduit (~32dp) déjà spécifié pour ce cas suit la même règle de position "à droite de la valeur du chrono", pas sous elle — cohérence transverse avec la nouvelle règle du bouton pause/play principal. +- Aucun changement sur les écrans `No-session`, `Actions`, `Connexion perdue` (#115) : ce ticket ne touche que `Séance active` et `Repos`, plus le remplacement ponctuel `FC` → icône partout où la fréquence cardiaque est affichée sur la montre. +- Pas de nouvelle donnée requise côté bridge/projection : ce ticket est une passe de simplification d'affichage, aucune extension de `WatchSessionProjection` ni de commande n'est nécessaire (à confirmer par Architect si le champ `statusLabel` était la source du libellé dupliqué — dans ce cas c'est un usage en double du même champ à corriger côté présentation montre, pas un nouveau champ à créer). + +### Hors périmètre +- Écran téléphone / notification (#108) : non concernés, ce ticket est montre uniquement. +- Pas de changement de la logique de choix du chrono dominant hors retrait du temps de série (ordre repos > étape/chrono prêt > score chrono inchangé, cf. [[gametime-ux-watch-companion]]). diff --git a/.ideai/tickets/161/issue.md b/.ideai/tickets/161/issue.md new file mode 100644 index 0000000..b3fced8 --- /dev/null +++ b/.ideai/tickets/161/issue.md @@ -0,0 +1,18 @@ +--- +id: "197dd7e4-3c2b-4fbe-9f63-442801d6720e" +number: 161 +title: "[UI] Petite refonte UI de la montre" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785219515504 +updatedAt: 1785241912171 +version: 8 +--- +Le but est d'afficher autant de choses sur la montre, mais d'une meilleure façon pour que ça soit un peu plus clair. Quand il y a un chrono étape, il y a marqué deux fois "chrono étape", une fois juste au dessus du chrono de l'étape, et une fois en dessous du bouton pause/play. J'aimerais que ça ne soit marqué qu'une seule fois. Pour éviter de surcharger l'affichage, je pense qu'on peu enlever le temps de série de l'afficahge de la montre. Enfin, pour répartir un peu plus l'affichage, on pourrait mettre le bouton start/pause à droite du chrono courant. Quand on est en repose, on a le nom du prochain exercice qui overlap un texte qui semble être le nombre de passage suivant (1/10 par exemple), je pense qu'on peut enlever ce texte. +Pour la fréquence cardiaque, au lieu d'afficher FC, j'aimerais qu'on affiche un petit coeur rouge. +Je pense que sur la montre on peut enlever le temps de série et se contenter des chronos necessaires à la réalisation des objectifs s'il y en a \ No newline at end of file diff --git a/.ideai/tickets/162/carnet.md b/.ideai/tickets/162/carnet.md new file mode 100644 index 0000000..352493a --- /dev/null +++ b/.ideai/tickets/162/carnet.md @@ -0,0 +1,62 @@ +--- +issueRef: "#162" +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859225 +--- + +## #162 — Cadrage UX compaction étape chrono+reps / chrono+score libre (UX, 2026-07-28) + +Statut : **cadrage exploitable, avis rendu sur la proposition utilisateur**. Écran concerné : exécution de séance téléphone (module `Séquence` d'étape, [[gametime-ux-exercise-steps]]). La montre n'est pas concernée (cas déjà traité en compact pour l'équivalent montre par #140). + +Avis sur la proposition initiale : **globalement bonne direction** (regrouper reps+chrono sur une ligne, alléger les CTA), avec deux ajustements : réduire aussi les titres empilés en légendes courtes sur la même ligne que leur valeur (pas seulement fusionner reps+chrono), et séparer les CTA de contrôle du chrono (Lancer/Réinitialiser, fréquents, faible enjeu) des CTA de navigation d'étape (Passer l'étape / Étape suivante, plus haut niveau) pour éviter une seule rangée de 4 boutons qui redevient dense. + +### 1. Hiérarchie d'information +1. Nom de l'étape (inchangé, une ligne, ellipse si trop long). +2. Les deux données de l'étape **sur une seule ligne à deux colonnes** : répétitions à gauche, chrono score à droite (ou l'inverse si le chrono est la donnée qui progresse activement — garder la donnée "vivante" côté où l'œil revient, à trancher avec DevFrontend selon l'existant visuel). +3. Contrôles du chrono (Lancer/Réinitialiser), rattachés visuellement à la valeur du chrono, pas à une rangée globale. +4. CTA de navigation d'étape, en bas, groupés. + +### 2. Disposition concrète + +**Cas A — étape Répétitions + chrono score d'étape :** +```text +Dribble main droite + +RÉPÉTITIONS CHRONO SCORE + 10 00:12 + [▶] [↺] + +[ Passer l'étape ] [ Étape suivante ] +``` +- Légendes (`RÉPÉTITIONS`, `CHRONO SCORE`) fusionnées en petite légende juste au-dessus de leur valeur, plus de titre pleine largeur séparé. +- `[▶] [↺]` : icônes seules (pas de texte), ~32dp, rattachées sous la valeur du chrono — réutiliser le même jeu d'icônes que le bouton toggle réduit déjà spécifié pour la montre (#140), pas un nouveau vocabulaire visuel. +- CTA du bas **côte à côte** (`Passer l'étape` en contour/secondaire, `Étape suivante` en plein/primaire) plutôt qu'empilés plein largeur — exception assumée par rapport aux boutons de série (`Terminer la série`/`Passer la série`, eux restent empilés) car le problème à résoudre ici est spécifiquement la hauteur cumulée, pas la clarté de hiérarchie. + +**Cas B — étape Temps (décompte) + score libre :** +```text +Pompes tempo + + 00:07 Score (paniers) + [-] 5 [+] + + [ Passer l'étape ] +``` +- Le chrono reste la donnée dominante (plus grande) car il progresse seul et pilote l'avancement automatique (cf. [[gametime-ux-exercise-steps]] : passage auto à 0). +- Le score libre passe d'un champ texte + clavier système à un **stepper +/-** compact (même pattern que le score montre #118), sur la même ligne que le chrono plutôt qu'en dessous. +- Un seul CTA en bas (`Passer l'étape`) : pas d'`Étape suivante` ici, l'avancement est automatique à la fin du décompte. + +### 3. À compacter / supprimer / déplacer +- Supprimer les titres pleine largeur (`RÉPÉTITIONS`, `Chrono score d'étape`) en tant que blocs séparés → deviennent des légendes courtes au-dessus de leur valeur, sur la même ligne que l'autre donnée. +- Déplacer `Étape suivante` : il quitte sa position isolée entre le bloc reps et le bloc chrono, rejoint la rangée de CTA du bas. +- Compacter `Lancer`/`Réinitialiser` en icônes seules, rattachées au chrono plutôt qu'en rangée de boutons texte pleine largeur. +- Remplacer le champ texte + clavier du score libre par un stepper +/-, ce qui supprime aussi le risque de clavier system qui recouvre l'écran (cf. point 4). + +### 4. Garde-fous lisibilité mobile +- Le stepper +/- évite l'ouverture du clavier système, qui sur petit écran peut masquer le chrono pile au moment où l'utilisateur en a besoin (risque identifié sur le cas B). +- Ne pas descendre le chiffre Anton du chrono/reps sous la taille de lisibilité déjà actée ailleurs dans l'app pour l'effort physique (lecture rapide, doigts/yeux sollicités) — si la place manque, réduire les marges/paddings avant de réduire la taille des chiffres. +- Cibles tactiles des icônes `[▶] [↺]` : conserver un minimum tactile confortable malgré la réduction visuelle (même logique que la réduction 48dp→32dp déjà validée pour le cas montre #140, qui reste un plancher acceptable, pas un objectif à réduire davantage). +- Rangée à deux colonnes : tester sur la largeur d'écran la plus étroite supportée avant de valider — si les deux légendes + valeurs entrent en collision, réduire d'abord l'espacement interne, pas passer les deux CTA de navigation à trois colonnes. + +### 5. Libellés / navigation +Aucun nouveau libellé, aucun changement de navigation. Les libellés existants (`RÉPÉTITIONS`, `Chrono score d'étape` → légende courte, `Passer l'étape`, `Étape suivante`, `Score de l'étape (unité)`) sont conservés tels quels, seule leur mise en page change. Pas d'impact sur le module séquence en configuration (formulaire exercice), uniquement sur l'écran d'exécution. diff --git a/.ideai/tickets/162/issue.md b/.ideai/tickets/162/issue.md new file mode 100644 index 0000000..d988f1c --- /dev/null +++ b/.ideai/tickets/162/issue.md @@ -0,0 +1,16 @@ +--- +id: "b39173a8-029a-45b4-9dc2-ded4f90445db" +number: 162 +title: "[UI] Chrono + répétitions sur une meme étape" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785241963854 +updatedAt: 1785248859225 +version: 6 +--- +Quand on a un chrono + un nombre de répétitions sur une même étape on a un scrolling entre les répétitions et le chrono. Il faudrait qu'on arrive a avoir les deux en visuel sans avoir à scroll. Actuellement on a de haut en bas le nom de l'étape, le nombre de répétitions, le titre repetitions, un bouton étape suivante,un titre chrono score d'étape, le chrono, sur la même ligne un bouton Lancer et un bouton réinitialiser puis un bouton passer l'étape. Je pense que dans ce cas la on pourrait avoir le nombre de répétitions avec son titre en dessous qui pourraient être sur la même ligne que le chrono puis en dessous un symbole pour lancer le chrono, un symbole pour le réinitialiser et un bouton passer l'étape et un bouton étape suivante. Ce n'est qu'une porposition, j'aimerais que UX puisse donner son avis dessus. Le but premier étant de garder une UI claire et sans scroll. Même soucis quand on a un chrono et un score libre a entrer. \ No newline at end of file diff --git a/.ideai/tickets/163/carnet.md b/.ideai/tickets/163/carnet.md new file mode 100644 index 0000000..1450a14 --- /dev/null +++ b/.ideai/tickets/163/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#163" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785248859271 +--- diff --git a/.ideai/tickets/163/issue.md b/.ideai/tickets/163/issue.md new file mode 100644 index 0000000..9ba1ba8 --- /dev/null +++ b/.ideai/tickets/163/issue.md @@ -0,0 +1,16 @@ +--- +id: "2c794bcc-09c1-47c7-a7ed-1e2f0e408ffd" +number: 163 +title: "Frequence cardiaque qui ne semble plus calculée si la montre à l'écran éteint" +status: "inProgress" +priority: "medium" +sprint: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785242433447 +updatedAt: 1785248859271 +version: 5 +--- +Quand l'écran de la montre est éteint, la fréquence cardiaque n'est aps actualisée (l'affichage est assombris sur le téléphone et la fréquance ne change pas). Pour les statistiques il fauit que la fréquence cardiaque continue d'être enregistrée \ No newline at end of file diff --git a/.ideai/tickets/164/carnet.md b/.ideai/tickets/164/carnet.md new file mode 100644 index 0000000..d6f7552 --- /dev/null +++ b/.ideai/tickets/164/carnet.md @@ -0,0 +1,22 @@ +--- +issueRef: "#164" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785244375351 +--- + +## #164 — Cadrage UX (2026-07-28) + +**Comportement attendu** : sur un chrono d'étape avec temps, bip court aux 3 dernières secondes (à chaque tick 3, 2, 1) synchronisé avec la valeur affichée, bip plus long au passage à 0. Sur la montre, une vibration se déclenche au passage à 0 — à aligner sur le pattern haptique déjà cadré pour "fin d'un chrono" ([[gametime-ux-watch-companion]] : double impulsion), pas un nouveau pattern à inventer sauf si Main confirme vouloir une vibration simple distincte. + +**Contraintes UI/feedback** : +- Le son ne doit toujours pas couper la musique en cours (invariant #92, contexte audio `sonification`/`mixWithOthers` déjà en place — vérifier non-régression, cette correction en dépend probablement). +- Répartition déjà actée à conserver : **son côté téléphone, vibration côté montre** — ne pas dupliquer le son sur la montre ni ajouter de vibration téléphone sur ce même événement ([[gametime-ux-watch-companion]] : « éviter la duplication agressive téléphone + montre sur le même événement »). +- La vibration montre doit se déclencher sur le passage à 0 tel qu'interpolé/reçu localement par la montre, pas sur un nouvel aller-retour réseau dédié. + +**À ne pas faire** : +- Ne pas ajouter de son sur la montre ni de vibration sur le téléphone. +- Ne pas faire prendre le focus audio à ce son (régression à surveiller en priorité, cf. #92) ni à une éventuelle API haptique montre. +- Ne pas inventer un nouveau pattern haptique montre sans vérifier d'abord si le double-impulsion déjà spécifié pour "fin de chrono" couvre déjà ce cas. + +**Libellé/navigation** : aucune évolution nécessaire, correctif de feedback sonore/haptique uniquement. diff --git a/.ideai/tickets/164/issue.md b/.ideai/tickets/164/issue.md new file mode 100644 index 0000000..dc56954 --- /dev/null +++ b/.ideai/tickets/164/issue.md @@ -0,0 +1,16 @@ +--- +id: "879caaa3-2658-4ad8-bfa0-a7444cffc1b0" +number: 164 +title: "[Bug] Plus de son en fin de chrono" +status: "inProgress" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785243194849 +updatedAt: 1785244375351 +version: 5 +--- +Il n'y a plus de bips en fin de chrono. Sur les 3 derniere seconde il doit y avoir un big court et un bip plus long à 0. J'aiemrais que la montre vibre quand le chrono arrive à 0 \ No newline at end of file diff --git a/.ideai/tickets/48/carnet.md b/.ideai/tickets/48/carnet.md index 103b593..410f4ee 100644 --- a/.ideai/tickets/48/carnet.md +++ b/.ideai/tickets/48/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#48" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784448980586 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785243107564 --- Logique d'auth (register/login/logout/authenticate) et middleware Bearer testés avec repositories fake en mémoire (10/10 tests verts), `dart analyze` clean. Non vérifié en conditions réelles : sandbox Main sans accès Docker → endpoints HTTP register/login/logout jamais exercés de bout en bout contre un vrai PostgreSQL. À faire avant mise en prod : une fois #52 livré, `docker compose up -d`, puis tester manuellement `POST /auth/register`, `/auth/login`, `/auth/logout` via curl contre le serveur réel. diff --git a/.ideai/tickets/48/issue.md b/.ideai/tickets/48/issue.md index 8a95d3c..f2ed17b 100644 --- a/.ideai/tickets/48/issue.md +++ b/.ideai/tickets/48/issue.md @@ -2,15 +2,15 @@ id: "6df36ec4-94cd-438b-a2ee-10c022f6848a" number: 48 title: "[Server] Auth comptes utilisateurs et tokens API" -status: "qa" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784411990980 -updatedAt: 1784448980586 -version: 4 +updatedAt: 1785243107564 +version: 5 --- Implémenter le domaine serveur `UserAccount` et `AuthSession` : création de compte, connexion, hash de mot de passe Argon2id ou bcrypt, émission de tokens d'accès + refresh tokens, middleware d'auth API, révocation/logout. Stockage PostgreSQL. Endpoints attendus : `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /me`. Aucun écran client dans ce ticket. \ No newline at end of file diff --git a/.ideai/tickets/49/carnet.md b/.ideai/tickets/49/carnet.md index a3990ef..48fc62f 100644 --- a/.ideai/tickets/49/carnet.md +++ b/.ideai/tickets/49/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#49" -version: 3 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784448367646 +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785243113748 --- Migrations SQL implémentées et revues manuellement (5 tables, contraintes, index, trigger `server_updated_at`), `dart analyze`/`dart test` verts. Non vérifié en conditions réelles : sandbox Main sans accès au daemon Docker → impossible de lancer un vrai PostgreSQL et d'exécuter `TEST_DATABASE_URL=... dart test` ou `dart run bin/migrate.dart` contre une vraie base. À faire avant mise en prod : lancer `docker compose up -d postgres` (une fois #52 livré) et rejouer `bin/migrate.dart` + le test d'intégration pour confirmer que les migrations s'appliquent sans erreur sur un vrai PostgreSQL 16. \ No newline at end of file diff --git a/.ideai/tickets/49/issue.md b/.ideai/tickets/49/issue.md index fd74406..b8ea121 100644 --- a/.ideai/tickets/49/issue.md +++ b/.ideai/tickets/49/issue.md @@ -2,15 +2,15 @@ id: "c73b39be-061c-44b4-b5c3-f482878fe58d" number: 49 title: "[Server] Schéma PostgreSQL sync-ready GameTime" -status: "qa" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784411997184 -updatedAt: 1784448367646 -version: 3 +updatedAt: 1785243113748 +version: 4 --- Créer le modèle serveur PostgreSQL pour les ressources synchronisables : Exercise, Program, WorkoutTemplate, WorkoutHistory, MediaAsset metadata, plus tables d'ownership par utilisateur. Chaque ressource doit stocker `ownerUserId`, `clientId`, `serverId`, `payloadJson`, `clientUpdatedAt`, `serverUpdatedAt`, `deletedAt`, `schemaVersion`, `originDeviceId`. Prévoir contraintes d'unicité `(owner_user_id, resource_type, client_id)`, index pull incrémental `(owner_user_id, resource_type, server_updated_at)`, migrations et tests repository. \ No newline at end of file diff --git a/.ideai/tickets/50/carnet.md b/.ideai/tickets/50/carnet.md index b684b25..de21870 100644 --- a/.ideai/tickets/50/carnet.md +++ b/.ideai/tickets/50/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#50" -version: 3 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784449447058 +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785243117713 --- Endpoints `POST /sync/push`, `GET /sync/pull`, `POST /sync/exchange` implémentés, protégés par le middleware Bearer. LWW rendu atomique côté SQL (CTE `INSERT ... ON CONFLICT ... WHERE client_updated_at < EXCLUDED.client_updated_at` + fallback pour `ignoredOlder`), relu manuellement et jugé correct. Tests unitaires avec fakes en mémoire verts (15/15), `dart analyze` clean. Non vérifié : aucun test de bout en bout contre un vrai PostgreSQL (pas d'accès Docker dans le sandbox Main). À faire avant mise en prod : une fois #52 livré, tester `push`/`pull`/`exchange` via curl contre le serveur réel + vérifier la concurrence de l'upsert LWW sous charge. \ No newline at end of file diff --git a/.ideai/tickets/50/issue.md b/.ideai/tickets/50/issue.md index af63bcb..9968c80 100644 --- a/.ideai/tickets/50/issue.md +++ b/.ideai/tickets/50/issue.md @@ -2,15 +2,15 @@ id: "983aa02b-7577-4fde-b77a-12355df0efdb" number: 50 title: "[Server] API de synchronisation incrémentale LWW" -status: "qa" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#48","kind":"dependsOn"},{"target":"#49","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784412001974 -updatedAt: 1784449447058 -version: 3 +updatedAt: 1785243117713 +version: 4 --- Implémenter les ports/use cases et endpoints de sync incrémentale authentifiée. Endpoints : `POST /sync/push`, `GET /sync/pull?since=...`, optionnel `POST /sync/exchange`. Stratégie v1 last-write-wins basée sur `clientUpdatedAt` puis tie-breaker `serverUpdatedAt`/`serverId`. Supporter upsert, soft delete, réponses par item (`accepted`, `ignoredOlder`, `conflictLwwApplied`, `error`) et cursor serveur monotone. Ne pas intégrer le client Flutter dans ce ticket. \ No newline at end of file diff --git a/.ideai/tickets/51/carnet.md b/.ideai/tickets/51/carnet.md index 25dbe94..ffaa50b 100644 --- a/.ideai/tickets/51/carnet.md +++ b/.ideai/tickets/51/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#51" -version: 3 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784450081385 +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785243124727 --- Endpoints `POST /shares`, `GET /shares/inbox`, `POST /shares/{id}/accept|decline|revoke` implémentés, protégés par le middleware Bearer. Acceptation crée une copie `synced_resources` (nouveaux IDs) pour le destinataire sans jamais modifier la ressource source de l'émetteur — relu et confirmé correct. Gestion des conflits : 404 si partage/destinataire introuvable ou révocation par non-émetteur, 409 si acceptation d'un partage révoqué ou déjà répondu. Emails destinataires inconnus retournés dans `unresolvedEmails` sans échouer toute la requête. Tests unitaires avec fakes en mémoire verts (24/24 sur toute la suite serveur), `dart analyze` clean. Non vérifié : aucun test de bout en bout contre un vrai PostgreSQL (pas d'accès Docker dans le sandbox Main). \ No newline at end of file diff --git a/.ideai/tickets/51/issue.md b/.ideai/tickets/51/issue.md index ab56ea8..77c00a4 100644 --- a/.ideai/tickets/51/issue.md +++ b/.ideai/tickets/51/issue.md @@ -2,15 +2,15 @@ id: "850ce975-99c3-4ee4-8d39-759ecc55139c" number: 51 title: "[Server] Partage ciblé de programmes et séances entre comptes" -status: "qa" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#48","kind":"dependsOn"},{"target":"#49","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784412007538 -updatedAt: 1784450081385 -version: 3 +updatedAt: 1785243124727 +version: 4 --- Implémenter le domaine de partage ciblé : `Share`, destinataires par compte utilisateur, statut `pending|accepted|declined|revoked`, payload snapshot autonome de Program ou WorkoutTemplate. Endpoints : `POST /shares`, `GET /shares/inbox`, `POST /shares/{id}/accept`, `POST /shares/{id}/decline`, `POST /shares/{id}/revoke`. À l'acceptation, créer une copie importable dans l'espace du destinataire, sans lien public ouvert. \ No newline at end of file diff --git a/.ideai/tickets/52/carnet.md b/.ideai/tickets/52/carnet.md index 6414840..621bd74 100644 --- a/.ideai/tickets/52/carnet.md +++ b/.ideai/tickets/52/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#52" -version: 3 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784450444550 +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785243127394 --- Dockerfile multi-stage (build `dart:stable` → runtime `debian:bookworm-slim`, `dart compile exe` AOT pour `server` et `migrate`), `docker-compose.yaml` (services `api`+`postgres:16-alpine`, healthcheck Postgres, `depends_on: service_healthy`, migrations auto au démarrage via `docker-entrypoint.sh`+`MIGRATE_ON_STARTUP`), `.env.example` documenté, `scripts/push-gitea-image.sh` sans aucun identifiant en dur (variables `GITEA_REGISTRY/OWNER/IMAGE_NAME/USERNAME/TOKEN`, login via --password-stdin). `docker compose config` validé (syntaxe/interpolation correctes), Dockerfile et scripts relus manuellement. Non vérifié : aucun `docker build`/`docker compose up` réel (pas d'accès Docker daemon dans le sandbox Main). À faire avant mise en prod : build réel de l'image, `docker compose up -d` complet avec vraie connexion Postgres, puis test du script de push une fois l'adresse Gitea fournie par l'utilisateur. \ No newline at end of file diff --git a/.ideai/tickets/52/issue.md b/.ideai/tickets/52/issue.md index b80136a..de50444 100644 --- a/.ideai/tickets/52/issue.md +++ b/.ideai/tickets/52/issue.md @@ -2,15 +2,15 @@ id: "28ebc668-c209-4e6a-b123-252a38a3c784" number: 52 title: "[Server] Packaging Docker Compose et push Gitea Registry" -status: "qa" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784412011625 -updatedAt: 1784450444550 -version: 3 +updatedAt: 1785243127394 +version: 4 --- Finaliser l'exploitation container : `server/docker-compose.yaml` paramétrable via `.env` pour host bind, port API interne/externe, origine reverse proxy, URL publique HTTPS, config PostgreSQL, secrets, volumes persistants. Ajouter `server/scripts/push-gitea-image.sh` acceptant registry, image, tag, username/token via variables d'environnement ou arguments, sans valeur en dur. Documenter le flux build/push/run. \ No newline at end of file diff --git a/.ideai/tickets/53/carnet.md b/.ideai/tickets/53/carnet.md index d41e7a2..88bd5c8 100644 --- a/.ideai/tickets/53/carnet.md +++ b/.ideai/tickets/53/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#53" -version: 3 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784469423570 +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785243133172 --- Contrat `server/openapi.yaml` (OpenAPI 3.0.3) documentant les 11 endpoints réels (health, auth register/login/logout, sync push/pull/exchange, shares create/inbox/accept/decline/revoke) avec sécurité bearerAuth et schémas de requêtes/réponses fidèles au code. Trou de couverture comblé : tests API HTTP directs pour les endpoints auth (register/login/logout), qui n'étaient testés qu'au niveau use case/middleware. `server/docs/integration-checklist.md` ajouté pour lister tout ce qui reste à vérifier manuellement une fois un vrai Docker/PostgreSQL disponible. Suite complète serveur verte (29/29), `dart analyze` clean. diff --git a/.ideai/tickets/53/issue.md b/.ideai/tickets/53/issue.md index 82c964e..42834de 100644 --- a/.ideai/tickets/53/issue.md +++ b/.ideai/tickets/53/issue.md @@ -2,15 +2,15 @@ id: "d5fce252-f3c4-499f-ad0a-d3c81ea13305" number: 53 title: "[Server] Tests API, contrats OpenAPI et vérification d'intégration" -status: "qa" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#50","kind":"dependsOn"},{"target":"#51","kind":"dependsOn"},{"target":"#52","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784412015509 -updatedAt: 1784469423570 -version: 3 +updatedAt: 1785243133172 +version: 4 --- Ajouter la couverture de validation serveur : tests unitaires domain/application, tests repository PostgreSQL, tests API auth/sync/share, fixtures de payload GameTime, vérification Docker Compose locale. Produire un contrat OpenAPI ou équivalent documentant auth, sync et partage pour les futurs tickets d'intégration Flutter. \ No newline at end of file diff --git a/.ideai/tickets/56/carnet.md b/.ideai/tickets/56/carnet.md index 9913a53..050aeb7 100644 --- a/.ideai/tickets/56/carnet.md +++ b/.ideai/tickets/56/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#56" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784482387469 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243136634 --- diff --git a/.ideai/tickets/56/issue.md b/.ideai/tickets/56/issue.md index 0bbaa07..4e166e8 100644 --- a/.ideai/tickets/56/issue.md +++ b/.ideai/tickets/56/issue.md @@ -2,15 +2,15 @@ id: "c902b71c-8033-42dc-a52a-eb6822a1e863" number: 56 title: "[DevBackend] Modèle domain + Drift pour exercices à étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#54","kind":"relatesTo"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784481516635 -updatedAt: 1784482387469 -version: 2 +updatedAt: 1785243136634 +version: 3 --- Implémenter le socle domain et Drift pour les exercices à étapes. Ajouter `ExerciseStep`, enum `ExerciseStepType time|reps`, liste `steps` sur `Exercise`, validation max 8 étapes, positions uniques, cible par défaut > 0, score d'étape optionnel avec `ScoreInputMode manual|stopwatch`, label/unité/valeurs par défaut. Ajouter snapshots d'étapes dans `ProgramExercise.toSnapshotJson` et dans les snapshots WorkoutTemplate/History. Migration Drift depuis schemaVersion 7 vers 8 : tables/colonnes nécessaires, contraintes et mappers repository. \ No newline at end of file diff --git a/.ideai/tickets/58/carnet.md b/.ideai/tickets/58/carnet.md index c136200..620f248 100644 --- a/.ideai/tickets/58/carnet.md +++ b/.ideai/tickets/58/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#58" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784483616127 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243140603 --- diff --git a/.ideai/tickets/58/issue.md b/.ideai/tickets/58/issue.md index c7fff79..8426918 100644 --- a/.ideai/tickets/58/issue.md +++ b/.ideai/tickets/58/issue.md @@ -2,15 +2,15 @@ id: "d18752bf-3e5b-4fb9-935c-064d0d2e837a" number: 58 title: "[DevBackend] Exécution persistante des étapes et résultats par passage" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#54","kind":"relatesTo"},{"target":"#56","kind":"dependsOn"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784481524706 -updatedAt: 1784483616127 -version: 2 +updatedAt: 1785243140603 +version: 3 --- Ajouter le modèle d'exécution des étapes distinct de `ActiveSetResult`. Créer une table dédiée type `ActiveExerciseStepProgressState` pour session+position de série, passage courant, étape courante, statut `idle|running|stopped`, `startedAt`, `accumulatedMs`, et actions pause/reprise/skip. Créer `ActiveExerciseStepResult` pour résultats par `activeWorkoutSessionId + programIndex + exerciseIndex + setIndex + passageIndex + stepSnapshotId/stepIndex`, statut `completed|skipped`, durée/rep cible snapshot, score manuel ou score chrono d'étape. À la clôture de séance, projeter vers `WorkoutHistoryStepResult` sans mélanger avec `WorkoutHistorySetResult`. \ No newline at end of file diff --git a/.ideai/tickets/59/carnet.md b/.ideai/tickets/59/carnet.md index 66efc83..99656f4 100644 --- a/.ideai/tickets/59/carnet.md +++ b/.ideai/tickets/59/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#59" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784483616855 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243143729 --- diff --git a/.ideai/tickets/59/issue.md b/.ideai/tickets/59/issue.md index 0dd8243..b3c7a79 100644 --- a/.ideai/tickets/59/issue.md +++ b/.ideai/tickets/59/issue.md @@ -2,15 +2,15 @@ id: "be486426-a17d-4521-b475-9230a2e30169" number: 59 title: "[DevFrontend] Éditeur d'exercice avec séquence d'étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#54","kind":"relatesTo"},{"target":"#56","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784481530478 -updatedAt: 1784483616855 -version: 2 +updatedAt: 1785243143729 +version: 3 --- Implémenter la section UX `Séquence d'étapes` dans `ExerciseFormScreen` : activation optionnelle, ajout/réordonnancement max 8, écran de détail d'étape, type exclusif temps/répétitions, cible obligatoire > 0, score d'étape optionnel avec mode manual/stopwatch. Préserver l'avertissement existant sur les exercices déjà utilisés : les programmes existants gardent leurs snapshots. \ No newline at end of file diff --git a/.ideai/tickets/60/carnet.md b/.ideai/tickets/60/carnet.md index 2492fc5..1f3aa92 100644 --- a/.ideai/tickets/60/carnet.md +++ b/.ideai/tickets/60/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#60" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784484539686 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243146698 --- diff --git a/.ideai/tickets/60/issue.md b/.ideai/tickets/60/issue.md index 92478b8..88328d6 100644 --- a/.ideai/tickets/60/issue.md +++ b/.ideai/tickets/60/issue.md @@ -2,15 +2,15 @@ id: "4807a946-834e-44a4-8a39-25ad951e6668" number: 60 title: "[DevFrontend] Exécution de séance avec module séquence et bips" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#54","kind":"relatesTo"},{"target":"#58","kind":"dependsOn"},{"target":"#59","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784481536357 -updatedAt: 1784484539686 -version: 2 +updatedAt: 1785243146698 +version: 3 --- Implémenter le module `Séquence` sur l'écran d'exécution : affichage passage/étape, étapes temps avec compte à rebours, étapes répétitions avec validation manuelle, boucle multi-passages si reps de série actives, skips distincts étape/passage/série, persistance après pause/kill via use cases backend. Ajouter un port audio testable et un adapter Flutter utilisant des bips courts/longs, sans dépendance des tests widget à un lecteur audio réel. \ No newline at end of file diff --git a/.ideai/tickets/61/carnet.md b/.ideai/tickets/61/carnet.md index 086eaba..353ff6d 100644 --- a/.ideai/tickets/61/carnet.md +++ b/.ideai/tickets/61/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#61" -version: 5 +version: 6 updatedBy: {"kind":"user"} -updatedAt: 1784650797661 +updatedAt: 1785243150941 --- Plan de séance : résumé de séquence par série (via `ActiveExerciseStepUseCases.readProgress`). Historique : détail des résultats d'étapes groupés par passage (durée/répétitions, score d'étape, badge "Passée" pour skipped), réutilise `WorkoutHistory.stepResults` déjà peuplé par #58. 102/102 tests verts, `flutter analyze` clean, APK debug buildé avec succès. diff --git a/.ideai/tickets/61/issue.md b/.ideai/tickets/61/issue.md index cd4bf89..cc3c86d 100644 --- a/.ideai/tickets/61/issue.md +++ b/.ideai/tickets/61/issue.md @@ -2,7 +2,7 @@ id: "1eed02ec-04af-4966-a0ff-449c4a957ea0" number: 61 title: "[DevFrontend] Plan de séance et historique avec résultats d'étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#54","kind":"relatesTo"},{"target":"#58","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"} createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"user"} createdAt: 1784481541650 -updatedAt: 1784650797661 -version: 5 +updatedAt: 1785243150941 +version: 6 --- Afficher les résultats d'étapes dans le plan de séance et l'historique sans remplacer les résultats de série. Une série avec étapes garde son état global `à faire/en cours/terminée/passée`; le détail affiche les passages, étapes terminées/passées, durées/répétitions et scores d'étape. L'édition ponctuelle d'une série passée/terminée permet de corriger les valeurs enregistrées sans rejouer la séquence interactive et sans déplacer la progression courante. \ No newline at end of file diff --git a/.ideai/tickets/62/carnet.md b/.ideai/tickets/62/carnet.md index b95ec0e..0bad827 100644 --- a/.ideai/tickets/62/carnet.md +++ b/.ideai/tickets/62/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#62" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784487426036 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243154694 --- diff --git a/.ideai/tickets/62/issue.md b/.ideai/tickets/62/issue.md index dcad27c..b3ff785 100644 --- a/.ideai/tickets/62/issue.md +++ b/.ideai/tickets/62/issue.md @@ -2,15 +2,15 @@ id: "a53d84b5-06d9-4683-ac64-9100cb60669f" number: 62 title: "[QA] Validation exercices à étapes, persistance et historique" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#54","kind":"relatesTo"},{"target":"#60","kind":"dependsOn"},{"target":"#61","kind":"dependsOn"}] agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784481549150 -updatedAt: 1784487426036 -version: 2 +updatedAt: 1785243154694 +version: 3 --- Préparer et exécuter le plan QA pour les exercices à étapes : création/édition et limites max 8, snapshots programme/séance, exécution étapes temps/répétitions, bips 3/2/1/0, enchaînement de chronos, skips étape/passage/série, pause/reprise/kill app, projection historique, édition ponctuelle sans déplacer la progression ni mélanger résultats d'étape et résultats de série. \ No newline at end of file diff --git a/.ideai/tickets/63/carnet.md b/.ideai/tickets/63/carnet.md index 5a03c66..119e452 100644 --- a/.ideai/tickets/63/carnet.md +++ b/.ideai/tickets/63/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#63" -version: 9 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784533228302 +version: 10 +updatedBy: {"kind":"user"} +updatedAt: 1785243102711 --- diff --git a/.ideai/tickets/63/issue.md b/.ideai/tickets/63/issue.md index 9647afa..bbf49c9 100644 --- a/.ideai/tickets/63/issue.md +++ b/.ideai/tickets/63/issue.md @@ -2,15 +2,15 @@ id: "17f54d9f-0317-429d-a275-480966976ced" number: 63 title: "Ajouter les features déjà présentes sur le serveur au client" -status: "qa" +status: "closed" priority: "medium" sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822" links: [{"target":"#57","kind":"dependsOn"},{"target":"#46","kind":"dependsOn"}] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784482089895 -updatedAt: 1784533228302 -version: 9 +updatedAt: 1785243102711 +version: 10 --- Une première version du serveur est disponible. J'aimerais que les features serveur soient ajouté à l'application (j'entends ici l'ajout de la totalité de la stack de la logique méier comme la sync des historiques etc, à la connexion). Pour la connexion, comme indiqué dans le ticket #57, la connexion n'est pas obligatoire pour l'acces à l'applicaiton, elle ne doit être que optionnelle, je pense entre autre à un menu "Profile" à créer. Le bon fonctionnement de l'application reste prioritaire sur le serveur, à savoir que le serveur sert l'applciation et pas l'inverse, c'est a dire que si une structure de donnée est actuellement différente entre le serveur et l'applciation, alors il faut modifier le serveur pour qu'il accepte la donnée du client. Je pense par exemple aux exercices, entre la création serveur et la réalisation du ticket, je pense que la forme des exercice a été modifiée (ajout entre autre des étapes dans une serie d'un exercice), il faudra alors adapter le serveur à ça. \ No newline at end of file diff --git a/.ideai/tickets/64/carnet.md b/.ideai/tickets/64/carnet.md index 5262def..151400d 100644 --- a/.ideai/tickets/64/carnet.md +++ b/.ideai/tickets/64/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#64" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784489186446 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243158451 --- diff --git a/.ideai/tickets/64/issue.md b/.ideai/tickets/64/issue.md index 76b1723..6b197ce 100644 --- a/.ideai/tickets/64/issue.md +++ b/.ideai/tickets/64/issue.md @@ -2,15 +2,15 @@ id: "4a18ba38-9b18-4b39-99a4-ad5a8779e47f" number: 64 title: "[DevBackend] Client online : session compte, stockage sécurisé et adapter API" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488337677 -updatedAt: 1784489186446 -version: 2 +updatedAt: 1785243158451 +version: 3 --- Ajouter le socle client online côté domain/application/infrastructure : entité locale `UserAccountSession` (profil cache non sensible), ports `AuthTokenStore`, `OnlineAccountRepository`/`RemoteGameTimeApi`, adapter HTTP sous `lib/infrastructure/remote/` basé sur `package:http` avec timeouts courts et erreurs typées silencieuses. Stocker access/refresh token via `flutter_secure_storage`; stocker email/pseudo/avatar/statut sync en Drift. Ne jamais bloquer l'usage offline si l'API échoue. \ No newline at end of file diff --git a/.ideai/tickets/65/carnet.md b/.ideai/tickets/65/carnet.md index 36baad2..268a6f0 100644 --- a/.ideai/tickets/65/carnet.md +++ b/.ideai/tickets/65/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#65" -version: 3 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784490128397 +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785243160967 --- SyncUseCases.synchronize() implémenté avec push/pull LWW réel, changement silencieux d'état (success/failure) sans jamais laisser remonter d'exception, réutilise la table change_log déjà présente (écrite mais jamais lue jusqu'ici). 119/119 tests verts, flutter analyze clean, APK debug buildé. diff --git a/.ideai/tickets/65/issue.md b/.ideai/tickets/65/issue.md index 70516c2..7236b3f 100644 --- a/.ideai/tickets/65/issue.md +++ b/.ideai/tickets/65/issue.md @@ -2,15 +2,15 @@ id: "f0c554b7-e388-49b3-aa74-0a47886961ad" number: 65 title: "[DevBackend] Sync client incrémentale LWW vers API serveur" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#64","kind":"dependsOn"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488343589 -updatedAt: 1784490128397 -version: 3 +updatedAt: 1785243160967 +version: 4 --- Remplacer le `NoOpSyncGateway` par une orchestration réelle offline-first. Mapper Exercise, Program, WorkoutTemplate, WorkoutHistory et MediaAsset metadata vers `resourceType/clientId/schemaVersion/clientUpdatedAt/deletedAt/payload`. Utiliser `change_log`, `syncState`, `updatedAt` et `localRevision` pour pousser les mutations, stocker localement `serverCursor`, appliquer `/sync/pull` LWW sans résolution interactive, puis marquer les ressources acceptées comme synced. Les erreurs réseau restent silencieuses et déclenchent retry différé. \ No newline at end of file diff --git a/.ideai/tickets/66/carnet.md b/.ideai/tickets/66/carnet.md index c714be8..f0d0136 100644 --- a/.ideai/tickets/66/carnet.md +++ b/.ideai/tickets/66/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#66" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784490921505 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243165428 --- diff --git a/.ideai/tickets/66/issue.md b/.ideai/tickets/66/issue.md index d8ae54b..a6e0b10 100644 --- a/.ideai/tickets/66/issue.md +++ b/.ideai/tickets/66/issue.md @@ -2,15 +2,15 @@ id: "bdcf80ef-1360-4baa-9c49-86b091412ba1" number: 66 title: "[DevBackend] Partage client : use cases, inbox cache et import local" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#64","kind":"dependsOn"},{"target":"#65","kind":"dependsOn"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488349264 -updatedAt: 1784490921505 -version: 2 +updatedAt: 1785243165428 +version: 3 --- Ajouter les ports/use cases client pour `POST /shares`, `GET /shares/inbox`, accept/decline/revoke. Stocker une inbox locale lisible offline et une file d'actions de partage en attente si réseau indisponible. À l'acceptation, importer une copie locale indépendante du Programme ou WorkoutTemplate depuis le payload reçu si disponible; l'accusé serveur peut partir ensuite, et la ressource créée côté serveur retombe aussi via le prochain `/sync/pull` sans doublon grâce aux IDs/mapping. \ No newline at end of file diff --git a/.ideai/tickets/67/carnet.md b/.ideai/tickets/67/carnet.md index d563e47..5f1a737 100644 --- a/.ideai/tickets/67/carnet.md +++ b/.ideai/tickets/67/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#67" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784490111791 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243168334 --- diff --git a/.ideai/tickets/67/issue.md b/.ideai/tickets/67/issue.md index c7a491b..94f9950 100644 --- a/.ideai/tickets/67/issue.md +++ b/.ideai/tickets/67/issue.md @@ -2,15 +2,15 @@ id: "961d38c4-a124-47da-8118-be8b559fc617" number: 67 title: "[DevFrontend] Profil et écrans auth optionnels" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#64","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488354627 -updatedAt: 1784490111791 -version: 2 +updatedAt: 1785243168334 +version: 3 --- Ajouter l'entrée `Profil` sur l'accueil et les surfaces UX connecté/déconnecté. Créer les écrans `Créer un compte`, `Se connecter`, confirmation logout. La connexion reste optionnelle, jamais affichée au démarrage. Erreurs serveur/réseau uniquement inline dans les formulaires, jamais popup globale. Profil affiché depuis cache local même hors ligne. \ No newline at end of file diff --git a/.ideai/tickets/68/carnet.md b/.ideai/tickets/68/carnet.md index c7bb9d2..c64f161 100644 --- a/.ideai/tickets/68/carnet.md +++ b/.ideai/tickets/68/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#68" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784491589721 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243170813 --- diff --git a/.ideai/tickets/68/issue.md b/.ideai/tickets/68/issue.md index a00c120..d2a96d9 100644 --- a/.ideai/tickets/68/issue.md +++ b/.ideai/tickets/68/issue.md @@ -2,15 +2,15 @@ id: "ac634023-e7ba-4588-9c7c-9f93c12d9aad" number: 68 title: "[DevFrontend] Statut de synchronisation discret et action manuelle" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#65","kind":"dependsOn"},{"target":"#67","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488359218 -updatedAt: 1784491589721 -version: 2 +updatedAt: 1785243170813 +version: 3 --- Afficher la section Synchronisation dans Profil : en cours, à jour, jamais faite, retry automatique, hors ligne. Ajouter `Synchroniser maintenant`. Ne jamais afficher de snackbar/dialog d'échec réseau hors contexte Profil. Optionnel : sous-titre discret de l'entrée Profil sur l'accueil. \ No newline at end of file diff --git a/.ideai/tickets/69/carnet.md b/.ideai/tickets/69/carnet.md index f4e6733..5d92d38 100644 --- a/.ideai/tickets/69/carnet.md +++ b/.ideai/tickets/69/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#69" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784525029743 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243173715 --- diff --git a/.ideai/tickets/69/issue.md b/.ideai/tickets/69/issue.md index 6d101eb..2ae1c42 100644 --- a/.ideai/tickets/69/issue.md +++ b/.ideai/tickets/69/issue.md @@ -2,15 +2,15 @@ id: "05482a20-83a6-44da-b0c6-d065a5a1ffba" number: 69 title: "[DevFrontend] Partage sortant et boîte de réception" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#66","kind":"dependsOn"},{"target":"#67","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488364646 -updatedAt: 1784525029743 -version: 2 +updatedAt: 1785243173715 +version: 3 --- Ajouter l'action `Partager` dans les écrans programme/séance enregistrés, formulaire email destinataire, états déconnecté et messages discrets. Ajouter `Profil > Partages reçus`, liste inbox, accepter/refuser, import local des programmes/séances reçus. Les erreurs réseau restent inline/neutres; accepter un partage rend la copie disponible offline. \ No newline at end of file diff --git a/.ideai/tickets/70/carnet.md b/.ideai/tickets/70/carnet.md index 1981664..1efe50d 100644 --- a/.ideai/tickets/70/carnet.md +++ b/.ideai/tickets/70/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#70" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784533224103 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243176716 --- diff --git a/.ideai/tickets/70/issue.md b/.ideai/tickets/70/issue.md index 2de8c12..87cdb31 100644 --- a/.ideai/tickets/70/issue.md +++ b/.ideai/tickets/70/issue.md @@ -2,15 +2,15 @@ id: "cb93ee24-0041-4926-b0c6-3ab26aab46ec" number: 70 title: "[QA] Validation client online offline-first" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#68","kind":"dependsOn"},{"target":"#69","kind":"dependsOn"}] agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784488373778 -updatedAt: 1784533224103 -version: 2 +updatedAt: 1785243176716 +version: 3 --- Valider le chantier client online : app utilisable sans compte, login/register/logout optionnels, tokens persistés, profil cache hors ligne, sync push/pull LWW, erreurs réseau silencieuses, retry différé, partage sortant ciblé, inbox, accept/decline/revoke, import local offline, absence de régression sur les parcours locaux exercices/programmes/séances/historique. \ No newline at end of file diff --git a/.ideai/tickets/71/carnet.md b/.ideai/tickets/71/carnet.md index 40c231a..18ad21b 100644 --- a/.ideai/tickets/71/carnet.md +++ b/.ideai/tickets/71/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#71" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784526657344 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785243094965 --- diff --git a/.ideai/tickets/71/issue.md b/.ideai/tickets/71/issue.md index bdc5964..af69790 100644 --- a/.ideai/tickets/71/issue.md +++ b/.ideai/tickets/71/issue.md @@ -2,15 +2,15 @@ id: "b7216520-cf5b-4e65-aeed-1839980d1fe6" number: 71 title: "[Bug] selection de score par défaut à 0" -status: "qa" +status: "closed" priority: "critical" sprint: null links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784490730564 -updatedAt: 1784526657344 -version: 4 +updatedAt: 1785243094965 +version: 5 --- Je dois pouvoir mettre un score par défaut à 0 lors de la création d'un exercice. Actuellement quand je met un score à 0, l'application me retourne une StateException car il y avisiblement un check qui se fait un le score > 0 \ No newline at end of file diff --git a/.ideai/tickets/72/carnet.md b/.ideai/tickets/72/carnet.md index feba76b..29b0f12 100644 --- a/.ideai/tickets/72/carnet.md +++ b/.ideai/tickets/72/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#72" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784527867008 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785243091609 --- diff --git a/.ideai/tickets/72/issue.md b/.ideai/tickets/72/issue.md index bb924b8..554bad2 100644 --- a/.ideai/tickets/72/issue.md +++ b/.ideai/tickets/72/issue.md @@ -2,15 +2,15 @@ id: "b388e21b-5262-4b3e-b075-5654d2a478f2" number: 72 title: "[Bug] les étapes des exercices ne sont pas affichés pendant la séance" -status: "qa" +status: "closed" priority: "critical" sprint: null links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784492460868 -updatedAt: 1784527867008 -version: 4 +updatedAt: 1785243091609 +version: 5 --- Pendant la séance, il n'y a aucune étape d'exercice affiché. J'ia un exercice divisé en 3 étapes avec des temps par étapes ou des répétitions, et quand je suis a cet exercices pendant ma séance, je n'ai pas les étapes d'affichées à l'écran. Je ne sais donc pas ce que je dois faire, ni combien de temps ou combien de fois. Je pense qu'il faudrait au moins le titre de chaque étape avec le nombre de répétitions ou le chrono qui défile et un bouton pour start/stop les chronos des étapes si les étapes possèdent un temps. \ No newline at end of file diff --git a/.ideai/tickets/73/carnet.md b/.ideai/tickets/73/carnet.md index 89c730f..7afe746 100644 --- a/.ideai/tickets/73/carnet.md +++ b/.ideai/tickets/73/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#73" -version: 5 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784565595136 +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1785243086218 --- diff --git a/.ideai/tickets/73/issue.md b/.ideai/tickets/73/issue.md index 1f7d8c8..344d9f6 100644 --- a/.ideai/tickets/73/issue.md +++ b/.ideai/tickets/73/issue.md @@ -2,15 +2,15 @@ id: "5ebceb98-b5d4-4a7d-8d4e-ab88c73a4c76" number: 73 title: "Pouvoir enchainer les chronos des étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#72","kind":"relatesTo"}] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784493103354 -updatedAt: 1784565595136 -version: 5 +updatedAt: 1785243086218 +version: 6 --- Dans le cas ou deux étapes à chronos s'enchainent, j'aimerais qu'une option proposant d'enchainer le start des chrono lorsque le rpécédent arrive à 0 soit proposé. Cette option serait disponible à la création de l'exercice, mais pourrait aussi être override dans le programme ainsi que dans la séance \ No newline at end of file diff --git a/.ideai/tickets/74/carnet.md b/.ideai/tickets/74/carnet.md index 06351e8..63de652 100644 --- a/.ideai/tickets/74/carnet.md +++ b/.ideai/tickets/74/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#74" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784526657602 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785243081689 --- diff --git a/.ideai/tickets/74/issue.md b/.ideai/tickets/74/issue.md index 04036d3..ec45ed5 100644 --- a/.ideai/tickets/74/issue.md +++ b/.ideai/tickets/74/issue.md @@ -2,16 +2,16 @@ id: "c18d9e4a-f6b4-4f6e-bfb5-b77c958f5534" number: 74 title: "[Bug] Serveur non lancable" -status: "qa" +status: "closed" priority: "critical" sprint: null links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784493302766 -updatedAt: 1784526657602 -version: 4 +updatedAt: 1785243081689 +version: 5 --- J'ai cette erreur quand j'essaie de build le serveur: ❯ docker compose up --build diff --git a/.ideai/tickets/75/carnet.md b/.ideai/tickets/75/carnet.md index b7fa4d1..946e583 100644 --- a/.ideai/tickets/75/carnet.md +++ b/.ideai/tickets/75/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#75" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784547809520 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243075122 --- diff --git a/.ideai/tickets/75/issue.md b/.ideai/tickets/75/issue.md index 186bd40..c52df75 100644 --- a/.ideai/tickets/75/issue.md +++ b/.ideai/tickets/75/issue.md @@ -2,15 +2,15 @@ id: "0197daa2-674e-4b03-9142-d0180874ffc4" number: 75 title: "[DevBackend] Modèle et migration pour auto-enchaînement des chronos d'étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#73","kind":"relatesTo"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784547220555 -updatedAt: 1784547809520 -version: 2 +updatedAt: 1785243075122 +version: 3 --- Ajouter le réglage `autoStartNextTimedStep` côté modèle. Sur `Exercise` : bool non-null par défaut true. Sur `ProgramExercise` : `autoStartNextTimedStepSnapshot` non-null + `autoStartNextTimedStepOverride` nullable pour revenir au réglage exercice snapshoté. Sur `WorkoutTemplateExerciseOverride` : `autoStartNextTimedStepOverride` nullable, décision consciente d'élargir l'override séance à un comportement booléen non structurel. Propager dans `ProgramExerciseConfig`, `_ProgramExerciseDraft`, snapshots JSON et mappers Drift. Migration Drift schemaVersion 13 -> 14 avec defaults true et colonnes nullable d'override. \ No newline at end of file diff --git a/.ideai/tickets/76/carnet.md b/.ideai/tickets/76/carnet.md index ebc5bd5..d72c7ca 100644 --- a/.ideai/tickets/76/carnet.md +++ b/.ideai/tickets/76/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#76" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784548663380 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243070204 --- diff --git a/.ideai/tickets/76/issue.md b/.ideai/tickets/76/issue.md index f0012fb..a6cbb98 100644 --- a/.ideai/tickets/76/issue.md +++ b/.ideai/tickets/76/issue.md @@ -2,15 +2,15 @@ id: "8896c34d-bc57-424b-967f-d3ef767185e1" number: 76 title: "[DevBackend] Résolution effective et auto-advance des chronos d'étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#73","kind":"relatesTo"},{"target":"#75","kind":"dependsOn"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784547227107 -updatedAt: 1784548663380 -version: 2 +updatedAt: 1785243070204 +version: 3 --- Adapter la résolution de séance et `ActiveExerciseStepUseCases` pour appliquer `séance > programme > exercice` à `autoStartNextTimedStep`. Inclure la valeur effective dans le snapshot résolu ou dans `_StepSequenceContext`. Modifier `_advanceState` et `_autoAdvanceElapsedTimers` : si une étape Temps expirée est suivie d'une étape Temps et que la valeur effective est false, enregistrer le résultat de l'étape expirée puis s'arrêter sur l'étape suivante en `stoppedTimer`, sans consommer l'overflow ni démarrer le chrono suivant. Conserver l'auto-enchaînement existant quand true. \ No newline at end of file diff --git a/.ideai/tickets/77/carnet.md b/.ideai/tickets/77/carnet.md index 43c8c40..03da0f9 100644 --- a/.ideai/tickets/77/carnet.md +++ b/.ideai/tickets/77/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#77" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784548663818 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243066150 --- diff --git a/.ideai/tickets/77/issue.md b/.ideai/tickets/77/issue.md index ab61526..abb255e 100644 --- a/.ideai/tickets/77/issue.md +++ b/.ideai/tickets/77/issue.md @@ -2,15 +2,15 @@ id: "835be070-248f-42fb-b996-4df86445f154" number: 77 title: "[DevFrontend] Réglages auto-enchaînement exercice, programme et séance" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#73","kind":"relatesTo"},{"target":"#75","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784547232756 -updatedAt: 1784548663818 -version: 2 +updatedAt: 1785243066150 +version: 3 --- Ajouter l'UI du réglage `Enchaîner automatiquement les chronos consécutifs` aux trois niveaux : `ExerciseFormScreen` valeur de base visible si séquence activée; `ProgramExerciseCustomizationScreen` override nullable avec indication héritage et action `Revenir au réglage de l'exercice`; `WorkoutTemplateProgramDetailScreen` override nullable avec action `Revenir au réglage du programme`. Attention à propager le champ dans `ProgramExerciseConfig` et `_ProgramExerciseDraft` comme pour `exerciseStepsSnapshot` corrigé au #72. \ No newline at end of file diff --git a/.ideai/tickets/78/carnet.md b/.ideai/tickets/78/carnet.md index 0148e66..5077418 100644 --- a/.ideai/tickets/78/carnet.md +++ b/.ideai/tickets/78/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#78" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784549136530 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243056992 --- diff --git a/.ideai/tickets/78/issue.md b/.ideai/tickets/78/issue.md index 45d5b75..657a43d 100644 --- a/.ideai/tickets/78/issue.md +++ b/.ideai/tickets/78/issue.md @@ -2,15 +2,15 @@ id: "e7769413-3d3d-48af-b55e-361c69aef4b2" number: 78 title: "[DevFrontend] État d'exécution Chrono suivant prêt" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#73","kind":"relatesTo"},{"target":"#76","kind":"dependsOn"},{"target":"#77","kind":"dependsOn"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784547239512 -updatedAt: 1784549136530 -version: 2 +updatedAt: 1785243056992 +version: 3 --- Adapter l'écran d'exécution des étapes pour afficher `Chrono suivant prêt` quand une étape Temps est prête après une étape Temps et que l'auto-enchaînement effectif est désactivé. Réutiliser `stoppedTimer` côté domaine; l'UI dérive le libellé/bouton depuis l'étape courante, le résultat précédent et la valeur effective. Bouton `Démarrer le chrono`, pas de pause séance, pas de rouge/erreur. Après kill/reprise, l'écran doit revenir à cet état sans démarrage automatique. \ No newline at end of file diff --git a/.ideai/tickets/79/carnet.md b/.ideai/tickets/79/carnet.md index a34c28f..f6af5cb 100644 --- a/.ideai/tickets/79/carnet.md +++ b/.ideai/tickets/79/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#79" -version: 2 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784565590789 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1785243052684 --- diff --git a/.ideai/tickets/79/issue.md b/.ideai/tickets/79/issue.md index d9402ae..61a74e7 100644 --- a/.ideai/tickets/79/issue.md +++ b/.ideai/tickets/79/issue.md @@ -2,15 +2,15 @@ id: "107fc29b-cd1d-40cc-9505-61180d081022" number: 79 title: "[QA] Validation auto-enchaînement configurable des chronos d'étapes" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [{"target":"#73","kind":"relatesTo"},{"target":"#78","kind":"dependsOn"}] agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1784547247343 -updatedAt: 1784565590789 -version: 2 +updatedAt: 1785243052684 +version: 3 --- Valider le réglage d'auto-enchaînement des chronos d'étapes : défaut true sur anciens/nouveaux exercices, override programme puis séance avec retour héritage, résolution séance > programme > exercice, comportement Temps->Temps true et false, plusieurs chronos consécutifs, fin de passage vers passage suivant, pause/reprise/kill app dans l'état `Chrono suivant prêt`, absence de régression sur étapes répétitions et skips. \ No newline at end of file diff --git a/.ideai/tickets/85/carnet.md b/.ideai/tickets/85/carnet.md index 3c77b92..fd05b39 100644 --- a/.ideai/tickets/85/carnet.md +++ b/.ideai/tickets/85/carnet.md @@ -1,6 +1,40 @@ --- issueRef: "#85" -version: 1 +version: 4 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784650728867 +updatedAt: 1785248859243 --- + +## #85 — Cadrage UX V1 packs partageables (UX, 2026-07-28) + +Arbitrage Main : avancer, périmètre borné (pas de feed social, pas de marketplace). V1 = extension du partage ciblé existant ([[gametime-server-architecture-sync-sharing]] : `Share`/`ShareRecipient`, inbox accept/decline/revoke), pas une nouvelle surface de découverte publique. + +### 1. Surfaces à ajouter/adapter +- **Adapter** l'écran de composition de partage existant : ajouter un choix "Séance/programme unique" vs "Pack" au moment de créer un envoi. En mode Pack, sélection multiple de séances-modèles/programmes existants + un nom de pack. Le picker de destinataires reste **identique** à l'existant (comptes déjà connus), aucune recherche/annuaire public ajouté. +- **Adapter** l'inbox de partages existante : les entrées de type Pack portent un badge `Pack · n séances` pour se distinguer visuellement d'un partage simple. +- **Ajouter** un seul écran nouveau, minimal : détail d'un pack reçu — nom du pack, auteur, liste des séances/programmes inclus (noms seulement), un bouton `Importer`. + +### 2. Parcours principal +- **Coach** : sélection multiple de séances/programmes existants → nomme le pack → choisit les destinataires parmi ses contacts déjà connectés (mécanique de partage actuelle, pas de découverte publique) → envoie. +- **Joueur** : inbox → ouvre l'entrée `Pack · n séances` → écran détail pack → `Importer` (un seul geste, tout ou rien) → chaque séance/programme est copiée dans son espace avec de nouveaux ids, la source de l'émetteur n'est jamais modifiée (règle déjà actée pour le partage ciblé). + +### 3. Métadonnées minimales affichées pour un pack +- Nom du pack, nom de l'auteur, nombre de séances/programmes inclus, liste des noms inclus, date d'envoi, statut (en attente/importé). +- Explicitement absent : description longue, tags/catégories, note, nombre d'imports par d'autres personnes. + +### 4. Exclu explicitement de la V1 +- Tout catalogue/liste de découverte publique de packs hors de ceux envoyés directement à l'utilisateur. +- Commentaires, likes, notes/étoiles. +- Monétisation (achat, don, prix). +- Import partiel/à la carte du contenu d'un pack : tout ou rien. +- Rôle "coach" formalisé au niveau compte (pas de badge, pas de vérification) — un coach est un utilisateur qui envoie à plusieurs destinataires, rien de plus en V1. +- Statistiques d'usage des packs (compteur d'imports, popularité). +- Mise à jour d'un pack déjà importé si la source change (cohérent avec la règle actuelle : le partage ne modifie jamais la copie du destinataire après import). + +### 5. Impacts libellés/navigation +- Pas de nouvel onglet de navigation racine ("Découvrir", "Marketplace"...). Tout reste sous les surfaces Partage/Programmes/Séances existantes — c'est ce qui borne explicitement la dérive vers un espace social séparé. +- Écran de composition de partage existant : ajout d'un simple sélecteur "Séance/programme" / "Pack", pas de nouvel écran d'entrée. +- Inbox existante : badge `Pack · n séances` ajouté, mais le libellé d'action reste `Importer` (même verbe que le partage ciblé simple, pas de nouveau vocabulaire à apprendre). + +### Hors périmètre pour Architect/Dev +Ce cadrage suppose que `Share`/`ShareRecipient` peuvent porter plusieurs ressources par envoi (pack = un envoi, plusieurs `SyncedResource` liés) plutôt qu'un nouveau concept serveur — à confirmer par Architect avant implémentation, mais pas de nouvelle entité métier proposée côté UX. diff --git a/.ideai/tickets/85/issue.md b/.ideai/tickets/85/issue.md index b03e10e..f226e51 100644 --- a/.ideai/tickets/85/issue.md +++ b/.ideai/tickets/85/issue.md @@ -2,16 +2,16 @@ id: "78ce00f0-b70c-4e21-a22a-42a78f31fa48" number: 85 title: "Packs de séances partageables (au-delà du partage ciblé existant)" -status: "open" +status: "inProgress" priority: "low" -sprint: null +sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822" links: [] agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784650728867 -updatedAt: 1784650728867 -version: 1 +updatedAt: 1785248859243 +version: 4 --- Constat Commercial : certains concurrents s'appuient sur des contenus créés par des coachs/communauté. GameTime a déjà une base de partage ciblé de programmes/séances entre comptes (#51, #66, #69, en QA) ; l'étape suivante serait des "packs" de séances plus larges (coach ou communauté), mais uniquement après arbitrage explicite de Main sur le positionnement (éviter de dériver vers un réseau social ou une marketplace). diff --git a/.ideai/tickets/88/carnet.md b/.ideai/tickets/88/carnet.md index 34bf8ca..ea63b76 100644 --- a/.ideai/tickets/88/carnet.md +++ b/.ideai/tickets/88/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#88" -version: 3 +version: 4 updatedBy: {"kind":"user"} -updatedAt: 1784665050645 +updatedAt: 1785243048210 --- diff --git a/.ideai/tickets/88/issue.md b/.ideai/tickets/88/issue.md index 8cb1812..ecac75e 100644 --- a/.ideai/tickets/88/issue.md +++ b/.ideai/tickets/88/issue.md @@ -2,7 +2,7 @@ id: "da3a2685-d581-4a50-a439-be01b8e596ee" number: 88 title: "[Bug] error au lancement d'un exercice" -status: "qa" +status: "closed" priority: "critical" sprint: null links: [] @@ -10,7 +10,7 @@ agentRefs: [] createdBy: {"kind":"user"} updatedBy: {"kind":"user"} createdAt: 1784651166409 -updatedAt: 1784665050645 -version: 3 +updatedAt: 1785243048210 +version: 4 --- Quand j'essaie de lancer le chrono d'un exercice, j'ai une erreur qui dit Impossible de demarrer l'exerccie: Invalid arguments (params[1]): Allowed parameters must either be null or or bool, int, nul, string... \ No newline at end of file diff --git a/.ideai/tickets/90/carnet.md b/.ideai/tickets/90/carnet.md index c26919f..0bb611c 100644 --- a/.ideai/tickets/90/carnet.md +++ b/.ideai/tickets/90/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#90" -version: 4 +version: 5 updatedBy: {"kind":"user"} -updatedAt: 1784665064484 +updatedAt: 1785243041954 --- diff --git a/.ideai/tickets/90/issue.md b/.ideai/tickets/90/issue.md index 5c53856..ee43c75 100644 --- a/.ideai/tickets/90/issue.md +++ b/.ideai/tickets/90/issue.md @@ -2,7 +2,7 @@ id: "a04feb24-2a6f-46b7-8e36-b8a113b7c2b5" number: 90 title: "[UI] Rework UI séance pour enlever le scroll" -status: "qa" +status: "closed" priority: "medium" sprint: null links: [] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"} createdBy: {"kind":"user"} updatedBy: {"kind":"user"} createdAt: 1784651486750 -updatedAt: 1784665064484 -version: 4 +updatedAt: 1785243041954 +version: 5 --- J'aimerais que l'UI pednant une séance soit légèrement revu pour faire en sorte que l'affichage n'ai aps de scroll, et que tout rentre directement dans l'écran du téléphone lorsqu'on a plusieurs étapes dans un exercice. Actuellement, lorsqu'un exercice propose plusieurs étapes, un scroll est obligatoire pour afficher l'étape et les bouton "Terminer la série" et "Passer la série". J'aiemrais une proposition d'UI/UX pour ça \ No newline at end of file diff --git a/.ideai/tickets/91/assets/build_mockup.py b/.ideai/tickets/91/assets/build_mockup.py new file mode 100644 index 0000000..af84afb --- /dev/null +++ b/.ideai/tickets/91/assets/build_mockup.py @@ -0,0 +1,174 @@ +import base64 +import pathlib +import textwrap + +root = pathlib.Path("/home/anthony/Documents/Projects/GameTime") +anton = base64.b64encode((root / "assets/fonts/Anton-Regular.ttf").read_bytes()).decode() +archivo = base64.b64encode((root / "assets/fonts/Archivo-Variable.ttf").read_bytes()).decode() + +# Palette Court Blazer (theme sombre) +GOLD = "#C9A24A" +CRIMSON = "#D72638" +BG = "#080A12" +SURFACE = "#141824" +TEXT = "#F5F1E8" +TEXT_SECONDARY = "#A7ADBA" +BORDER = "#242A3A" + +TILE_W = 260 +TILE_H = 330 +FACE_D = 216 +GAP = 24 +N = 5 +WIDTH = N * TILE_W + (N + 1) * GAP +HEIGHT = TILE_H + 190 + +def watch_face(cx, cy, r, label_top, big_value, big_color, status_line, button_label, button_style, disconnected=False): + parts = [] + # bezel + parts.append(f'') + parts.append(f'') + # crimson top accent arc (clin d\'oeil liseré 2px des cartes cles, adapte en arc sur le bezel interne) + if not disconnected: + parts.append(f'') + else: + parts.append(f'') + + top_y = cy - r * 0.44 + value_y = cy - r * 0.02 + status_y = cy + r * 0.30 + btn_cy = cy + r * 0.62 + btn_w = r * 1.5 + btn_h = 40 + + label_color = TEXT_SECONDARY if not disconnected else "#5A6072" + parts.append( + f'{label_top}' + ) + + parts.append( + f'{big_value}' + ) + + parts.append( + f'{status_line}' + ) + + # button pill + bx = cx - btn_w / 2 + by = btn_cy - btn_h / 2 + if button_style == "gold_fill": + fill, stroke, textcol = GOLD, GOLD, "#1A1206" + elif button_style == "crimson_outline": + fill, stroke, textcol = "none", CRIMSON, CRIMSON + elif button_style == "crimson_fill": + fill, stroke, textcol = CRIMSON, CRIMSON, TEXT + else: # muted outline (disconnected) + fill, stroke, textcol = "none", "#3A4152", TEXT_SECONDARY + + parts.append( + f'' + ) + parts.append( + f'{button_label}' + ) + return "\n".join(parts) + + +states = [ + dict( + caption="1 — Pret a demarrer", + sub="Etape / exercice sous chrono, pas encore lance", + label_top="DRIBBLE COMBO", + big_value="00:00", + big_color=TEXT_SECONDARY, + status_line="Serie 2 / 4", + button_label="Demarrer", + button_style="gold_fill", + ), + dict( + caption="2 — Chrono en cours", + sub="Le chrono actif tourne (exercice ou etape)", + label_top="DRIBBLE COMBO", + big_value="00:23", + big_color=GOLD, + status_line="Serie 2 / 4 - en cours", + button_label="Arreter", + button_style="crimson_outline", + ), + dict( + caption="3 — Chrono en pause", + sub="Chrono arrete avec valeur, reprise possible", + label_top="DRIBBLE COMBO", + big_value="00:23", + big_color=GOLD, + status_line="Serie 2 / 4 - en pause", + button_label="Reprendre", + button_style="gold_fill", + ), + dict( + caption="4 — Pas de chrono actif", + sub="Serie reps/score libre : l'action utile est de conclure", + label_top="TIRS EN SUSPENSION", + big_value="2 / 4", + big_color=GOLD, + status_line="Serie en cours", + button_label="Terminer la serie", + button_style="crimson_fill", + ), + dict( + caption="5 — Montre non synchronisee", + sub="Perte de liaison : dernier etat connu, sans alarmer", + label_top="DERNIERE DONNEE RECUE", + big_value="00:23", + big_color="#5A6072", + status_line="Non synchronisee", + button_label="Reessayer", + button_style="muted", + disconnected=True, + ), +] + +svg_parts = [ + f'', + '', + f'', + f'GAMETIME — MONTRE SYNCHRONISEE', + f'Ecran unique, bouton contextuel unique — style Court Blazer', +] + +for i, st in enumerate(states): + cx = GAP + TILE_W * i + TILE_W / 2 + cy = 96 + FACE_D / 2 + 10 + r = FACE_D / 2 + svg_parts.append(watch_face(cx, cy, r, st["label_top"], st["big_value"], st["big_color"], + st["status_line"], st["button_label"], st["button_style"], + disconnected=st.get("disconnected", False))) + cap_y = cy + r + 34 + sub_y = cap_y + 22 + svg_parts.append( + f'{st["caption"]}' + ) + lines = textwrap.wrap(st["sub"], width=30) + for li, line in enumerate(lines): + svg_parts.append( + f'{line}' + ) + +svg_parts.append('') + +out = root / ".ideai/tickets/91/assets/watch_screen_mockup.svg" +out.write_text("\n".join(svg_parts), encoding="utf-8") +print("written", out, out.stat().st_size) diff --git a/.ideai/tickets/91/assets/watch_screen_mockup.png b/.ideai/tickets/91/assets/watch_screen_mockup.png new file mode 100644 index 0000000000000000000000000000000000000000..debf98e9a53a29098a2381c755edc954451301da GIT binary patch literal 88400 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

oo|k%WwqUtFQnkY$4*7m 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 W=+q3Z%`q!LVd&}V=d#Wzp$PzN-Jq5L literal 0 HcmV?d00001 diff --git a/.ideai/tickets/91/assets/watch_screen_mockup.svg b/.ideai/tickets/91/assets/watch_screen_mockup.svg new file mode 100644 index 0000000..d2fbe73 --- /dev/null +++ b/.ideai/tickets/91/assets/watch_screen_mockup.svg @@ -0,0 +1,64 @@ + + + +GAMETIME — MONTRE SYNCHRONISEE +Ecran unique, bouton contextuel unique — style Court Blazer + + + +DRIBBLE COMBO +00:00 +Serie 2 / 4 + +Demarrer +1 — Pret a demarrer +Etape / exercice sous chrono, +pas encore lance + + + +DRIBBLE COMBO +00:23 +Serie 2 / 4 - en cours + +Arreter +2 — Chrono en cours +Le chrono actif tourne +(exercice ou etape) + + + +DRIBBLE COMBO +00:23 +Serie 2 / 4 - en pause + +Reprendre +3 — Chrono en pause +Chrono arrete avec valeur, +reprise possible + + + +TIRS EN SUSPENSION +2 / 4 +Serie en cours + +Terminer la serie +4 — Pas de chrono actif +Serie reps/score libre : +l'action utile est de conclure + + + +DERNIERE DONNEE RECUE +00:23 +Non synchronisee + +Reessayer +5 — Montre non synchronisee +Perte de liaison : dernier +etat connu, sans alarmer + \ No newline at end of file diff --git a/.ideai/tickets/91/carnet.md b/.ideai/tickets/91/carnet.md index ef9b7e6..df947da 100644 --- a/.ideai/tickets/91/carnet.md +++ b/.ideai/tickets/91/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#91" -version: 3 +version: 4 updatedBy: {"kind":"user"} -updatedAt: 1784656427938 +updatedAt: 1785230763779 --- diff --git a/.ideai/tickets/91/issue.md b/.ideai/tickets/91/issue.md index f76f554..b7db179 100644 --- a/.ideai/tickets/91/issue.md +++ b/.ideai/tickets/91/issue.md @@ -2,7 +2,7 @@ id: "990a5e5a-8490-43ab-b7f6-7cae61be25fd" number: 91 title: "Interface montre synchronisée" -status: "open" +status: "closed" priority: "low" sprint: null links: [] @@ -10,7 +10,7 @@ agentRefs: [] createdBy: {"kind":"user"} updatedBy: {"kind":"user"} createdAt: 1784656109713 -updatedAt: 1784656427938 -version: 3 +updatedAt: 1785230763779 +version: 4 --- J'aimerais avoir une version pour ma montre android qui soit synchronisée avec l'application téléphone. Elle me permettrait entre autre de lancer/arrêter les chronos (donc dans le cas d'un exercice sous chrono, de lancer le chrono ou de le mettre en pause) ou dans le cas d'une étape de lancer le chrono de l'étape, en gardant la décision prise comme quoi si la première étape est un chrono et que l'exercice est sous chrono, le lancement de chrono est commun a l'exercice et à l'étape. Ca permettrait en plus de ça de pouvoir passer une étape, une séquence, une série ou terminer une série. Bref, pouvoir gérer sa séance depuis sa montre. l'UX/UI est bien sur a voir avec l'agent UX pour savoir comment organiser ça. Heavy propose une fonctionnalité similaire si il y a besoin d'une inspiration \ No newline at end of file diff --git a/.ideai/tickets/94/carnet.md b/.ideai/tickets/94/carnet.md new file mode 100644 index 0000000..69f711f --- /dev/null +++ b/.ideai/tickets/94/carnet.md @@ -0,0 +1,142 @@ +--- +issueRef: "#94" +version: 8 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784936913063 +--- + + +--- + +# Architect — Cadrage point 2 : regroupement des étapes sans chrono (ticket #94) + +Périmètre : uniquement le point 2 de l'analyse UX ci-dessus (regroupement/auto-complétion silencieuse des étapes sans chrono). Les points 1/3/4/5 restent des ajustements UI purs, DevFrontend les traite directement sans ce cadrage. + +## Ce qui existe déjà et se réutilise tel quel + +Code lu : `lib/application/use_cases.dart` (`ActiveExerciseStepUseCases`, ~L3087-3653) et `lib/domain/entities.dart` (`ActiveExerciseStepProgressState`, `ActiveExerciseStepResult`, `ExerciseStep`, `ActiveExerciseStepProgressStatus`). + +- `_StepSequenceContext` porte déjà `steps`, `expectedPassages`, `autoStartNextTimedStep` (résolu depuis #73/#76) — tout ce qu'il faut pour scanner la timeline à venir sans lecture DB supplémentaire. +- `_advanceState(context, state, now, {completedStep})` fait déjà avancer d'une étape en traversant les frontières de passage (`nextStep >= steps.length → nextPassage++`) et sait déjà produire `sequenceComplete` en fin de dernier passage. C'est exactement la mécanique de traversée qu'il faut réutiliser en boucle pour le regroupement — **pas besoin de réécrire la logique de frontière de passage**, juste de l'appeler plusieurs fois d'affilée avant l'action déclenchante, comme `_autoAdvanceElapsedTimers` le fait déjà pour les chronos qui s'enchaînent. +- `_stepResult(...)` sait déjà construire un `ActiveExerciseStepResult` `completed` avec `actualReps ?? step.defaultTargetValue` (cf. `completeCurrentStep`, L3301-3303) — c'est la convention à réutiliser pour l'auto-complétion silencieuse (valeur cible enregistrée comme valeur réalisée, jamais `null`, cohérent avec `gametime-architecture-exercise-steps` qui laissait ce choix à l'implémentation). +- `ActiveExerciseStepProgressStatus.waitingManual` reste le statut correct pour une étape reps individuelle ; on ne touche pas à l'enum. + +## Ce qui doit changer : nouvelle logique de calcul, pas de nouveau champ persisté + +**Pas de changement de modèle de données.** `ActiveExerciseStepProgressState` et `ActiveExerciseStepResult` restent inchangés dans leur forme. La notion de « suite silencieuse » est **dérivée à la volée** depuis `context.steps` + position courante, exactement comme `_isNextTimedStepReady` (présentation, ~L2354-2368) dérive déjà `Chrono suivant prêt` sans champ dédié. Raison : la suite n'est qu'une lecture en avance sur une liste déjà chargée en mémoire (max 8 étapes, cf. `gametime-architecture-exercise-steps`), aucune raison de la persister. + +### Règle d'éligibilité au regroupement silencieux + +Une étape est éligible au regroupement silencieux si et seulement si : + +```dart +bool _isSilentStep(ExerciseStep step) { + if (step.type == ExerciseStepType.time) return false; + if (step.hasScore && step.scoreInputMode == ScoreInputMode.stopwatch) return false; + return true; // reps sans score, ou reps avec score manuel +} +``` + +C'est la traduction directe du cas limite déjà identifié par UX : une étape reps à score chrono a son propre chrono actif et reste un point d'interaction à part entière. + +### Calcul de la suite courante (nouvelle fonction application, pure, sans I/O) + +```dart +_SilentStepGroup _resolveSilentGroup( + _StepSequenceContext context, + ActiveExerciseStepProgressState state, +) { + final steps = []; + var passage = state.currentPassageIndex; + var index = state.currentStepIndex; + while (true) { + final step = context.steps[index]; + if (!_isSilentStep(step)) { + return _SilentStepGroup(steps: steps, blockingStep: step, blockingPassageIndex: passage, reachesSequenceEnd: false); + } + steps.add(step); + index++; + if (index >= context.steps.length) { + index = 0; + passage++; + if (passage >= context.expectedPassages) { + return _SilentStepGroup(steps: steps, blockingStep: null, blockingPassageIndex: passage, reachesSequenceEnd: true); + } + } + } +} +``` + +Appelée uniquement quand l'étape courante (`_currentStep(context, state)`) est elle-même silencieuse — sinon la vue reste le mode `_CurrentStepPane` classique existant (étape chronométrée, ou étape reps isolée). En pratique une étape reps isolée forme un groupe à 1 élément : le layout groupé et le layout actuel convergent visuellement pour ce cas trivial. DevFrontend affiche le layout groupé dès qu'il y a ≥1 étape silencieuse en tête, pas seulement ≥2, pour ne pas avoir deux présentations différentes selon la longueur de la suite. + +### Port/contrat exposé à la présentation + +Étendre `ActiveExerciseStepProgressView` (pas de nouveau port séparé — c'est le même port `ActiveExerciseStepUseCases`/vue déjà consommé par l'écran) avec : + +```dart +final class ActiveExerciseStepProgressView { + // ... champs existants inchangés (state, steps, expectedPassages, results) + final List silentPendingSteps; // vide si l'étape courante n'est pas silencieuse + final ExerciseStep? silentGroupBlockingStep; // étape chronométrée qui suit la suite, null si fin de séquence + final bool silentGroupReachesSequenceEnd; // true si la suite va jusqu'à sequenceComplete +} +``` + +Calculée dans `startOrResumeProgress`/`readProgress` à partir de `_resolveSilentGroup`, en lecture seule — aucune écriture tant que l'utilisateur n'a pas déclenché l'action de clôture. `silentPendingSteps` est la liste à afficher (« Pompes 10 reps / Squats 15 reps / Fentes 10 reps ») ; `silentGroupBlockingStep` alimente la ligne « Chrono suivant : Gainage · 20 s » ; `silentGroupReachesSequenceEnd` sert à afficher le message de fin de série au lieu du bouton `Lancer`. + +### Nouvelle méthode d'auto-complétion silencieuse (écriture) + +Un seul point d'entrée, appelé par les deux actions déclenchantes : + +```dart +Future completePendingSilentSteps({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) async { + final context = await _stepContext(...); + var state = await _requiredStepProgressState(...); + final now = clock.now(); + while (_isSilentStep(_currentStep(context, state))) { + final step = _currentStep(context, state); + final result = _stepResult( + context: context, state: state, step: step, + status: SetResultStatus.completed, now: now, + actualReps: step.type == ExerciseStepType.reps ? step.defaultTargetValue : null, + ); + await sessionRepository.saveExerciseStepResult(result); + state = _advanceState(context, state, now, completedStep: step); + } + await sessionRepository.saveExerciseStepProgressState(state); + return state; +} +``` + +Réutilise `_advanceState` tel quel pour la traversée multi-passages (aucune modification de cette méthode nécessaire : elle gère déjà `nextPassage++` et `sequenceComplete`). + +**Intégration côté « Lancer » (chrono suivant)** : `startTimer(...)` doit appeler `completePendingSilentSteps(...)` en tout premier (si l'étape courante est silencieuse) avant sa logique actuelle — après quoi l'étape courante est bien l'étape chronométrée bloquante, et le reste de `startTimer` fonctionne sans modification. + +**Intégration côté « Terminer la série »** : le use case derrière `_finishSet` côté présentation (`recordCurrentSetResult`/`upsertSetResultAtPosition`, `lib/application/use_cases.dart` ~L2435-2487) doit, quand une séquence d'étapes existe pour la série (`context` résoluble sans exception) et que l'état n'est pas déjà `sequenceComplete`, appeler `completePendingSilentSteps(...)` avant d'enregistrer le résultat de série — silencieusement, sans passer par un dialogue supplémentaire (contrairement au dialogue existant `Chrono non lancé` qui gère un autre cas). Point d'attention DevBackend : ne déclencher cet appel que si l'étape courante est silencieuse ; si elle est déjà chronométrée/bloquante, ne rien faire (comportement actuel inchangé, l'utilisateur clôture une série avec une étape chrono en cours comme aujourd'hui). + +### Cas limites couverts par ce calcul + +- **Traversée de plusieurs passages** : couverte nativement par la boucle `while` de `_resolveSilentGroup`/`completePendingSilentSteps`, qui réutilise `_advanceState` — aucune règle spéciale à écrire, c'est la même mécanique que `_autoAdvanceElapsedTimers` applique déjà pour les chronos. +- **Étape reps à score chrono** : exclue par `_isSilentStep`, casse le regroupement comme demandé — elle redevient un point d'arrêt classique (`waitingManual`), sans changement de comportement pour elle. +- **Séquence entièrement sans chrono** : `silentGroupReachesSequenceEnd == true` dès `startOrResumeProgress`, dès la première étape ; `completePendingSilentSteps` sera appelée uniquement à `Terminer la série`, donc aucune écriture avant cette action (conforme à « l'utilisateur ne touche le téléphone qu'une fois, à la fin »). +- **Reprise après kill/pause** : rien à faire de spécial, `silentPendingSteps` est recalculé à chaque `startOrResumeProgress`/`readProgress` depuis l'état persistant réel (pas de champ volatile en mémoire à perdre). + +### Pas de réglage configurable + +Confirmé avec la recommandation UX : comportement toujours actif, pas d'override façon `autoStartNextTimedStep` (#73). Contrairement à #73, il n'existe ici aucune fenêtre de temps réel entre étapes silencieuses (pas de chrono qui tourne), donc aucun cas d'usage produit ne justifie une confirmation étape par étape. Ne pas ajouter de champ domaine `Exercise`/`ProgramExercise`/`WorkoutTemplateExerciseOverride` pour ce point. Si un besoin réel émerge après usage, rouvrir un ticket dédié plutôt que d'anticiper. + +## Découpage en lots + +- **B1 — DevBackend** : `_isSilentStep`, `_resolveSilentGroup`, extension de `ActiveExerciseStepProgressView` (3 nouveaux champs), `completePendingSilentSteps`, intégration dans `startTimer` et dans le use case de clôture de série (`recordCurrentSetResult`/équivalent derrière `_finishSet`). Tests ciblés : suite silencieuse simple, suite traversant 2+ passages, suite bloquée par étape à score chrono, suite qui va jusqu'à `sequenceComplete`, reprise après kill au milieu d'une suite silencieuse. +- **F — DevFrontend** (en plus des points 1/3/4/5 déjà cadrés UI par UX) : nouveau layout du panneau séquence quand `silentPendingSteps.isNotEmpty` (liste « À enchaîner sans interruption » + ligne `Chrono suivant : nom · cible` + bouton `Lancer`, ou message de fin de série sans bouton dédié si `silentGroupReachesSequenceEnd`), réutilisant le format déjà défini par UX dans son cadrage ci-dessus. Dépend de B1. + +## Étape suivante + +- **Git** : créer la branche de travail pour #94. +- **DevBackend** : lot B1 ci-dessus (point 2 uniquement). +- **DevFrontend** : points 1, 2 (lot F, dépend de B1), 3, 4, 5 — cadrage UX complet disponible plus haut dans ce carnet pour 1/3/4/5, cadrage Architect ci-dessus pour le point 2. diff --git a/.ideai/tickets/94/issue.md b/.ideai/tickets/94/issue.md new file mode 100644 index 0000000..e41a4f0 --- /dev/null +++ b/.ideai/tickets/94/issue.md @@ -0,0 +1,24 @@ +--- +id: "2dd7f167-7478-49f0-a069-7420cc1464f4" +number: 94 +title: "Refonte systeme etapes passage et serie d'un exercice" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784732039058 +updatedAt: 1784936913063 +version: 8 +--- +Dans un exercice il y a un systeme de serie, de répétitions, d'étapes et de passage. Ca fait beaucoup, et ce n'est pas forcemment très clair autant a utiliser qu'a configurer. Il faut, sans perdre de fonctionnalité, épurer tout ça ou le rendre plus clair en ayant un affichage moins chargé. Par ne pas perdre de fonctionnalité, j'entends qu'il faut que l'ont puisse créer un maximum d'exercices en les organisant correctement. Il est important qu'on puisse enchainer différent mouvement au sein d'un même exercice et plusieurs fois ou pendant un certain temps. + +J'entends par la que si je veux m'échauffer au niveau du dribble par exemple, il est important que je puisse faire 15sec mains gauche, puis 15sec mains droite par exemple sans avoir a toucher mon téléphone qui me dit quand changer de main (c'était l'interet des étapes avec chrono). je pense aussi qu'il est important de pouvoir consulter les différentes étapes d'une série avant de lancer la série pour savoir quoi faire lorsque le temps de chaque étape est écoulé. +J'aimerais aussi qu'on trouve un meilleur affichage pour les étapes qui n'ont pas de chrono mais un nombre de répétition. Le but des étapes et de pouvoir les enchainer sans avoir a toucher le téléphone, si j'ai beaucoup d'étapes avec très peu de répétitions je me retrouve a toujours avoir le téléphone dans les mains, on pourrait par exemple, s'il y a une succession d'étapes sans chrono, les afficher avec le nombre de répétition d'étapes à faire. ça éviterait de devoir, pour chaque étape, passer à l'étape suivante, on aurait à le faire que pour terminer la série si c'était la derniere étape du dernier passage ou pour lancer le chrono de l'étape suivant si l'étape qui suit la dernière étape sans chrono est finie. + +La notion de passage doit permettre de boucler sur une séquence d'étapes sans temps de repos alors que la notion de série doit permettre de boucler sur une séquence de passage avec du temps de repos. J'aimerais que ça soit un peu plus clair. Je pense qu'a coté des étapes, on pourrait se contenter d'afficher notre nombre de passage uniquement s'il y en a plusieurs en marquant par exemple uniquement 1/5. On pourrait aussi enlever "Etape 1/4" étant donné qu'on a déjà l'information avec les carrés qui représentent les étapes. + +Quand l'exercice n'est pas encore démarré, le bouton Démarrer l'exercice vient écraser tout le texte sur le côté de l'écran et le chrono ainsi que le texte de la première étape (si l'étape possède un chrono) est écrasé entre le nom de l'étape et le bouton "Passer l'étape". Il faut trouver quelque chose pour corriger ça. Enlever "etape 1/4" qui est une répétition du nombre d'étapes et enlever "Passage réalisés" qui est actuelleemnt juste au dessus du bouton Terminer la série en mettant par exemple l'informaation à la place du titre SEQUENCE qui je pense ne sert pas à grand chose pourrait aussi permettre de gagner de la place et de la lisibilité. +On pourrait aussi enlever le bouton "Pause" étant donné qu'il fait la même chose que la fleche de retour, et pourquoi pas mettre le temps de la séance sur cette ligne la \ No newline at end of file diff --git a/.ideai/tickets/95/carnet.md b/.ideai/tickets/95/carnet.md new file mode 100644 index 0000000..dcb1a70 --- /dev/null +++ b/.ideai/tickets/95/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#95" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784991949110 +--- diff --git a/.ideai/tickets/95/issue.md b/.ideai/tickets/95/issue.md new file mode 100644 index 0000000..bde4100 --- /dev/null +++ b/.ideai/tickets/95/issue.md @@ -0,0 +1,27 @@ +--- +id: "67ec03c4-2fba-491c-9b87-a9bd219ea7a7" +number: 95 +title: "#91-A [DevBackend] Contrats watch bridge partagés + façade WatchCompanionUseCases" +status: "closed" +priority: "medium" +sprint: null +links: [] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991949110 +updatedAt: 1784991949110 +version: 1 +--- +Partie #91. Cf. mémoires `gametime-architecture-watch-companion` et `gametime-ux-watch-companion`. + +Créer le package Dart partagé `packages/watch_bridge_contract/` avec les DTO/enums de transport versionnés : +- `WatchCommandEnvelope { schemaVersion, commandId, type, sessionId, expectedRevision, sentAtEpochMs }` +- enum `WatchCommandType` : startCurrentExercise, pauseSession, resumeSession, startPreparedTimedStep, skipCurrentStep, skipCurrentPassage, finishCurrentSet, skipCurrentSet, skipCurrentRest +- enum `WatchCommandAck` : accepted, acceptedNoOp, rejectedStaleRevision, rejectedNotApplicable, rejectedNoActiveSession, rejectedSessionMismatch, rejectedPhoneBusy +- `WatchSessionProjection` + `WatchTimerProjection` (champs exacts cf. mémoire archi) +- Sérialisation JSON stable + versionnée (parsers tolérants pour champs futurs). + +Ajouter la façade `WatchCompanionUseCases` (port application) exposant les points d'entrée que l'adapter Android (D) implémentera : envoi de projection, réception/routing de commande. Pas de logique Android ici. + +Définition de done : package partagé créé, DTO/enums sérialisables, tests unitaires de sérialisation/deserialization (round-trip + champs manquants tolérés), `dart analyze` propre. diff --git a/.ideai/tickets/96/carnet.md b/.ideai/tickets/96/carnet.md new file mode 100644 index 0000000..2d82446 --- /dev/null +++ b/.ideai/tickets/96/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#96" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784992086885 +--- diff --git a/.ideai/tickets/96/issue.md b/.ideai/tickets/96/issue.md new file mode 100644 index 0000000..e16baed --- /dev/null +++ b/.ideai/tickets/96/issue.md @@ -0,0 +1,22 @@ +--- +id: "90445962-af59-435b-b083-a33e3124806c" +number: 96 +title: "#91-C [DevBackend] Routing des commandes montre vers les use cases d'exécution existants" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#95","kind":"dependsOn"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991949120 +updatedAt: 1784992086885 +version: 2 +--- +Partie #91, dépend de #91-A et #91-B. + +Recevoir les `WatchCommandEnvelope`, valider `expectedRevision == currentRevision` (garantie d'ordre + idempotence : un retry ou doublon ne doit jamais avancer deux fois une étape/passage/série). Traitement **séquentiel** côté téléphone. Router vers les **mêmes use cases d'exécution existants** que le téléphone (pas de duplication de logique). Renvoyer le `WatchCommandAck`. + +Préserver côté use cases la règle : si première étape chrono + exercice sous chrono, le démarrage est commun (la commande `startCurrentExercise` lance les deux — pas de logique montre). + +Définition de done : routing fonctionnel vers use cases existants, acks corrects, tests unitaires : commande nominal, révision stale → rejected, retry/doublon → acceptedNoOp (pas de double avance), commandes non applicables selon phase. diff --git a/.ideai/tickets/97/carnet.md b/.ideai/tickets/97/carnet.md new file mode 100644 index 0000000..e915fab --- /dev/null +++ b/.ideai/tickets/97/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#97" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784992086886 +--- diff --git a/.ideai/tickets/97/issue.md b/.ideai/tickets/97/issue.md new file mode 100644 index 0000000..89834ba --- /dev/null +++ b/.ideai/tickets/97/issue.md @@ -0,0 +1,24 @@ +--- +id: "a8c9ec77-2e81-4306-841e-e9a7714afc8f" +number: 97 +title: "#91-B [DevBackend] Projection compacte d'exécution téléphone -> montre" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#95","kind":"dependsOn"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991949132 +updatedAt: 1784992086886 +version: 2 +--- +Partie #91, dépend de #91-A. + +Côté téléphone, calculer la `WatchSessionProjection` depuis l'état d'exécution actif (même source de vérité que l'écran d'exécution, pas de deuxième modèle). Mapper : +- phase : noActiveSession | ready | running | paused | nextTimerReady | restRunning | restPaused | betweenSetsReady +- seriesIndex/seriesTotal, exerciseName, passageIndex/passageTotal?, stepIndex/stepTotal?, stepName? +- dominantTimer? + secondaryTimers avec timestamps autoritaires (referenceEpochMs, accumulatedMs, startedAtEpochMs?, targetMs?) pour interpolation locale montre +- primaryAction + secondaryActions, nextExerciseName?, statusLabel?, phoneReachable, revision + +Définition de done : projection produite depuis l'état actif, tests unitaires couvrant chaque phase + transitions clés (chrono running, Chrono suivant prêt, repos, entre séries). diff --git a/.ideai/tickets/98/carnet.md b/.ideai/tickets/98/carnet.md new file mode 100644 index 0000000..d366dac --- /dev/null +++ b/.ideai/tickets/98/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#98" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784992086886 +--- diff --git a/.ideai/tickets/98/issue.md b/.ideai/tickets/98/issue.md new file mode 100644 index 0000000..3d0e1f0 --- /dev/null +++ b/.ideai/tickets/98/issue.md @@ -0,0 +1,26 @@ +--- +id: "7c891c9a-c136-4d24-90bb-06688191c7a5" +number: 98 +title: "#91-D [DevBackend] Adapter Android Wear Data Layer + foreground service téléphone" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#97","kind":"dependsOn"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991949143 +updatedAt: 1784992086886 +version: 2 +--- +Partie #91, dépend de #91-B et #91-C. + +Implémenter l'adapter d'infrastructure Android (Wearable Data Layer natif) derrière la façade de A : +- `MessageClient` : commandes montre -> téléphone + acks retour. +- `DataClient` : dernier état `WatchSessionProjection` téléphone -> montre. +- `CapabilityClient` : découverte + reconnexion ; resync complet de l'état à la reconnexion. +- Foreground service Android pour garder l'exécution + le canal vivants quand le téléphone est verrouillé / app en arrière-plan pendant une séance active (contraintes Android, notifications). +- Heartbeat téléphone ~5 s pendant qu'un chrono tourne (en plus de l'interpolation locale montre). +- Pas de cloud, offline/local uniquement. + +Définition de done : adapter branché sur la façade, foreground service fonctionnel, reconnexion/resync validés, tests d'intégration (mock du Data Layer) au vert. diff --git a/.ideai/tickets/99/carnet.md b/.ideai/tickets/99/carnet.md new file mode 100644 index 0000000..ca4d984 --- /dev/null +++ b/.ideai/tickets/99/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#99" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784992086885 +--- diff --git a/.ideai/tickets/99/issue.md b/.ideai/tickets/99/issue.md new file mode 100644 index 0000000..18f4c55 --- /dev/null +++ b/.ideai/tickets/99/issue.md @@ -0,0 +1,24 @@ +--- +id: "aeb796ac-3f2b-4cb8-a531-f7e8b47dbadb" +number: 99 +title: "#91-F [DevFrontend] Client watch bridge + pending/latence/reconnexion + haptiques" +status: "closed" +priority: "medium" +sprint: null +links: [{"target":"#98","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784991960324 +updatedAt: 1784992086885 +version: 2 +--- +Partie #91, dépend de #91-D et #91-E. + +Côté montre, implémenter le client du canal (derrière l'adapter D) et le comportement temps réel : +- Envoi des commandes avec état `Envoi...`, recalage de l'UI **uniquement** sur l'état confirmé du téléphone (jamais d'application locale de la commande). +- Interpolation locale du chrono à partir des timestamps autoritaires de la projection. +- Gestion des 3 états de latence UX : latence courte, attente perceptible, connexion perdue ; reconnexion + resync complet. +- Haptiques déclenchées au ack (sans prendre le focus audio, cf. #92). Pas de son obligatoire au MVP. + +Définition de done : commandes fonctionnelles montre -> téléphone avec ack/latence/reconnexion, interpolation chrono fluide, haptiques au ack, cohérence source-de-vérité téléphone respectée. Tests widget/intégration au vert. diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 082352b..7cf83b1 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 94 + "nextNumber": 165 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 1240141..95c12b7 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -547,61 +547,61 @@ "issueRef": "#48", "path": "48", "title": "[Server] Auth comptes utilisateurs et tokens API", - "status": "qa", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784448980586 + "updatedAt": 1785243107564 }, { "issueRef": "#49", "path": "49", "title": "[Server] Schéma PostgreSQL sync-ready GameTime", - "status": "qa", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784448367646 + "updatedAt": 1785243113748 }, { "issueRef": "#50", "path": "50", "title": "[Server] API de synchronisation incrémentale LWW", - "status": "qa", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784449447058 + "updatedAt": 1785243117713 }, { "issueRef": "#51", "path": "51", "title": "[Server] Partage ciblé de programmes et séances entre comptes", - "status": "qa", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784450081385 + "updatedAt": 1785243124727 }, { "issueRef": "#52", "path": "52", "title": "[Server] Packaging Docker Compose et push Gitea Registry", - "status": "qa", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784450444550 + "updatedAt": 1785243127394 }, { "issueRef": "#53", "path": "53", "title": "[Server] Tests API, contrats OpenAPI et vérification d'intégration", - "status": "qa", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784469423570 + "updatedAt": 1785243133172 }, { "issueRef": "#54", @@ -619,13 +619,13 @@ "issueRef": "#56", "path": "56", "title": "[DevBackend] Modèle domain + Drift pour exercices à étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784482387469 + "updatedAt": 1785243136634 }, { "issueRef": "#57", @@ -643,265 +643,265 @@ "issueRef": "#58", "path": "58", "title": "[DevBackend] Exécution persistante des étapes et résultats par passage", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784483616127 + "updatedAt": 1785243140603 }, { "issueRef": "#59", "path": "59", "title": "[DevFrontend] Éditeur d'exercice avec séquence d'étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784483616855 + "updatedAt": 1785243143729 }, { "issueRef": "#60", "path": "60", "title": "[DevFrontend] Exécution de séance avec module séquence et bips", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784484539686 + "updatedAt": 1785243146698 }, { "issueRef": "#61", "path": "61", "title": "[DevFrontend] Plan de séance et historique avec résultats d'étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784650797661 + "updatedAt": 1785243150941 }, { "issueRef": "#62", "path": "62", "title": "[QA] Validation exercices à étapes, persistance et historique", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], - "updatedAt": 1784487426036 + "updatedAt": 1785243154694 }, { "issueRef": "#63", "path": "63", "title": "Ajouter les features déjà présentes sur le serveur au client", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784533228302 + "updatedAt": 1785243102711 }, { "issueRef": "#64", "path": "64", "title": "[DevBackend] Client online : session compte, stockage sécurisé et adapter API", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784489186446 + "updatedAt": 1785243158451 }, { "issueRef": "#65", "path": "65", "title": "[DevBackend] Sync client incrémentale LWW vers API serveur", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784490128397 + "updatedAt": 1785243160967 }, { "issueRef": "#66", "path": "66", "title": "[DevBackend] Partage client : use cases, inbox cache et import local", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784490921505 + "updatedAt": 1785243165428 }, { "issueRef": "#67", "path": "67", "title": "[DevFrontend] Profil et écrans auth optionnels", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784490111791 + "updatedAt": 1785243168334 }, { "issueRef": "#68", "path": "68", "title": "[DevFrontend] Statut de synchronisation discret et action manuelle", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784491589721 + "updatedAt": 1785243170813 }, { "issueRef": "#69", "path": "69", "title": "[DevFrontend] Partage sortant et boîte de réception", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784525029743 + "updatedAt": 1785243173715 }, { "issueRef": "#70", "path": "70", "title": "[QA] Validation client online offline-first", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], - "updatedAt": 1784533224103 + "updatedAt": 1785243176716 }, { "issueRef": "#71", "path": "71", "title": "[Bug] selection de score par défaut à 0", - "status": "qa", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784526657344 + "updatedAt": 1785243094965 }, { "issueRef": "#72", "path": "72", "title": "[Bug] les étapes des exercices ne sont pas affichés pendant la séance", - "status": "qa", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784527867008 + "updatedAt": 1785243091609 }, { "issueRef": "#73", "path": "73", "title": "Pouvoir enchainer les chronos des étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784565595136 + "updatedAt": 1785243086218 }, { "issueRef": "#74", "path": "74", "title": "[Bug] Serveur non lancable", - "status": "qa", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784526657602 + "updatedAt": 1785243081689 }, { "issueRef": "#75", "path": "75", "title": "[DevBackend] Modèle et migration pour auto-enchaînement des chronos d'étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784547809520 + "updatedAt": 1785243075122 }, { "issueRef": "#76", "path": "76", "title": "[DevBackend] Résolution effective et auto-advance des chronos d'étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784548663380 + "updatedAt": 1785243070204 }, { "issueRef": "#77", "path": "77", "title": "[DevFrontend] Réglages auto-enchaînement exercice, programme et séance", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784548663818 + "updatedAt": 1785243066150 }, { "issueRef": "#78", "path": "78", "title": "[DevFrontend] État d'exécution Chrono suivant prêt", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784549136530 + "updatedAt": 1785243056992 }, { "issueRef": "#79", "path": "79", "title": "[QA] Validation auto-enchaînement configurable des chronos d'étapes", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], - "updatedAt": 1784565590789 + "updatedAt": 1785243052684 }, { "issueRef": "#80", @@ -943,25 +943,15 @@ "assignedAgentIds": [], "updatedAt": 1784718420341 }, - { - "issueRef": "#84", - "path": "84", - "title": "Export/import local des données (sauvegarde indépendante de la sync serveur)", - "status": "open", - "priority": "medium", - "sprint": null, - "assignedAgentIds": [], - "updatedAt": 1784718798540 - }, { "issueRef": "#85", "path": "85", "title": "Packs de séances partageables (au-delà du partage ciblé existant)", - "status": "open", + "status": "inProgress", "priority": "low", - "sprint": null, + "sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822", "assignedAgentIds": [], - "updatedAt": 1784650728867 + "updatedAt": 1785248859243 }, { "issueRef": "#86", @@ -987,33 +977,33 @@ "issueRef": "#88", "path": "88", "title": "[Bug] error au lancement d'un exercice", - "status": "qa", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784665050645 + "updatedAt": 1785243048210 }, { "issueRef": "#90", "path": "90", "title": "[UI] Rework UI séance pour enlever le scroll", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], - "updatedAt": 1784665064484 + "updatedAt": 1785243041954 }, { "issueRef": "#91", "path": "91", "title": "Interface montre synchronisée", - "status": "open", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784656427938 + "updatedAt": 1785230763779 }, { "issueRef": "#92", @@ -1038,6 +1028,752 @@ "57695b92-24d0-4876-837c-76116e70a6ae" ], "updatedAt": 1784705595811 + }, + { + "issueRef": "#94", + "path": "94", + "title": "Refonte systeme etapes passage et serie d'un exercice", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784936913063 + }, + { + "issueRef": "#95", + "path": "95", + "title": "#91-A [DevBackend] Contrats watch bridge partagés + façade WatchCompanionUseCases", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784991949110 + }, + { + "issueRef": "#96", + "path": "96", + "title": "#91-C [DevBackend] Routing des commandes montre vers les use cases d'exécution existants", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784992086885 + }, + { + "issueRef": "#97", + "path": "97", + "title": "#91-B [DevBackend] Projection compacte d'exécution téléphone -> montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784992086886 + }, + { + "issueRef": "#98", + "path": "98", + "title": "#91-D [DevBackend] Adapter Android Wear Data Layer + foreground service téléphone", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784992086886 + }, + { + "issueRef": "#99", + "path": "99", + "title": "#91-F [DevFrontend] Client watch bridge + pending/latence/reconnexion + haptiques", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784992086885 + }, + { + "issueRef": "#100", + "path": "100", + "title": "#91-G [QA] Validation companion watch offline/local", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "7efa512f-3b3a-47b5-ade0-a2dd13073055" + ], + "updatedAt": 1785002671405 + }, + { + "issueRef": "#101", + "path": "101", + "title": "#91-E [DevFrontend] App Wear OS Flutter dédiée + navigation UX montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784992086885 + }, + { + "issueRef": "#102", + "path": "102", + "title": "Ajouter un bandeau dans la zone de notification du téléphone", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785217009929 + }, + { + "issueRef": "#103", + "path": "103", + "title": "Icone appplication montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785217006191 + }, + { + "issueRef": "#104", + "path": "104", + "title": "Refonte UI montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785217003099 + }, + { + "issueRef": "#105", + "path": "105", + "title": "Incrementer decrementer le score depuis la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216998986 + }, + { + "issueRef": "#106", + "path": "106", + "title": "Ajouter aux séances des données statisques pouvant etre renvoyées par la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785221560663 + }, + { + "issueRef": "#108", + "path": "108", + "title": "#102-A [UX] Conception du bandeau notification téléphone pendant une séance", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785134675226 + }, + { + "issueRef": "#109", + "path": "109", + "title": "#102-B [Architect] Cadrage technique notification de séance Android", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f8f40941-ecf7-4830-b9de-8818a099f448" + ], + "updatedAt": 1785134675243 + }, + { + "issueRef": "#111", + "path": "111", + "title": "#102-D [QA] Validation notification de séance Android", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "7efa512f-3b3a-47b5-ade0-a2dd13073055" + ], + "updatedAt": 1785142184934 + }, + { + "issueRef": "#112", + "path": "112", + "title": "#103-A [UX] Validation d'usage de la même icône sur montre et téléphone", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785134675260 + }, + { + "issueRef": "#114", + "path": "114", + "title": "#103-C [QA] Validation icône application montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "7efa512f-3b3a-47b5-ade0-a2dd13073055" + ], + "updatedAt": 1785142176024 + }, + { + "issueRef": "#115", + "path": "115", + "title": "#104-A [UX] Refonte UI montre alignée sur la DA Court Blazer", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785134675276 + }, + { + "issueRef": "#117", + "path": "117", + "title": "#104-C [QA] Validation refonte UI montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "7efa512f-3b3a-47b5-ade0-a2dd13073055" + ], + "updatedAt": 1785142171875 + }, + { + "issueRef": "#118", + "path": "118", + "title": "#105-A [UX] Conception des contrôles score +/- sur montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785137390315 + }, + { + "issueRef": "#119", + "path": "119", + "title": "#105-B [Architect] Extension watch bridge pour score +/-", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f8f40941-ecf7-4830-b9de-8818a099f448" + ], + "updatedAt": 1785137408716 + }, + { + "issueRef": "#120", + "path": "120", + "title": "#105-C [DevBackend] Routing score montre vers les use cases d'exécution", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1785137390334 + }, + { + "issueRef": "#122", + "path": "122", + "title": "#105-E [QA] Validation score montre +/-", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "7efa512f-3b3a-47b5-ade0-a2dd13073055" + ], + "updatedAt": 1785142162842 + }, + { + "issueRef": "#123", + "path": "123", + "title": "#106-A [UX] Cadrage transparent des statistiques issues de la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785137390347 + }, + { + "issueRef": "#124", + "path": "124", + "title": "#106-B [Architect] Architecture collecte capteurs montre -> séance", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f8f40941-ecf7-4830-b9de-8818a099f448" + ], + "updatedAt": 1785137390360 + }, + { + "issueRef": "#126", + "path": "126", + "title": "[Bug] Chrono de la montre qui ne descend pas", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216995347 + }, + { + "issueRef": "#127", + "path": "127", + "title": "[Bug] Application de la montre qui s'enlève quand l'écran s'éteint", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216992076 + }, + { + "issueRef": "#128", + "path": "128", + "title": "[UI] remettre un bouton pause/play de chrono sur la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785216987980 + }, + { + "issueRef": "#129", + "path": "129", + "title": "[UI] Afficher l'objectif de score sur la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785217016893 + }, + { + "issueRef": "#130", + "path": "130", + "title": "[Bug] Erreur sur le bouton terminer la série", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785243026193 + }, + { + "issueRef": "#131", + "path": "131", + "title": "[UI] téléphone erreur Bottom Overflowed by 82 pixel", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785216979125 + }, + { + "issueRef": "#132", + "path": "132", + "title": "[Bug] Switch actions vers séance en cours sur la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785141275322 + }, + { + "issueRef": "#133", + "path": "133", + "title": "[UI] retirer le titre étape 1/2", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216975714 + }, + { + "issueRef": "#134", + "path": "134", + "title": "[Bug] le bouton de la montre \"passer la serie\" ne fonctionne pas", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785141267072 + }, + { + "issueRef": "#135", + "path": "135", + "title": "[Bug] bouton pause/play de la montre qui ne fonctionne pas toujours", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785151136170 + }, + { + "issueRef": "#136", + "path": "136", + "title": "[Bug] \"passer le repos\" sur la montre ne fonctionne pas", + "status": "qa", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [], + "updatedAt": 1785244375293 + }, + { + "issueRef": "#137", + "path": "137", + "title": "[Bug] Connexion au compte impossible", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785147020900 + }, + { + "issueRef": "#138", + "path": "138", + "title": "[UI] Enlever les textes qui informent qu'on a pas besoin de compte", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216971861 + }, + { + "issueRef": "#139", + "path": "139", + "title": "Faire augmenter les scores étape et séries en même temps", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785160475878 + }, + { + "issueRef": "#140", + "path": "140", + "title": "Ajouter le chrono sur la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785160464016 + }, + { + "issueRef": "#141", + "path": "141", + "title": "Petit icone de sport sur la montre", + "status": "closed", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785216922010 + }, + { + "issueRef": "#142", + "path": "142", + "title": "Statistiques a afficher sur la séance en cours", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785221553966 + }, + { + "issueRef": "#143", + "path": "143", + "title": "Statistiques terrain de basket", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785143072501 + }, + { + "issueRef": "#144", + "path": "144", + "title": "[DevBackend/DevFrontend] Fréquence cardiaque live montre sur séance en cours", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785221565015 + }, + { + "issueRef": "#145", + "path": "145", + "title": "[Architect/DevFrontend] Faisabilité et implémentation estimation calories montre", + "status": "qa", + "priority": "low", + "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "assignedAgentIds": [], + "updatedAt": 1785248859123 + }, + { + "issueRef": "#146", + "path": "146", + "title": "[Spike] Faisabilité GPS terrain + détection de shoot montre", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785143531526 + }, + { + "issueRef": "#147", + "path": "147", + "title": "[Bug] Enregistrement exercice impossible avec des etapes", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785242960861 + }, + { + "issueRef": "#148", + "path": "148", + "title": "[Bug] je ne peux pas lancer de chrono avec la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216966035 + }, + { + "issueRef": "#149", + "path": "149", + "title": "[Bug] Création de programme sans exercice possible", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785160491348 + }, + { + "issueRef": "#150", + "path": "150", + "title": "Enlever le texte de la page de connexion", + "status": "closed", + "priority": "low", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785216960781 + }, + { + "issueRef": "#151", + "path": "151", + "title": "Pouvoir lancer le chrono de l'étape depuis la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785179756629 + }, + { + "issueRef": "#152", + "path": "152", + "title": "Afficher le nom de l'étape en cours sur la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1785179756654 + }, + { + "issueRef": "#153", + "path": "153", + "title": "Augmenter le score de l'étape avec la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785179756676 + }, + { + "issueRef": "#154", + "path": "154", + "title": "Retirer le bouton \"lancer une seance\" de la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785179756697 + }, + { + "issueRef": "#155", + "path": "155", + "title": "[UX] Cadrage surfaces statistiques live téléphone/montre et historique", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [], + "updatedAt": 1785248859142 + }, + { + "issueRef": "#156", + "path": "156", + "title": "[Architect] Architecture collecte/persistance statistiques montre étendues", + "status": "qa", + "priority": "medium", + "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "assignedAgentIds": [], + "updatedAt": 1785248859161 + }, + { + "issueRef": "#157", + "path": "157", + "title": "[DevFrontend] Distance live montre sur séance en cours", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [], + "updatedAt": 1785248859176 + }, + { + "issueRef": "#158", + "path": "158", + "title": "[DevBackend/DevFrontend] Agrégats et historique statistiques montre", + "status": "qa", + "priority": "medium", + "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "assignedAgentIds": [], + "updatedAt": 1785248859190 + }, + { + "issueRef": "#159", + "path": "159", + "title": "[DevFrontend] Surface montre statistiques live (FC principale + panneau secondaire)", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [], + "updatedAt": 1785248859208 + }, + { + "issueRef": "#160", + "path": "160", + "title": "[DevFrontend] Graphiques historiques statistiques montre", + "status": "inProgress", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [], + "updatedAt": 1785248859257 + }, + { + "issueRef": "#161", + "path": "161", + "title": "[UI] Petite refonte UI de la montre", + "status": "closed", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785241912171 + }, + { + "issueRef": "#162", + "path": "162", + "title": "[UI] Chrono + répétitions sur une meme étape", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785248859225 + }, + { + "issueRef": "#163", + "path": "163", + "title": "Frequence cardiaque qui ne semble plus calculée si la montre à l'écran éteint", + "status": "inProgress", + "priority": "medium", + "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785248859271 + }, + { + "issueRef": "#164", + "path": "164", + "title": "[Bug] Plus de son en fin de chrono", + "status": "inProgress", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [], + "updatedAt": 1785244375351 } ] } \ No newline at end of file diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 7e502d0..12b492c 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -14,6 +14,7 @@ abstract interface class AppDependencies { ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases; ActiveExerciseStepUseCases get activeExerciseStepUseCases; ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases; + WorkoutTelemetryUseCases get workoutTelemetryUseCases; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; WorkoutHistoryUseCases get workoutHistoryUseCases; ProgressionStatsUseCase get progressionStatsUseCase; @@ -35,6 +36,7 @@ final class AppBootstrap implements AppDependencies { required this.activeWorkoutSessionUseCases, required this.activeExerciseStepUseCases, required this.activeWorkoutSensorUseCases, + required this.workoutTelemetryUseCases, required this.watchCompanionProjectionUseCases, required this.watchCompanionCommandHandler, required this.watchWearDataLayerAdapter, @@ -67,6 +69,8 @@ final class AppBootstrap implements AppDependencies { final ActiveExerciseStepUseCases activeExerciseStepUseCases; @override final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases; + @override + final WorkoutTelemetryUseCases workoutTelemetryUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionCommandHandler watchCompanionCommandHandler; final WatchWearDataLayerAdapter watchWearDataLayerAdapter; @@ -107,6 +111,7 @@ final class AppBootstrap implements AppDependencies { final templateRepository = DriftWorkoutTemplateRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database); final historyRepository = DriftWorkoutHistoryRepository(database); + final telemetryRepository = DriftWorkoutTelemetryRepository(database); final progressionStatsRepository = DriftProgressionStatsRepository( database, ); @@ -134,6 +139,11 @@ final class AppBootstrap implements AppDependencies { final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases( clock: clock, ); + final workoutTelemetryUseCases = WorkoutTelemetryUseCases( + repository: telemetryRepository, + clock: clock, + ids: ids, + ); final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases( sessionRepository: activeSessionRepository, clock: clock, @@ -156,6 +166,7 @@ final class AppBootstrap implements AppDependencies { projectionSource: watchCompanionProjectionUseCases, workoutHistoryUseCases: workoutHistoryUseCases, activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, + workoutTelemetryUseCases: workoutTelemetryUseCases, ); final sessionNotificationCoordinator = SessionNotificationCoordinator( projections: watchCompanionProjectionUseCases.projections, @@ -221,6 +232,7 @@ final class AppBootstrap implements AppDependencies { activeWorkoutSessionUseCases: activeWorkoutSessionUseCases, activeExerciseStepUseCases: activeExerciseStepUseCases, activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, + workoutTelemetryUseCases: workoutTelemetryUseCases, watchCompanionProjectionUseCases: watchCompanionProjectionUseCases, watchCompanionCommandHandler: watchCompanionCommandHandler, watchWearDataLayerAdapter: watchWearDataLayerAdapter, @@ -228,6 +240,7 @@ final class AppBootstrap implements AppDependencies { closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( sessionRepository: activeSessionRepository, historyRepository: historyRepository, + telemetryRepository: telemetryRepository, clock: clock, ids: ids, originDeviceId: originDeviceId, diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 14768cf..90482c2 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -845,6 +845,7 @@ abstract interface class WorkoutTemplateRepository { Future findById(String id); Future> listActive(); Future save(WorkoutTemplate template); + Future saveAll(List templates); Future saveProgram(WorkoutTemplateProgram program); Future saveOverride(WorkoutTemplateExerciseOverride override); Future replaceComposition(WorkoutTemplate template, DateTime deletedAt); @@ -923,8 +924,11 @@ abstract interface class WorkoutHistoryRepository { Future save(WorkoutHistory history); Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }); Future saveSetResult(WorkoutHistorySetResult result); @@ -932,6 +936,25 @@ abstract interface class WorkoutHistoryRepository { Future delete(String id, DateTime deletedAt); } +abstract interface class WorkoutTelemetryRepository { + Future saveSample(WorkoutTelemetrySample sample); + Future> listSamples(String sessionId); + Future replaceAggregatesForSession({ + required String sessionId, + required List aggregates, + }); + Future> listAggregates(String sessionId); + Future findAggregate({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }); +} + final class ActivePerformanceMeasures { const ActivePerformanceMeasures({ required this.timeEnabled, diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 0bc1638..140d61f 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -558,6 +558,8 @@ final class ShareUseCases { required ShareResourceType resourceType, required String localResourceId, required List recipientEmails, + String? packName, + List localResourceIds = const [], }) async { final emails = recipientEmails .map((email) => email.trim()) @@ -566,7 +568,12 @@ final class ShareUseCases { if (emails.isEmpty) { throw const DomainException('At least one recipient email is required.'); } - final payload = await _sharePayload(resourceType, localResourceId); + final payload = await _sharePayload( + resourceType, + localResourceId, + packName: packName, + localResourceIds: localResourceIds, + ); final token = await tokenStore.readToken(); if (token == null) { return const ShareSendResult(status: ShareSendStatus.notConnected); @@ -613,6 +620,10 @@ final class ShareUseCases { } } + Future> listShareableWorkoutTemplates() { + return templateRepository.listActive(); + } + Future acceptShare(String shareId) async { final cachedItem = await inboxRepository.findByShareId(shareId); final token = await tokenStore.readToken(); @@ -621,8 +632,16 @@ final class ShareUseCases { return; } try { - final createdResource = await remoteShareApi.acceptShare(shareId, token); - await localChanges.applyRemoteItem(createdResource); + if (cachedItem?.resourceType == ShareResourceType.pack) { + await remoteShareApi.acceptShare(shareId, token); + await _importSharedPayload(cachedItem!); + } else { + final createdResource = await remoteShareApi.acceptShare( + shareId, + token, + ); + await localChanges.applyRemoteItem(createdResource); + } await inboxRepository.markStatus( shareId, ShareInboxStatus.accepted, @@ -696,8 +715,10 @@ final class ShareUseCases { Future> _sharePayload( ShareResourceType resourceType, - String localResourceId, - ) async { + String localResourceId, { + String? packName, + List localResourceIds = const [], + }) async { switch (resourceType) { case ShareResourceType.program: final program = await programRepository.findById(localResourceId); @@ -711,6 +732,27 @@ final class ShareUseCases { throw const DomainException('Workout template not found.'); } return _workoutTemplateSharePayload(template); + case ShareResourceType.pack: + final name = packName?.trim(); + if (name == null || name.isEmpty) { + throw const DomainException('Pack name is required.'); + } + final ids = localResourceIds + .map((id) => id.trim()) + .where((id) => id.isNotEmpty) + .toList(growable: false); + if (ids.isEmpty) { + throw const DomainException('At least one workout is required.'); + } + final templates = []; + for (final id in ids) { + final template = await templateRepository.findById(id); + if (template == null) { + throw const DomainException('Workout template not found.'); + } + templates.add(template); + } + return _packSharePayload(name, templates); } } @@ -748,11 +790,16 @@ final class ShareUseCases { ); return; case PendingShareActionType.accept: - final created = await remoteShareApi.acceptShare( - action.shareId!, - token, - ); - await localChanges.applyRemoteItem(created); + final item = await inboxRepository.findByShareId(action.shareId!); + if (item?.resourceType == ShareResourceType.pack) { + await remoteShareApi.acceptShare(action.shareId!, token); + } else { + final created = await remoteShareApi.acceptShare( + action.shareId!, + token, + ); + await localChanges.applyRemoteItem(created); + } await inboxRepository.markStatus( action.shareId!, ShareInboxStatus.accepted, @@ -812,9 +859,39 @@ final class ShareUseCases { _workoutTemplateCopyFromSharePayload(payload, now), ); return; + case ShareResourceType.pack: + final templates = _workoutTemplateCopiesFromPackSharePayload( + payload, + now, + ); + await templateRepository.saveAll(templates); + return; } } + List _workoutTemplateCopiesFromPackSharePayload( + Map payload, + DateTime now, + ) { + final rawItems = payload['workouts']; + if (rawItems is! List || rawItems.isEmpty) { + throw const DomainException('Pack payload is empty.'); + } + final templates = []; + for (final raw in rawItems) { + if (raw is! Map) { + throw const DomainException('Invalid pack payload.'); + } + templates.add( + _workoutTemplateCopyFromSharePayload( + Map.from(raw), + now, + ), + ); + } + return templates; + } + Program _programCopyFromSharePayload( Map payload, DateTime now, @@ -4030,28 +4107,27 @@ final class WatchSessionProjectionProjector { final setTimerProjection = setTimer == null ? null : _setTimerProjection(setTimer, now); - final hideScoreStopwatchTimer = _shouldHideScoreStopwatchForSetTimer( - snapshot: snapshot, - setTimerProjection: setTimerProjection, - ); - final timers = [ + final allTimers = [ if (activeRest != null) _restTimerProjection(activeRest, now), if (currentStep != null && currentStep.type == ExerciseStepType.time) _stepTimerProjection(stepState, currentStep, now), ?setTimerProjection, - if (!hideScoreStopwatchTimer) ?scoreStopwatchTimer, + ?scoreStopwatchTimer, ]; - final dominantTimer = timers.isEmpty ? null : timers.first; + final displayTimers = allTimers + .where((timer) => timer.kind != WatchTimerKind.setTimer) + .toList(growable: false); + final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first; final secondaryTimers = dominantTimer == null ? const [] - : timers.skip(1).toList(growable: false); + : displayTimers.skip(1).toList(growable: false); final phase = _phase( session: session, snapshot: snapshot, activeRest: activeRest, stepState: stepState, currentStep: currentStep, - timers: timers, + timers: allTimers, ); return WatchSessionProjection( @@ -4414,16 +4490,6 @@ WatchTimerProjection? _setTimerProjection( ); } -bool _shouldHideScoreStopwatchForSetTimer({ - required _ResolvedExerciseSnapshot snapshot, - required WatchTimerProjection? setTimerProjection, -}) { - return snapshot.timeEnabled && - snapshot.scoreEnabled && - snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch && - setTimerProjection != null; -} - ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) { return snapshot.steps.isEmpty ? null : snapshot.steps.first; } @@ -5335,6 +5401,7 @@ final class CloseWorkoutSessionUseCase { const CloseWorkoutSessionUseCase({ required this.sessionRepository, required this.historyRepository, + this.telemetryRepository, required this.clock, required this.ids, required this.originDeviceId, @@ -5342,6 +5409,7 @@ final class CloseWorkoutSessionUseCase { final ActiveSessionRepository sessionRepository; final WorkoutHistoryRepository historyRepository; + final WorkoutTelemetryRepository? telemetryRepository; final Clock clock; final IdGenerator ids; final String originDeviceId; @@ -5380,6 +5448,10 @@ final class CloseWorkoutSessionUseCase { ids: ids, originDeviceId: originDeviceId, ); + final telemetryAggregate = await telemetryRepository?.findAggregate( + sessionId: session.metadata.id, + scope: WorkoutTelemetryAggregateScope.session, + ); final history = WorkoutHistory( metadata: EntityMetadata( id: historyId, @@ -5450,6 +5522,11 @@ final class CloseWorkoutSessionUseCase { ) .toList(), }), + minHeartRateBpm: telemetryAggregate?.minHeartRateBpm, + averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm, + maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm, + totalDistanceMeters: telemetryAggregate?.totalDistanceMeters, + totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal, results: historyResults, stepResults: historyStepResults, ); @@ -5458,6 +5535,247 @@ final class CloseWorkoutSessionUseCase { } } +final class WorkoutTelemetryUseCases { + const WorkoutTelemetryUseCases({ + required this.repository, + required this.clock, + required this.ids, + }); + + final WorkoutTelemetryRepository repository; + final Clock clock; + final IdGenerator ids; + + Future> recordTelemetrySample( + WatchTelemetrySample sample, + ) async { + final domainSample = _telemetrySampleFromWatch(sample); + if (domainSample == null) { + return const []; + } + final inserted = await repository.saveSample(domainSample); + if (!inserted) { + return const []; + } + final samples = await repository.listSamples(domainSample.sessionId); + final aggregates = _telemetryAggregatesFromSamples(samples); + await repository.replaceAggregatesForSession( + sessionId: domainSample.sessionId, + aggregates: aggregates, + ); + return aggregates; + } + + WorkoutTelemetrySample? _telemetrySampleFromWatch( + WatchTelemetrySample sample, + ) { + final sessionId = sample.sessionId.trim(); + final hasHeartRate = + sample.heartRateBpm != null && sample.heartRateBpm! > 0; + final hasDistance = + sample.distanceMeters != null && sample.distanceMeters! >= 0; + final hasCalories = + sample.caloriesKcal != null && sample.caloriesKcal! >= 0; + if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) { + return null; + } + final capturedAt = sample.capturedAtEpochMs > 0 + ? DateTime.fromMillisecondsSinceEpoch( + sample.capturedAtEpochMs, + isUtc: true, + ) + : clock.now(); + return WorkoutTelemetrySample( + id: _telemetrySampleId(sample), + sessionId: sessionId, + capturedAt: capturedAt, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + passageIndex: sample.passageIndex, + stepIndex: sample.stepIndex, + programSnapshotId: sample.programSnapshotId, + exerciseSnapshotId: sample.exerciseSnapshotId, + stepSnapshotId: sample.stepSnapshotId, + heartRateBpm: hasHeartRate ? sample.heartRateBpm : null, + distanceMeters: hasDistance ? sample.distanceMeters : null, + caloriesKcal: hasCalories ? sample.caloriesKcal : null, + ); + } + + String _telemetrySampleId(WatchTelemetrySample sample) { + final stableId = sample.sampleId?.trim(); + return stableId == null || stableId.isEmpty ? ids.newId() : stableId; + } +} + +List _telemetryAggregatesFromSamples( + List samples, +) { + final builders = <_TelemetryScopeKey, _TelemetryAggregateBuilder>{}; + for (final sample in samples) { + for (final key in _telemetryScopeKeys(sample)) { + builders + .putIfAbsent(key, () => _TelemetryAggregateBuilder(key)) + .add(sample); + } + } + return [for (final builder in builders.values) builder.build()]; +} + +List<_TelemetryScopeKey> _telemetryScopeKeys(WorkoutTelemetrySample sample) { + final keys = [ + _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.session, + ), + ]; + if (sample.programIndex != null && sample.exerciseIndex != null) { + keys.add( + _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.exercise, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + ), + ); + } + if (sample.programIndex != null && + sample.exerciseIndex != null && + sample.setIndex != null) { + keys.add( + _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.set, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + ), + ); + } + if (sample.programIndex != null && + sample.exerciseIndex != null && + sample.setIndex != null && + sample.stepIndex != null) { + keys.add( + _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.step, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + passageIndex: sample.passageIndex, + stepIndex: sample.stepIndex, + ), + ); + } + return keys; +} + +final class _TelemetryScopeKey { + const _TelemetryScopeKey({ + required this.sessionId, + required this.scope, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + }); + + final String sessionId; + final WorkoutTelemetryAggregateScope scope; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _TelemetryScopeKey && + sessionId == other.sessionId && + scope == other.scope && + programIndex == other.programIndex && + exerciseIndex == other.exerciseIndex && + setIndex == other.setIndex && + passageIndex == other.passageIndex && + stepIndex == other.stepIndex; + } + + @override + int get hashCode => Object.hash( + sessionId, + scope, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + ); +} + +final class _TelemetryAggregateBuilder { + _TelemetryAggregateBuilder(this.key); + + final _TelemetryScopeKey key; + var sampleCount = 0; + var heartRateCount = 0; + var heartRateSum = 0.0; + int? minHeartRateBpm; + int? maxHeartRateBpm; + double? maxDistanceMeters; + double? maxCaloriesKcal; + + void add(WorkoutTelemetrySample sample) { + sampleCount += 1; + final heartRate = sample.heartRateBpm; + if (heartRate != null) { + heartRateCount += 1; + heartRateSum += heartRate; + minHeartRateBpm = minHeartRateBpm == null + ? heartRate + : (heartRate < minHeartRateBpm! ? heartRate : minHeartRateBpm); + maxHeartRateBpm = maxHeartRateBpm == null + ? heartRate + : (heartRate > maxHeartRateBpm! ? heartRate : maxHeartRateBpm); + } + final distance = sample.distanceMeters; + if (distance != null) { + maxDistanceMeters = maxDistanceMeters == null + ? distance + : (distance > maxDistanceMeters! ? distance : maxDistanceMeters); + } + final calories = sample.caloriesKcal; + if (calories != null) { + maxCaloriesKcal = maxCaloriesKcal == null + ? calories + : (calories > maxCaloriesKcal! ? calories : maxCaloriesKcal); + } + } + + WorkoutTelemetryAggregate build() { + return WorkoutTelemetryAggregate( + sessionId: key.sessionId, + scope: key.scope, + programIndex: key.programIndex, + exerciseIndex: key.exerciseIndex, + setIndex: key.setIndex, + passageIndex: key.passageIndex, + stepIndex: key.stepIndex, + sampleCount: sampleCount, + minHeartRateBpm: minHeartRateBpm, + averageHeartRateBpm: heartRateCount == 0 + ? null + : heartRateSum / heartRateCount, + maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: maxDistanceMeters, + totalCaloriesKcal: maxCaloriesKcal, + ); + } +} + final class ActiveWorkoutSensorState { const ActiveWorkoutSensorState({ required this.sessionId, @@ -5707,13 +6025,18 @@ final class WorkoutHistoryUseCases { final history = _findHistoryForSummary(histories, summary); if (history == null || history.averageHeartRateBpm != null || - history.maxHeartRateBpm != null) { + history.maxHeartRateBpm != null || + history.totalDistanceMeters != null || + history.totalCaloriesKcal != null) { return false; } await repository.patchHeartRateSummary( historyId: history.metadata.id, + minHeartRateBpm: summary.minHeartRateBpm, averageHeartRateBpm: summary.averageHeartRateBpm!, maxHeartRateBpm: summary.maxHeartRateBpm!, + totalDistanceMeters: summary.totalDistanceMeters, + totalCaloriesKcal: summary.totalCaloriesKcal, patchedAt: clock.now(), ); return true; @@ -5746,7 +6069,11 @@ final class WorkoutHistoryUseCases { summary.averageHeartRateBpm != null && summary.averageHeartRateBpm! > 0 && summary.maxHeartRateBpm != null && - summary.maxHeartRateBpm! > 0; + summary.maxHeartRateBpm! > 0 && + (summary.minHeartRateBpm == null || summary.minHeartRateBpm! > 0) && + (summary.totalDistanceMeters == null || + summary.totalDistanceMeters! >= 0) && + (summary.totalCaloriesKcal == null || summary.totalCaloriesKcal! >= 0); } } @@ -6355,6 +6682,14 @@ Map _workoutTemplateSharePayload(WorkoutTemplate template) => { .toList(), }; +Map _packSharePayload( + String name, + List templates, +) => { + 'name': name, + 'workouts': templates.map(_workoutTemplateSharePayload).toList(), +}; + Map _metadataSharePayload(EntityMetadata metadata) => { 'id': metadata.id, 'createdAt': metadata.createdAt.toUtc().toIso8601String(), diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 143a891..e090f1c 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -29,7 +29,7 @@ enum ActiveSetTimerStatus { running, paused, stopped, skipped } enum ActiveScoreStopwatchStatus { running, paused, stopped } -enum ShareResourceType { program, workoutTemplate } +enum ShareResourceType { program, workoutTemplate, pack } enum ShareInboxStatus { pending, accepted, declined, revoked } @@ -1476,16 +1476,25 @@ final class WorkoutHistory { required this.totalActiveMs, required this.completed, required this.historySnapshotJson, + this.minHeartRateBpm, this.averageHeartRateBpm, this.maxHeartRateBpm, + this.totalDistanceMeters, + this.totalCaloriesKcal, this.results = const [], this.stepResults = const [], }) { + _requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm'); _requireNullablePositiveDouble( averageHeartRateBpm, 'Average heart rate bpm', ); _requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm'); + _requireNullableNonNegativeDouble( + totalDistanceMeters, + 'Total distance meters', + ); + _requireNullableNonNegativeDouble(totalCaloriesKcal, 'Total calories kcal'); } final EntityMetadata metadata; @@ -1497,15 +1506,21 @@ final class WorkoutHistory { final int totalActiveMs; final bool completed; final String historySnapshotJson; + final int? minHeartRateBpm; final double? averageHeartRateBpm; final int? maxHeartRateBpm; + final double? totalDistanceMeters; + final double? totalCaloriesKcal; final List results; final List stepResults; WorkoutHistory copyWith({ EntityMetadata? metadata, + Object? minHeartRateBpm = _unchanged, Object? averageHeartRateBpm = _unchanged, Object? maxHeartRateBpm = _unchanged, + Object? totalDistanceMeters = _unchanged, + Object? totalCaloriesKcal = _unchanged, }) { return WorkoutHistory( metadata: metadata ?? this.metadata, @@ -1517,18 +1532,149 @@ final class WorkoutHistory { totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson, + minHeartRateBpm: minHeartRateBpm == _unchanged + ? this.minHeartRateBpm + : minHeartRateBpm as int?, averageHeartRateBpm: averageHeartRateBpm == _unchanged ? this.averageHeartRateBpm : averageHeartRateBpm as double?, maxHeartRateBpm: maxHeartRateBpm == _unchanged ? this.maxHeartRateBpm : maxHeartRateBpm as int?, + totalDistanceMeters: totalDistanceMeters == _unchanged + ? this.totalDistanceMeters + : totalDistanceMeters as double?, + totalCaloriesKcal: totalCaloriesKcal == _unchanged + ? this.totalCaloriesKcal + : totalCaloriesKcal as double?, results: results, stepResults: stepResults, ); } } +enum WorkoutTelemetryAggregateScope { + session('session'), + exercise('exercise'), + set('set'), + step('step'); + + const WorkoutTelemetryAggregateScope(this.wireName); + + final String wireName; + + static WorkoutTelemetryAggregateScope parse(String value) { + for (final scope in values) { + if (scope.wireName == value) { + return scope; + } + } + throw DomainException('Unsupported telemetry scope: $value.'); + } +} + +final class WorkoutTelemetrySample { + WorkoutTelemetrySample({ + required String id, + required String sessionId, + required DateTime capturedAt, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + this.programSnapshotId, + this.exerciseSnapshotId, + this.stepSnapshotId, + this.heartRateBpm, + this.distanceMeters, + this.caloriesKcal, + }) : id = _nonBlank(id, 'Telemetry sample id'), + sessionId = _nonBlank(sessionId, 'Telemetry session id'), + capturedAt = capturedAt.toUtc() { + _requireNullableNonNegative(programIndex, 'Program index'); + _requireNullableNonNegative(exerciseIndex, 'Exercise index'); + _requireNullableNonNegative(setIndex, 'Set index'); + _requireNullableNonNegative(passageIndex, 'Passage index'); + _requireNullableNonNegative(stepIndex, 'Step index'); + _requireNullablePositive(heartRateBpm, 'Heart rate bpm'); + _requireNullableNonNegativeDouble(distanceMeters, 'Distance meters'); + _requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal'); + if (heartRateBpm == null && + distanceMeters == null && + caloriesKcal == null) { + throw const DomainException( + 'Telemetry sample must contain at least one metric.', + ); + } + } + + final String id; + final String sessionId; + final DateTime capturedAt; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final String? programSnapshotId; + final String? exerciseSnapshotId; + final String? stepSnapshotId; + final int? heartRateBpm; + final double? distanceMeters; + final double? caloriesKcal; +} + +final class WorkoutTelemetryAggregate { + WorkoutTelemetryAggregate({ + required String sessionId, + required this.scope, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + required this.sampleCount, + this.minHeartRateBpm, + this.averageHeartRateBpm, + this.maxHeartRateBpm, + this.totalDistanceMeters, + this.totalCaloriesKcal, + }) : sessionId = _nonBlank(sessionId, 'Telemetry aggregate session id') { + _requireNullableNonNegative(programIndex, 'Program index'); + _requireNullableNonNegative(exerciseIndex, 'Exercise index'); + _requireNullableNonNegative(setIndex, 'Set index'); + _requireNullableNonNegative(passageIndex, 'Passage index'); + _requireNullableNonNegative(stepIndex, 'Step index'); + _requireNonNegative(sampleCount, 'Telemetry sample count'); + _requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm'); + _requireNullablePositiveDouble( + averageHeartRateBpm, + 'Average heart rate bpm', + ); + _requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm'); + _requireNullableNonNegativeDouble( + totalDistanceMeters, + 'Total distance meters', + ); + _requireNullableNonNegativeDouble(totalCaloriesKcal, 'Total calories kcal'); + } + + final String sessionId; + final WorkoutTelemetryAggregateScope scope; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final int sampleCount; + final int? minHeartRateBpm; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; + final double? totalDistanceMeters; + final double? totalCaloriesKcal; +} + final class WorkoutHistorySetResult { WorkoutHistorySetResult({ required this.metadata, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 9de4fc0..6cc29bc 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -28,6 +28,8 @@ part 'app_database.g.dart'; RemoteResourceMappings, ShareInboxItems, SyncMetadataEntries, + WorkoutTelemetryAggregates, + WorkoutTelemetrySamples, WorkoutHistories, WorkoutHistorySetResults, WorkoutHistoryStepResults, @@ -49,7 +51,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 22; + int get schemaVersion => 24; @override MigrationStrategy get migration { @@ -129,6 +131,12 @@ final class AppDatabase extends _$AppDatabase { if (from < 22) { await _migrateToSchema22(); } + if (from < 23) { + await _migrateToSchema23(); + } + if (from < 24) { + await _migrateToSchema24(migrator); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -138,6 +146,9 @@ final class AppDatabase extends _$AppDatabase { } Future _createIndexes() async { + if (!await _hasTable(_syncableTableNames.first)) { + return; + } for (final tableName in _syncableTableNames) { await customStatement( 'CREATE INDEX IF NOT EXISTS idx_${tableName}_deleted_at ' @@ -250,6 +261,14 @@ final class AppDatabase extends _$AppDatabase { 'ON workout_history (completed, started_at) ' 'WHERE deleted_at IS NULL', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_samples_session ' + 'ON workout_telemetry_samples (session_id, captured_at)', + ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_aggregates_session ' + 'ON workout_telemetry_aggregates (session_id, scope)', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id ' 'ON workout_history_set_results (workout_history_id)', @@ -816,6 +835,127 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states ( ); } + Future _migrateToSchema23() async { + await customStatement(''' +CREATE TABLE IF NOT EXISTS share_inbox_items_v23 ( + share_id TEXT NOT NULL PRIMARY KEY, + sender_user_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK ( + resource_type IN ('program', 'workoutTemplate', 'pack') + ), + payload_json TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('pending', 'accepted', 'declined', 'revoked') + ), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + responded_at INTEGER +) +'''); + await customStatement(''' +INSERT INTO share_inbox_items_v23 ( + share_id, + sender_user_id, + resource_type, + payload_json, + status, + created_at, + updated_at, + responded_at +) +SELECT + share_id, + sender_user_id, + resource_type, + payload_json, + status, + created_at, + updated_at, + responded_at +FROM share_inbox_items +'''); + await customStatement('DROP TABLE share_inbox_items'); + await customStatement( + 'ALTER TABLE share_inbox_items_v23 RENAME TO share_inbox_items', + ); + + await customStatement(''' +CREATE TABLE IF NOT EXISTS pending_share_actions_v23 ( + id TEXT NOT NULL PRIMARY KEY, + action_type TEXT NOT NULL CHECK ( + action_type IN ('send', 'accept', 'decline', 'revoke') + ), + share_id TEXT, + resource_type TEXT CHECK ( + resource_type IS NULL OR + resource_type IN ('program', 'workoutTemplate', 'pack') + ), + payload_json TEXT, + recipient_emails_json TEXT, + created_at INTEGER NOT NULL, + last_attempt_at INTEGER, + attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')) +) +'''); + await customStatement(''' +INSERT INTO pending_share_actions_v23 ( + id, + action_type, + share_id, + resource_type, + payload_json, + recipient_emails_json, + created_at, + last_attempt_at, + attempt_count, + status +) +SELECT + id, + action_type, + share_id, + resource_type, + payload_json, + recipient_emails_json, + created_at, + last_attempt_at, + attempt_count, + status +FROM pending_share_actions +'''); + await customStatement('DROP TABLE pending_share_actions'); + await customStatement( + 'ALTER TABLE pending_share_actions_v23 RENAME TO pending_share_actions', + ); + } + + Future _migrateToSchema24(Migrator migrator) async { + await _addColumnIfMissing( + tableName: 'workout_history', + columnName: 'min_heart_rate_bpm', + definition: + 'min_heart_rate_bpm INTEGER CHECK ' + '(min_heart_rate_bpm IS NULL OR min_heart_rate_bpm > 0)', + ); + await _addColumnIfMissing( + tableName: 'workout_history', + columnName: 'total_distance_meters', + definition: + 'total_distance_meters REAL CHECK ' + '(total_distance_meters IS NULL OR total_distance_meters >= 0)', + ); + await _addColumnIfMissing( + tableName: 'workout_history', + columnName: 'total_calories_kcal', + definition: + 'total_calories_kcal REAL CHECK ' + '(total_calories_kcal IS NULL OR total_calories_kcal >= 0)', + ); + await migrator.createTable(workoutTelemetrySamples); + await migrator.createTable(workoutTelemetryAggregates); + } + Future _backfillWorkoutHistorySetSourceExerciseIds() async { await customStatement(r''' UPDATE workout_history_set_results AS result @@ -890,4 +1030,12 @@ WHERE result.source_exercise_id_snapshot IS NULL final rows = await customSelect('PRAGMA table_info($tableName)').get(); return rows.any((row) => row.data['name'] == columnName); } + + Future _hasTable(String tableName) async { + final rows = await customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(tableName)], + ).get(); + return rows.isNotEmpty; + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index fe707b6..074f98d 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -23693,6 +23693,1729 @@ class SyncMetadataEntriesCompanion extends UpdateCompanion { } } +class $WorkoutTelemetryAggregatesTable extends WorkoutTelemetryAggregates + with + TableInfo<$WorkoutTelemetryAggregatesTable, WorkoutTelemetryAggregate> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $WorkoutTelemetryAggregatesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _sessionIdMeta = const VerificationMeta( + 'sessionId', + ); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scopeMeta = const VerificationMeta('scope'); + @override + late final GeneratedColumn scope = GeneratedColumn( + 'scope', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _programIndexMeta = const VerificationMeta( + 'programIndex', + ); + @override + late final GeneratedColumn programIndex = GeneratedColumn( + 'program_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _exerciseIndexMeta = const VerificationMeta( + 'exerciseIndex', + ); + @override + late final GeneratedColumn exerciseIndex = GeneratedColumn( + 'exercise_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _setIndexMeta = const VerificationMeta( + 'setIndex', + ); + @override + late final GeneratedColumn setIndex = GeneratedColumn( + 'set_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _passageIndexMeta = const VerificationMeta( + 'passageIndex', + ); + @override + late final GeneratedColumn passageIndex = GeneratedColumn( + 'passage_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _stepIndexMeta = const VerificationMeta( + 'stepIndex', + ); + @override + late final GeneratedColumn stepIndex = GeneratedColumn( + 'step_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _sampleCountMeta = const VerificationMeta( + 'sampleCount', + ); + @override + late final GeneratedColumn sampleCount = GeneratedColumn( + 'sample_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _minHeartRateBpmMeta = const VerificationMeta( + 'minHeartRateBpm', + ); + @override + late final GeneratedColumn minHeartRateBpm = GeneratedColumn( + 'min_heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _averageHeartRateBpmMeta = + const VerificationMeta('averageHeartRateBpm'); + @override + late final GeneratedColumn averageHeartRateBpm = + GeneratedColumn( + 'average_heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _maxHeartRateBpmMeta = const VerificationMeta( + 'maxHeartRateBpm', + ); + @override + late final GeneratedColumn maxHeartRateBpm = GeneratedColumn( + 'max_heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _totalDistanceMetersMeta = + const VerificationMeta('totalDistanceMeters'); + @override + late final GeneratedColumn totalDistanceMeters = + GeneratedColumn( + 'total_distance_meters', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _totalCaloriesKcalMeta = const VerificationMeta( + 'totalCaloriesKcal', + ); + @override + late final GeneratedColumn totalCaloriesKcal = + GeneratedColumn( + 'total_calories_kcal', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + sessionId, + scope, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + sampleCount, + minHeartRateBpm, + averageHeartRateBpm, + maxHeartRateBpm, + totalDistanceMeters, + totalCaloriesKcal, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'workout_telemetry_aggregates'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('session_id')) { + context.handle( + _sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta), + ); + } else if (isInserting) { + context.missing(_sessionIdMeta); + } + if (data.containsKey('scope')) { + context.handle( + _scopeMeta, + scope.isAcceptableOrUnknown(data['scope']!, _scopeMeta), + ); + } else if (isInserting) { + context.missing(_scopeMeta); + } + if (data.containsKey('program_index')) { + context.handle( + _programIndexMeta, + programIndex.isAcceptableOrUnknown( + data['program_index']!, + _programIndexMeta, + ), + ); + } + if (data.containsKey('exercise_index')) { + context.handle( + _exerciseIndexMeta, + exerciseIndex.isAcceptableOrUnknown( + data['exercise_index']!, + _exerciseIndexMeta, + ), + ); + } + if (data.containsKey('set_index')) { + context.handle( + _setIndexMeta, + setIndex.isAcceptableOrUnknown(data['set_index']!, _setIndexMeta), + ); + } + if (data.containsKey('passage_index')) { + context.handle( + _passageIndexMeta, + passageIndex.isAcceptableOrUnknown( + data['passage_index']!, + _passageIndexMeta, + ), + ); + } + if (data.containsKey('step_index')) { + context.handle( + _stepIndexMeta, + stepIndex.isAcceptableOrUnknown(data['step_index']!, _stepIndexMeta), + ); + } + if (data.containsKey('sample_count')) { + context.handle( + _sampleCountMeta, + sampleCount.isAcceptableOrUnknown( + data['sample_count']!, + _sampleCountMeta, + ), + ); + } else if (isInserting) { + context.missing(_sampleCountMeta); + } + if (data.containsKey('min_heart_rate_bpm')) { + context.handle( + _minHeartRateBpmMeta, + minHeartRateBpm.isAcceptableOrUnknown( + data['min_heart_rate_bpm']!, + _minHeartRateBpmMeta, + ), + ); + } + if (data.containsKey('average_heart_rate_bpm')) { + context.handle( + _averageHeartRateBpmMeta, + averageHeartRateBpm.isAcceptableOrUnknown( + data['average_heart_rate_bpm']!, + _averageHeartRateBpmMeta, + ), + ); + } + if (data.containsKey('max_heart_rate_bpm')) { + context.handle( + _maxHeartRateBpmMeta, + maxHeartRateBpm.isAcceptableOrUnknown( + data['max_heart_rate_bpm']!, + _maxHeartRateBpmMeta, + ), + ); + } + if (data.containsKey('total_distance_meters')) { + context.handle( + _totalDistanceMetersMeta, + totalDistanceMeters.isAcceptableOrUnknown( + data['total_distance_meters']!, + _totalDistanceMetersMeta, + ), + ); + } + if (data.containsKey('total_calories_kcal')) { + context.handle( + _totalCaloriesKcalMeta, + totalCaloriesKcal.isAcceptableOrUnknown( + data['total_calories_kcal']!, + _totalCaloriesKcalMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => const {}; + @override + WorkoutTelemetryAggregate map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WorkoutTelemetryAggregate( + sessionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}session_id'], + )!, + scope: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}scope'], + )!, + programIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}program_index'], + ), + exerciseIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}exercise_index'], + ), + setIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}set_index'], + ), + passageIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}passage_index'], + ), + stepIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}step_index'], + ), + sampleCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sample_count'], + )!, + minHeartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}min_heart_rate_bpm'], + ), + averageHeartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}average_heart_rate_bpm'], + ), + maxHeartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}max_heart_rate_bpm'], + ), + totalDistanceMeters: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}total_distance_meters'], + ), + totalCaloriesKcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}total_calories_kcal'], + ), + ); + } + + @override + $WorkoutTelemetryAggregatesTable createAlias(String alias) { + return $WorkoutTelemetryAggregatesTable(attachedDatabase, alias); + } +} + +class WorkoutTelemetryAggregate extends DataClass + implements Insertable { + final String sessionId; + final String scope; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final int sampleCount; + final int? minHeartRateBpm; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; + final double? totalDistanceMeters; + final double? totalCaloriesKcal; + const WorkoutTelemetryAggregate({ + required this.sessionId, + required this.scope, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + required this.sampleCount, + this.minHeartRateBpm, + this.averageHeartRateBpm, + this.maxHeartRateBpm, + this.totalDistanceMeters, + this.totalCaloriesKcal, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['session_id'] = Variable(sessionId); + map['scope'] = Variable(scope); + if (!nullToAbsent || programIndex != null) { + map['program_index'] = Variable(programIndex); + } + if (!nullToAbsent || exerciseIndex != null) { + map['exercise_index'] = Variable(exerciseIndex); + } + if (!nullToAbsent || setIndex != null) { + map['set_index'] = Variable(setIndex); + } + if (!nullToAbsent || passageIndex != null) { + map['passage_index'] = Variable(passageIndex); + } + if (!nullToAbsent || stepIndex != null) { + map['step_index'] = Variable(stepIndex); + } + map['sample_count'] = Variable(sampleCount); + if (!nullToAbsent || minHeartRateBpm != null) { + map['min_heart_rate_bpm'] = Variable(minHeartRateBpm); + } + if (!nullToAbsent || averageHeartRateBpm != null) { + map['average_heart_rate_bpm'] = Variable(averageHeartRateBpm); + } + if (!nullToAbsent || maxHeartRateBpm != null) { + map['max_heart_rate_bpm'] = Variable(maxHeartRateBpm); + } + if (!nullToAbsent || totalDistanceMeters != null) { + map['total_distance_meters'] = Variable(totalDistanceMeters); + } + if (!nullToAbsent || totalCaloriesKcal != null) { + map['total_calories_kcal'] = Variable(totalCaloriesKcal); + } + return map; + } + + WorkoutTelemetryAggregatesCompanion toCompanion(bool nullToAbsent) { + return WorkoutTelemetryAggregatesCompanion( + sessionId: Value(sessionId), + scope: Value(scope), + programIndex: programIndex == null && nullToAbsent + ? const Value.absent() + : Value(programIndex), + exerciseIndex: exerciseIndex == null && nullToAbsent + ? const Value.absent() + : Value(exerciseIndex), + setIndex: setIndex == null && nullToAbsent + ? const Value.absent() + : Value(setIndex), + passageIndex: passageIndex == null && nullToAbsent + ? const Value.absent() + : Value(passageIndex), + stepIndex: stepIndex == null && nullToAbsent + ? const Value.absent() + : Value(stepIndex), + sampleCount: Value(sampleCount), + minHeartRateBpm: minHeartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(minHeartRateBpm), + averageHeartRateBpm: averageHeartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(averageHeartRateBpm), + maxHeartRateBpm: maxHeartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(maxHeartRateBpm), + totalDistanceMeters: totalDistanceMeters == null && nullToAbsent + ? const Value.absent() + : Value(totalDistanceMeters), + totalCaloriesKcal: totalCaloriesKcal == null && nullToAbsent + ? const Value.absent() + : Value(totalCaloriesKcal), + ); + } + + factory WorkoutTelemetryAggregate.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return WorkoutTelemetryAggregate( + sessionId: serializer.fromJson(json['sessionId']), + scope: serializer.fromJson(json['scope']), + programIndex: serializer.fromJson(json['programIndex']), + exerciseIndex: serializer.fromJson(json['exerciseIndex']), + setIndex: serializer.fromJson(json['setIndex']), + passageIndex: serializer.fromJson(json['passageIndex']), + stepIndex: serializer.fromJson(json['stepIndex']), + sampleCount: serializer.fromJson(json['sampleCount']), + minHeartRateBpm: serializer.fromJson(json['minHeartRateBpm']), + averageHeartRateBpm: serializer.fromJson( + json['averageHeartRateBpm'], + ), + maxHeartRateBpm: serializer.fromJson(json['maxHeartRateBpm']), + totalDistanceMeters: serializer.fromJson( + json['totalDistanceMeters'], + ), + totalCaloriesKcal: serializer.fromJson( + json['totalCaloriesKcal'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sessionId': serializer.toJson(sessionId), + 'scope': serializer.toJson(scope), + 'programIndex': serializer.toJson(programIndex), + 'exerciseIndex': serializer.toJson(exerciseIndex), + 'setIndex': serializer.toJson(setIndex), + 'passageIndex': serializer.toJson(passageIndex), + 'stepIndex': serializer.toJson(stepIndex), + 'sampleCount': serializer.toJson(sampleCount), + 'minHeartRateBpm': serializer.toJson(minHeartRateBpm), + 'averageHeartRateBpm': serializer.toJson(averageHeartRateBpm), + 'maxHeartRateBpm': serializer.toJson(maxHeartRateBpm), + 'totalDistanceMeters': serializer.toJson(totalDistanceMeters), + 'totalCaloriesKcal': serializer.toJson(totalCaloriesKcal), + }; + } + + WorkoutTelemetryAggregate copyWith({ + String? sessionId, + String? scope, + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + int? sampleCount, + Value minHeartRateBpm = const Value.absent(), + Value averageHeartRateBpm = const Value.absent(), + Value maxHeartRateBpm = const Value.absent(), + Value totalDistanceMeters = const Value.absent(), + Value totalCaloriesKcal = const Value.absent(), + }) => WorkoutTelemetryAggregate( + sessionId: sessionId ?? this.sessionId, + scope: scope ?? this.scope, + programIndex: programIndex.present ? programIndex.value : this.programIndex, + exerciseIndex: exerciseIndex.present + ? exerciseIndex.value + : this.exerciseIndex, + setIndex: setIndex.present ? setIndex.value : this.setIndex, + passageIndex: passageIndex.present ? passageIndex.value : this.passageIndex, + stepIndex: stepIndex.present ? stepIndex.value : this.stepIndex, + sampleCount: sampleCount ?? this.sampleCount, + minHeartRateBpm: minHeartRateBpm.present + ? minHeartRateBpm.value + : this.minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm.present + ? averageHeartRateBpm.value + : this.averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm.present + ? maxHeartRateBpm.value + : this.maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters.present + ? totalDistanceMeters.value + : this.totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal.present + ? totalCaloriesKcal.value + : this.totalCaloriesKcal, + ); + WorkoutTelemetryAggregate copyWithCompanion( + WorkoutTelemetryAggregatesCompanion data, + ) { + return WorkoutTelemetryAggregate( + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + scope: data.scope.present ? data.scope.value : this.scope, + programIndex: data.programIndex.present + ? data.programIndex.value + : this.programIndex, + exerciseIndex: data.exerciseIndex.present + ? data.exerciseIndex.value + : this.exerciseIndex, + setIndex: data.setIndex.present ? data.setIndex.value : this.setIndex, + passageIndex: data.passageIndex.present + ? data.passageIndex.value + : this.passageIndex, + stepIndex: data.stepIndex.present ? data.stepIndex.value : this.stepIndex, + sampleCount: data.sampleCount.present + ? data.sampleCount.value + : this.sampleCount, + minHeartRateBpm: data.minHeartRateBpm.present + ? data.minHeartRateBpm.value + : this.minHeartRateBpm, + averageHeartRateBpm: data.averageHeartRateBpm.present + ? data.averageHeartRateBpm.value + : this.averageHeartRateBpm, + maxHeartRateBpm: data.maxHeartRateBpm.present + ? data.maxHeartRateBpm.value + : this.maxHeartRateBpm, + totalDistanceMeters: data.totalDistanceMeters.present + ? data.totalDistanceMeters.value + : this.totalDistanceMeters, + totalCaloriesKcal: data.totalCaloriesKcal.present + ? data.totalCaloriesKcal.value + : this.totalCaloriesKcal, + ); + } + + @override + String toString() { + return (StringBuffer('WorkoutTelemetryAggregate(') + ..write('sessionId: $sessionId, ') + ..write('scope: $scope, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('passageIndex: $passageIndex, ') + ..write('stepIndex: $stepIndex, ') + ..write('sampleCount: $sampleCount, ') + ..write('minHeartRateBpm: $minHeartRateBpm, ') + ..write('averageHeartRateBpm: $averageHeartRateBpm, ') + ..write('maxHeartRateBpm: $maxHeartRateBpm, ') + ..write('totalDistanceMeters: $totalDistanceMeters, ') + ..write('totalCaloriesKcal: $totalCaloriesKcal') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + sessionId, + scope, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + sampleCount, + minHeartRateBpm, + averageHeartRateBpm, + maxHeartRateBpm, + totalDistanceMeters, + totalCaloriesKcal, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WorkoutTelemetryAggregate && + other.sessionId == this.sessionId && + other.scope == this.scope && + other.programIndex == this.programIndex && + other.exerciseIndex == this.exerciseIndex && + other.setIndex == this.setIndex && + other.passageIndex == this.passageIndex && + other.stepIndex == this.stepIndex && + other.sampleCount == this.sampleCount && + other.minHeartRateBpm == this.minHeartRateBpm && + other.averageHeartRateBpm == this.averageHeartRateBpm && + other.maxHeartRateBpm == this.maxHeartRateBpm && + other.totalDistanceMeters == this.totalDistanceMeters && + other.totalCaloriesKcal == this.totalCaloriesKcal); +} + +class WorkoutTelemetryAggregatesCompanion + extends UpdateCompanion { + final Value sessionId; + final Value scope; + final Value programIndex; + final Value exerciseIndex; + final Value setIndex; + final Value passageIndex; + final Value stepIndex; + final Value sampleCount; + final Value minHeartRateBpm; + final Value averageHeartRateBpm; + final Value maxHeartRateBpm; + final Value totalDistanceMeters; + final Value totalCaloriesKcal; + final Value rowid; + const WorkoutTelemetryAggregatesCompanion({ + this.sessionId = const Value.absent(), + this.scope = const Value.absent(), + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.passageIndex = const Value.absent(), + this.stepIndex = const Value.absent(), + this.sampleCount = const Value.absent(), + this.minHeartRateBpm = const Value.absent(), + this.averageHeartRateBpm = const Value.absent(), + this.maxHeartRateBpm = const Value.absent(), + this.totalDistanceMeters = const Value.absent(), + this.totalCaloriesKcal = const Value.absent(), + this.rowid = const Value.absent(), + }); + WorkoutTelemetryAggregatesCompanion.insert({ + required String sessionId, + required String scope, + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.passageIndex = const Value.absent(), + this.stepIndex = const Value.absent(), + required int sampleCount, + this.minHeartRateBpm = const Value.absent(), + this.averageHeartRateBpm = const Value.absent(), + this.maxHeartRateBpm = const Value.absent(), + this.totalDistanceMeters = const Value.absent(), + this.totalCaloriesKcal = const Value.absent(), + this.rowid = const Value.absent(), + }) : sessionId = Value(sessionId), + scope = Value(scope), + sampleCount = Value(sampleCount); + static Insertable custom({ + Expression? sessionId, + Expression? scope, + Expression? programIndex, + Expression? exerciseIndex, + Expression? setIndex, + Expression? passageIndex, + Expression? stepIndex, + Expression? sampleCount, + Expression? minHeartRateBpm, + Expression? averageHeartRateBpm, + Expression? maxHeartRateBpm, + Expression? totalDistanceMeters, + Expression? totalCaloriesKcal, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (sessionId != null) 'session_id': sessionId, + if (scope != null) 'scope': scope, + if (programIndex != null) 'program_index': programIndex, + if (exerciseIndex != null) 'exercise_index': exerciseIndex, + if (setIndex != null) 'set_index': setIndex, + if (passageIndex != null) 'passage_index': passageIndex, + if (stepIndex != null) 'step_index': stepIndex, + if (sampleCount != null) 'sample_count': sampleCount, + if (minHeartRateBpm != null) 'min_heart_rate_bpm': minHeartRateBpm, + if (averageHeartRateBpm != null) + 'average_heart_rate_bpm': averageHeartRateBpm, + if (maxHeartRateBpm != null) 'max_heart_rate_bpm': maxHeartRateBpm, + if (totalDistanceMeters != null) + 'total_distance_meters': totalDistanceMeters, + if (totalCaloriesKcal != null) 'total_calories_kcal': totalCaloriesKcal, + if (rowid != null) 'rowid': rowid, + }); + } + + WorkoutTelemetryAggregatesCompanion copyWith({ + Value? sessionId, + Value? scope, + Value? programIndex, + Value? exerciseIndex, + Value? setIndex, + Value? passageIndex, + Value? stepIndex, + Value? sampleCount, + Value? minHeartRateBpm, + Value? averageHeartRateBpm, + Value? maxHeartRateBpm, + Value? totalDistanceMeters, + Value? totalCaloriesKcal, + Value? rowid, + }) { + return WorkoutTelemetryAggregatesCompanion( + sessionId: sessionId ?? this.sessionId, + scope: scope ?? this.scope, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + passageIndex: passageIndex ?? this.passageIndex, + stepIndex: stepIndex ?? this.stepIndex, + sampleCount: sampleCount ?? this.sampleCount, + minHeartRateBpm: minHeartRateBpm ?? this.minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm ?? this.averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm ?? this.maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters ?? this.totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal ?? this.totalCaloriesKcal, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (scope.present) { + map['scope'] = Variable(scope.value); + } + if (programIndex.present) { + map['program_index'] = Variable(programIndex.value); + } + if (exerciseIndex.present) { + map['exercise_index'] = Variable(exerciseIndex.value); + } + if (setIndex.present) { + map['set_index'] = Variable(setIndex.value); + } + if (passageIndex.present) { + map['passage_index'] = Variable(passageIndex.value); + } + if (stepIndex.present) { + map['step_index'] = Variable(stepIndex.value); + } + if (sampleCount.present) { + map['sample_count'] = Variable(sampleCount.value); + } + if (minHeartRateBpm.present) { + map['min_heart_rate_bpm'] = Variable(minHeartRateBpm.value); + } + if (averageHeartRateBpm.present) { + map['average_heart_rate_bpm'] = Variable( + averageHeartRateBpm.value, + ); + } + if (maxHeartRateBpm.present) { + map['max_heart_rate_bpm'] = Variable(maxHeartRateBpm.value); + } + if (totalDistanceMeters.present) { + map['total_distance_meters'] = Variable( + totalDistanceMeters.value, + ); + } + if (totalCaloriesKcal.present) { + map['total_calories_kcal'] = Variable(totalCaloriesKcal.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WorkoutTelemetryAggregatesCompanion(') + ..write('sessionId: $sessionId, ') + ..write('scope: $scope, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('passageIndex: $passageIndex, ') + ..write('stepIndex: $stepIndex, ') + ..write('sampleCount: $sampleCount, ') + ..write('minHeartRateBpm: $minHeartRateBpm, ') + ..write('averageHeartRateBpm: $averageHeartRateBpm, ') + ..write('maxHeartRateBpm: $maxHeartRateBpm, ') + ..write('totalDistanceMeters: $totalDistanceMeters, ') + ..write('totalCaloriesKcal: $totalCaloriesKcal, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $WorkoutTelemetrySamplesTable extends WorkoutTelemetrySamples + with TableInfo<$WorkoutTelemetrySamplesTable, WorkoutTelemetrySample> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $WorkoutTelemetrySamplesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sessionIdMeta = const VerificationMeta( + 'sessionId', + ); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _capturedAtMeta = const VerificationMeta( + 'capturedAt', + ); + @override + late final GeneratedColumn capturedAt = GeneratedColumn( + 'captured_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _programIndexMeta = const VerificationMeta( + 'programIndex', + ); + @override + late final GeneratedColumn programIndex = GeneratedColumn( + 'program_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _exerciseIndexMeta = const VerificationMeta( + 'exerciseIndex', + ); + @override + late final GeneratedColumn exerciseIndex = GeneratedColumn( + 'exercise_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _setIndexMeta = const VerificationMeta( + 'setIndex', + ); + @override + late final GeneratedColumn setIndex = GeneratedColumn( + 'set_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _passageIndexMeta = const VerificationMeta( + 'passageIndex', + ); + @override + late final GeneratedColumn passageIndex = GeneratedColumn( + 'passage_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _stepIndexMeta = const VerificationMeta( + 'stepIndex', + ); + @override + late final GeneratedColumn stepIndex = GeneratedColumn( + 'step_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _programSnapshotIdMeta = const VerificationMeta( + 'programSnapshotId', + ); + @override + late final GeneratedColumn programSnapshotId = + GeneratedColumn( + 'program_snapshot_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _exerciseSnapshotIdMeta = + const VerificationMeta('exerciseSnapshotId'); + @override + late final GeneratedColumn exerciseSnapshotId = + GeneratedColumn( + 'exercise_snapshot_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _stepSnapshotIdMeta = const VerificationMeta( + 'stepSnapshotId', + ); + @override + late final GeneratedColumn stepSnapshotId = GeneratedColumn( + 'step_snapshot_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _heartRateBpmMeta = const VerificationMeta( + 'heartRateBpm', + ); + @override + late final GeneratedColumn heartRateBpm = GeneratedColumn( + 'heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _distanceMetersMeta = const VerificationMeta( + 'distanceMeters', + ); + @override + late final GeneratedColumn distanceMeters = GeneratedColumn( + 'distance_meters', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _caloriesKcalMeta = const VerificationMeta( + 'caloriesKcal', + ); + @override + late final GeneratedColumn caloriesKcal = GeneratedColumn( + 'calories_kcal', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + sessionId, + capturedAt, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + programSnapshotId, + exerciseSnapshotId, + stepSnapshotId, + heartRateBpm, + distanceMeters, + caloriesKcal, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'workout_telemetry_samples'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('session_id')) { + context.handle( + _sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta), + ); + } else if (isInserting) { + context.missing(_sessionIdMeta); + } + if (data.containsKey('captured_at')) { + context.handle( + _capturedAtMeta, + capturedAt.isAcceptableOrUnknown(data['captured_at']!, _capturedAtMeta), + ); + } else if (isInserting) { + context.missing(_capturedAtMeta); + } + if (data.containsKey('program_index')) { + context.handle( + _programIndexMeta, + programIndex.isAcceptableOrUnknown( + data['program_index']!, + _programIndexMeta, + ), + ); + } + if (data.containsKey('exercise_index')) { + context.handle( + _exerciseIndexMeta, + exerciseIndex.isAcceptableOrUnknown( + data['exercise_index']!, + _exerciseIndexMeta, + ), + ); + } + if (data.containsKey('set_index')) { + context.handle( + _setIndexMeta, + setIndex.isAcceptableOrUnknown(data['set_index']!, _setIndexMeta), + ); + } + if (data.containsKey('passage_index')) { + context.handle( + _passageIndexMeta, + passageIndex.isAcceptableOrUnknown( + data['passage_index']!, + _passageIndexMeta, + ), + ); + } + if (data.containsKey('step_index')) { + context.handle( + _stepIndexMeta, + stepIndex.isAcceptableOrUnknown(data['step_index']!, _stepIndexMeta), + ); + } + if (data.containsKey('program_snapshot_id')) { + context.handle( + _programSnapshotIdMeta, + programSnapshotId.isAcceptableOrUnknown( + data['program_snapshot_id']!, + _programSnapshotIdMeta, + ), + ); + } + if (data.containsKey('exercise_snapshot_id')) { + context.handle( + _exerciseSnapshotIdMeta, + exerciseSnapshotId.isAcceptableOrUnknown( + data['exercise_snapshot_id']!, + _exerciseSnapshotIdMeta, + ), + ); + } + if (data.containsKey('step_snapshot_id')) { + context.handle( + _stepSnapshotIdMeta, + stepSnapshotId.isAcceptableOrUnknown( + data['step_snapshot_id']!, + _stepSnapshotIdMeta, + ), + ); + } + if (data.containsKey('heart_rate_bpm')) { + context.handle( + _heartRateBpmMeta, + heartRateBpm.isAcceptableOrUnknown( + data['heart_rate_bpm']!, + _heartRateBpmMeta, + ), + ); + } + if (data.containsKey('distance_meters')) { + context.handle( + _distanceMetersMeta, + distanceMeters.isAcceptableOrUnknown( + data['distance_meters']!, + _distanceMetersMeta, + ), + ); + } + if (data.containsKey('calories_kcal')) { + context.handle( + _caloriesKcalMeta, + caloriesKcal.isAcceptableOrUnknown( + data['calories_kcal']!, + _caloriesKcalMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + WorkoutTelemetrySample map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WorkoutTelemetrySample( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + sessionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}session_id'], + )!, + capturedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}captured_at'], + )!, + programIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}program_index'], + ), + exerciseIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}exercise_index'], + ), + setIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}set_index'], + ), + passageIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}passage_index'], + ), + stepIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}step_index'], + ), + programSnapshotId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}program_snapshot_id'], + ), + exerciseSnapshotId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exercise_snapshot_id'], + ), + stepSnapshotId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}step_snapshot_id'], + ), + heartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}heart_rate_bpm'], + ), + distanceMeters: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}distance_meters'], + ), + caloriesKcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}calories_kcal'], + ), + ); + } + + @override + $WorkoutTelemetrySamplesTable createAlias(String alias) { + return $WorkoutTelemetrySamplesTable(attachedDatabase, alias); + } +} + +class WorkoutTelemetrySample extends DataClass + implements Insertable { + final String id; + final String sessionId; + final DateTime capturedAt; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final String? programSnapshotId; + final String? exerciseSnapshotId; + final String? stepSnapshotId; + final int? heartRateBpm; + final double? distanceMeters; + final double? caloriesKcal; + const WorkoutTelemetrySample({ + required this.id, + required this.sessionId, + required this.capturedAt, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + this.programSnapshotId, + this.exerciseSnapshotId, + this.stepSnapshotId, + this.heartRateBpm, + this.distanceMeters, + this.caloriesKcal, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['session_id'] = Variable(sessionId); + map['captured_at'] = Variable(capturedAt); + if (!nullToAbsent || programIndex != null) { + map['program_index'] = Variable(programIndex); + } + if (!nullToAbsent || exerciseIndex != null) { + map['exercise_index'] = Variable(exerciseIndex); + } + if (!nullToAbsent || setIndex != null) { + map['set_index'] = Variable(setIndex); + } + if (!nullToAbsent || passageIndex != null) { + map['passage_index'] = Variable(passageIndex); + } + if (!nullToAbsent || stepIndex != null) { + map['step_index'] = Variable(stepIndex); + } + if (!nullToAbsent || programSnapshotId != null) { + map['program_snapshot_id'] = Variable(programSnapshotId); + } + if (!nullToAbsent || exerciseSnapshotId != null) { + map['exercise_snapshot_id'] = Variable(exerciseSnapshotId); + } + if (!nullToAbsent || stepSnapshotId != null) { + map['step_snapshot_id'] = Variable(stepSnapshotId); + } + if (!nullToAbsent || heartRateBpm != null) { + map['heart_rate_bpm'] = Variable(heartRateBpm); + } + if (!nullToAbsent || distanceMeters != null) { + map['distance_meters'] = Variable(distanceMeters); + } + if (!nullToAbsent || caloriesKcal != null) { + map['calories_kcal'] = Variable(caloriesKcal); + } + return map; + } + + WorkoutTelemetrySamplesCompanion toCompanion(bool nullToAbsent) { + return WorkoutTelemetrySamplesCompanion( + id: Value(id), + sessionId: Value(sessionId), + capturedAt: Value(capturedAt), + programIndex: programIndex == null && nullToAbsent + ? const Value.absent() + : Value(programIndex), + exerciseIndex: exerciseIndex == null && nullToAbsent + ? const Value.absent() + : Value(exerciseIndex), + setIndex: setIndex == null && nullToAbsent + ? const Value.absent() + : Value(setIndex), + passageIndex: passageIndex == null && nullToAbsent + ? const Value.absent() + : Value(passageIndex), + stepIndex: stepIndex == null && nullToAbsent + ? const Value.absent() + : Value(stepIndex), + programSnapshotId: programSnapshotId == null && nullToAbsent + ? const Value.absent() + : Value(programSnapshotId), + exerciseSnapshotId: exerciseSnapshotId == null && nullToAbsent + ? const Value.absent() + : Value(exerciseSnapshotId), + stepSnapshotId: stepSnapshotId == null && nullToAbsent + ? const Value.absent() + : Value(stepSnapshotId), + heartRateBpm: heartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(heartRateBpm), + distanceMeters: distanceMeters == null && nullToAbsent + ? const Value.absent() + : Value(distanceMeters), + caloriesKcal: caloriesKcal == null && nullToAbsent + ? const Value.absent() + : Value(caloriesKcal), + ); + } + + factory WorkoutTelemetrySample.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return WorkoutTelemetrySample( + id: serializer.fromJson(json['id']), + sessionId: serializer.fromJson(json['sessionId']), + capturedAt: serializer.fromJson(json['capturedAt']), + programIndex: serializer.fromJson(json['programIndex']), + exerciseIndex: serializer.fromJson(json['exerciseIndex']), + setIndex: serializer.fromJson(json['setIndex']), + passageIndex: serializer.fromJson(json['passageIndex']), + stepIndex: serializer.fromJson(json['stepIndex']), + programSnapshotId: serializer.fromJson( + json['programSnapshotId'], + ), + exerciseSnapshotId: serializer.fromJson( + json['exerciseSnapshotId'], + ), + stepSnapshotId: serializer.fromJson(json['stepSnapshotId']), + heartRateBpm: serializer.fromJson(json['heartRateBpm']), + distanceMeters: serializer.fromJson(json['distanceMeters']), + caloriesKcal: serializer.fromJson(json['caloriesKcal']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'sessionId': serializer.toJson(sessionId), + 'capturedAt': serializer.toJson(capturedAt), + 'programIndex': serializer.toJson(programIndex), + 'exerciseIndex': serializer.toJson(exerciseIndex), + 'setIndex': serializer.toJson(setIndex), + 'passageIndex': serializer.toJson(passageIndex), + 'stepIndex': serializer.toJson(stepIndex), + 'programSnapshotId': serializer.toJson(programSnapshotId), + 'exerciseSnapshotId': serializer.toJson(exerciseSnapshotId), + 'stepSnapshotId': serializer.toJson(stepSnapshotId), + 'heartRateBpm': serializer.toJson(heartRateBpm), + 'distanceMeters': serializer.toJson(distanceMeters), + 'caloriesKcal': serializer.toJson(caloriesKcal), + }; + } + + WorkoutTelemetrySample copyWith({ + String? id, + String? sessionId, + DateTime? capturedAt, + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value programSnapshotId = const Value.absent(), + Value exerciseSnapshotId = const Value.absent(), + Value stepSnapshotId = const Value.absent(), + Value heartRateBpm = const Value.absent(), + Value distanceMeters = const Value.absent(), + Value caloriesKcal = const Value.absent(), + }) => WorkoutTelemetrySample( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + capturedAt: capturedAt ?? this.capturedAt, + programIndex: programIndex.present ? programIndex.value : this.programIndex, + exerciseIndex: exerciseIndex.present + ? exerciseIndex.value + : this.exerciseIndex, + setIndex: setIndex.present ? setIndex.value : this.setIndex, + passageIndex: passageIndex.present ? passageIndex.value : this.passageIndex, + stepIndex: stepIndex.present ? stepIndex.value : this.stepIndex, + programSnapshotId: programSnapshotId.present + ? programSnapshotId.value + : this.programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId.present + ? exerciseSnapshotId.value + : this.exerciseSnapshotId, + stepSnapshotId: stepSnapshotId.present + ? stepSnapshotId.value + : this.stepSnapshotId, + heartRateBpm: heartRateBpm.present ? heartRateBpm.value : this.heartRateBpm, + distanceMeters: distanceMeters.present + ? distanceMeters.value + : this.distanceMeters, + caloriesKcal: caloriesKcal.present ? caloriesKcal.value : this.caloriesKcal, + ); + WorkoutTelemetrySample copyWithCompanion( + WorkoutTelemetrySamplesCompanion data, + ) { + return WorkoutTelemetrySample( + id: data.id.present ? data.id.value : this.id, + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + capturedAt: data.capturedAt.present + ? data.capturedAt.value + : this.capturedAt, + programIndex: data.programIndex.present + ? data.programIndex.value + : this.programIndex, + exerciseIndex: data.exerciseIndex.present + ? data.exerciseIndex.value + : this.exerciseIndex, + setIndex: data.setIndex.present ? data.setIndex.value : this.setIndex, + passageIndex: data.passageIndex.present + ? data.passageIndex.value + : this.passageIndex, + stepIndex: data.stepIndex.present ? data.stepIndex.value : this.stepIndex, + programSnapshotId: data.programSnapshotId.present + ? data.programSnapshotId.value + : this.programSnapshotId, + exerciseSnapshotId: data.exerciseSnapshotId.present + ? data.exerciseSnapshotId.value + : this.exerciseSnapshotId, + stepSnapshotId: data.stepSnapshotId.present + ? data.stepSnapshotId.value + : this.stepSnapshotId, + heartRateBpm: data.heartRateBpm.present + ? data.heartRateBpm.value + : this.heartRateBpm, + distanceMeters: data.distanceMeters.present + ? data.distanceMeters.value + : this.distanceMeters, + caloriesKcal: data.caloriesKcal.present + ? data.caloriesKcal.value + : this.caloriesKcal, + ); + } + + @override + String toString() { + return (StringBuffer('WorkoutTelemetrySample(') + ..write('id: $id, ') + ..write('sessionId: $sessionId, ') + ..write('capturedAt: $capturedAt, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('passageIndex: $passageIndex, ') + ..write('stepIndex: $stepIndex, ') + ..write('programSnapshotId: $programSnapshotId, ') + ..write('exerciseSnapshotId: $exerciseSnapshotId, ') + ..write('stepSnapshotId: $stepSnapshotId, ') + ..write('heartRateBpm: $heartRateBpm, ') + ..write('distanceMeters: $distanceMeters, ') + ..write('caloriesKcal: $caloriesKcal') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + sessionId, + capturedAt, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + programSnapshotId, + exerciseSnapshotId, + stepSnapshotId, + heartRateBpm, + distanceMeters, + caloriesKcal, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WorkoutTelemetrySample && + other.id == this.id && + other.sessionId == this.sessionId && + other.capturedAt == this.capturedAt && + other.programIndex == this.programIndex && + other.exerciseIndex == this.exerciseIndex && + other.setIndex == this.setIndex && + other.passageIndex == this.passageIndex && + other.stepIndex == this.stepIndex && + other.programSnapshotId == this.programSnapshotId && + other.exerciseSnapshotId == this.exerciseSnapshotId && + other.stepSnapshotId == this.stepSnapshotId && + other.heartRateBpm == this.heartRateBpm && + other.distanceMeters == this.distanceMeters && + other.caloriesKcal == this.caloriesKcal); +} + +class WorkoutTelemetrySamplesCompanion + extends UpdateCompanion { + final Value id; + final Value sessionId; + final Value capturedAt; + final Value programIndex; + final Value exerciseIndex; + final Value setIndex; + final Value passageIndex; + final Value stepIndex; + final Value programSnapshotId; + final Value exerciseSnapshotId; + final Value stepSnapshotId; + final Value heartRateBpm; + final Value distanceMeters; + final Value caloriesKcal; + final Value rowid; + const WorkoutTelemetrySamplesCompanion({ + this.id = const Value.absent(), + this.sessionId = const Value.absent(), + this.capturedAt = const Value.absent(), + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.passageIndex = const Value.absent(), + this.stepIndex = const Value.absent(), + this.programSnapshotId = const Value.absent(), + this.exerciseSnapshotId = const Value.absent(), + this.stepSnapshotId = const Value.absent(), + this.heartRateBpm = const Value.absent(), + this.distanceMeters = const Value.absent(), + this.caloriesKcal = const Value.absent(), + this.rowid = const Value.absent(), + }); + WorkoutTelemetrySamplesCompanion.insert({ + required String id, + required String sessionId, + required DateTime capturedAt, + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.passageIndex = const Value.absent(), + this.stepIndex = const Value.absent(), + this.programSnapshotId = const Value.absent(), + this.exerciseSnapshotId = const Value.absent(), + this.stepSnapshotId = const Value.absent(), + this.heartRateBpm = const Value.absent(), + this.distanceMeters = const Value.absent(), + this.caloriesKcal = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + sessionId = Value(sessionId), + capturedAt = Value(capturedAt); + static Insertable custom({ + Expression? id, + Expression? sessionId, + Expression? capturedAt, + Expression? programIndex, + Expression? exerciseIndex, + Expression? setIndex, + Expression? passageIndex, + Expression? stepIndex, + Expression? programSnapshotId, + Expression? exerciseSnapshotId, + Expression? stepSnapshotId, + Expression? heartRateBpm, + Expression? distanceMeters, + Expression? caloriesKcal, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (sessionId != null) 'session_id': sessionId, + if (capturedAt != null) 'captured_at': capturedAt, + if (programIndex != null) 'program_index': programIndex, + if (exerciseIndex != null) 'exercise_index': exerciseIndex, + if (setIndex != null) 'set_index': setIndex, + if (passageIndex != null) 'passage_index': passageIndex, + if (stepIndex != null) 'step_index': stepIndex, + if (programSnapshotId != null) 'program_snapshot_id': programSnapshotId, + if (exerciseSnapshotId != null) + 'exercise_snapshot_id': exerciseSnapshotId, + if (stepSnapshotId != null) 'step_snapshot_id': stepSnapshotId, + if (heartRateBpm != null) 'heart_rate_bpm': heartRateBpm, + if (distanceMeters != null) 'distance_meters': distanceMeters, + if (caloriesKcal != null) 'calories_kcal': caloriesKcal, + if (rowid != null) 'rowid': rowid, + }); + } + + WorkoutTelemetrySamplesCompanion copyWith({ + Value? id, + Value? sessionId, + Value? capturedAt, + Value? programIndex, + Value? exerciseIndex, + Value? setIndex, + Value? passageIndex, + Value? stepIndex, + Value? programSnapshotId, + Value? exerciseSnapshotId, + Value? stepSnapshotId, + Value? heartRateBpm, + Value? distanceMeters, + Value? caloriesKcal, + Value? rowid, + }) { + return WorkoutTelemetrySamplesCompanion( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + capturedAt: capturedAt ?? this.capturedAt, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + passageIndex: passageIndex ?? this.passageIndex, + stepIndex: stepIndex ?? this.stepIndex, + programSnapshotId: programSnapshotId ?? this.programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId ?? this.exerciseSnapshotId, + stepSnapshotId: stepSnapshotId ?? this.stepSnapshotId, + heartRateBpm: heartRateBpm ?? this.heartRateBpm, + distanceMeters: distanceMeters ?? this.distanceMeters, + caloriesKcal: caloriesKcal ?? this.caloriesKcal, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (capturedAt.present) { + map['captured_at'] = Variable(capturedAt.value); + } + if (programIndex.present) { + map['program_index'] = Variable(programIndex.value); + } + if (exerciseIndex.present) { + map['exercise_index'] = Variable(exerciseIndex.value); + } + if (setIndex.present) { + map['set_index'] = Variable(setIndex.value); + } + if (passageIndex.present) { + map['passage_index'] = Variable(passageIndex.value); + } + if (stepIndex.present) { + map['step_index'] = Variable(stepIndex.value); + } + if (programSnapshotId.present) { + map['program_snapshot_id'] = Variable(programSnapshotId.value); + } + if (exerciseSnapshotId.present) { + map['exercise_snapshot_id'] = Variable(exerciseSnapshotId.value); + } + if (stepSnapshotId.present) { + map['step_snapshot_id'] = Variable(stepSnapshotId.value); + } + if (heartRateBpm.present) { + map['heart_rate_bpm'] = Variable(heartRateBpm.value); + } + if (distanceMeters.present) { + map['distance_meters'] = Variable(distanceMeters.value); + } + if (caloriesKcal.present) { + map['calories_kcal'] = Variable(caloriesKcal.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WorkoutTelemetrySamplesCompanion(') + ..write('id: $id, ') + ..write('sessionId: $sessionId, ') + ..write('capturedAt: $capturedAt, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('passageIndex: $passageIndex, ') + ..write('stepIndex: $stepIndex, ') + ..write('programSnapshotId: $programSnapshotId, ') + ..write('exerciseSnapshotId: $exerciseSnapshotId, ') + ..write('stepSnapshotId: $stepSnapshotId, ') + ..write('heartRateBpm: $heartRateBpm, ') + ..write('distanceMeters: $distanceMeters, ') + ..write('caloriesKcal: $caloriesKcal, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $WorkoutHistoriesTable extends WorkoutHistories with TableInfo<$WorkoutHistoriesTable, WorkoutHistory> { @override @@ -23922,6 +25645,17 @@ class $WorkoutHistoriesTable extends WorkoutHistories type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _minHeartRateBpmMeta = const VerificationMeta( + 'minHeartRateBpm', + ); + @override + late final GeneratedColumn minHeartRateBpm = GeneratedColumn( + 'min_heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); static const VerificationMeta _averageHeartRateBpmMeta = const VerificationMeta('averageHeartRateBpm'); @override @@ -23944,6 +25678,29 @@ class $WorkoutHistoriesTable extends WorkoutHistories type: DriftSqlType.int, requiredDuringInsert: false, ); + static const VerificationMeta _totalDistanceMetersMeta = + const VerificationMeta('totalDistanceMeters'); + @override + late final GeneratedColumn totalDistanceMeters = + GeneratedColumn( + 'total_distance_meters', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _totalCaloriesKcalMeta = const VerificationMeta( + 'totalCaloriesKcal', + ); + @override + late final GeneratedColumn totalCaloriesKcal = + GeneratedColumn( + 'total_calories_kcal', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -23965,8 +25722,11 @@ class $WorkoutHistoriesTable extends WorkoutHistories totalActiveMs, completed, historySnapshotJson, + minHeartRateBpm, averageHeartRateBpm, maxHeartRateBpm, + totalDistanceMeters, + totalCaloriesKcal, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -24148,6 +25908,15 @@ class $WorkoutHistoriesTable extends WorkoutHistories } else if (isInserting) { context.missing(_historySnapshotJsonMeta); } + if (data.containsKey('min_heart_rate_bpm')) { + context.handle( + _minHeartRateBpmMeta, + minHeartRateBpm.isAcceptableOrUnknown( + data['min_heart_rate_bpm']!, + _minHeartRateBpmMeta, + ), + ); + } if (data.containsKey('average_heart_rate_bpm')) { context.handle( _averageHeartRateBpmMeta, @@ -24166,6 +25935,24 @@ class $WorkoutHistoriesTable extends WorkoutHistories ), ); } + if (data.containsKey('total_distance_meters')) { + context.handle( + _totalDistanceMetersMeta, + totalDistanceMeters.isAcceptableOrUnknown( + data['total_distance_meters']!, + _totalDistanceMetersMeta, + ), + ); + } + if (data.containsKey('total_calories_kcal')) { + context.handle( + _totalCaloriesKcalMeta, + totalCaloriesKcal.isAcceptableOrUnknown( + data['total_calories_kcal']!, + _totalCaloriesKcalMeta, + ), + ); + } return context; } @@ -24251,6 +26038,10 @@ class $WorkoutHistoriesTable extends WorkoutHistories DriftSqlType.string, data['${effectivePrefix}history_snapshot_json'], )!, + minHeartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}min_heart_rate_bpm'], + ), averageHeartRateBpm: attachedDatabase.typeMapping.read( DriftSqlType.double, data['${effectivePrefix}average_heart_rate_bpm'], @@ -24259,6 +26050,14 @@ class $WorkoutHistoriesTable extends WorkoutHistories DriftSqlType.int, data['${effectivePrefix}max_heart_rate_bpm'], ), + totalDistanceMeters: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}total_distance_meters'], + ), + totalCaloriesKcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}total_calories_kcal'], + ), ); } @@ -24288,8 +26087,11 @@ class WorkoutHistory extends DataClass implements Insertable { final int totalActiveMs; final bool completed; final String historySnapshotJson; + final int? minHeartRateBpm; final double? averageHeartRateBpm; final int? maxHeartRateBpm; + final double? totalDistanceMeters; + final double? totalCaloriesKcal; const WorkoutHistory({ required this.id, required this.createdAt, @@ -24310,8 +26112,11 @@ class WorkoutHistory extends DataClass implements Insertable { required this.totalActiveMs, required this.completed, required this.historySnapshotJson, + this.minHeartRateBpm, this.averageHeartRateBpm, this.maxHeartRateBpm, + this.totalDistanceMeters, + this.totalCaloriesKcal, }); @override Map toColumns(bool nullToAbsent) { @@ -24351,12 +26156,21 @@ class WorkoutHistory extends DataClass implements Insertable { map['total_active_ms'] = Variable(totalActiveMs); map['completed'] = Variable(completed); map['history_snapshot_json'] = Variable(historySnapshotJson); + if (!nullToAbsent || minHeartRateBpm != null) { + map['min_heart_rate_bpm'] = Variable(minHeartRateBpm); + } if (!nullToAbsent || averageHeartRateBpm != null) { map['average_heart_rate_bpm'] = Variable(averageHeartRateBpm); } if (!nullToAbsent || maxHeartRateBpm != null) { map['max_heart_rate_bpm'] = Variable(maxHeartRateBpm); } + if (!nullToAbsent || totalDistanceMeters != null) { + map['total_distance_meters'] = Variable(totalDistanceMeters); + } + if (!nullToAbsent || totalCaloriesKcal != null) { + map['total_calories_kcal'] = Variable(totalCaloriesKcal); + } return map; } @@ -24394,12 +26208,21 @@ class WorkoutHistory extends DataClass implements Insertable { totalActiveMs: Value(totalActiveMs), completed: Value(completed), historySnapshotJson: Value(historySnapshotJson), + minHeartRateBpm: minHeartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(minHeartRateBpm), averageHeartRateBpm: averageHeartRateBpm == null && nullToAbsent ? const Value.absent() : Value(averageHeartRateBpm), maxHeartRateBpm: maxHeartRateBpm == null && nullToAbsent ? const Value.absent() : Value(maxHeartRateBpm), + totalDistanceMeters: totalDistanceMeters == null && nullToAbsent + ? const Value.absent() + : Value(totalDistanceMeters), + totalCaloriesKcal: totalCaloriesKcal == null && nullToAbsent + ? const Value.absent() + : Value(totalCaloriesKcal), ); } @@ -24436,10 +26259,17 @@ class WorkoutHistory extends DataClass implements Insertable { historySnapshotJson: serializer.fromJson( json['historySnapshotJson'], ), + minHeartRateBpm: serializer.fromJson(json['minHeartRateBpm']), averageHeartRateBpm: serializer.fromJson( json['averageHeartRateBpm'], ), maxHeartRateBpm: serializer.fromJson(json['maxHeartRateBpm']), + totalDistanceMeters: serializer.fromJson( + json['totalDistanceMeters'], + ), + totalCaloriesKcal: serializer.fromJson( + json['totalCaloriesKcal'], + ), ); } @override @@ -24469,8 +26299,11 @@ class WorkoutHistory extends DataClass implements Insertable { 'totalActiveMs': serializer.toJson(totalActiveMs), 'completed': serializer.toJson(completed), 'historySnapshotJson': serializer.toJson(historySnapshotJson), + 'minHeartRateBpm': serializer.toJson(minHeartRateBpm), 'averageHeartRateBpm': serializer.toJson(averageHeartRateBpm), 'maxHeartRateBpm': serializer.toJson(maxHeartRateBpm), + 'totalDistanceMeters': serializer.toJson(totalDistanceMeters), + 'totalCaloriesKcal': serializer.toJson(totalCaloriesKcal), }; } @@ -24494,8 +26327,11 @@ class WorkoutHistory extends DataClass implements Insertable { int? totalActiveMs, bool? completed, String? historySnapshotJson, + Value minHeartRateBpm = const Value.absent(), Value averageHeartRateBpm = const Value.absent(), Value maxHeartRateBpm = const Value.absent(), + Value totalDistanceMeters = const Value.absent(), + Value totalCaloriesKcal = const Value.absent(), }) => WorkoutHistory( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -24524,12 +26360,21 @@ class WorkoutHistory extends DataClass implements Insertable { totalActiveMs: totalActiveMs ?? this.totalActiveMs, completed: completed ?? this.completed, historySnapshotJson: historySnapshotJson ?? this.historySnapshotJson, + minHeartRateBpm: minHeartRateBpm.present + ? minHeartRateBpm.value + : this.minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm.present ? averageHeartRateBpm.value : this.averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm.present ? maxHeartRateBpm.value : this.maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters.present + ? totalDistanceMeters.value + : this.totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal.present + ? totalCaloriesKcal.value + : this.totalCaloriesKcal, ); WorkoutHistory copyWithCompanion(WorkoutHistoriesCompanion data) { return WorkoutHistory( @@ -24574,12 +26419,21 @@ class WorkoutHistory extends DataClass implements Insertable { historySnapshotJson: data.historySnapshotJson.present ? data.historySnapshotJson.value : this.historySnapshotJson, + minHeartRateBpm: data.minHeartRateBpm.present + ? data.minHeartRateBpm.value + : this.minHeartRateBpm, averageHeartRateBpm: data.averageHeartRateBpm.present ? data.averageHeartRateBpm.value : this.averageHeartRateBpm, maxHeartRateBpm: data.maxHeartRateBpm.present ? data.maxHeartRateBpm.value : this.maxHeartRateBpm, + totalDistanceMeters: data.totalDistanceMeters.present + ? data.totalDistanceMeters.value + : this.totalDistanceMeters, + totalCaloriesKcal: data.totalCaloriesKcal.present + ? data.totalCaloriesKcal.value + : this.totalCaloriesKcal, ); } @@ -24607,8 +26461,11 @@ class WorkoutHistory extends DataClass implements Insertable { ..write('totalActiveMs: $totalActiveMs, ') ..write('completed: $completed, ') ..write('historySnapshotJson: $historySnapshotJson, ') + ..write('minHeartRateBpm: $minHeartRateBpm, ') ..write('averageHeartRateBpm: $averageHeartRateBpm, ') - ..write('maxHeartRateBpm: $maxHeartRateBpm') + ..write('maxHeartRateBpm: $maxHeartRateBpm, ') + ..write('totalDistanceMeters: $totalDistanceMeters, ') + ..write('totalCaloriesKcal: $totalCaloriesKcal') ..write(')')) .toString(); } @@ -24634,8 +26491,11 @@ class WorkoutHistory extends DataClass implements Insertable { totalActiveMs, completed, historySnapshotJson, + minHeartRateBpm, averageHeartRateBpm, maxHeartRateBpm, + totalDistanceMeters, + totalCaloriesKcal, ]); @override bool operator ==(Object other) => @@ -24661,8 +26521,11 @@ class WorkoutHistory extends DataClass implements Insertable { other.totalActiveMs == this.totalActiveMs && other.completed == this.completed && other.historySnapshotJson == this.historySnapshotJson && + other.minHeartRateBpm == this.minHeartRateBpm && other.averageHeartRateBpm == this.averageHeartRateBpm && - other.maxHeartRateBpm == this.maxHeartRateBpm); + other.maxHeartRateBpm == this.maxHeartRateBpm && + other.totalDistanceMeters == this.totalDistanceMeters && + other.totalCaloriesKcal == this.totalCaloriesKcal); } class WorkoutHistoriesCompanion extends UpdateCompanion { @@ -24685,8 +26548,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { final Value totalActiveMs; final Value completed; final Value historySnapshotJson; + final Value minHeartRateBpm; final Value averageHeartRateBpm; final Value maxHeartRateBpm; + final Value totalDistanceMeters; + final Value totalCaloriesKcal; final Value rowid; const WorkoutHistoriesCompanion({ this.id = const Value.absent(), @@ -24708,8 +26574,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { this.totalActiveMs = const Value.absent(), this.completed = const Value.absent(), this.historySnapshotJson = const Value.absent(), + this.minHeartRateBpm = const Value.absent(), this.averageHeartRateBpm = const Value.absent(), this.maxHeartRateBpm = const Value.absent(), + this.totalDistanceMeters = const Value.absent(), + this.totalCaloriesKcal = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutHistoriesCompanion.insert({ @@ -24732,8 +26601,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { required int totalActiveMs, required bool completed, required String historySnapshotJson, + this.minHeartRateBpm = const Value.absent(), this.averageHeartRateBpm = const Value.absent(), this.maxHeartRateBpm = const Value.absent(), + this.totalDistanceMeters = const Value.absent(), + this.totalCaloriesKcal = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -24767,8 +26639,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { Expression? totalActiveMs, Expression? completed, Expression? historySnapshotJson, + Expression? minHeartRateBpm, Expression? averageHeartRateBpm, Expression? maxHeartRateBpm, + Expression? totalDistanceMeters, + Expression? totalCaloriesKcal, Expression? rowid, }) { return RawValuesInsertable({ @@ -24795,9 +26670,13 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { if (completed != null) 'completed': completed, if (historySnapshotJson != null) 'history_snapshot_json': historySnapshotJson, + if (minHeartRateBpm != null) 'min_heart_rate_bpm': minHeartRateBpm, if (averageHeartRateBpm != null) 'average_heart_rate_bpm': averageHeartRateBpm, if (maxHeartRateBpm != null) 'max_heart_rate_bpm': maxHeartRateBpm, + if (totalDistanceMeters != null) + 'total_distance_meters': totalDistanceMeters, + if (totalCaloriesKcal != null) 'total_calories_kcal': totalCaloriesKcal, if (rowid != null) 'rowid': rowid, }); } @@ -24822,8 +26701,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { Value? totalActiveMs, Value? completed, Value? historySnapshotJson, + Value? minHeartRateBpm, Value? averageHeartRateBpm, Value? maxHeartRateBpm, + Value? totalDistanceMeters, + Value? totalCaloriesKcal, Value? rowid, }) { return WorkoutHistoriesCompanion( @@ -24848,8 +26730,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { totalActiveMs: totalActiveMs ?? this.totalActiveMs, completed: completed ?? this.completed, historySnapshotJson: historySnapshotJson ?? this.historySnapshotJson, + minHeartRateBpm: minHeartRateBpm ?? this.minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm ?? this.averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm ?? this.maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters ?? this.totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal ?? this.totalCaloriesKcal, rowid: rowid ?? this.rowid, ); } @@ -24922,6 +26807,9 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { historySnapshotJson.value, ); } + if (minHeartRateBpm.present) { + map['min_heart_rate_bpm'] = Variable(minHeartRateBpm.value); + } if (averageHeartRateBpm.present) { map['average_heart_rate_bpm'] = Variable( averageHeartRateBpm.value, @@ -24930,6 +26818,14 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { if (maxHeartRateBpm.present) { map['max_heart_rate_bpm'] = Variable(maxHeartRateBpm.value); } + if (totalDistanceMeters.present) { + map['total_distance_meters'] = Variable( + totalDistanceMeters.value, + ); + } + if (totalCaloriesKcal.present) { + map['total_calories_kcal'] = Variable(totalCaloriesKcal.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -24960,8 +26856,11 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { ..write('totalActiveMs: $totalActiveMs, ') ..write('completed: $completed, ') ..write('historySnapshotJson: $historySnapshotJson, ') + ..write('minHeartRateBpm: $minHeartRateBpm, ') ..write('averageHeartRateBpm: $averageHeartRateBpm, ') ..write('maxHeartRateBpm: $maxHeartRateBpm, ') + ..write('totalDistanceMeters: $totalDistanceMeters, ') + ..write('totalCaloriesKcal: $totalCaloriesKcal, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -31668,6 +33567,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { ); late final $SyncMetadataEntriesTable syncMetadataEntries = $SyncMetadataEntriesTable(this); + late final $WorkoutTelemetryAggregatesTable workoutTelemetryAggregates = + $WorkoutTelemetryAggregatesTable(this); + late final $WorkoutTelemetrySamplesTable workoutTelemetrySamples = + $WorkoutTelemetrySamplesTable(this); late final $WorkoutHistoriesTable workoutHistories = $WorkoutHistoriesTable( this, ); @@ -31708,6 +33611,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { remoteResourceMappings, shareInboxItems, syncMetadataEntries, + workoutTelemetryAggregates, + workoutTelemetrySamples, workoutHistories, workoutHistorySetResults, workoutHistoryStepResults, @@ -46410,6 +48315,805 @@ typedef $$SyncMetadataEntriesTableProcessedTableManager = SyncMetadataEntry, PrefetchHooks Function() >; +typedef $$WorkoutTelemetryAggregatesTableCreateCompanionBuilder = + WorkoutTelemetryAggregatesCompanion Function({ + required String sessionId, + required String scope, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value passageIndex, + Value stepIndex, + required int sampleCount, + Value minHeartRateBpm, + Value averageHeartRateBpm, + Value maxHeartRateBpm, + Value totalDistanceMeters, + Value totalCaloriesKcal, + Value rowid, + }); +typedef $$WorkoutTelemetryAggregatesTableUpdateCompanionBuilder = + WorkoutTelemetryAggregatesCompanion Function({ + Value sessionId, + Value scope, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value passageIndex, + Value stepIndex, + Value sampleCount, + Value minHeartRateBpm, + Value averageHeartRateBpm, + Value maxHeartRateBpm, + Value totalDistanceMeters, + Value totalCaloriesKcal, + Value rowid, + }); + +class $$WorkoutTelemetryAggregatesTableFilterComposer + extends Composer<_$AppDatabase, $WorkoutTelemetryAggregatesTable> { + $$WorkoutTelemetryAggregatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get sessionId => $composableBuilder( + column: $table.sessionId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scope => $composableBuilder( + column: $table.scope, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stepIndex => $composableBuilder( + column: $table.stepIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sampleCount => $composableBuilder( + column: $table.sampleCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get minHeartRateBpm => $composableBuilder( + column: $table.minHeartRateBpm, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get averageHeartRateBpm => $composableBuilder( + column: $table.averageHeartRateBpm, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get maxHeartRateBpm => $composableBuilder( + column: $table.maxHeartRateBpm, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get totalDistanceMeters => $composableBuilder( + column: $table.totalDistanceMeters, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get totalCaloriesKcal => $composableBuilder( + column: $table.totalCaloriesKcal, + builder: (column) => ColumnFilters(column), + ); +} + +class $$WorkoutTelemetryAggregatesTableOrderingComposer + extends Composer<_$AppDatabase, $WorkoutTelemetryAggregatesTable> { + $$WorkoutTelemetryAggregatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get sessionId => $composableBuilder( + column: $table.sessionId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scope => $composableBuilder( + column: $table.scope, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stepIndex => $composableBuilder( + column: $table.stepIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sampleCount => $composableBuilder( + column: $table.sampleCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get minHeartRateBpm => $composableBuilder( + column: $table.minHeartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get averageHeartRateBpm => $composableBuilder( + column: $table.averageHeartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get maxHeartRateBpm => $composableBuilder( + column: $table.maxHeartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get totalDistanceMeters => $composableBuilder( + column: $table.totalDistanceMeters, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get totalCaloriesKcal => $composableBuilder( + column: $table.totalCaloriesKcal, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$WorkoutTelemetryAggregatesTableAnnotationComposer + extends Composer<_$AppDatabase, $WorkoutTelemetryAggregatesTable> { + $$WorkoutTelemetryAggregatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get sessionId => + $composableBuilder(column: $table.sessionId, builder: (column) => column); + + GeneratedColumn get scope => + $composableBuilder(column: $table.scope, builder: (column) => column); + + GeneratedColumn get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => column, + ); + + GeneratedColumn get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => column, + ); + + GeneratedColumn get setIndex => + $composableBuilder(column: $table.setIndex, builder: (column) => column); + + GeneratedColumn get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => column, + ); + + GeneratedColumn get stepIndex => + $composableBuilder(column: $table.stepIndex, builder: (column) => column); + + GeneratedColumn get sampleCount => $composableBuilder( + column: $table.sampleCount, + builder: (column) => column, + ); + + GeneratedColumn get minHeartRateBpm => $composableBuilder( + column: $table.minHeartRateBpm, + builder: (column) => column, + ); + + GeneratedColumn get averageHeartRateBpm => $composableBuilder( + column: $table.averageHeartRateBpm, + builder: (column) => column, + ); + + GeneratedColumn get maxHeartRateBpm => $composableBuilder( + column: $table.maxHeartRateBpm, + builder: (column) => column, + ); + + GeneratedColumn get totalDistanceMeters => $composableBuilder( + column: $table.totalDistanceMeters, + builder: (column) => column, + ); + + GeneratedColumn get totalCaloriesKcal => $composableBuilder( + column: $table.totalCaloriesKcal, + builder: (column) => column, + ); +} + +class $$WorkoutTelemetryAggregatesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $WorkoutTelemetryAggregatesTable, + WorkoutTelemetryAggregate, + $$WorkoutTelemetryAggregatesTableFilterComposer, + $$WorkoutTelemetryAggregatesTableOrderingComposer, + $$WorkoutTelemetryAggregatesTableAnnotationComposer, + $$WorkoutTelemetryAggregatesTableCreateCompanionBuilder, + $$WorkoutTelemetryAggregatesTableUpdateCompanionBuilder, + ( + WorkoutTelemetryAggregate, + BaseReferences< + _$AppDatabase, + $WorkoutTelemetryAggregatesTable, + WorkoutTelemetryAggregate + >, + ), + WorkoutTelemetryAggregate, + PrefetchHooks Function() + > { + $$WorkoutTelemetryAggregatesTableTableManager( + _$AppDatabase db, + $WorkoutTelemetryAggregatesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WorkoutTelemetryAggregatesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$WorkoutTelemetryAggregatesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$WorkoutTelemetryAggregatesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value sessionId = const Value.absent(), + Value scope = const Value.absent(), + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value sampleCount = const Value.absent(), + Value minHeartRateBpm = const Value.absent(), + Value averageHeartRateBpm = const Value.absent(), + Value maxHeartRateBpm = const Value.absent(), + Value totalDistanceMeters = const Value.absent(), + Value totalCaloriesKcal = const Value.absent(), + Value rowid = const Value.absent(), + }) => WorkoutTelemetryAggregatesCompanion( + sessionId: sessionId, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + sampleCount: sampleCount, + minHeartRateBpm: minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String sessionId, + required String scope, + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + required int sampleCount, + Value minHeartRateBpm = const Value.absent(), + Value averageHeartRateBpm = const Value.absent(), + Value maxHeartRateBpm = const Value.absent(), + Value totalDistanceMeters = const Value.absent(), + Value totalCaloriesKcal = const Value.absent(), + Value rowid = const Value.absent(), + }) => WorkoutTelemetryAggregatesCompanion.insert( + sessionId: sessionId, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + sampleCount: sampleCount, + minHeartRateBpm: minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$WorkoutTelemetryAggregatesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $WorkoutTelemetryAggregatesTable, + WorkoutTelemetryAggregate, + $$WorkoutTelemetryAggregatesTableFilterComposer, + $$WorkoutTelemetryAggregatesTableOrderingComposer, + $$WorkoutTelemetryAggregatesTableAnnotationComposer, + $$WorkoutTelemetryAggregatesTableCreateCompanionBuilder, + $$WorkoutTelemetryAggregatesTableUpdateCompanionBuilder, + ( + WorkoutTelemetryAggregate, + BaseReferences< + _$AppDatabase, + $WorkoutTelemetryAggregatesTable, + WorkoutTelemetryAggregate + >, + ), + WorkoutTelemetryAggregate, + PrefetchHooks Function() + >; +typedef $$WorkoutTelemetrySamplesTableCreateCompanionBuilder = + WorkoutTelemetrySamplesCompanion Function({ + required String id, + required String sessionId, + required DateTime capturedAt, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value passageIndex, + Value stepIndex, + Value programSnapshotId, + Value exerciseSnapshotId, + Value stepSnapshotId, + Value heartRateBpm, + Value distanceMeters, + Value caloriesKcal, + Value rowid, + }); +typedef $$WorkoutTelemetrySamplesTableUpdateCompanionBuilder = + WorkoutTelemetrySamplesCompanion Function({ + Value id, + Value sessionId, + Value capturedAt, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value passageIndex, + Value stepIndex, + Value programSnapshotId, + Value exerciseSnapshotId, + Value stepSnapshotId, + Value heartRateBpm, + Value distanceMeters, + Value caloriesKcal, + Value rowid, + }); + +class $$WorkoutTelemetrySamplesTableFilterComposer + extends Composer<_$AppDatabase, $WorkoutTelemetrySamplesTable> { + $$WorkoutTelemetrySamplesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sessionId => $composableBuilder( + column: $table.sessionId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get capturedAt => $composableBuilder( + column: $table.capturedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stepIndex => $composableBuilder( + column: $table.stepIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programSnapshotId => $composableBuilder( + column: $table.programSnapshotId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseSnapshotId => $composableBuilder( + column: $table.exerciseSnapshotId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stepSnapshotId => $composableBuilder( + column: $table.stepSnapshotId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get heartRateBpm => $composableBuilder( + column: $table.heartRateBpm, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get distanceMeters => $composableBuilder( + column: $table.distanceMeters, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get caloriesKcal => $composableBuilder( + column: $table.caloriesKcal, + builder: (column) => ColumnFilters(column), + ); +} + +class $$WorkoutTelemetrySamplesTableOrderingComposer + extends Composer<_$AppDatabase, $WorkoutTelemetrySamplesTable> { + $$WorkoutTelemetrySamplesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sessionId => $composableBuilder( + column: $table.sessionId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get capturedAt => $composableBuilder( + column: $table.capturedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stepIndex => $composableBuilder( + column: $table.stepIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programSnapshotId => $composableBuilder( + column: $table.programSnapshotId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseSnapshotId => $composableBuilder( + column: $table.exerciseSnapshotId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stepSnapshotId => $composableBuilder( + column: $table.stepSnapshotId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get heartRateBpm => $composableBuilder( + column: $table.heartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get distanceMeters => $composableBuilder( + column: $table.distanceMeters, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get caloriesKcal => $composableBuilder( + column: $table.caloriesKcal, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$WorkoutTelemetrySamplesTableAnnotationComposer + extends Composer<_$AppDatabase, $WorkoutTelemetrySamplesTable> { + $$WorkoutTelemetrySamplesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get sessionId => + $composableBuilder(column: $table.sessionId, builder: (column) => column); + + GeneratedColumn get capturedAt => $composableBuilder( + column: $table.capturedAt, + builder: (column) => column, + ); + + GeneratedColumn get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => column, + ); + + GeneratedColumn get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => column, + ); + + GeneratedColumn get setIndex => + $composableBuilder(column: $table.setIndex, builder: (column) => column); + + GeneratedColumn get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => column, + ); + + GeneratedColumn get stepIndex => + $composableBuilder(column: $table.stepIndex, builder: (column) => column); + + GeneratedColumn get programSnapshotId => $composableBuilder( + column: $table.programSnapshotId, + builder: (column) => column, + ); + + GeneratedColumn get exerciseSnapshotId => $composableBuilder( + column: $table.exerciseSnapshotId, + builder: (column) => column, + ); + + GeneratedColumn get stepSnapshotId => $composableBuilder( + column: $table.stepSnapshotId, + builder: (column) => column, + ); + + GeneratedColumn get heartRateBpm => $composableBuilder( + column: $table.heartRateBpm, + builder: (column) => column, + ); + + GeneratedColumn get distanceMeters => $composableBuilder( + column: $table.distanceMeters, + builder: (column) => column, + ); + + GeneratedColumn get caloriesKcal => $composableBuilder( + column: $table.caloriesKcal, + builder: (column) => column, + ); +} + +class $$WorkoutTelemetrySamplesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $WorkoutTelemetrySamplesTable, + WorkoutTelemetrySample, + $$WorkoutTelemetrySamplesTableFilterComposer, + $$WorkoutTelemetrySamplesTableOrderingComposer, + $$WorkoutTelemetrySamplesTableAnnotationComposer, + $$WorkoutTelemetrySamplesTableCreateCompanionBuilder, + $$WorkoutTelemetrySamplesTableUpdateCompanionBuilder, + ( + WorkoutTelemetrySample, + BaseReferences< + _$AppDatabase, + $WorkoutTelemetrySamplesTable, + WorkoutTelemetrySample + >, + ), + WorkoutTelemetrySample, + PrefetchHooks Function() + > { + $$WorkoutTelemetrySamplesTableTableManager( + _$AppDatabase db, + $WorkoutTelemetrySamplesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WorkoutTelemetrySamplesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$WorkoutTelemetrySamplesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$WorkoutTelemetrySamplesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value sessionId = const Value.absent(), + Value capturedAt = const Value.absent(), + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value programSnapshotId = const Value.absent(), + Value exerciseSnapshotId = const Value.absent(), + Value stepSnapshotId = const Value.absent(), + Value heartRateBpm = const Value.absent(), + Value distanceMeters = const Value.absent(), + Value caloriesKcal = const Value.absent(), + Value rowid = const Value.absent(), + }) => WorkoutTelemetrySamplesCompanion( + id: id, + sessionId: sessionId, + capturedAt: capturedAt, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + stepSnapshotId: stepSnapshotId, + heartRateBpm: heartRateBpm, + distanceMeters: distanceMeters, + caloriesKcal: caloriesKcal, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String sessionId, + required DateTime capturedAt, + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value programSnapshotId = const Value.absent(), + Value exerciseSnapshotId = const Value.absent(), + Value stepSnapshotId = const Value.absent(), + Value heartRateBpm = const Value.absent(), + Value distanceMeters = const Value.absent(), + Value caloriesKcal = const Value.absent(), + Value rowid = const Value.absent(), + }) => WorkoutTelemetrySamplesCompanion.insert( + id: id, + sessionId: sessionId, + capturedAt: capturedAt, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + stepSnapshotId: stepSnapshotId, + heartRateBpm: heartRateBpm, + distanceMeters: distanceMeters, + caloriesKcal: caloriesKcal, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$WorkoutTelemetrySamplesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $WorkoutTelemetrySamplesTable, + WorkoutTelemetrySample, + $$WorkoutTelemetrySamplesTableFilterComposer, + $$WorkoutTelemetrySamplesTableOrderingComposer, + $$WorkoutTelemetrySamplesTableAnnotationComposer, + $$WorkoutTelemetrySamplesTableCreateCompanionBuilder, + $$WorkoutTelemetrySamplesTableUpdateCompanionBuilder, + ( + WorkoutTelemetrySample, + BaseReferences< + _$AppDatabase, + $WorkoutTelemetrySamplesTable, + WorkoutTelemetrySample + >, + ), + WorkoutTelemetrySample, + PrefetchHooks Function() + >; typedef $$WorkoutHistoriesTableCreateCompanionBuilder = WorkoutHistoriesCompanion Function({ required String id, @@ -46431,8 +49135,11 @@ typedef $$WorkoutHistoriesTableCreateCompanionBuilder = required int totalActiveMs, required bool completed, required String historySnapshotJson, + Value minHeartRateBpm, Value averageHeartRateBpm, Value maxHeartRateBpm, + Value totalDistanceMeters, + Value totalCaloriesKcal, Value rowid, }); typedef $$WorkoutHistoriesTableUpdateCompanionBuilder = @@ -46456,8 +49163,11 @@ typedef $$WorkoutHistoriesTableUpdateCompanionBuilder = Value totalActiveMs, Value completed, Value historySnapshotJson, + Value minHeartRateBpm, Value averageHeartRateBpm, Value maxHeartRateBpm, + Value totalDistanceMeters, + Value totalCaloriesKcal, Value rowid, }); @@ -46670,6 +49380,11 @@ class $$WorkoutHistoriesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get minHeartRateBpm => $composableBuilder( + column: $table.minHeartRateBpm, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get averageHeartRateBpm => $composableBuilder( column: $table.averageHeartRateBpm, builder: (column) => ColumnFilters(column), @@ -46680,6 +49395,16 @@ class $$WorkoutHistoriesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get totalDistanceMeters => $composableBuilder( + column: $table.totalDistanceMeters, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get totalCaloriesKcal => $composableBuilder( + column: $table.totalCaloriesKcal, + builder: (column) => ColumnFilters(column), + ); + $$WorkoutTemplatesTableFilterComposer get sourceWorkoutTemplateId { final $$WorkoutTemplatesTableFilterComposer composer = $composerBuilder( composer: this, @@ -46876,6 +49601,11 @@ class $$WorkoutHistoriesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get minHeartRateBpm => $composableBuilder( + column: $table.minHeartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get averageHeartRateBpm => $composableBuilder( column: $table.averageHeartRateBpm, builder: (column) => ColumnOrderings(column), @@ -46886,6 +49616,16 @@ class $$WorkoutHistoriesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get totalDistanceMeters => $composableBuilder( + column: $table.totalDistanceMeters, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get totalCaloriesKcal => $composableBuilder( + column: $table.totalCaloriesKcal, + builder: (column) => ColumnOrderings(column), + ); + $$WorkoutTemplatesTableOrderingComposer get sourceWorkoutTemplateId { final $$WorkoutTemplatesTableOrderingComposer composer = $composerBuilder( composer: this, @@ -47013,6 +49753,11 @@ class $$WorkoutHistoriesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get minHeartRateBpm => $composableBuilder( + column: $table.minHeartRateBpm, + builder: (column) => column, + ); + GeneratedColumn get averageHeartRateBpm => $composableBuilder( column: $table.averageHeartRateBpm, builder: (column) => column, @@ -47023,6 +49768,16 @@ class $$WorkoutHistoriesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get totalDistanceMeters => $composableBuilder( + column: $table.totalDistanceMeters, + builder: (column) => column, + ); + + GeneratedColumn get totalCaloriesKcal => $composableBuilder( + column: $table.totalCaloriesKcal, + builder: (column) => column, + ); + $$WorkoutTemplatesTableAnnotationComposer get sourceWorkoutTemplateId { final $$WorkoutTemplatesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -47181,8 +49936,11 @@ class $$WorkoutHistoriesTableTableManager Value totalActiveMs = const Value.absent(), Value completed = const Value.absent(), Value historySnapshotJson = const Value.absent(), + Value minHeartRateBpm = const Value.absent(), Value averageHeartRateBpm = const Value.absent(), Value maxHeartRateBpm = const Value.absent(), + Value totalDistanceMeters = const Value.absent(), + Value totalCaloriesKcal = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistoriesCompanion( id: id, @@ -47204,8 +49962,11 @@ class $$WorkoutHistoriesTableTableManager totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson, + minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, rowid: rowid, ), createCompanionCallback: @@ -47230,8 +49991,11 @@ class $$WorkoutHistoriesTableTableManager required int totalActiveMs, required bool completed, required String historySnapshotJson, + Value minHeartRateBpm = const Value.absent(), Value averageHeartRateBpm = const Value.absent(), Value maxHeartRateBpm = const Value.absent(), + Value totalDistanceMeters = const Value.absent(), + Value totalCaloriesKcal = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistoriesCompanion.insert( id: id, @@ -47253,8 +50017,11 @@ class $$WorkoutHistoriesTableTableManager totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson, + minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -50964,6 +53731,17 @@ class $AppDatabaseManager { $$ShareInboxItemsTableTableManager(_db, _db.shareInboxItems); $$SyncMetadataEntriesTableTableManager get syncMetadataEntries => $$SyncMetadataEntriesTableTableManager(_db, _db.syncMetadataEntries); + $$WorkoutTelemetryAggregatesTableTableManager + get workoutTelemetryAggregates => + $$WorkoutTelemetryAggregatesTableTableManager( + _db, + _db.workoutTelemetryAggregates, + ); + $$WorkoutTelemetrySamplesTableTableManager get workoutTelemetrySamples => + $$WorkoutTelemetrySamplesTableTableManager( + _db, + _db.workoutTelemetrySamples, + ); $$WorkoutHistoriesTableTableManager get workoutHistories => $$WorkoutHistoriesTableTableManager(_db, _db.workoutHistories); $$WorkoutHistorySetResultsTableTableManager get workoutHistorySetResults => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 763bb68..cb2364f 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -950,25 +950,38 @@ final class DriftWorkoutTemplateRepository @override Future save(domain.WorkoutTemplate template) async { await database.transaction(() async { - await _upsertWithChangeLog( - database: database, - tableName: 'workout_templates', - entityType: 'WorkoutTemplate', - metadata: template.metadata, - write: () => database - .into(database.workoutTemplates) - .insertOnConflictUpdate(_workoutTemplateCompanion(template)), - ); - await _writeWorkoutTemplateStarterMetadata(database, template); - for (final program in template.programs) { - await saveProgram(program); - } - for (final override in template.overrides) { - await saveOverride(override); + await _saveTemplate(template); + }); + } + + @override + Future saveAll(List templates) async { + await database.transaction(() async { + for (final template in templates) { + await _saveTemplate(template); } }); } + Future _saveTemplate(domain.WorkoutTemplate template) async { + await _upsertWithChangeLog( + database: database, + tableName: 'workout_templates', + entityType: 'WorkoutTemplate', + metadata: template.metadata, + write: () => database + .into(database.workoutTemplates) + .insertOnConflictUpdate(_workoutTemplateCompanion(template)), + ); + await _writeWorkoutTemplateStarterMetadata(database, template); + for (final program in template.programs) { + await saveProgram(program); + } + for (final override in template.overrides) { + await saveOverride(override); + } + } + @override Future saveProgram(domain.WorkoutTemplateProgram program) async { await _upsertWithChangeLog( @@ -1614,11 +1627,18 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async { - if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) { + if ((minHeartRateBpm != null && minHeartRateBpm <= 0) || + averageHeartRateBpm <= 0 || + maxHeartRateBpm <= 0 || + (totalDistanceMeters != null && totalDistanceMeters < 0) || + (totalCaloriesKcal != null && totalCaloriesKcal < 0)) { return; } final row = @@ -1626,8 +1646,11 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { (table) => table.id.equals(historyId) & table.deletedAt.isNull() & + table.minHeartRateBpm.isNull() & table.averageHeartRateBpm.isNull() & - table.maxHeartRateBpm.isNull(), + table.maxHeartRateBpm.isNull() & + table.totalDistanceMeters.isNull() & + table.totalCaloriesKcal.isNull(), )) .getSingleOrNull(); if (row == null) { @@ -1641,8 +1664,11 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { updatedAt: Value(patchedAt.toUtc()), syncState: const Value('dirty'), localRevision: Value(revision), + minHeartRateBpm: Value(minHeartRateBpm), averageHeartRateBpm: Value(averageHeartRateBpm), maxHeartRateBpm: Value(maxHeartRateBpm), + totalDistanceMeters: Value(totalDistanceMeters), + totalCaloriesKcal: Value(totalCaloriesKcal), ), ); await _writeChangeLog( @@ -1736,6 +1762,103 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { } } +final class DriftWorkoutTelemetryRepository + implements WorkoutTelemetryRepository { + const DriftWorkoutTelemetryRepository(this.database); + + final db.AppDatabase database; + + @override + Future saveSample(domain.WorkoutTelemetrySample sample) async { + final existing = await (database.select( + database.workoutTelemetrySamples, + )..where((table) => table.id.equals(sample.id))).getSingleOrNull(); + if (existing != null) { + return false; + } + final inserted = await database + .into(database.workoutTelemetrySamples) + .insert( + _workoutTelemetrySampleCompanion(sample), + mode: InsertMode.insertOrIgnore, + ); + return inserted > 0; + } + + @override + Future> listSamples( + String sessionId, + ) async { + final rows = + await (database.select(database.workoutTelemetrySamples) + ..where((table) => table.sessionId.equals(sessionId)) + ..orderBy([(table) => OrderingTerm.asc(table.capturedAt)])) + .get(); + return rows.map(_workoutTelemetrySampleFromRow).toList(); + } + + @override + Future replaceAggregatesForSession({ + required String sessionId, + required List aggregates, + }) async { + await database.transaction(() async { + await (database.delete( + database.workoutTelemetryAggregates, + )..where((table) => table.sessionId.equals(sessionId))).go(); + if (aggregates.isEmpty) { + return; + } + await database.batch((batch) { + batch.insertAll( + database.workoutTelemetryAggregates, + aggregates.map(_workoutTelemetryAggregateCompanion).toList(), + ); + }); + }); + } + + @override + Future> listAggregates( + String sessionId, + ) async { + final rows = await (database.select( + database.workoutTelemetryAggregates, + )..where((table) => table.sessionId.equals(sessionId))).get(); + return rows.map(_workoutTelemetryAggregateFromRow).toList(); + } + + @override + Future findAggregate({ + required String sessionId, + required domain.WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + final rows = + await (database.select(database.workoutTelemetryAggregates)..where( + (table) => + table.sessionId.equals(sessionId) & + table.scope.equals(scope.wireName), + )) + .get(); + for (final row in rows) { + final aggregate = _workoutTelemetryAggregateFromRow(row); + if (aggregate.programIndex == programIndex && + aggregate.exerciseIndex == exerciseIndex && + aggregate.setIndex == setIndex && + aggregate.passageIndex == passageIndex && + aggregate.stepIndex == stepIndex) { + return aggregate; + } + } + return null; + } +} + final class DriftLocalDataBackupRepository implements LocalDataBackupRepository { const DriftLocalDataBackupRepository(this.database); @@ -4363,8 +4486,52 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion( totalActiveMs: Value(history.totalActiveMs), completed: Value(history.completed), historySnapshotJson: Value(history.historySnapshotJson), + minHeartRateBpm: Value(history.minHeartRateBpm), averageHeartRateBpm: Value(history.averageHeartRateBpm), maxHeartRateBpm: Value(history.maxHeartRateBpm), + totalDistanceMeters: Value(history.totalDistanceMeters), + totalCaloriesKcal: Value(history.totalCaloriesKcal), + ); +} + +db.WorkoutTelemetrySamplesCompanion _workoutTelemetrySampleCompanion( + domain.WorkoutTelemetrySample sample, +) { + return db.WorkoutTelemetrySamplesCompanion.insert( + id: sample.id, + sessionId: sample.sessionId, + capturedAt: sample.capturedAt.toUtc(), + programIndex: Value(sample.programIndex), + exerciseIndex: Value(sample.exerciseIndex), + setIndex: Value(sample.setIndex), + passageIndex: Value(sample.passageIndex), + stepIndex: Value(sample.stepIndex), + programSnapshotId: Value(sample.programSnapshotId), + exerciseSnapshotId: Value(sample.exerciseSnapshotId), + stepSnapshotId: Value(sample.stepSnapshotId), + heartRateBpm: Value(sample.heartRateBpm), + distanceMeters: Value(sample.distanceMeters), + caloriesKcal: Value(sample.caloriesKcal), + ); +} + +db.WorkoutTelemetryAggregatesCompanion _workoutTelemetryAggregateCompanion( + domain.WorkoutTelemetryAggregate aggregate, +) { + return db.WorkoutTelemetryAggregatesCompanion.insert( + sessionId: aggregate.sessionId, + scope: aggregate.scope.wireName, + programIndex: Value(aggregate.programIndex), + exerciseIndex: Value(aggregate.exerciseIndex), + setIndex: Value(aggregate.setIndex), + passageIndex: Value(aggregate.passageIndex), + stepIndex: Value(aggregate.stepIndex), + sampleCount: aggregate.sampleCount, + minHeartRateBpm: Value(aggregate.minHeartRateBpm), + averageHeartRateBpm: Value(aggregate.averageHeartRateBpm), + maxHeartRateBpm: Value(aggregate.maxHeartRateBpm), + totalDistanceMeters: Value(aggregate.totalDistanceMeters), + totalCaloriesKcal: Value(aggregate.totalCaloriesKcal), ); } @@ -4480,13 +4647,57 @@ domain.WorkoutHistory _workoutHistoryFromRow( totalActiveMs: row.totalActiveMs, completed: row.completed, historySnapshotJson: row.historySnapshotJson, + minHeartRateBpm: row.minHeartRateBpm, averageHeartRateBpm: row.averageHeartRateBpm, maxHeartRateBpm: row.maxHeartRateBpm, + totalDistanceMeters: row.totalDistanceMeters, + totalCaloriesKcal: row.totalCaloriesKcal, results: results, stepResults: stepResults, ); } +domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow( + db.WorkoutTelemetrySample row, +) { + return domain.WorkoutTelemetrySample( + id: row.id, + sessionId: row.sessionId, + capturedAt: _utc(row.capturedAt), + programIndex: row.programIndex, + exerciseIndex: row.exerciseIndex, + setIndex: row.setIndex, + passageIndex: row.passageIndex, + stepIndex: row.stepIndex, + programSnapshotId: row.programSnapshotId, + exerciseSnapshotId: row.exerciseSnapshotId, + stepSnapshotId: row.stepSnapshotId, + heartRateBpm: row.heartRateBpm, + distanceMeters: row.distanceMeters, + caloriesKcal: row.caloriesKcal, + ); +} + +domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow( + db.WorkoutTelemetryAggregate row, +) { + return domain.WorkoutTelemetryAggregate( + sessionId: row.sessionId, + scope: domain.WorkoutTelemetryAggregateScope.parse(row.scope), + programIndex: row.programIndex, + exerciseIndex: row.exerciseIndex, + setIndex: row.setIndex, + passageIndex: row.passageIndex, + stepIndex: row.stepIndex, + sampleCount: row.sampleCount, + minHeartRateBpm: row.minHeartRateBpm, + averageHeartRateBpm: row.averageHeartRateBpm, + maxHeartRateBpm: row.maxHeartRateBpm, + totalDistanceMeters: row.totalDistanceMeters, + totalCaloriesKcal: row.totalCaloriesKcal, + ); +} + domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( db.WorkoutHistorySetResult row, ) { @@ -4739,8 +4950,11 @@ Map _workoutHistoryPayload(domain.WorkoutHistory history) => { 'totalActiveMs': history.totalActiveMs, 'completed': history.completed, 'historySnapshotJson': history.historySnapshotJson, + 'minHeartRateBpm': history.minHeartRateBpm, 'averageHeartRateBpm': history.averageHeartRateBpm, 'maxHeartRateBpm': history.maxHeartRateBpm, + 'totalDistanceMeters': history.totalDistanceMeters, + 'totalCaloriesKcal': history.totalCaloriesKcal, }; Map _localWorkoutHistoryPayload( @@ -4895,8 +5109,11 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( completed: payload['completed'] as bool? ?? false, historySnapshotJson: payload['historySnapshotJson'] as String? ?? '{"programs":[]}', + minHeartRateBpm: payload['minHeartRateBpm'] as int?, averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(), maxHeartRateBpm: payload['maxHeartRateBpm'] as int?, + totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(), + totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(), results: _workoutHistorySetResultsFromPayload(payload['results'], metadata), stepResults: _workoutHistoryStepResultsFromPayload( payload['stepResults'], @@ -5412,12 +5629,14 @@ OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (value) { String _shareResourceTypeToDb(domain.ShareResourceType type) => switch (type) { domain.ShareResourceType.program => 'program', domain.ShareResourceType.workoutTemplate => 'workoutTemplate', + domain.ShareResourceType.pack => 'pack', }; domain.ShareResourceType _shareResourceTypeFromDb(String value) => switch (value) { 'program' => domain.ShareResourceType.program, 'workoutTemplate' => domain.ShareResourceType.workoutTemplate, + 'pack' => domain.ShareResourceType.pack, _ => throw domain.DomainException('Unknown share resource type: $value'), }; diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 515304f..bd5c385 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -110,7 +110,7 @@ class ShareInboxItems extends Table { @override List get customConstraints => [ - "CHECK (resource_type IN ('program', 'workoutTemplate'))", + "CHECK (resource_type IN ('program', 'workoutTemplate', 'pack'))", "CHECK (status IN ('pending', 'accepted', 'declined', 'revoked'))", ]; } @@ -138,7 +138,7 @@ class PendingShareActions extends Table { List get customConstraints => [ "CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))", 'CHECK (resource_type IS NULL OR resource_type IN ' - "('program', 'workoutTemplate'))", + "('program', 'workoutTemplate', 'pack'))", "CHECK (status IN ('pending', 'succeeded', 'failed'))", ]; } @@ -750,14 +750,94 @@ class WorkoutHistories extends SyncableTable { IntColumn get totalActiveMs => integer()(); BoolColumn get completed => boolean()(); TextColumn get historySnapshotJson => text().withLength(min: 1)(); + IntColumn get minHeartRateBpm => integer().nullable()(); RealColumn get averageHeartRateBpm => real().nullable()(); IntColumn get maxHeartRateBpm => integer().nullable()(); + RealColumn get totalDistanceMeters => real().nullable()(); + RealColumn get totalCaloriesKcal => real().nullable()(); @override List get customConstraints => [ 'CHECK (total_active_ms >= 0)', + 'CHECK (min_heart_rate_bpm IS NULL OR min_heart_rate_bpm > 0)', 'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)', 'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)', + 'CHECK (total_distance_meters IS NULL OR total_distance_meters >= 0)', + 'CHECK (total_calories_kcal IS NULL OR total_calories_kcal >= 0)', + ]; +} + +class WorkoutTelemetrySamples extends Table { + @override + String get tableName => 'workout_telemetry_samples'; + + TextColumn get id => text()(); + TextColumn get sessionId => text().withLength(min: 1)(); + DateTimeColumn get capturedAt => dateTime()(); + IntColumn get programIndex => integer().nullable()(); + IntColumn get exerciseIndex => integer().nullable()(); + IntColumn get setIndex => integer().nullable()(); + IntColumn get passageIndex => integer().nullable()(); + IntColumn get stepIndex => integer().nullable()(); + TextColumn get programSnapshotId => text().nullable()(); + TextColumn get exerciseSnapshotId => text().nullable()(); + TextColumn get stepSnapshotId => text().nullable()(); + IntColumn get heartRateBpm => integer().nullable()(); + RealColumn get distanceMeters => real().nullable()(); + RealColumn get caloriesKcal => real().nullable()(); + + @override + Set get primaryKey => {id}; + + @override + List get customConstraints => [ + 'CHECK (program_index IS NULL OR program_index >= 0)', + 'CHECK (exercise_index IS NULL OR exercise_index >= 0)', + 'CHECK (set_index IS NULL OR set_index >= 0)', + 'CHECK (passage_index IS NULL OR passage_index >= 0)', + 'CHECK (step_index IS NULL OR step_index >= 0)', + 'CHECK (heart_rate_bpm IS NULL OR heart_rate_bpm > 0)', + 'CHECK (distance_meters IS NULL OR distance_meters >= 0)', + 'CHECK (calories_kcal IS NULL OR calories_kcal >= 0)', + 'CHECK (heart_rate_bpm IS NOT NULL OR distance_meters IS NOT NULL OR ' + 'calories_kcal IS NOT NULL)', + ]; +} + +class WorkoutTelemetryAggregates extends Table { + @override + String get tableName => 'workout_telemetry_aggregates'; + + TextColumn get sessionId => text().withLength(min: 1)(); + TextColumn get scope => text()(); + IntColumn get programIndex => integer().nullable()(); + IntColumn get exerciseIndex => integer().nullable()(); + IntColumn get setIndex => integer().nullable()(); + IntColumn get passageIndex => integer().nullable()(); + IntColumn get stepIndex => integer().nullable()(); + IntColumn get sampleCount => integer()(); + IntColumn get minHeartRateBpm => integer().nullable()(); + RealColumn get averageHeartRateBpm => real().nullable()(); + IntColumn get maxHeartRateBpm => integer().nullable()(); + RealColumn get totalDistanceMeters => real().nullable()(); + RealColumn get totalCaloriesKcal => real().nullable()(); + + @override + List get customConstraints => [ + "CHECK (scope IN ('session', 'exercise', 'set', 'step'))", + 'CHECK (program_index IS NULL OR program_index >= 0)', + 'CHECK (exercise_index IS NULL OR exercise_index >= 0)', + 'CHECK (set_index IS NULL OR set_index >= 0)', + 'CHECK (passage_index IS NULL OR passage_index >= 0)', + 'CHECK (step_index IS NULL OR step_index >= 0)', + 'CHECK (sample_count >= 0)', + 'CHECK (min_heart_rate_bpm IS NULL OR min_heart_rate_bpm > 0)', + 'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)', + 'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)', + 'CHECK (total_distance_meters IS NULL OR total_distance_meters >= 0)', + 'CHECK (total_calories_kcal IS NULL OR total_calories_kcal >= 0)', + 'UNIQUE (session_id, scope, program_index, exercise_index, set_index, ' + 'passage_index, step_index)', ]; } diff --git a/lib/infrastructure/remote/share_api.dart b/lib/infrastructure/remote/share_api.dart index 72df141..fbd87f4 100644 --- a/lib/infrastructure/remote/share_api.dart +++ b/lib/infrastructure/remote/share_api.dart @@ -94,11 +94,13 @@ RemoteSyncedItem _syncedItemFromJson(Map json) { String _shareResourceTypeToWire(ShareResourceType type) => switch (type) { ShareResourceType.program => 'program', ShareResourceType.workoutTemplate => 'workoutTemplate', + ShareResourceType.pack => 'pack', }; ShareResourceType _shareResourceTypeFromWire(String value) => switch (value) { 'program' => ShareResourceType.program, 'workoutTemplate' => ShareResourceType.workoutTemplate, + 'pack' => ShareResourceType.pack, _ => throw RemoteAuthException( RemoteAuthFailure.unknown, 'Unknown share resource type: $value', diff --git a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart index 98d690b..082b6ea 100644 --- a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart +++ b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart @@ -13,12 +13,14 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher { required WatchProjectionSource projectionSource, WorkoutHistoryUseCases? workoutHistoryUseCases, ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases, + WorkoutTelemetryUseCases? workoutTelemetryUseCases, Duration projectionRefreshInterval = const Duration(seconds: 2), }) : _nativeChannel = nativeChannel, _commandIngress = commandIngress, _projectionSource = projectionSource, _workoutHistoryUseCases = workoutHistoryUseCases, _activeWorkoutSensorUseCases = activeWorkoutSensorUseCases, + _workoutTelemetryUseCases = workoutTelemetryUseCases, _projectionRefreshInterval = projectionRefreshInterval; final WatchBridgeNativeChannel _nativeChannel; @@ -26,6 +28,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher { final WatchProjectionSource _projectionSource; final WorkoutHistoryUseCases? _workoutHistoryUseCases; final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases; + final WorkoutTelemetryUseCases? _workoutTelemetryUseCases; final Duration _projectionRefreshInterval; final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{}; final _subscriptions = >[]; @@ -60,10 +63,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher { ); } final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases; - if (activeWorkoutSensorUseCases != null) { + final workoutTelemetryUseCases = _workoutTelemetryUseCases; + if (activeWorkoutSensorUseCases != null || + workoutTelemetryUseCases != null) { _subscriptions.add( _nativeChannel.sensorSamples.listen((sample) { - activeWorkoutSensorUseCases.recordTelemetrySample(sample); + activeWorkoutSensorUseCases?.recordTelemetrySample(sample); + unawaited(workoutTelemetryUseCases?.recordTelemetrySample(sample)); }), ); } diff --git a/lib/presentation/exercise_step_audio.dart b/lib/presentation/exercise_step_audio.dart index b1aacf9..d0da0af 100644 --- a/lib/presentation/exercise_step_audio.dart +++ b/lib/presentation/exercise_step_audio.dart @@ -1,4 +1,5 @@ import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; abstract interface class ExerciseStepAudioCuePlayer { Future playShortCountdownBeep(); @@ -13,9 +14,7 @@ final class AudioplayersExerciseStepAudioCuePlayer AudioplayersExerciseStepAudioCuePlayer(); final AudioPlayer _player = AudioPlayer(); - late final Future _audioContextReady = _player.setAudioContext( - _stepCueAudioContext, - ); + late final Future _audioContextReady = _configureAudioContext(); @override Future playShortCountdownBeep() { @@ -33,6 +32,14 @@ final class AudioplayersExerciseStepAudioCuePlayer await _player.play(AssetSource(assetPath)); } + Future _configureAudioContext() async { + try { + await _player.setAudioContext(_stepCueAudioContext); + } on Object catch (error) { + debugPrint('Audio cue context ignored: $error'); + } + } + @override Future dispose() { return _player.dispose(); diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart index fce0f61..9745b2e 100644 --- a/lib/presentation/profile_screen.dart +++ b/lib/presentation/profile_screen.dart @@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'share_inbox_screen.dart'; +import 'share_screen.dart'; import 'theme.dart'; final class ProfileScreen extends StatefulWidget { @@ -69,6 +70,7 @@ final class _ProfileScreenState extends State { syncUseCases: widget.syncUseCases, onLogout: () => _confirmLogout(context), onShares: () => _openReceivedShares(context), + onSharePack: () => _openSharePack(context), dataExportUseCase: widget.dataExportUseCase, dataImportUseCase: widget.dataImportUseCase, backupFileExporter: widget.backupFileExporter, @@ -153,6 +155,18 @@ final class _ProfileScreenState extends State { ); } + void _openSharePack(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SharePackSelectionScreen( + shareUseCases: widget.shareUseCases, + authUseCases: widget.authUseCases, + syncUseCases: widget.syncUseCases, + ), + ), + ); + } + void _reloadSession() { setState(() { _session = widget.authUseCases.currentSession(); @@ -258,6 +272,7 @@ final class _SignedInProfile extends StatelessWidget { required this.syncUseCases, required this.onLogout, required this.onShares, + required this.onSharePack, required this.dataExportUseCase, required this.dataImportUseCase, required this.backupFileExporter, @@ -268,6 +283,7 @@ final class _SignedInProfile extends StatelessWidget { final SyncUseCases syncUseCases; final VoidCallback onLogout; final VoidCallback onShares; + final VoidCallback onSharePack; final DataExportUseCase dataExportUseCase; final DataImportUseCase dataImportUseCase; final LocalBackupFileExporter backupFileExporter; @@ -320,18 +336,34 @@ final class _SignedInProfile extends StatelessWidget { ), const SizedBox(height: 12), CourtBlazerAccentPanel( - child: Material( - type: MaterialType.transparency, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.inbox_outlined), - title: const Text('Partages reçus'), - subtitle: const Text( - "Programmes et séances reçus d'autres comptes", + child: Column( + children: [ + Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.inventory_2_outlined), + title: const Text('Partager un pack'), + subtitle: const Text('Envoyer plusieurs séances ensemble'), + trailing: const Icon(Icons.chevron_right), + onTap: onSharePack, + ), ), - trailing: const Icon(Icons.chevron_right), - onTap: onShares, - ), + const Divider(height: 1), + Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.inbox_outlined), + title: const Text('Partages reçus'), + subtitle: const Text( + "Programmes, séances et packs reçus d'autres comptes", + ), + trailing: const Icon(Icons.chevron_right), + onTap: onShares, + ), + ), + ], ), ), ], diff --git a/lib/presentation/share_inbox_screen.dart b/lib/presentation/share_inbox_screen.dart index c5a8cac..a4690ef 100644 --- a/lib/presentation/share_inbox_screen.dart +++ b/lib/presentation/share_inbox_screen.dart @@ -46,6 +46,9 @@ final class _ShareInboxScreenState extends State { final item = items[index]; return _ShareInboxTile( item: item, + onOpen: item.resourceType == ShareResourceType.pack + ? () => _openPack(item) + : null, onAccept: item.status == ShareInboxStatus.pending ? () => _accept(item) : null, @@ -80,6 +83,22 @@ final class _ShareInboxScreenState extends State { ); } + Future _openPack(ShareInboxItem item) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _SharePackDetailScreen( + item: item, + onImport: item.status == ShareInboxStatus.pending + ? () => _accept(item) + : null, + onDecline: item.status == ShareInboxStatus.pending + ? () => _decline(item) + : null, + ), + ), + ); + } + Future _decline(ShareInboxItem item) async { await widget.shareUseCases.declineShare(item.shareId); if (!mounted) return; @@ -94,11 +113,13 @@ final class _ShareInboxScreenState extends State { final class _ShareInboxTile extends StatelessWidget { const _ShareInboxTile({ required this.item, + required this.onOpen, required this.onAccept, required this.onDecline, }); final ShareInboxItem item; + final VoidCallback? onOpen; final VoidCallback? onAccept; final VoidCallback? onDecline; @@ -112,11 +133,7 @@ final class _ShareInboxTile extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - item.resourceType == ShareResourceType.program - ? Icons.list_alt - : Icons.event_note, - ), + Icon(_resourceIcon(item.resourceType)), const SizedBox(width: 12), Expanded( child: Column( @@ -140,31 +157,46 @@ final class _ShareInboxTile extends StatelessWidget { ], ), ), - Chip( - visualDensity: VisualDensity.compact, - label: Text(_statusLabel(item.status)), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Chip( + visualDensity: VisualDensity.compact, + label: Text(_statusLabel(item.status)), + ), + if (item.resourceType == ShareResourceType.pack) ...[ + const SizedBox(height: 4), + Chip( + visualDensity: VisualDensity.compact, + label: Text(_packBadge(summary.itemCount)), + ), + ], + ], ), ], ), if (item.status == ShareInboxStatus.pending) ...[ const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: onDecline, - child: const Text('Refuser'), + if (item.resourceType == ShareResourceType.pack) + FilledButton(onPressed: onOpen, child: const Text('Voir le pack')) + else + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onDecline, + child: const Text('Refuser'), + ), ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - onPressed: onAccept, - child: const Text('Accepter'), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: onAccept, + child: const Text('Accepter'), + ), ), - ), - ], - ), + ], + ), ], ], ), @@ -196,11 +228,91 @@ final class _EmptyInbox extends StatelessWidget { } } +final class _SharePackDetailScreen extends StatelessWidget { + const _SharePackDetailScreen({ + required this.item, + required this.onImport, + required this.onDecline, + }); + + final ShareInboxItem item; + final VoidCallback? onImport; + final VoidCallback? onDecline; + + @override + Widget build(BuildContext context) { + final summary = _summaryFromPayload(item); + final workouts = _packWorkoutNames(item); + return Scaffold( + appBar: AppBar(title: const Text('Pack reçu')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + CourtBlazerAccentPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + summary.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text('Envoyé par ${item.senderUserId}'), + const SizedBox(height: 8), + Chip( + visualDensity: VisualDensity.compact, + label: Text(_packBadge(summary.itemCount)), + ), + const SizedBox(height: 16), + Text('Séances', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final name in workouts) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_note), + title: Text(name), + ), + if (item.status == ShareInboxStatus.pending) ...[ + const SizedBox(height: 16), + FilledButton( + onPressed: onImport == null + ? null + : () { + onImport!(); + Navigator.of(context).pop(); + }, + child: const Text('Importer'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: onDecline == null + ? null + : () { + onDecline!(); + Navigator.of(context).pop(); + }, + child: const Text('Refuser'), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + final class _InboxSummary { - const _InboxSummary({required this.name, required this.detail}); + const _InboxSummary({ + required this.name, + required this.detail, + this.itemCount = 0, + }); final String name; final String detail; + final int itemCount; } _InboxSummary _summaryFromPayload(ShareInboxItem item) { @@ -216,6 +328,11 @@ _InboxSummary _summaryFromPayload(ShareInboxItem item) { name: name, detail: _templateDetail(payload), ), + ShareResourceType.pack => _InboxSummary( + name: name, + detail: _packDetail(payload), + itemCount: _packItemCount(payload), + ), }; } on Object { return const _InboxSummary(name: 'Partage GameTime', detail: ''); @@ -247,10 +364,45 @@ String _templateDetail(Map payload) { '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}'; } +String _packDetail(Map payload) { + final count = _packItemCount(payload); + return '$count séance${count > 1 ? 's' : ''}'; +} + +int _packItemCount(Map payload) { + return (payload['workouts'] as List? ?? const []).length; +} + +List _packWorkoutNames(ShareInboxItem item) { + try { + final payload = jsonDecode(item.payloadJson) as Map; + final workouts = payload['workouts'] as List? ?? const []; + return workouts + .whereType() + .map((raw) => raw['name'] as String? ?? 'Séance partagée') + .toList(growable: false); + } on Object { + return const []; + } +} + +String _packBadge(int count) { + return 'Pack · $count séance${count > 1 ? 's' : ''}'; +} + +IconData _resourceIcon(ShareResourceType type) { + return switch (type) { + ShareResourceType.program => Icons.list_alt, + ShareResourceType.workoutTemplate => Icons.event_note, + ShareResourceType.pack => Icons.inventory_2_outlined, + }; +} + String _resourceTitle(ShareResourceType type) { return switch (type) { ShareResourceType.program => 'Programme', ShareResourceType.workoutTemplate => 'Séance', + ShareResourceType.pack => 'Pack', }; } @@ -267,5 +419,6 @@ String _acceptedMessage(ShareResourceType type) { return switch (type) { ShareResourceType.program => 'Programme ajouté.', ShareResourceType.workoutTemplate => 'Séance ajoutée.', + ShareResourceType.pack => 'Pack importé.', }; } diff --git a/lib/presentation/share_screen.dart b/lib/presentation/share_screen.dart index e2413a4..e086cae 100644 --- a/lib/presentation/share_screen.dart +++ b/lib/presentation/share_screen.dart @@ -12,6 +12,8 @@ final class ShareFormScreen extends StatefulWidget { required this.localResourceId, required this.resourceName, required this.resourceSummary, + this.localResourceIds = const [], + this.packName, this.authUseCases, this.syncUseCases, super.key, @@ -22,6 +24,8 @@ final class ShareFormScreen extends StatefulWidget { final SyncUseCases? syncUseCases; final ShareResourceType resourceType; final String localResourceId; + final List localResourceIds; + final String? packName; final String resourceName; final String resourceSummary; @@ -106,6 +110,8 @@ final class _ShareFormScreenState extends State { final result = await widget.shareUseCases.sendShare( resourceType: widget.resourceType, localResourceId: widget.localResourceId, + localResourceIds: widget.localResourceIds, + packName: widget.packName, recipientEmails: _parseEmails(_emailsController.text), ); if (!mounted) return; @@ -154,6 +160,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget { final targetLabel = switch (resourceType) { ShareResourceType.program => 'ce programme', ShareResourceType.workoutTemplate => 'cette séance', + ShareResourceType.pack => 'ce pack', }; final canOpenAuth = authUseCases != null && syncUseCases != null; return Scaffold( @@ -254,6 +261,7 @@ String _shareTitle(ShareResourceType type) { return switch (type) { ShareResourceType.program => 'Partager le programme', ShareResourceType.workoutTemplate => 'Partager la séance', + ShareResourceType.pack => 'Partager le pack', }; } @@ -261,5 +269,156 @@ String _resourceTitle(ShareResourceType type) { return switch (type) { ShareResourceType.program => 'Programme', ShareResourceType.workoutTemplate => 'Séance', + ShareResourceType.pack => 'Pack', }; } + +final class SharePackSelectionScreen extends StatefulWidget { + const SharePackSelectionScreen({ + required this.shareUseCases, + this.authUseCases, + this.syncUseCases, + super.key, + }); + + final ShareUseCases shareUseCases; + final AuthUseCases? authUseCases; + final SyncUseCases? syncUseCases; + + @override + State createState() => + _SharePackSelectionScreenState(); +} + +final class _SharePackSelectionScreenState + extends State { + final _nameController = TextEditingController(); + late Future> _templates; + final _selectedTemplateIds = {}; + + @override + void initState() { + super.initState(); + _templates = widget.shareUseCases.listShareableWorkoutTemplates(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Composer un pack')), + body: FutureBuilder>( + future: _templates, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final templates = snapshot.data ?? const []; + if (templates.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Aucune séance à partager.', + textAlign: TextAlign.center, + ), + ), + ); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + CourtBlazerAccentPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Pack à partager', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom du pack', + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + Text( + 'Séances', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + for (final template in templates) + Material( + type: MaterialType.transparency, + child: CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _selectedTemplateIds.contains( + template.metadata.id, + ), + title: Text(template.name), + subtitle: Text(_templatePackSummary(template)), + onChanged: (selected) { + setState(() { + if (selected == true) { + _selectedTemplateIds.add(template.metadata.id); + } else { + _selectedTemplateIds.remove( + template.metadata.id, + ); + } + }); + }, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _canContinue ? _continueToRecipients : null, + icon: const Icon(Icons.inventory_2_outlined), + label: const Text('Continuer'), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + bool get _canContinue => + _nameController.text.trim().isNotEmpty && _selectedTemplateIds.isNotEmpty; + + Future _continueToRecipients() async { + final name = _nameController.text.trim(); + final ids = _selectedTemplateIds.toList(growable: false); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ShareFormScreen( + shareUseCases: widget.shareUseCases, + authUseCases: widget.authUseCases, + syncUseCases: widget.syncUseCases, + resourceType: ShareResourceType.pack, + localResourceId: '', + localResourceIds: ids, + packName: name, + resourceName: name, + resourceSummary: '${ids.length} séance${ids.length > 1 ? 's' : ''}', + ), + ), + ); + } +} + +String _templatePackSummary(WorkoutTemplate template) { + return '${template.programs.length} programme' + '${template.programs.length > 1 ? 's' : ''}'; +} diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index b2f54b7..a53a655 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -1634,7 +1634,13 @@ final class _WorkoutExecutionScreenState extends State { final rest = await widget.activeUseCases.findActiveRest( sessionId: _session.metadata.id, ); - if (!mounted || rest == null) { + if (!mounted) { + return false; + } + if (rest == null) { + _activeRestStateId = null; + _restTicker?.cancel(); + _restTicker = null; return false; } final after = ExecutionPosition( @@ -2706,6 +2712,96 @@ final class _CurrentStepPane extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + final hasRepsWithStopwatchScore = + step.type == ExerciseStepType.reps && + step.hasScore && + step.scoreInputMode == ScoreInputMode.stopwatch; + final hasTimedWithManualScore = + step.type == ExerciseStepType.time && + step.hasScore && + step.scoreInputMode == ScoreInputMode.manual; + if (hasRepsWithStopwatchScore || hasTimedWithManualScore) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + step.name, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Expanded( + child: LayoutBuilder( + builder: (context, bodyConstraints) { + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: bodyConstraints.maxWidth, + height: hasRepsWithStopwatchScore ? 150 : 132, + child: hasRepsWithStopwatchScore + ? _RepsWithStopwatchScoreStepBody( + step: step, + elapsedLabel: stepScoreElapsedLabel, + running: stepScoreRunning, + onStart: onStartStepScore, + onStop: onStopStepScore, + onReset: onResetStepScore, + ) + : _TimedStepWithManualScoreBody( + step: step, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus + .runningTimer, + readyToStart: _isNextTimedStepReady(view), + scoreController: stepScoreController, + onStartTimer: onStartTimer, + ), + ), + ); + }, + ), + ), + const SizedBox(height: 8), + if (hasRepsWithStopwatchScore) + 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), + Expanded( + child: FilledButton.icon( + onPressed: onCompleteStep, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + icon: const Icon(Icons.check), + label: const Text('Étape suivante'), + ), + ), + ], + ) + else + OutlinedButton( + onPressed: onSkipStep, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + child: const Text('Passer l’étape'), + ), + ], + ); + } return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 8), child: ConstrainedBox( @@ -2860,6 +2956,274 @@ final class _TimedStepBody extends StatelessWidget { } } +final class _RepsWithStopwatchScoreStepBody extends StatelessWidget { + const _RepsWithStopwatchScoreStepBody({ + required this.step, + required this.elapsedLabel, + required this.running, + required this.onStart, + required this.onStop, + required this.onReset, + }); + + final ExerciseStep step; + final String elapsedLabel; + final bool running; + final VoidCallback onStart; + final VoidCallback onStop; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('RÉPÉTITIONS', style: theme.textTheme.labelLarge), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${step.defaultTargetValue}', + style: AppTextStyles.timer( + context, + ).copyWith(color: theme.colorScheme.primary), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('CHRONO SCORE', style: theme.textTheme.labelLarge), + const SizedBox(height: 6), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + elapsedLabel, + style: AppTextStyles.scoreNumber(context), + ), + ), + const SizedBox(height: 8), + _CompactStepScoreStopwatchControls( + running: running, + onStart: onStart, + onStop: onStop, + onReset: onReset, + ), + ], + ), + ), + ], + ); + } +} + +final class _TimedStepWithManualScoreBody extends StatelessWidget { + const _TimedStepWithManualScoreBody({ + required this.step, + required this.remainingLabel, + required this.running, + required this.readyToStart, + required this.scoreController, + required this.onStartTimer, + }); + + final ExerciseStep step; + final String remainingLabel; + final bool running; + final bool readyToStart; + final TextEditingController scoreController; + final VoidCallback onStartTimer; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('CHRONO', style: theme.textTheme.labelLarge), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + remainingLabel, + style: theme.textTheme.displayMedium?.copyWith( + fontFamily: 'Anton', + fontFeatures: AppTextStyles.timer(context).fontFeatures, + color: theme.colorScheme.primary, + ), + ), + ), + if (!running) ...[ + const SizedBox(height: 8), + if (readyToStart) ...[ + Text( + 'Chrono prêt', + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 4), + ], + IconButton.filled( + tooltip: 'Lancer', + onPressed: onStartTimer, + icon: const Icon(Icons.play_arrow), + ), + ], + ], + ), + ), + const SizedBox(width: 14), + Expanded( + flex: 2, + child: Center( + child: _StepManualScoreStepper( + controller: scoreController, + initialValue: step.defaultTargetScore, + ), + ), + ), + ], + ); + } +} + +final class _CompactStepScoreStopwatchControls extends StatelessWidget { + const _CompactStepScoreStopwatchControls({ + required this.running, + required this.onStart, + required this.onStop, + required this.onReset, + }); + + final bool running; + final VoidCallback onStart; + final VoidCallback onStop; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filled( + tooltip: running ? 'Arrêter' : 'Lancer', + onPressed: running ? onStop : onStart, + icon: Icon(running ? Icons.stop : Icons.play_arrow), + ), + const SizedBox(width: 8), + IconButton.outlined( + tooltip: 'Réinitialiser', + onPressed: onReset, + icon: const Icon(Icons.restart_alt), + ), + ], + ); + } +} + +final class _StepManualScoreStepper extends StatefulWidget { + const _StepManualScoreStepper({ + required this.controller, + required this.initialValue, + }); + + final TextEditingController controller; + final double? initialValue; + + @override + State<_StepManualScoreStepper> createState() => + _StepManualScoreStepperState(); +} + +final class _StepManualScoreStepperState + extends State<_StepManualScoreStepper> { + double get _value { + final parsed = double.tryParse(widget.controller.text.trim()); + return parsed ?? widget.initialValue ?? 0; + } + + void _changeBy(double delta) { + final next = (_value + delta).clamp(0, 999).toDouble(); + widget.controller.text = _formatManualScoreInput(next); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('SCORE', style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Diminuer', + onPressed: () => _changeBy(-1), + constraints: const BoxConstraints.tightFor(width: 34, height: 34), + padding: EdgeInsets.zero, + iconSize: 20, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(Icons.remove), + ), + SizedBox( + width: 46, + child: Text( + _formatManualScoreInput(_value), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.scoreNumber(context), + ), + ), + IconButton( + tooltip: 'Augmenter', + onPressed: () => _changeBy(1), + constraints: const BoxConstraints.tightFor(width: 34, height: 34), + padding: EdgeInsets.zero, + iconSize: 20, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(Icons.add), + ), + ], + ), + ], + ); + } +} + +String _formatManualScoreInput(double value) { + return value == value.roundToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); +} + bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) { final step = view.currentStep; if (step == null || diff --git a/server/bin/server.dart b/server/bin/server.dart index 2c910c1..749c852 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -67,12 +67,7 @@ Future main(List arguments) async { ids: ids, ), listInbox: ListInboxUseCase(shares: shares), - acceptShare: AcceptShareUseCase( - shares: shares, - resources: resources, - clock: clock, - ids: ids, - ), + acceptShare: AcceptShareUseCase(shares: shares, clock: clock, ids: ids), declineShare: DeclineShareUseCase(shares: shares, clock: clock), revokeShare: RevokeShareUseCase(shares: shares, clock: clock), authenticateRequest: authenticateRequest, diff --git a/server/lib/api/share_api.dart b/server/lib/api/share_api.dart index 861be7c..70dcad1 100644 --- a/server/lib/api/share_api.dart +++ b/server/lib/api/share_api.dart @@ -30,14 +30,21 @@ final class ShareApi { } try { final body = await _readJsonObject(request); + final shareKind = _optionalString(body, 'shareKind') ?? 'single'; final result = await createShare.execute( senderUserId: authenticated.user.id, - resourceType: _requiredString(body, 'resourceType'), - payloadJson: _requiredObject(body, 'payload'), + shareKind: shareKind, + packName: _optionalString(body, 'packName'), + resourceType: _optionalString(body, 'resourceType'), + payloadJson: _optionalObject(body, 'payload'), + packItems: shareKind == ShareKind.pack.wireName + ? _packItems(body['items']) + : const [], recipientEmails: _requiredStringList(body, 'recipientEmails'), ); return _jsonResponse(201, { 'shareId': result.share.id, + 'shareKind': result.share.kind.wireName, 'recipientUserIds': result.recipientUserIds, 'unresolvedEmails': result.unresolvedEmails, }); @@ -62,7 +69,9 @@ final class ShareApi { { 'shareId': item.share.id, 'senderUserId': item.share.senderUserId, - 'resourceType': item.share.resourceType.wireName, + 'shareKind': item.share.kind.wireName, + 'packName': item.share.packName, + 'resourceType': item.share.resourceType?.wireName, 'payload': item.share.payloadJson, 'status': item.recipient.status.wireName, 'createdAt': item.share.createdAt.toIso8601String(), @@ -83,7 +92,12 @@ final class ShareApi { recipientUserId: authenticated.user.id, ); return _jsonResponse(200, { - 'createdResource': _resourceJson(result.createdResource), + if (result.createdResources.length == 1) + 'createdResource': _resourceJson(result.createdResource), + 'createdResources': [ + for (final resource in result.createdResources) + _resourceJson(resource), + ], }); } on ShareNotFoundException catch (error) { return _errorResponse(404, error.message); @@ -140,6 +154,26 @@ Map _resourceJson(SyncedResource resource) { }; } +List _packItems(Object? rawItems) { + if (rawItems is! List) { + throw const FormatException('items must be an array.'); + } + return [ + for (final rawItem in rawItems) + if (rawItem is Map) + _packItem(Map.from(rawItem)) + else + throw const FormatException('items must contain only objects.'), + ]; +} + +SharePackItemInput _packItem(Map item) { + return SharePackItemInput( + resourceType: _requiredString(item, 'resourceType'), + payloadJson: _requiredObject(item, 'payload'), + ); +} + Future> _readJsonObject(Request request) async { final raw = await request.readAsString(); final decoded = jsonDecode(raw); @@ -157,6 +191,18 @@ String _requiredString(Map body, String key) { return value; } +String? _optionalString(Map body, String key) { + final value = body[key]; + if (value == null) { + return null; + } + if (value is! String) { + throw FormatException('$key must be a string.'); + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + Map _requiredObject(Map body, String key) { final value = body[key]; if (value is Map) { @@ -168,6 +214,13 @@ Map _requiredObject(Map body, String key) { throw FormatException('$key must be a JSON object.'); } +Map? _optionalObject(Map body, String key) { + if (!body.containsKey(key)) { + return null; + } + return _requiredObject(body, key); +} + List _requiredStringList(Map body, String key) { final value = body[key]; if (value is! List) { diff --git a/server/lib/application/ports.dart b/server/lib/application/ports.dart index 6b1d62b..5ed957e 100644 --- a/server/lib/application/ports.dart +++ b/server/lib/application/ports.dart @@ -68,6 +68,12 @@ abstract interface class ShareRepository { required DateTime respondedAt, }); + Future> acceptShare({ + required String recipientId, + required DateTime respondedAt, + required List resources, + }); + Future revokeShare({ required String shareId, required DateTime revokedAt, diff --git a/server/lib/application/share_use_cases.dart b/server/lib/application/share_use_cases.dart index b652e5e..d050dcb 100644 --- a/server/lib/application/share_use_cases.dart +++ b/server/lib/application/share_use_cases.dart @@ -14,9 +14,21 @@ final class CreateShareResult { } final class AcceptShareResult { - const AcceptShareResult({required this.createdResource}); + const AcceptShareResult({required this.createdResources}); - final SyncedResource createdResource; + final List createdResources; + + SyncedResource get createdResource => createdResources.single; +} + +final class SharePackItemInput { + const SharePackItemInput({ + required this.resourceType, + required this.payloadJson, + }); + + final String resourceType; + final Map payloadJson; } final class CreateShareUseCase { @@ -34,11 +46,21 @@ final class CreateShareUseCase { Future execute({ required String senderUserId, - required String resourceType, - required Map payloadJson, + String shareKind = 'single', + String? packName, + required String? resourceType, + required Map? payloadJson, + List packItems = const [], required List recipientEmails, }) async { - final parsedType = _shareResourceType(resourceType); + final parsedKind = ShareKind.parse(shareKind); + final shareContent = _shareContent( + kind: parsedKind, + packName: packName, + resourceType: resourceType, + payloadJson: payloadJson, + packItems: packItems, + ); final emails = _normalizedEmails(recipientEmails); if (emails.isEmpty) { throw const ValidationException('recipientEmails must not be empty.'); @@ -60,8 +82,10 @@ final class CreateShareUseCase { final share = Share( id: ids.newId(), senderUserId: senderUserId, - resourceType: parsedType, - payloadJson: payloadJson, + kind: parsedKind, + resourceType: shareContent.resourceType, + packName: shareContent.packName, + payloadJson: shareContent.payloadJson, createdAt: now, ); final recipients = [ @@ -94,13 +118,11 @@ final class ListInboxUseCase { final class AcceptShareUseCase { const AcceptShareUseCase({ required this.shares, - required this.resources, required this.clock, required this.ids, }); final ShareRepository shares; - final SyncedResourceRepository resources; final Clock clock; final IdGenerator ids; @@ -124,23 +146,25 @@ final class AcceptShareUseCase { } final now = clock.now().toUtc(); - final resource = SyncedResource( - serverId: ids.newId(), - ownerUserId: recipientUserId, - resourceType: share.resourceType, - clientId: ids.newId(), - payloadJson: share.payloadJson, - schemaVersion: _schemaVersionFromPayload(share.payloadJson), - clientUpdatedAt: now, - serverUpdatedAt: now, - ); - final written = await resources.upsertWithLww(resource); - await shares.updateRecipientStatus( + final resourcesToCreate = [ + for (final item in _resourcesFromShare(share)) + SyncedResource( + serverId: ids.newId(), + ownerUserId: recipientUserId, + resourceType: item.resourceType, + clientId: ids.newId(), + payloadJson: item.payloadJson, + schemaVersion: _schemaVersionFromPayload(item.payloadJson), + clientUpdatedAt: now, + serverUpdatedAt: now, + ), + ]; + final written = await shares.acceptShare( recipientId: recipient.id, - status: ShareRecipientStatus.accepted, respondedAt: now, + resources: resourcesToCreate, ); - return AcceptShareResult(createdResource: written.resource); + return AcceptShareResult(createdResources: written); } } @@ -205,6 +229,140 @@ SyncedResourceType _shareResourceType(String value) { return type; } +final class _ShareContent { + const _ShareContent({ + required this.resourceType, + required this.packName, + required this.payloadJson, + }); + + final SyncedResourceType? resourceType; + final String? packName; + final Map payloadJson; +} + +_ShareContent _shareContent({ + required ShareKind kind, + required String? packName, + required String? resourceType, + required Map? payloadJson, + required List packItems, +}) { + return switch (kind) { + ShareKind.single => _singleShareContent( + resourceType: resourceType, + payloadJson: payloadJson, + ), + ShareKind.pack => _packShareContent(packName: packName, items: packItems), + }; +} + +_ShareContent _singleShareContent({ + required String? resourceType, + required Map? payloadJson, +}) { + final normalizedType = _blankToNull(resourceType); + if (normalizedType == null) { + throw const ValidationException('resourceType must not be blank.'); + } + final payload = payloadJson; + if (payload == null) { + throw const ValidationException('payload is required.'); + } + return _ShareContent( + resourceType: _shareResourceType(normalizedType), + packName: null, + payloadJson: payload, + ); +} + +_ShareContent _packShareContent({ + required String? packName, + required List items, +}) { + final normalizedPackName = _blankToNull(packName); + if (normalizedPackName == null) { + throw const ValidationException('packName must not be blank.'); + } + if (items.isEmpty) { + throw const ValidationException('items must not be empty.'); + } + return _ShareContent( + resourceType: null, + packName: normalizedPackName, + payloadJson: { + 'items': [ + for (final item in items) + { + 'resourceType': _shareResourceType(item.resourceType).wireName, + 'payload': item.payloadJson, + }, + ], + }, + ); +} + +final class _ShareResourceData { + const _ShareResourceData({ + required this.resourceType, + required this.payloadJson, + }); + + final SyncedResourceType resourceType; + final Map payloadJson; +} + +List<_ShareResourceData> _resourcesFromShare(Share share) { + return switch (share.kind) { + ShareKind.single => [ + _ShareResourceData( + resourceType: share.resourceType!, + payloadJson: share.payloadJson, + ), + ], + ShareKind.pack => _packResourcesFromPayload(share.payloadJson), + }; +} + +List<_ShareResourceData> _packResourcesFromPayload( + Map payloadJson, +) { + final rawItems = payloadJson['items']; + if (rawItems is! List || rawItems.isEmpty) { + throw const ValidationException('Pack share payload must contain items.'); + } + return [ + for (final rawItem in rawItems) + if (rawItem is Map) + _packResourceFromPayload(Map.from(rawItem)) + else + throw const ValidationException('Pack item must be a JSON object.'), + ]; +} + +_ShareResourceData _packResourceFromPayload(Map item) { + final resourceType = item['resourceType']; + if (resourceType is! String || resourceType.trim().isEmpty) { + throw const ValidationException( + 'Pack item resourceType must not be blank.', + ); + } + final payload = item['payload']; + if (payload is Map) { + return _ShareResourceData( + resourceType: _shareResourceType(resourceType), + payloadJson: payload, + ); + } + if (payload is Map) { + return _ShareResourceData( + resourceType: _shareResourceType(resourceType), + payloadJson: Map.from(payload), + ); + } + throw const ValidationException('Pack item payload must be a JSON object.'); +} + List _normalizedEmails(List emails) { return emails .map((email) => email.trim().toLowerCase()) @@ -213,6 +371,11 @@ List _normalizedEmails(List emails) { .toList(growable: false); } +String? _blankToNull(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} + int _schemaVersionFromPayload(Map payloadJson) { final schemaVersion = payloadJson['schemaVersion']; return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1; diff --git a/server/lib/domain/entities.dart b/server/lib/domain/entities.dart index 8ed3b6e..a13fbb0 100644 --- a/server/lib/domain/entities.dart +++ b/server/lib/domain/entities.dart @@ -173,30 +173,69 @@ enum ShareRecipientStatus { } } +enum ShareKind { + single('single'), + pack('pack'); + + const ShareKind(this.wireName); + + final String wireName; + + static ShareKind parse(String value) { + for (final kind in values) { + if (kind.wireName == value) { + return kind; + } + } + throw ValidationException('Unsupported share kind: $value.'); + } +} + final class Share { Share({ required String id, required String senderUserId, + this.kind = ShareKind.single, required this.resourceType, + String? packName, required Map payloadJson, required DateTime createdAt, DateTime? revokedAt, }) : id = _nonBlank(id, 'Share id'), senderUserId = _nonBlank(senderUserId, 'Sender user id'), + packName = _blankToNull(packName), payloadJson = Map.unmodifiable(payloadJson), createdAt = createdAt.toUtc(), revokedAt = revokedAt?.toUtc() { - if (resourceType != SyncedResourceType.program && - resourceType != SyncedResourceType.workoutTemplate) { - throw const ValidationException( - 'Shares only support program and workoutTemplate resources.', - ); + if (kind == ShareKind.single) { + final type = resourceType; + if (type != SyncedResourceType.program && + type != SyncedResourceType.workoutTemplate) { + throw const ValidationException( + 'Single shares only support program and workoutTemplate resources.', + ); + } + } + if (kind == ShareKind.pack) { + if (resourceType != null) { + throw const ValidationException( + 'Pack shares must not set resourceType.', + ); + } + if (this.packName == null) { + throw const ValidationException('Pack name must not be blank.'); + } + } + if (kind == ShareKind.pack && payloadJson['items'] is! List) { + throw const ValidationException('Pack share payload must contain items.'); } } final String id; final String senderUserId; - final SyncedResourceType resourceType; + final ShareKind kind; + final SyncedResourceType? resourceType; + final String? packName; final Map payloadJson; final DateTime createdAt; final DateTime? revokedAt; @@ -230,3 +269,8 @@ String _nonBlank(String value, String label) { } return trimmed; } + +String? _blankToNull(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} diff --git a/server/lib/infrastructure/postgres/share_repository.dart b/server/lib/infrastructure/postgres/share_repository.dart index ee0586a..b4b04f9 100644 --- a/server/lib/infrastructure/postgres/share_repository.dart +++ b/server/lib/infrastructure/postgres/share_repository.dart @@ -18,18 +18,20 @@ final class PostgresShareRepository implements ShareRepository { await connection.execute( Sql.named(''' INSERT INTO shares ( - id, sender_user_id, resource_type, payload_json, created_at, - revoked_at + id, sender_user_id, share_kind, pack_name, resource_type, + payload_json, created_at, revoked_at ) VALUES ( - @id::uuid, @sender_user_id::uuid, @resource_type, - @payload_json::jsonb, @created_at, @revoked_at + @id::uuid, @sender_user_id::uuid, @share_kind, @pack_name, + @resource_type, @payload_json::jsonb, @created_at, @revoked_at ) '''), parameters: { 'id': share.id, 'sender_user_id': share.senderUserId, - 'resource_type': share.resourceType.wireName, + 'share_kind': share.kind.wireName, + 'pack_name': share.packName, + 'resource_type': share.resourceType?.wireName, 'payload_json': jsonEncode(share.payloadJson), 'created_at': share.createdAt, 'revoked_at': share.revokedAt, @@ -66,6 +68,8 @@ final class PostgresShareRepository implements ShareRepository { SELECT s.id AS share_id, s.sender_user_id, + s.share_kind, + s.pack_name, s.resource_type, s.payload_json, s.created_at, @@ -91,8 +95,8 @@ final class PostgresShareRepository implements ShareRepository { Future findShareById(String shareId) async { final result = await connection.execute( Sql.named(''' - SELECT id, sender_user_id, resource_type, payload_json, created_at, - revoked_at + SELECT id, sender_user_id, share_kind, pack_name, resource_type, + payload_json, created_at, revoked_at FROM shares WHERE id = @id::uuid LIMIT 1 @@ -146,6 +150,33 @@ final class PostgresShareRepository implements ShareRepository { ); } + @override + Future> acceptShare({ + required String recipientId, + required DateTime respondedAt, + required List resources, + }) { + return connection.runTx((session) async { + final written = []; + for (final resource in resources) { + written.add(await _upsertResource(session, resource)); + } + await session.execute( + Sql.named(''' + UPDATE share_recipients + SET status = @status, responded_at = @responded_at + WHERE id = @id::uuid + '''), + parameters: { + 'id': recipientId, + 'status': ShareRecipientStatus.accepted.wireName, + 'responded_at': respondedAt.toUtc(), + }, + ); + return written; + }); + } + @override Future revokeShare({ required String shareId, @@ -173,9 +204,11 @@ Share _shareFromValues(Map values) { return Share( id: _stringValue(values['share_id'] ?? values['id']), senderUserId: _stringValue(values['sender_user_id']), - resourceType: SyncedResourceType.parse( - _stringValue(values['resource_type']), + kind: ShareKind.parse( + _stringValue(values['share_kind'] ?? ShareKind.single.wireName), ), + packName: values['pack_name'] as String?, + resourceType: _nullableResourceType(values['resource_type']), payloadJson: _payloadValue(values['payload_json']), createdAt: _dateTimeValue(values['created_at']), revokedAt: _nullableDateTimeValue(values['revoked_at']), @@ -192,6 +225,90 @@ ShareRecipient _recipientFromValues(Map values) { ); } +Future _upsertResource( + Session session, + SyncedResource resource, +) async { + final result = await session.execute( + Sql.named(''' + WITH upserted AS ( + INSERT INTO synced_resources ( + server_id, owner_user_id, resource_type, client_id, payload_json, + schema_version, client_updated_at, deleted_at, origin_device_id + ) + VALUES ( + @server_id::uuid, @owner_user_id::uuid, @resource_type, @client_id, + @payload_json::jsonb, @schema_version, @client_updated_at, + @deleted_at, @origin_device_id + ) + ON CONFLICT (owner_user_id, resource_type, client_id) + DO UPDATE SET + payload_json = EXCLUDED.payload_json, + schema_version = EXCLUDED.schema_version, + client_updated_at = EXCLUDED.client_updated_at, + deleted_at = EXCLUDED.deleted_at, + origin_device_id = EXCLUDED.origin_device_id + WHERE synced_resources.client_updated_at < EXCLUDED.client_updated_at + RETURNING + server_id, owner_user_id, resource_type, client_id, payload_json, + schema_version, client_updated_at, server_updated_at, deleted_at, + origin_device_id + ) + SELECT * FROM upserted + UNION ALL + SELECT + existing.server_id, existing.owner_user_id, existing.resource_type, + existing.client_id, existing.payload_json, existing.schema_version, + existing.client_updated_at, existing.server_updated_at, + existing.deleted_at, existing.origin_device_id + FROM synced_resources existing + WHERE existing.owner_user_id = @owner_user_id::uuid + AND existing.resource_type = @resource_type + AND existing.client_id = @client_id + AND NOT EXISTS (SELECT 1 FROM upserted) + LIMIT 1 + '''), + parameters: { + 'server_id': resource.serverId, + 'owner_user_id': resource.ownerUserId, + 'resource_type': resource.resourceType.wireName, + 'client_id': resource.clientId, + 'payload_json': jsonEncode(resource.payloadJson), + 'schema_version': resource.schemaVersion, + 'client_updated_at': resource.clientUpdatedAt, + 'deleted_at': resource.deletedAt, + 'origin_device_id': resource.originDeviceId, + }, + ); + return _resourceFromValues( + result.single.toColumnMap() as Map, + ); +} + +SyncedResource _resourceFromValues(Map values) { + return SyncedResource( + serverId: _stringValue(values['server_id']), + ownerUserId: _stringValue(values['owner_user_id']), + resourceType: SyncedResourceType.parse( + _stringValue(values['resource_type']), + ), + clientId: _stringValue(values['client_id']), + payloadJson: _payloadValue(values['payload_json']), + schemaVersion: values['schema_version'] as int, + clientUpdatedAt: _dateTimeValue(values['client_updated_at']), + serverUpdatedAt: _dateTimeValue(values['server_updated_at']), + deletedAt: _nullableDateTimeValue(values['deleted_at']), + originDeviceId: values['origin_device_id'] as String?, + ); +} + +SyncedResourceType? _nullableResourceType(Object? value) { + if (value == null) { + return null; + } + return SyncedResourceType.parse(_stringValue(value)); +} + Map _payloadValue(Object? value) { if (value is Map) { return value; diff --git a/server/migrations/0002_share_packs.sql b/server/migrations/0002_share_packs.sql new file mode 100644 index 0000000..8f40717 --- /dev/null +++ b/server/migrations/0002_share_packs.sql @@ -0,0 +1,42 @@ +ALTER TABLE shares + ADD COLUMN IF NOT EXISTS share_kind text NOT NULL DEFAULT 'single'; + +ALTER TABLE shares + ADD COLUMN IF NOT EXISTS pack_name text; + +ALTER TABLE shares + ALTER COLUMN resource_type DROP NOT NULL; + +ALTER TABLE shares + DROP CONSTRAINT IF EXISTS shares_resource_type_check; + +ALTER TABLE shares + DROP CONSTRAINT IF EXISTS shares_kind_check; + +ALTER TABLE shares + DROP CONSTRAINT IF EXISTS shares_shape_check; + +ALTER TABLE shares + ADD CONSTRAINT shares_resource_type_check CHECK ( + resource_type IS NULL OR resource_type IN ('program', 'workoutTemplate') + ); + +ALTER TABLE shares + ADD CONSTRAINT shares_kind_check CHECK (share_kind IN ('single', 'pack')); + +ALTER TABLE shares + ADD CONSTRAINT shares_shape_check CHECK ( + ( + share_kind = 'single' + AND resource_type IN ('program', 'workoutTemplate') + AND pack_name IS NULL + ) + OR + ( + share_kind = 'pack' + AND resource_type IS NULL + AND length(trim(pack_name)) > 0 + AND COALESCE(jsonb_typeof(payload_json -> 'items') = 'array', false) + AND jsonb_array_length(payload_json -> 'items') > 0 + ) + ); diff --git a/server/openapi.yaml b/server/openapi.yaml index 5974096..a20e4c8 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -367,6 +367,10 @@ components: type: string enum: [program, workoutTemplate] + ShareKind: + type: string + enum: [single, pack] + ShareRecipientStatus: type: string enum: [pending, accepted, declined, revoked] @@ -517,12 +521,26 @@ components: CreateShareRequest: type: object - required: [resourceType, payload, recipientEmails] + required: [recipientEmails] properties: + shareKind: + description: Defaults to single when omitted. + allOf: + - $ref: '#/components/schemas/ShareKind' + packName: + type: string + nullable: true resourceType: - $ref: '#/components/schemas/ShareResourceType' + nullable: true + allOf: + - $ref: '#/components/schemas/ShareResourceType' payload: $ref: '#/components/schemas/JsonObject' + nullable: true + items: + type: array + items: + $ref: '#/components/schemas/SharePackItem' recipientEmails: type: array minItems: 1 @@ -530,12 +548,23 @@ components: type: string format: email + SharePackItem: + type: object + required: [resourceType, payload] + properties: + resourceType: + $ref: '#/components/schemas/ShareResourceType' + payload: + $ref: '#/components/schemas/JsonObject' + CreateShareResponse: type: object - required: [shareId, recipientUserIds, unresolvedEmails] + required: [shareId, shareKind, recipientUserIds, unresolvedEmails] properties: shareId: type: string + shareKind: + $ref: '#/components/schemas/ShareKind' recipientUserIds: type: array items: @@ -560,7 +589,7 @@ components: required: - shareId - senderUserId - - resourceType + - shareKind - payload - status - createdAt @@ -569,8 +598,15 @@ components: type: string senderUserId: type: string + shareKind: + $ref: '#/components/schemas/ShareKind' + packName: + type: string + nullable: true resourceType: - $ref: '#/components/schemas/ShareResourceType' + nullable: true + allOf: + - $ref: '#/components/schemas/ShareResourceType' payload: $ref: '#/components/schemas/JsonObject' status: @@ -585,7 +621,12 @@ components: AcceptShareResponse: type: object - required: [createdResource] + required: [createdResources] properties: createdResource: $ref: '#/components/schemas/SyncedResourceItem' + description: Present only for single-resource shares. + createdResources: + type: array + items: + $ref: '#/components/schemas/SyncedResourceItem' diff --git a/server/test/postgres_migrations_test.dart b/server/test/postgres_migrations_test.dart index dd5622b..6850681 100644 --- a/server/test/postgres_migrations_test.dart +++ b/server/test/postgres_migrations_test.dart @@ -44,5 +44,17 @@ void main() { 'share_recipients', }), ); + + final shareColumns = await connection.execute(''' + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'shares' + AND column_name IN ('share_kind', 'pack_name', 'resource_type') + '''); + expect( + shareColumns.map((row) => row[0] as String).toSet(), + containsAll({'share_kind', 'pack_name', 'resource_type'}), + ); }); } diff --git a/server/test/share_api_test.dart b/server/test/share_api_test.dart index 7f9b4df..b3cd5f2 100644 --- a/server/test/share_api_test.dart +++ b/server/test/share_api_test.dart @@ -25,8 +25,50 @@ void main() { } }); - test('create share returns share id, resolved recipients and unresolved emails', - () async { + test( + 'create share returns share id, resolved recipients and unresolved emails', + () async { + final shares = _FakeShareRepository(); + final users = _FakeUserRepository([ + _user('user-1', 'user@example.com'), + _user('user-2', 'friend@example.com'), + ]); + final handler = buildApiHandler( + shareApi: _shareApi(users: users, shares: shares), + ); + + final response = await handler( + Request( + 'POST', + Uri.parse('http://localhost/shares'), + headers: {'authorization': 'Bearer valid-token'}, + body: jsonEncode({ + 'resourceType': 'program', + 'payload': {'schemaVersion': 2, 'name': 'Programme été'}, + 'recipientEmails': [ + ' friend@example.com ', + 'missing@example.com', + 'user@example.com', + ], + }), + ), + ); + + final body = jsonDecode(await response.readAsString()) as Map; + + expect(response.statusCode, 201); + expect(body['shareId'], 'id-1'); + expect(body['shareKind'], 'single'); + expect(body['recipientUserIds'], ['user-2']); + expect(body['unresolvedEmails'], ['missing@example.com']); + expect( + shares.shareById['id-1']?.resourceType, + SyncedResourceType.program, + ); + }, + ); + + test('create pack share returns share id and stores pack metadata', () async { final shares = _FakeShareRepository(); final users = _FakeUserRepository([ _user('user-1', 'user@example.com'), @@ -42,24 +84,32 @@ void main() { Uri.parse('http://localhost/shares'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ - 'resourceType': 'program', - 'payload': {'schemaVersion': 2, 'name': 'Programme été'}, - 'recipientEmails': [ - ' friend@example.com ', - 'missing@example.com', - 'user@example.com', + 'shareKind': 'pack', + 'packName': 'Pack été', + 'items': [ + { + 'resourceType': 'program', + 'payload': {'schemaVersion': 2, 'name': 'Programme'}, + }, + { + 'resourceType': 'workoutTemplate', + 'payload': {'schemaVersion': 3, 'name': 'Séance'}, + }, ], + 'recipientEmails': ['friend@example.com'], }), ), ); final body = jsonDecode(await response.readAsString()) as Map; + final share = shares.shareById['id-1']; expect(response.statusCode, 201); - expect(body['shareId'], 'id-1'); - expect(body['recipientUserIds'], ['user-2']); - expect(body['unresolvedEmails'], ['missing@example.com']); - expect(shares.shareById['id-1']?.resourceType, SyncedResourceType.program); + expect(body['shareKind'], 'pack'); + expect(share?.kind, ShareKind.pack); + expect(share?.packName, 'Pack été'); + expect(share?.resourceType, isNull); + expect(share?.payloadJson['items'], hasLength(2)); }); test('create share returns 400 for invalid resource type', () async { @@ -79,53 +129,55 @@ void main() { ); expect(response.statusCode, 400); - expect( - jsonDecode(await response.readAsString()), - {'error': 'resourceType must be program or workoutTemplate.'}, - ); + expect(jsonDecode(await response.readAsString()), { + 'error': 'resourceType must be program or workoutTemplate.', + }); }); - test('inbox returns serialized shares for the authenticated recipient', - () async { - final shares = _FakeShareRepository() - ..seedInbox( - recipientUserId: 'user-1', - items: [ - ShareInboxItem( - share: Share( - id: 'share-1', - senderUserId: 'sender-1', - resourceType: SyncedResourceType.program, - payloadJson: {'name': 'Programme A'}, - createdAt: DateTime.utc(2026, 7, 19, 10), + test( + 'inbox returns serialized shares for the authenticated recipient', + () async { + final shares = _FakeShareRepository() + ..seedInbox( + recipientUserId: 'user-1', + items: [ + ShareInboxItem( + share: Share( + id: 'share-1', + senderUserId: 'sender-1', + resourceType: SyncedResourceType.program, + payloadJson: {'name': 'Programme A'}, + createdAt: DateTime.utc(2026, 7, 19, 10), + ), + recipient: ShareRecipient( + id: 'recipient-1', + shareId: 'share-1', + recipientUserId: 'user-1', + ), ), - recipient: ShareRecipient( - id: 'recipient-1', - shareId: 'share-1', - recipientUserId: 'user-1', - ), - ), - ], + ], + ); + final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); + + final response = await handler( + Request( + 'GET', + Uri.parse('http://localhost/shares/inbox'), + headers: {'authorization': 'Bearer valid-token'}, + ), ); - final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); - final response = await handler( - Request( - 'GET', - Uri.parse('http://localhost/shares/inbox'), - headers: {'authorization': 'Bearer valid-token'}, - ), - ); + final body = jsonDecode(await response.readAsString()) as Map; + final items = body['items'] as List; - final body = jsonDecode(await response.readAsString()) as Map; - final items = body['items'] as List; - - expect(response.statusCode, 200); - expect(items, hasLength(1)); - expect((items.single as Map)['shareId'], 'share-1'); - expect((items.single as Map)['resourceType'], 'program'); - expect((items.single as Map)['status'], 'pending'); - }); + expect(response.statusCode, 200); + expect(items, hasLength(1)); + expect((items.single as Map)['shareId'], 'share-1'); + expect((items.single as Map)['shareKind'], 'single'); + expect((items.single as Map)['resourceType'], 'program'); + expect((items.single as Map)['status'], 'pending'); + }, + ); test('accept share returns the copied synced resource', () async { final shares = _FakeShareRepository() @@ -145,10 +197,7 @@ void main() { ), ], ); - final resources = _FakeSyncedResourceRepository(); - final handler = buildApiHandler( - shareApi: _shareApi(shares: shares, resources: resources), - ); + final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( @@ -164,9 +213,68 @@ void main() { expect(response.statusCode, 200); expect(created['resourceType'], 'workoutTemplate'); expect(created['payload'], {'schemaVersion': 3, 'name': 'Template A'}); - expect(shares.recipientById['recipient-1']?.status, - ShareRecipientStatus.accepted); - expect(resources.items.single.ownerUserId, 'user-1'); + expect(body['createdResources'], hasLength(1)); + expect( + shares.recipientById['recipient-1']?.status, + ShareRecipientStatus.accepted, + ); + expect(shares.acceptedResources.single.ownerUserId, 'user-1'); + }); + + test('accept pack share returns all copied synced resources', () async { + final shares = _FakeShareRepository() + ..seedShare( + share: Share( + id: 'share-1', + senderUserId: 'sender-1', + kind: ShareKind.pack, + resourceType: null, + packName: 'Pack été', + payloadJson: { + 'items': [ + { + 'resourceType': 'program', + 'payload': {'schemaVersion': 2, 'name': 'Programme'}, + }, + { + 'resourceType': 'workoutTemplate', + 'payload': {'schemaVersion': 3, 'name': 'Séance'}, + }, + ], + }, + createdAt: DateTime.utc(2026, 7, 19, 10), + ), + recipients: [ + ShareRecipient( + id: 'recipient-1', + shareId: 'share-1', + recipientUserId: 'user-1', + ), + ], + ); + final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); + + final response = await handler( + Request( + 'POST', + Uri.parse('http://localhost/shares/share-1/accept'), + headers: {'authorization': 'Bearer valid-token'}, + ), + ); + + final body = jsonDecode(await response.readAsString()) as Map; + final created = body['createdResources'] as List; + + expect(response.statusCode, 200); + expect(body.containsKey('createdResource'), isFalse); + expect(created.map((item) => (item as Map)['resourceType']), [ + 'program', + 'workoutTemplate', + ]); + expect( + shares.recipientById['recipient-1']?.status, + ShareRecipientStatus.accepted, + ); }); test('accept share returns 404 when share is missing', () async { @@ -181,10 +289,9 @@ void main() { ); expect(response.statusCode, 404); - expect( - jsonDecode(await response.readAsString()), - {'error': 'Share not found.'}, - ); + expect(jsonDecode(await response.readAsString()), { + 'error': 'Share not found.', + }); }); test('accept share returns 409 when share was already answered', () async { @@ -218,10 +325,9 @@ void main() { ); expect(response.statusCode, 409); - expect( - jsonDecode(await response.readAsString()), - {'error': 'Share has already been answered.'}, - ); + expect(jsonDecode(await response.readAsString()), { + 'error': 'Share has already been answered.', + }); }); test('decline share returns 204 and updates recipient status', () async { @@ -253,8 +359,10 @@ void main() { ); expect(response.statusCode, 204); - expect(shares.recipientById['recipient-1']?.status, - ShareRecipientStatus.declined); + expect( + shares.recipientById['recipient-1']?.status, + ShareRecipientStatus.declined, + ); }); test('decline share returns 409 when share was already answered', () async { @@ -288,10 +396,9 @@ void main() { ); expect(response.statusCode, 409); - expect( - jsonDecode(await response.readAsString()), - {'error': 'Share has already been answered.'}, - ); + expect(jsonDecode(await response.readAsString()), { + 'error': 'Share has already been answered.', + }); }); test('revoke share returns 204 and marks share as revoked', () async { @@ -343,23 +450,17 @@ void main() { ); expect(response.statusCode, 404); - expect( - jsonDecode(await response.readAsString()), - {'error': 'Share not found.'}, - ); + expect(jsonDecode(await response.readAsString()), { + 'error': 'Share not found.', + }); }); } -ShareApi _shareApi({ - _FakeUserRepository? users, - _FakeShareRepository? shares, - _FakeSyncedResourceRepository? resources, -}) { +ShareApi _shareApi({_FakeUserRepository? users, _FakeShareRepository? shares}) { final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12)); final ids = _FakeIds(); final userRepository = - users ?? - _FakeUserRepository([_user('user-1', 'user@example.com')]); + users ?? _FakeUserRepository([_user('user-1', 'user@example.com')]); final sessions = _FakeAuthSessionRepository( AuthSession( id: 'session-1', @@ -377,7 +478,6 @@ ShareApi _shareApi({ clock: clock, ); final shareRepository = shares ?? _FakeShareRepository(); - final resourceRepository = resources ?? _FakeSyncedResourceRepository(); return ShareApi( createShare: CreateShareUseCase( users: userRepository, @@ -388,7 +488,6 @@ ShareApi _shareApi({ listInbox: ListInboxUseCase(shares: shareRepository), acceptShare: AcceptShareUseCase( shares: shareRepository, - resources: resourceRepository, clock: clock, ids: ids, ), @@ -452,6 +551,7 @@ final class _FakeShareRepository implements ShareRepository { final recipientById = {}; final recipientByKey = {}; final inboxByRecipient = >{}; + final acceptedResources = []; void seedShare({ required Share share, @@ -533,6 +633,21 @@ final class _FakeShareRepository implements ShareRepository { } } + @override + Future> acceptShare({ + required String recipientId, + required DateTime respondedAt, + required List resources, + }) async { + acceptedResources.addAll(resources); + await updateRecipientStatus( + recipientId: recipientId, + status: ShareRecipientStatus.accepted, + respondedAt: respondedAt, + ); + return resources; + } + @override Future revokeShare({ required String shareId, @@ -545,7 +660,9 @@ final class _FakeShareRepository implements ShareRepository { final updated = Share( id: existing.id, senderUserId: existing.senderUserId, + kind: existing.kind, resourceType: existing.resourceType, + packName: existing.packName, payloadJson: existing.payloadJson, createdAt: existing.createdAt, revokedAt: revokedAt, @@ -554,30 +671,6 @@ final class _FakeShareRepository implements ShareRepository { } } -final class _FakeSyncedResourceRepository implements SyncedResourceRepository { - final items = []; - - @override - Future upsertWithLww(SyncedResource resource) async { - items.add(resource); - return SyncWriteResult( - status: SyncWriteStatus.accepted, - resource: resource, - ); - } - - @override - Future> findAllForUserSince({ - required String ownerUserId, - DateTime? since, - }) async { - return items - .where((item) => item.ownerUserId == ownerUserId) - .where((item) => since == null || item.serverUpdatedAt.isAfter(since)) - .toList(); - } -} - final class _FakeTokenService implements OpaqueTokenService { @override String generateToken() => 'valid-token'; diff --git a/server/test/share_use_cases_test.dart b/server/test/share_use_cases_test.dart index bdbaf1c..0abd8a7 100644 --- a/server/test/share_use_cases_test.dart +++ b/server/test/share_use_cases_test.dart @@ -48,6 +48,40 @@ void main() { ]); }); + test('create pack share stores a composite payload', () async { + final useCase = CreateShareUseCase( + users: users, + shares: shares, + clock: clock, + ids: ids, + ); + + final result = await useCase.execute( + senderUserId: 'sender', + shareKind: 'pack', + packName: 'Pack été', + resourceType: null, + payloadJson: null, + packItems: const [ + SharePackItemInput( + resourceType: 'program', + payloadJson: {'schemaVersion': 2, 'name': 'Programme'}, + ), + SharePackItemInput( + resourceType: 'workoutTemplate', + payloadJson: {'schemaVersion': 3, 'name': 'Séance'}, + ), + ], + recipientEmails: ['friend@example.com'], + ); + + expect(result.share.kind, ShareKind.pack); + expect(result.share.packName, 'Pack été'); + expect(result.share.resourceType, isNull); + expect(result.share.payloadJson['items'], hasLength(2)); + expect(shares.recipients.single.recipientUserId, 'recipient-1'); + }); + test( 'accept creates a copied synced resource without touching source', () async { @@ -75,7 +109,6 @@ void main() { resources.put(source); final useCase = AcceptShareUseCase( shares: shares, - resources: resources, clock: clock, ids: ids, ); @@ -97,6 +130,54 @@ void main() { }, ); + test('accept pack creates all copied resources atomically', () async { + final share = _share( + id: 'share-1', + kind: ShareKind.pack, + resourceType: null, + packName: 'Pack été', + payloadJson: { + 'items': [ + { + 'resourceType': 'program', + 'payload': {'schemaVersion': 2, 'name': 'Programme'}, + }, + { + 'resourceType': 'workoutTemplate', + 'payload': {'schemaVersion': 3, 'name': 'Séance'}, + }, + ], + }, + ); + shares.putShare(share); + shares.putRecipient( + ShareRecipient( + id: 'recipient-row-1', + shareId: 'share-1', + recipientUserId: 'recipient-1', + ), + ); + final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids); + + final result = await useCase.execute( + shareId: 'share-1', + recipientUserId: 'recipient-1', + ); + + expect(result.createdResources.map((item) => item.resourceType), [ + SyncedResourceType.program, + SyncedResourceType.workoutTemplate, + ]); + expect(result.createdResources.map((item) => item.ownerUserId).toSet(), { + 'recipient-1', + }); + expect(result.createdResources.map((item) => item.schemaVersion), [2, 3]); + expect( + shares.recipient('share-1', 'recipient-1')?.status, + ShareRecipientStatus.accepted, + ); + }); + test('accept on already answered share fails', () async { shares.putShare(_share(id: 'share-1')); shares.putRecipient( @@ -108,12 +189,7 @@ void main() { respondedAt: clock.now(), ), ); - final useCase = AcceptShareUseCase( - shares: shares, - resources: resources, - clock: clock, - ids: ids, - ); + final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids); expect( () => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'), @@ -130,12 +206,7 @@ void main() { recipientUserId: 'recipient-1', ), ); - final useCase = AcceptShareUseCase( - shares: shares, - resources: resources, - clock: clock, - ids: ids, - ); + final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids); expect( () => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'), @@ -224,7 +295,9 @@ UserAccount _user(String id, String email) { Share _share({ required String id, String senderUserId = 'sender', - SyncedResourceType resourceType = SyncedResourceType.program, + ShareKind kind = ShareKind.single, + SyncedResourceType? resourceType = SyncedResourceType.program, + String? packName, Map payloadJson = const {'schemaVersion': 1}, DateTime? createdAt, DateTime? revokedAt, @@ -232,7 +305,9 @@ Share _share({ return Share( id: id, senderUserId: senderUserId, + kind: kind, resourceType: resourceType, + packName: packName, payloadJson: payloadJson, createdAt: createdAt ?? DateTime.utc(2026, 7, 19, 12), revokedAt: revokedAt, @@ -376,6 +451,20 @@ final class _FakeShareRepository implements ShareRepository { ); } + @override + Future> acceptShare({ + required String recipientId, + required DateTime respondedAt, + required List resources, + }) async { + await updateRecipientStatus( + recipientId: recipientId, + status: ShareRecipientStatus.accepted, + respondedAt: respondedAt, + ); + return resources; + } + @override Future revokeShare({ required String shareId, @@ -386,7 +475,9 @@ final class _FakeShareRepository implements ShareRepository { shares[index] = Share( id: share.id, senderUserId: share.senderUserId, + kind: share.kind, resourceType: share.resourceType, + packName: share.packName, payloadJson: share.payloadJson, createdAt: share.createdAt, revokedAt: revokedAt, diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index de1c134..a23cf71 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; +import 'package:gametime/infrastructure/local/local.dart' as local; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { @@ -1192,6 +1194,51 @@ void main() { expect(repository.session?.endedAt, isNull); }); + test('close materializes session telemetry aggregate into history', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 28, 10)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + ); + final sessionRepository = _FakeActiveSessionRepository()..session = session; + final historyRepository = _FakeWorkoutHistoryRepository(); + final telemetryRepository = _FakeWorkoutTelemetryRepository() + ..aggregates.add( + WorkoutTelemetryAggregate( + sessionId: session.metadata.id, + scope: WorkoutTelemetryAggregateScope.session, + sampleCount: 3, + minHeartRateBpm: 118, + averageHeartRateBpm: 132.5, + maxHeartRateBpm: 151, + totalDistanceMeters: 720, + totalCaloriesKcal: 56, + ), + ); + final useCase = CloseWorkoutSessionUseCase( + sessionRepository: sessionRepository, + historyRepository: historyRepository, + telemetryRepository: telemetryRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final history = await useCase.close( + sessionId: session.metadata.id, + nameSnapshot: 'Séance', + completed: true, + ); + + expect(history.minHeartRateBpm, 118); + expect(history.averageHeartRateBpm, 132.5); + expect(history.maxHeartRateBpm, 151); + expect(history.totalDistanceMeters, 720); + expect(history.totalCaloriesKcal, 56); + expect(historyRepository.histories.single, history); + }); + test('adjustRestSeconds persists adjusted rest duration', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final repository = _FakeActiveSessionRepository() @@ -2522,6 +2569,31 @@ void main() { expect(pendingRepository.actions.single.payloadJson, contains('Programme')); }); + test('ShareUseCases sendShare supports workout packs', () async { + final remoteShareApi = _FakeRemoteShareApi(); + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add(_playableTemplate(id: 'template-1', name: 'Séance 1')) + ..templates.add(_playableTemplate(id: 'template-2', name: 'Séance 2')); + + final result = + await _shareUseCase( + remoteShareApi: remoteShareApi, + templateRepository: templateRepository, + ).sendShare( + resourceType: ShareResourceType.pack, + localResourceId: '', + localResourceIds: const ['template-1', 'template-2'], + packName: 'Pack reprise', + recipientEmails: const ['coach@example.com'], + ); + + expect(result.status, ShareSendStatus.sent); + expect(remoteShareApi.lastResourceType, ShareResourceType.pack); + expect(remoteShareApi.lastPayload?['name'], 'Pack reprise'); + expect(remoteShareApi.lastPayload?['workouts'], isA>()); + expect(remoteShareApi.lastPayload?['workouts'], hasLength(2)); + }); + test('ShareUseCases acceptShare imports returned resource locally', () async { final remoteShareApi = _FakeRemoteShareApi() ..acceptResult = _remoteSharedProgramItem(); @@ -2583,6 +2655,121 @@ void main() { }, ); + test( + 'ShareUseCases acceptShare sans token importe un pack complet', + () async { + final pendingRepository = _FakePendingShareActionRepository(); + 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( + tokenStore: _FakeAuthTokenStore(), + inboxRepository: inboxRepository, + pendingActionRepository: pendingRepository, + templateRepository: templateRepository, + ).acceptShare('share-1'); + + expect(templateRepository.templates, hasLength(2)); + expect( + templateRepository.templates.map((template) => template.name), + containsAll(['Séance A', 'Séance B']), + ); + expect( + templateRepository.templates.map((template) => template.metadata.id), + isNot(contains('template-a')), + ); + expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); + expect( + pendingRepository.actions.single.actionType, + PendingShareActionType.accept, + ); + }, + ); + + test( + 'ShareUseCases acceptShare sans token annule tout le pack si un import échoue', + () async { + final database = local.AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final inboxRepository = local.DriftShareInboxRepository(database); + final pendingRepository = local.DriftPendingShareActionRepository( + database, + ); + final templateRepository = local.DriftWorkoutTemplateRepository(database); + await inboxRepository.upsert( + _shareInboxItem( + shareId: 'share-pack-failing', + status: ShareInboxStatus.pending, + resourceType: ShareResourceType.pack, + payloadJson: jsonEncode({ + 'name': 'Pack incomplet', + 'workouts': [ + _sharedTemplatePayload(id: 'template-a', name: 'Séance A'), + { + 'id': 'template-b', + 'name': 'Séance B', + 'programs': [ + { + 'id': 'template-b-program-1', + 'position': 0, + 'programNameSnapshot': 'Programme A', + 'defaultRestSecondsSnapshot': 60, + 'programSnapshotJson': jsonEncode({'exercises': const []}), + }, + { + 'id': 'template-b-program-2', + 'position': 0, + 'programNameSnapshot': 'Programme B', + 'defaultRestSecondsSnapshot': 60, + 'programSnapshotJson': jsonEncode({'exercises': const []}), + }, + ], + 'overrides': const [], + }, + ], + }), + ), + ); + final useCase = ShareUseCases( + tokenStore: _FakeAuthTokenStore(), + remoteShareApi: _FakeRemoteShareApi(), + inboxRepository: inboxRepository, + pendingActionRepository: pendingRepository, + localChanges: _FakeLocalSyncChangeRepository(), + programRepository: local.DriftProgramRepository(database), + templateRepository: templateRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await expectLater( + useCase.acceptShare('share-pack-failing'), + throwsA(isA()), + ); + + expect(await templateRepository.listActive(), isEmpty); + expect( + (await inboxRepository.findByShareId('share-pack-failing'))!.status, + ShareInboxStatus.pending, + ); + expect(await pendingRepository.listPending(), isEmpty); + }, + ); + test('ShareUseCases refreshInbox updates the local cache', () async { final remoteShareApi = _FakeRemoteShareApi() ..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)]; @@ -3038,6 +3225,113 @@ void main() { }, ); + test( + 'WorkoutTelemetryUseCases persists samples and aggregates by scope', + () async { + final repository = _FakeWorkoutTelemetryRepository(); + final useCase = WorkoutTelemetryUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)), + ids: _FakeIds(), + ); + + final ignored = await useCase.recordTelemetrySample( + const WatchTelemetrySample( + sampleId: 'sample-empty', + sessionId: 'session-1', + capturedAtEpochMs: 1, + ), + ); + final first = await useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-1', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 28, + 10, + ).millisecondsSinceEpoch, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, + heartRateBpm: 120, + distanceMeters: 500, + caloriesKcal: 42, + ), + ); + final duplicate = await useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-1', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 28, + 10, + 1, + ).millisecondsSinceEpoch, + heartRateBpm: 160, + distanceMeters: 300, + caloriesKcal: 10, + ), + ); + final second = await useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-2', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 28, + 10, + 2, + ).millisecondsSinceEpoch, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, + heartRateBpm: 150, + distanceMeters: 620, + caloriesKcal: 48, + ), + ); + + expect(ignored, isEmpty); + expect(first.map((aggregate) => aggregate.scope), [ + WorkoutTelemetryAggregateScope.session, + WorkoutTelemetryAggregateScope.exercise, + WorkoutTelemetryAggregateScope.set, + WorkoutTelemetryAggregateScope.step, + ]); + expect(duplicate, isEmpty); + expect(repository.samples, hasLength(2)); + + final sessionAggregate = second.singleWhere( + (aggregate) => + aggregate.scope == WorkoutTelemetryAggregateScope.session, + ); + expect(sessionAggregate.sampleCount, 2); + expect(sessionAggregate.minHeartRateBpm, 120); + expect(sessionAggregate.averageHeartRateBpm, 135); + expect(sessionAggregate.maxHeartRateBpm, 150); + expect(sessionAggregate.totalDistanceMeters, 620); + expect(sessionAggregate.totalCaloriesKcal, 48); + expect( + second.any( + (aggregate) => + aggregate.scope == WorkoutTelemetryAggregateScope.step && + aggregate.programIndex == 0 && + aggregate.exerciseIndex == 0 && + aggregate.setIndex == 0 && + aggregate.stepIndex == 0, + ), + isTrue, + ); + }, + ); + test( 'WorkoutHistoryUseCases ignores insufficient heart rate summary', () async { @@ -4270,6 +4564,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi { var acceptCalls = 0; var declineCalls = 0; var revokeCalls = 0; + ShareResourceType? lastResourceType; + Map? lastPayload; @override Future acceptShare(String shareId, String token) async { @@ -4316,6 +4612,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi { required String token, }) async { sendCalls += 1; + lastResourceType = resourceType; + lastPayload = payload; final error = exception; if (error != null) { throw error; @@ -4419,13 +4717,14 @@ Program _shareableProgram() { ShareInboxItem _shareInboxItem({ String shareId = 'share-1', + ShareResourceType resourceType = ShareResourceType.program, required ShareInboxStatus status, String payloadJson = '{"name":"Programme partagé"}', }) { return ShareInboxItem( shareId: shareId, senderUserId: 'sender-1', - resourceType: ShareResourceType.program, + resourceType: resourceType, payloadJson: payloadJson, status: status, createdAt: DateTime.utc(2026, 7, 17, 12), @@ -4472,6 +4771,26 @@ Map _sharedProgramPayload() { }; } +Map _sharedTemplatePayload({ + required String id, + required String name, +}) { + return { + 'id': id, + 'name': name, + 'programs': [ + { + 'id': '$id-program', + 'position': 0, + 'programNameSnapshot': 'Programme', + 'defaultRestSecondsSnapshot': 60, + 'programSnapshotJson': jsonEncode({'exercises': const []}), + }, + ], + 'overrides': const [], + }; +} + RemoteSyncedItem _remoteSharedProgramItem() { return RemoteSyncedItem( resourceType: SyncResourceType.program, @@ -4767,6 +5086,13 @@ final class _FakeWorkoutTemplateRepository templates.add(template); } + @override + Future saveAll(List templates) async { + for (final template in templates) { + await save(template); + } + } + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -4851,8 +5177,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async { final index = histories.indexWhere( @@ -4868,8 +5197,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { final history = histories[index]; histories[index] = history.copyWith( metadata: history.metadata.touch(patchedAt), + minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, ); } @@ -4891,6 +5223,73 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { } } +final class _FakeWorkoutTelemetryRepository + implements WorkoutTelemetryRepository { + final samples = []; + final aggregates = []; + + @override + Future saveSample(WorkoutTelemetrySample sample) async { + if (samples.any((existing) => existing.id == sample.id)) { + return false; + } + samples.add(sample); + samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); + return true; + } + + @override + Future> listSamples(String sessionId) async { + return samples + .where((sample) => sample.sessionId == sessionId) + .toList(growable: false); + } + + @override + Future replaceAggregatesForSession({ + required String sessionId, + required List aggregates, + }) async { + this.aggregates.removeWhere( + (aggregate) => aggregate.sessionId == sessionId, + ); + this.aggregates.addAll(aggregates); + } + + @override + Future> listAggregates( + String sessionId, + ) async { + return aggregates + .where((aggregate) => aggregate.sessionId == sessionId) + .toList(growable: false); + } + + @override + Future findAggregate({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + return aggregates + .where( + (aggregate) => + aggregate.sessionId == sessionId && + aggregate.scope == scope && + aggregate.programIndex == programIndex && + aggregate.exerciseIndex == exerciseIndex && + aggregate.setIndex == setIndex && + aggregate.passageIndex == passageIndex && + aggregate.stepIndex == stepIndex, + ) + .firstOrNull; + } +} + ActiveWorkoutSessionUseCases _activeUseCase( _FakeActiveSessionRepository repository, _FakeClock clock, diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 1debd94..55f1118 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -764,6 +764,13 @@ final class _FakeWorkoutTemplateRepository templates.add(template); } + @override + Future saveAll(List templates) async { + for (final template in templates) { + await save(template); + } + } + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index 9dd13e7..a5830ff 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -40,7 +40,7 @@ void main() { expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); }); - test('projects running with duration timer before stopwatch score', () async { + test('projects running with step timer before stopwatch score', () async { final now = DateTime.utc(2026, 7, 25, 12); final session = _session( timeEnabled: true, @@ -75,7 +75,7 @@ void main() { now.millisecondsSinceEpoch, ); expect(projection.secondaryTimers.map((timer) => timer.kind), [ - WatchTimerKind.setTimer, + WatchTimerKind.scoreStopwatch, ]); expect(projection.primaryAction, WatchPrimaryAction.pauseSession); expect( @@ -85,7 +85,7 @@ void main() { }); test( - 'projects duration and stopwatch score with only duration timer visible', + 'projects duration and stopwatch score with stopwatch timer visible', () async { final now = DateTime.utc(2026, 7, 25, 12); final session = _session( @@ -108,7 +108,7 @@ void main() { final projection = await projector.project(revision: 1); expect(projection.phase, WatchSessionPhase.running); - expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer); + expect(projection.dominantTimer?.kind, WatchTimerKind.scoreStopwatch); expect(projection.secondaryTimers, isEmpty); expect(projection.primaryAction, WatchPrimaryAction.pauseSession); expect( @@ -118,6 +118,28 @@ void main() { }, ); + test( + 'keeps set timer out of display timers while preserving phase', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(timeEnabled: true, repsEnabled: false); + final repository = _FakeActiveSessionRepository() + ..session = session + ..setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + startedAt: now.subtract(const Duration(seconds: 6)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.running); + expect(projection.dominantTimer, isNull); + expect(projection.secondaryTimers, isEmpty); + expect(projection.primaryAction, WatchPrimaryAction.pauseSession); + }, + ); + test('projects manual score state for the current set', () async { final session = _session(scoreEnabled: true, targetScore: 8); final repository = _FakeActiveSessionRepository() diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 17b59f0..74b9695 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; @@ -17,6 +18,7 @@ void main() { late local.DriftProgressionStatsRepository progressionStatsRepository; late local.DriftLocalSyncChangeRepository syncChangeRepository; late local.DriftLocalDataBackupRepository localDataBackupRepository; + late local.DriftWorkoutTelemetryRepository telemetryRepository; late local.DriftExercisePerformanceReferenceRepository performanceReferenceRepository; @@ -32,6 +34,7 @@ void main() { ); syncChangeRepository = local.DriftLocalSyncChangeRepository(database); localDataBackupRepository = local.DriftLocalDataBackupRepository(database); + telemetryRepository = local.DriftWorkoutTelemetryRepository(database); performanceReferenceRepository = local.DriftExercisePerformanceReferenceRepository(database); }); @@ -121,9 +124,289 @@ void main() { expect(await columnNames('exercises'), contains('tags_json')); expect(await columnNames('programs'), contains('tags_json')); expect(await columnNames('workout_templates'), contains('tags_json')); - expect(database.schemaVersion, 19); + expect( + await columnNames('workout_history'), + contains('min_heart_rate_bpm'), + ); + expect( + await columnNames('workout_history'), + contains('total_distance_meters'), + ); + expect( + await columnNames('workout_history'), + contains('total_calories_kcal'), + ); + expect(await columnNames('workout_telemetry_samples'), contains('id')); + expect( + await columnNames('workout_telemetry_aggregates'), + contains('sample_count'), + ); + expect(database.schemaVersion, 24); }); + test( + 'telemetry repository persists samples and replaces aggregates', + () async { + final first = WorkoutTelemetrySample( + id: 'sample-1', + sessionId: 'session-1', + capturedAt: DateTime.utc(2026, 7, 28, 10), + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, + heartRateBpm: 120, + distanceMeters: 500, + caloriesKcal: 42, + ); + final second = WorkoutTelemetrySample( + id: 'sample-2', + sessionId: 'session-1', + capturedAt: DateTime.utc(2026, 7, 28, 10, 1), + heartRateBpm: 150, + distanceMeters: 620, + caloriesKcal: 48, + ); + + expect(await telemetryRepository.saveSample(first), isTrue); + expect(await telemetryRepository.saveSample(first), isFalse); + expect(await telemetryRepository.saveSample(second), isTrue); + + final samples = await telemetryRepository.listSamples('session-1'); + expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']); + + await telemetryRepository.replaceAggregatesForSession( + sessionId: 'session-1', + aggregates: [ + WorkoutTelemetryAggregate( + sessionId: 'session-1', + scope: WorkoutTelemetryAggregateScope.session, + sampleCount: 2, + minHeartRateBpm: 120, + averageHeartRateBpm: 135, + maxHeartRateBpm: 150, + totalDistanceMeters: 620, + totalCaloriesKcal: 48, + ), + ], + ); + + final aggregate = await telemetryRepository.findAggregate( + sessionId: 'session-1', + scope: WorkoutTelemetryAggregateScope.session, + ); + expect(aggregate?.sampleCount, 2); + expect(aggregate?.minHeartRateBpm, 120); + expect(aggregate?.averageHeartRateBpm, 135); + expect(aggregate?.maxHeartRateBpm, 150); + expect(aggregate?.totalDistanceMeters, 620); + expect(aggregate?.totalCaloriesKcal, 48); + + await telemetryRepository.replaceAggregatesForSession( + sessionId: 'session-1', + aggregates: const [], + ); + expect(await telemetryRepository.listAggregates('session-1'), isEmpty); + }, + ); + + test('share inbox and pending actions persist workout packs', () async { + final now = DateTime.utc(2026, 7, 28, 10); + final inboxRepository = local.DriftShareInboxRepository(database); + final pendingRepository = local.DriftPendingShareActionRepository(database); + + await inboxRepository.upsert( + ShareInboxItem( + shareId: 'share-pack-1', + senderUserId: 'sender-1', + resourceType: ShareResourceType.pack, + payloadJson: jsonEncode({ + 'kind': 'pack', + 'name': 'Pack reprise', + 'workouts': const [], + }), + status: ShareInboxStatus.pending, + createdAt: now, + ), + ); + await pendingRepository.add( + PendingShareAction( + id: 'pending-pack-1', + actionType: PendingShareActionType.send, + resourceType: ShareResourceType.pack, + payloadJson: jsonEncode({ + 'kind': 'pack', + 'name': 'Pack reprise', + 'workouts': const [], + }), + recipientEmailsJson: jsonEncode(['coach@example.com']), + createdAt: now, + ), + ); + + final inboxItem = await inboxRepository.findByShareId('share-pack-1'); + final pendingActions = await pendingRepository.listPending(); + + expect(inboxItem, isNotNull); + expect(inboxItem!.resourceType, ShareResourceType.pack); + expect(pendingActions.single.resourceType, ShareResourceType.pack); + }); + + test('migration 22 to 24 preserves share rows and accepts packs', () async { + final file = File( + '${Directory.systemTemp.path}/gametime_schema24_${DateTime.now().microsecondsSinceEpoch}.sqlite', + ); + addTearDown(() async { + if (await file.exists()) { + await file.delete(); + } + }); + final seedDatabase = local.AppDatabase(NativeDatabase(file)); + await seedDatabase.customSelect('SELECT 1').getSingle(); + await seedDatabase.customStatement('DROP TABLE share_inbox_items'); + await seedDatabase.customStatement('DROP TABLE pending_share_actions'); + await seedDatabase.customStatement(''' +CREATE TABLE share_inbox_items ( + share_id TEXT NOT NULL PRIMARY KEY, + sender_user_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK ( + resource_type IN ('program', 'workoutTemplate') + ), + payload_json TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('pending', 'accepted', 'declined', 'revoked') + ), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + responded_at INTEGER +) +'''); + await seedDatabase.customStatement(''' +CREATE TABLE pending_share_actions ( + id TEXT NOT NULL PRIMARY KEY, + action_type TEXT NOT NULL CHECK ( + action_type IN ('send', 'accept', 'decline', 'revoke') + ), + share_id TEXT, + resource_type TEXT CHECK ( + resource_type IS NULL OR + resource_type IN ('program', 'workoutTemplate') + ), + payload_json TEXT, + recipient_emails_json TEXT, + created_at INTEGER NOT NULL, + last_attempt_at INTEGER, + attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')) +) +'''); + await seedDatabase.customStatement( + 'INSERT INTO share_inbox_items VALUES ' + '''('share-program-1', 'sender-1', 'program', '{"name":"P"}', ''' + "'pending', 1785225600000, 1785225600000, NULL)", + ); + await seedDatabase.customStatement( + 'INSERT INTO pending_share_actions VALUES ' + '''('pending-program-1', 'send', NULL, 'program', '{"name":"P"}', ''' + ''''["coach@example.com"]', 1785225600000, NULL, 0, 'pending')''', + ); + await seedDatabase.customStatement('PRAGMA user_version = 22'); + await seedDatabase.close(); + + final migratedDatabase = local.AppDatabase(NativeDatabase(file)); + addTearDown(migratedDatabase.close); + + final inboxRepository = local.DriftShareInboxRepository(migratedDatabase); + final pendingRepository = local.DriftPendingShareActionRepository( + migratedDatabase, + ); + final now = DateTime.utc(2026, 7, 28, 11); + + await inboxRepository.upsert( + ShareInboxItem( + shareId: 'share-pack-1', + senderUserId: 'sender-2', + resourceType: ShareResourceType.pack, + payloadJson: jsonEncode({ + 'kind': 'pack', + 'name': 'Pack reprise', + 'workouts': const [], + }), + status: ShareInboxStatus.pending, + createdAt: now, + ), + ); + await pendingRepository.add( + PendingShareAction( + id: 'pending-pack-1', + actionType: PendingShareActionType.send, + resourceType: ShareResourceType.pack, + payloadJson: jsonEncode({ + 'kind': 'pack', + 'name': 'Pack reprise', + 'workouts': const [], + }), + recipientEmailsJson: jsonEncode(['coach@example.com']), + createdAt: now, + ), + ); + + final version = await migratedDatabase + .customSelect('PRAGMA user_version') + .getSingle(); + final inboxItems = await inboxRepository.listAll(); + final pendingActions = await pendingRepository.listPending(); + + expect(version.data['user_version'], 24); + expect(inboxItems.map((item) => item.shareId), contains('share-program-1')); + expect(inboxItems.map((item) => item.shareId), contains('share-pack-1')); + expect( + pendingActions.map((action) => action.id), + containsAll(['pending-program-1', 'pending-pack-1']), + ); + }); + + test( + 'workout template saveAll rolls back every template on failure', + () async { + final now = DateTime.utc(2026, 7, 28, 12); + final valid = WorkoutTemplate( + metadata: _metadata('template-valid', now), + name: 'Séance valide', + ); + final invalid = WorkoutTemplate( + metadata: _metadata('template-invalid', now), + name: 'Séance invalide', + programs: [ + WorkoutTemplateProgram( + metadata: _metadata('template-invalid-program-1', now), + workoutTemplateId: 'template-invalid', + position: 0, + programNameSnapshot: 'Programme A', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: jsonEncode({'exercises': const []}), + ), + WorkoutTemplateProgram( + metadata: _metadata('template-invalid-program-2', now), + workoutTemplateId: 'template-invalid', + position: 0, + programNameSnapshot: 'Programme B', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: jsonEncode({'exercises': const []}), + ), + ], + ); + + await expectLater( + templateRepository.saveAll([valid, invalid]), + throwsA(isA()), + ); + + expect(await templateRepository.findById(valid.metadata.id), isNull); + expect(await templateRepository.findById(invalid.metadata.id), isNull); + }, + ); + test('repositories save and load normalized tags', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( diff --git a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart index 822b803..11087a7 100644 --- a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart +++ b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart @@ -4,7 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/infrastructure/infrastructure.dart' - hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult; + hide + WorkoutHistory, + WorkoutHistorySetResult, + WorkoutHistoryStepResult, + WorkoutTelemetryAggregate, + WorkoutTelemetrySample; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { @@ -221,6 +226,48 @@ void main() { await adapter.stop(); await sensorUseCases.dispose(); }); + + test('persists telemetry samples from the native stream', () async { + final native = _FakeWatchBridgeNativeChannel(); + final source = _FakeProjectionSource(_runningProjection(revision: 1)); + final telemetryRepository = _FakeWorkoutTelemetryRepository(); + final adapter = _adapter( + native: native, + source: source, + telemetryUseCases: WorkoutTelemetryUseCases( + repository: telemetryRepository, + clock: _FakeClock(_now), + ids: _FakeIds(), + ), + ); + await adapter.start(); + + native.emitSensorSample( + WatchSensorSample( + sampleId: 'sample-1', + sessionId: 'session-1', + recordedAtEpochMs: _now.millisecondsSinceEpoch, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, + heartRateBpm: 120, + distanceMeters: 500, + caloriesKcal: 42, + ), + ); + await Future.delayed(Duration.zero); + + expect(telemetryRepository.samples.single.id, 'sample-1'); + final aggregate = telemetryRepository.aggregates.singleWhere( + (aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session, + ); + expect(aggregate.sampleCount, 1); + expect(aggregate.minHeartRateBpm, 120); + expect(aggregate.totalDistanceMeters, 500); + expect(aggregate.totalCaloriesKcal, 42); + await adapter.stop(); + }); } WatchWearDataLayerAdapter _adapter({ @@ -229,6 +276,7 @@ WatchWearDataLayerAdapter _adapter({ required _FakeProjectionSource source, WorkoutHistoryUseCases? historyUseCases, ActiveWorkoutSensorUseCases? sensorUseCases, + WorkoutTelemetryUseCases? telemetryUseCases, Duration heartbeatInterval = const Duration(seconds: 5), }) { return WatchWearDataLayerAdapter( @@ -237,6 +285,7 @@ WatchWearDataLayerAdapter _adapter({ projectionSource: source, workoutHistoryUseCases: historyUseCases, activeWorkoutSensorUseCases: sensorUseCases, + workoutTelemetryUseCases: telemetryUseCases, projectionRefreshInterval: heartbeatInterval, ); } @@ -312,6 +361,16 @@ final class _FakeClock implements Clock { DateTime now() => value; } +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'id-$_next'; + } +} + final class _FakeProjectionSource implements WatchProjectionSource { _FakeProjectionSource(this.current); @@ -466,8 +525,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async { final index = histories.indexWhere( @@ -497,6 +559,72 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { Future delete(String id, DateTime deletedAt) async {} } +final class _FakeWorkoutTelemetryRepository + implements WorkoutTelemetryRepository { + final samples = []; + final aggregates = []; + + @override + Future saveSample(WorkoutTelemetrySample sample) async { + if (samples.any((existing) => existing.id == sample.id)) { + return false; + } + samples.add(sample); + return true; + } + + @override + Future> listSamples(String sessionId) async { + return samples + .where((sample) => sample.sessionId == sessionId) + .toList(growable: false); + } + + @override + Future replaceAggregatesForSession({ + required String sessionId, + required List aggregates, + }) async { + this.aggregates.removeWhere( + (aggregate) => aggregate.sessionId == sessionId, + ); + this.aggregates.addAll(aggregates); + } + + @override + Future> listAggregates( + String sessionId, + ) async { + return aggregates + .where((aggregate) => aggregate.sessionId == sessionId) + .toList(growable: false); + } + + @override + Future findAggregate({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + return aggregates + .where( + (aggregate) => + aggregate.sessionId == sessionId && + aggregate.scope == scope && + aggregate.programIndex == programIndex && + aggregate.exerciseIndex == exerciseIndex && + aggregate.setIndex == setIndex && + aggregate.passageIndex == passageIndex && + aggregate.stepIndex == stepIndex, + ) + .firstOrNull; + } +} + final class _SentAck { const _SentAck(this.command, this.ack, this.revisionAtAck); diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 79e8f7b..1e0d25e 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -510,8 +510,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async {} @@ -539,6 +542,9 @@ final class _FakeWorkoutTemplateRepository @override Future save(WorkoutTemplate template) async {} + @override + Future saveAll(List templates) async {} + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 52b8382..599f82b 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -230,6 +230,11 @@ final class _FakeBootstrap implements AppDependencies { activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases( clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ), + workoutTelemetryUseCases = WorkoutTelemetryUseCases( + repository: _FakeWorkoutTelemetryRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + ), closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase( sessionRepository: activeRepository, historyRepository: _FakeWorkoutHistoryRepository(), @@ -319,6 +324,9 @@ final class _FakeBootstrap implements AppDependencies { @override final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases; + @override + final WorkoutTelemetryUseCases workoutTelemetryUseCases; + @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @@ -868,6 +876,9 @@ final class _FakeWorkoutTemplateRepository @override Future save(WorkoutTemplate template) async {} + @override + Future saveAll(List templates) async {} + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -891,8 +902,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async {} @@ -903,6 +917,43 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { Future saveStepResult(WorkoutHistoryStepResult result) async {} } +final class _FakeWorkoutTelemetryRepository + implements WorkoutTelemetryRepository { + @override + Future saveSample(WorkoutTelemetrySample sample) async => true; + + @override + Future> listSamples(String sessionId) async { + return const []; + } + + @override + Future replaceAggregatesForSession({ + required String sessionId, + required List aggregates, + }) async {} + + @override + Future> listAggregates( + String sessionId, + ) async { + return const []; + } + + @override + Future findAggregate({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + return null; + } +} + final class _FakeProgressionStatsRepository implements ProgressionStatsRepository { @override diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index bdaad08..4c6c42f 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -1019,6 +1019,9 @@ final class _FakeWorkoutTemplateRepository @override Future save(WorkoutTemplate template) async {} + @override + Future saveAll(List templates) async {} + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index e51e273..d5e784f 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -1095,6 +1095,13 @@ final class _FakeWorkoutTemplateRepository templates.add(template); } + @override + Future saveAll(List templates) async { + for (final template in templates) { + await save(template); + } + } + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} diff --git a/test/presentation/progression_screen_test.dart b/test/presentation/progression_screen_test.dart index 11d5ea0..8cd15dc 100644 --- a/test/presentation/progression_screen_test.dart +++ b/test/presentation/progression_screen_test.dart @@ -459,8 +459,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async {} @@ -488,6 +491,9 @@ final class _FakeWorkoutTemplateRepository @override Future save(WorkoutTemplate template) async {} + @override + Future saveAll(List templates) async {} + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} diff --git a/test/presentation/share_inbox_screen_test.dart b/test/presentation/share_inbox_screen_test.dart index bfbb3cc..3a9bcce 100644 --- a/test/presentation/share_inbox_screen_test.dart +++ b/test/presentation/share_inbox_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/share_inbox_screen.dart'; +import 'package:gametime/presentation/share_screen.dart'; void main() { testWidgets('la boîte de réception affiche les partages et les actions', ( @@ -71,6 +72,108 @@ void main() { ); }); + testWidgets('un pack reçu affiche un détail et importe toutes les séances', ( + tester, + ) async { + final remoteShareApi = _FakeRemoteShareApi() + ..inboxItems = [ + _shareItem( + shareId: 'share-pack', + resourceType: ShareResourceType.pack, + name: 'Pack reprise', + status: ShareInboxStatus.pending, + ), + ]; + final inboxRepository = _FakeShareInboxRepository(); + final templateRepository = _FakeWorkoutTemplateRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ShareInboxScreen( + shareUseCases: _shareUseCases( + remoteShareApi: remoteShareApi, + inboxRepository: inboxRepository, + templateRepository: templateRepository, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Pack reprise'), findsOneWidget); + expect(find.text('Pack · 2 séances'), findsOneWidget); + expect(find.text('Voir le pack'), findsOneWidget); + + await tester.tap(find.text('Voir le pack')); + await tester.pumpAndSettle(); + + expect(find.text('Pack reçu'), findsOneWidget); + expect(find.text('Séance tirs'), findsOneWidget); + expect(find.text('Séance jambes'), findsOneWidget); + + await tester.tap(find.text('Importer')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(templateRepository.saved, hasLength(2)); + expect( + templateRepository.saved.map((template) => template.name), + containsAll(['Séance tirs', 'Séance jambes']), + ); + expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); + expect(find.text('Pack importé.'), findsOneWidget); + }); + + testWidgets('composer un pack ouvre le formulaire de partage pack', ( + tester, + ) async { + final remoteShareApi = _FakeRemoteShareApi(); + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add(_workoutTemplate('template-1', 'Séance tirs')) + ..templates.add(_workoutTemplate('template-2', 'Séance jambes')); + + await tester.pumpWidget( + MaterialApp( + home: SharePackSelectionScreen( + shareUseCases: _shareUseCases( + remoteShareApi: remoteShareApi, + inboxRepository: _FakeShareInboxRepository(), + templateRepository: templateRepository, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.enterText( + find.widgetWithText(TextField, 'Nom du pack'), + 'Pack reprise', + ); + await tester.tap(find.text('Séance tirs')); + await tester.tap(find.text('Séance jambes')); + await tester.pump(); + await tester.tap(find.text('Continuer')); + await tester.pumpAndSettle(); + + expect(find.text('Partager le pack'), findsOneWidget); + expect(find.text('Pack reprise'), findsOneWidget); + expect(find.text('2 séances'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Emails destinataires'), + 'coach@example.com', + ); + await tester.tap(find.text('Envoyer le partage')); + await tester.pump(); + await tester.pump(); + + expect(remoteShareApi.lastResourceType, ShareResourceType.pack); + expect(remoteShareApi.lastPayload?['name'], 'Pack reprise'); + expect(remoteShareApi.lastPayload?['workouts'], hasLength(2)); + }); + testWidgets( 'accepter un partage en cache hors connexion importe une copie locale', (tester) async { @@ -129,6 +232,7 @@ ShareUseCases _shareUseCases({ required _FakeShareInboxRepository inboxRepository, _FakePendingShareActionRepository? pendingActionRepository, _FakeProgramRepository? programRepository, + _FakeWorkoutTemplateRepository? templateRepository, }) { return ShareUseCases( tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'), @@ -138,7 +242,7 @@ ShareUseCases _shareUseCases({ pendingActionRepository ?? _FakePendingShareActionRepository(), localChanges: _FakeLocalSyncChangeRepository(), programRepository: programRepository ?? _FakeProgramRepository(), - templateRepository: _FakeWorkoutTemplateRepository(), + templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', @@ -180,6 +284,13 @@ ShareInboxItem _shareItem({ }, ], }, + ShareResourceType.pack => { + 'name': name, + 'workouts': [ + _templatePayload('template-1', 'Séance tirs'), + _templatePayload('template-2', 'Séance jambes'), + ], + }, }; return ShareInboxItem( shareId: shareId, @@ -191,6 +302,23 @@ ShareInboxItem _shareItem({ ); } +Map _templatePayload(String id, String name) { + return { + 'id': id, + 'name': name, + 'programs': [ + { + 'id': '$id-program', + 'position': 0, + 'programNameSnapshot': 'Programme', + 'defaultRestSecondsSnapshot': 60, + 'programSnapshotJson': jsonEncode({'exercises': const []}), + }, + ], + 'overrides': const [], + }; +} + final class _FakeClock implements Clock { const _FakeClock(this.value); @@ -233,6 +361,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi { List inboxItems = const []; var acceptCalls = 0; var declineCalls = 0; + ShareResourceType? lastResourceType; + Map? lastPayload; @override Future acceptShare(String shareId, String token) async { @@ -271,6 +401,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi { required List recipientEmails, required String token, }) async { + lastResourceType = resourceType; + lastPayload = payload; return const RemoteShareSendResult( shareId: 'share-1', recipientUserIds: ['recipient-1'], @@ -388,11 +520,18 @@ final class _FakeProgramRepository implements ProgramRepository { final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { - @override - Future findById(String id) async => null; + final templates = []; + final saved = []; @override - Future> listActive() async => const []; + Future findById(String id) async { + return templates + .where((template) => template.metadata.id == id) + .firstOrNull; + } + + @override + Future> listActive() async => List.of(templates); @override Future replaceComposition( @@ -401,7 +540,16 @@ final class _FakeWorkoutTemplateRepository ) async {} @override - Future save(WorkoutTemplate template) async {} + Future save(WorkoutTemplate template) async { + saved.add(template); + } + + @override + Future saveAll(List templates) async { + for (final template in templates) { + await save(template); + } + } @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -409,3 +557,31 @@ final class _FakeWorkoutTemplateRepository @override Future saveProgram(WorkoutTemplateProgram program) async {} } + +WorkoutTemplate _workoutTemplate(String id, String name) { + return WorkoutTemplate( + metadata: EntityMetadata( + id: id, + createdAt: DateTime.utc(2026, 7, 17), + updatedAt: DateTime.utc(2026, 7, 17), + originDeviceId: 'device-1', + ), + name: name, + programs: [ + WorkoutTemplateProgram( + metadata: EntityMetadata( + id: '$id-program', + createdAt: DateTime.utc(2026, 7, 17), + updatedAt: DateTime.utc(2026, 7, 17), + originDeviceId: 'device-1', + ), + workoutTemplateId: id, + sourceProgramId: null, + position: 0, + programNameSnapshot: 'Programme', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: jsonEncode({'exercises': const []}), + ), + ], + ); +} diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 3c296db..4349256 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -895,6 +895,81 @@ void main() { expect(repository.restStates.single.skippedAt, isNotNull); }); + testWidgets('les mutations externes de repos resynchronisent l’écran', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 2, + restSeconds: 60, + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(find.text('Repos avant la prochaine série'), findsNothing); + repository.restStates.add( + ActiveRestState( + metadata: _metadata('rest-1'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: DateTime.now().toUtc(), + ), + ); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(find.text('Repos avant la prochaine série'), findsOneWidget); + + repository.restStates.clear(); + repository.session = ActiveWorkoutSession( + metadata: session.metadata, + sourceWorkoutTemplateId: session.sourceWorkoutTemplateId, + status: ActiveWorkoutStatus.running, + startedAt: session.startedAt, + lastPersistedAt: session.lastPersistedAt, + elapsedActiveMs: session.elapsedActiveMs, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 1, + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + ); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(find.text('Repos avant la prochaine série'), findsNothing); + expect(find.text('Terminer la série'), findsOneWidget); + }); + testWidgets('un repos en pause est repris sans compter la durée de pause', ( tester, ) async { @@ -1629,6 +1704,139 @@ void main() { expect(find.byType(SingleChildScrollView), findsWidgets); }); + testWidgets('répétitions et chrono score d’étape tiennent sans overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + targetReps: 12, + exerciseSteps: [ + _step( + 'step-1', + 0, + 'Pompes tempo', + ExerciseStepType.reps, + 12, + hasScore: true, + scoreInputMode: ScoreInputMode.stopwatch, + ), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('RÉPÉTITIONS'), findsOneWidget); + expect(find.text('CHRONO SCORE'), findsOneWidget); + expect(find.byTooltip('Lancer'), findsOneWidget); + expect(find.byTooltip('Réinitialiser'), findsOneWidget); + expect( + find.widgetWithText(OutlinedButton, 'Passer l’étape'), + findsOneWidget, + ); + expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsOneWidget); + expect(find.widgetWithText(TextButton, 'Réinitialiser'), findsNothing); + }); + + testWidgets('chrono décompte et score libre tiennent avec stepper compact', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + timeEnabled: false, + repsEnabled: false, + scoreEnabled: false, + exerciseSteps: [ + _step( + 'step-1', + 0, + 'Maintien défensif', + ExerciseStepType.time, + 10, + hasScore: true, + scoreInputMode: ScoreInputMode.manual, + scoreLabel: 'Paniers', + scoreUnit: 'points', + defaultTargetScore: 5, + ), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('CHRONO'), findsOneWidget); + expect(find.text('SCORE'), findsOneWidget); + expect(find.text('00:10'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect(find.byTooltip('Diminuer'), findsOneWidget); + expect(find.byTooltip('Augmenter'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Score de l’étape'), findsNothing); + expect( + find.widgetWithText(OutlinedButton, 'Passer l’étape'), + findsOneWidget, + ); + expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsNothing); + }); + testWidgets('l’écran actif avec séquence longue reste sans scroll', ( tester, ) async { @@ -1790,6 +1998,24 @@ void main() { expect(find.text('00:03'), findsOneWidget); expect(audio.shortBeeps, 1); + + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 3); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('00:02'), findsOneWidget); + expect(audio.shortBeeps, 2); + + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 4); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('00:01'), findsOneWidget); + expect(audio.shortBeeps, 3); + + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 5, 200); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(audio.longBeeps, 1); }); testWidgets( @@ -2727,6 +2953,9 @@ final class _FakeWorkoutTemplateRepository @override Future save(WorkoutTemplate template) async {} + @override + Future saveAll(List templates) async {} + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -2750,8 +2979,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async {} diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index b1bb9ee..2641a5e 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -1007,6 +1007,13 @@ final class _FakeWorkoutTemplateRepository templates.add(template); } + @override + Future saveAll(List templates) async { + for (final template in templates) { + await save(template); + } + } + @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -1207,8 +1214,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future patchHeartRateSummary({ required String historyId, + int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, required DateTime patchedAt, }) async {} diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt index 3577e63..fce62b4 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets class WatchBridgeListenerService : WearableListenerService() { override fun onDataChanged(dataEvents: DataEventBuffer) { + WatchBridgePlugin.attachApplicationContext(applicationContext) try { for (event in dataEvents) { WatchBridgePlugin.handleDataEvent(event) @@ -19,6 +20,7 @@ class WatchBridgeListenerService : WearableListenerService() { } override fun onMessageReceived(messageEvent: MessageEvent) { + WatchBridgePlugin.attachApplicationContext(applicationContext) if (messageEvent.path != WatchBridgePlugin.ACK_PATH) { return } @@ -27,6 +29,7 @@ class WatchBridgeListenerService : WearableListenerService() { } override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) { + WatchBridgePlugin.attachApplicationContext(applicationContext) if (capabilityInfo.name != WatchBridgePlugin.PHONE_CAPABILITY) { return } @@ -41,6 +44,7 @@ class WatchBridgeListenerService : WearableListenerService() { override fun onCreate() { super.onCreate() + WatchBridgePlugin.attachApplicationContext(applicationContext) WatchBridgePlugin.requestCapabilityRefresh(applicationContext) WatchBridgePlugin.requestLatestProjection(applicationContext) } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 68f1d36..6cb67ec 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -56,8 +56,12 @@ object WatchBridgePlugin { private var lastSensorPermissionRequestEpochMs = 0L private var pendingSensorPermissionRequest = false - fun register(flutterEngine: FlutterEngine, context: Context) { + fun attachApplicationContext(context: Context) { appContext = context.applicationContext + } + + fun register(flutterEngine: FlutterEngine, context: Context) { + attachApplicationContext(context) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) .setMethodCallHandler(::handleMethodCall) EventChannel(flutterEngine.dartExecutor.binaryMessenger, PROJECTION_CHANNEL) diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index a6cc1ef..fd0cca3 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -1,6 +1,8 @@ package com.gametime.watch.bridge +import android.annotation.SuppressLint import android.content.Context +import android.os.PowerManager import android.util.Log import androidx.health.services.client.HealthServices import androidx.health.services.client.MeasureClient @@ -37,6 +39,7 @@ internal class WatchHeartRateCollector( private val registeredDataTypes = mutableSetOf>() private var shouldAggregate = false private var appContext: Context? = null + private var wakeLock: PowerManager.WakeLock? = null private val callback = object : MeasureCallback { override fun onAvailabilityChanged( @@ -101,6 +104,7 @@ internal class WatchHeartRateCollector( return } appContext = context.applicationContext + acquireWakeLock(context) val measureClient = HealthServices.getClient(context).measureClient registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) @@ -217,6 +221,7 @@ internal class WatchHeartRateCollector( } registeredDataTypes.clear() appContext = null + releaseWakeLock() } private fun registerMeasureCallbackIfNeeded( @@ -235,6 +240,34 @@ internal class WatchHeartRateCollector( } } + @SuppressLint("WakelockTimeout") + private fun acquireWakeLock(context: Context) { + val existing = wakeLock + if (existing?.isHeld == true) { + return + } + val powerManager = context.applicationContext + .getSystemService(Context.POWER_SERVICE) as? PowerManager + ?: return + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "GameTime:HeartRateCollection", + ).apply { + setReferenceCounted(false) + acquire() + } + Log.d(TAG, "heart rate collection wake lock acquired sessionId=$sessionId") + } + + private fun releaseWakeLock() { + val lock = wakeLock + wakeLock = null + if (lock?.isHeld == true) { + lock.release() + Log.d(TAG, "heart rate collection wake lock released") + } + } + private fun reset(nextSessionId: String?) { sessionId = nextSessionId sampleCount = 0 diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index ae0621b..f0c3e26 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; import '../application/watch_session_view_model.dart'; @@ -22,6 +23,8 @@ final class _WatchSessionScreenState extends State { var _lastFailureNoticeSerial = 0; var _returnToSessionAfterSecondarySuccess = false; var _secondaryActionStartFailureSerial = 0; + final _timerRemainingMsByKey = {}; + final _completionHapticTimerKeys = {}; @override void initState() { @@ -50,6 +53,7 @@ final class _WatchSessionScreenState extends State { _syncFailureNotice(state); _syncSecondaryNavigation(state); final projection = state.projection; + _syncTimerCompletionHaptic(projection); if (projection.phase == WatchSessionPhase.noActiveSession) { final phoneReachable = projection.phoneReachable && !state.connectionLost; @@ -198,6 +202,26 @@ final class _WatchSessionScreenState extends State { }); } + void _syncTimerCompletionHaptic(WatchSessionProjection projection) { + final timer = _primaryDisplayTimer(projection); + if (timer == null || + timer.displayMode != WatchTimerDisplayMode.countdown || + timer.runState != WatchTimerRunState.running) { + return; + } + final key = _timerHapticKey(projection, timer); + final remainingMs = _displayDuration(timer).inMilliseconds; + final previousRemainingMs = _timerRemainingMsByKey[key]; + _timerRemainingMsByKey[key] = remainingMs; + if (remainingMs > 0 || + previousRemainingMs == null || + previousRemainingMs <= 0 || + !_completionHapticTimerKeys.add(key)) { + return; + } + unawaited(HapticFeedback.heavyImpact()); + } + Future _confirm({ required String title, required String message, @@ -392,8 +416,9 @@ final class _SessionMainView extends StatelessWidget { projection.phase == WatchSessionPhase.restPaused; final connectionLost = state.connectionLost || !projection.phoneReachable; final hasActions = projection.secondaryActions.isNotEmpty; - final timer = projection.dominantTimer; + final timer = _primaryDisplayTimer(projection); final showsTimer = timer != null; + final footerText = _sessionFooterText(state); final canToggleTimer = showsTimer && !connectionLost && @@ -485,24 +510,21 @@ final class _SessionMainView extends StatelessWidget { ], ), ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Text( - state.commandPending - ? _primaryLabel(state) - : state.staleProjection - ? 'Dernier état reçu' - : _exerciseProgress(projection), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontSize: 10), + if (footerText.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Text( + footerText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontSize: 10), + ), ), - ), ], ); } @@ -633,15 +655,23 @@ final class _MainHeartRateLine extends StatelessWidget { } return Padding( padding: const EdgeInsets.only(top: 2), - child: Text( - 'FC $label', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: const Color(0xFFA7ADBA), - fontSize: 11, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.favorite, size: 11, color: Color(0xFFFF4D5E)), + const SizedBox(width: 3), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFFA7ADBA), + fontSize: 11, + ), + ), + ], ), ); } @@ -691,7 +721,11 @@ final class _ActiveContent extends StatelessWidget { onDecrement: onDecrementScore, ); } - final timer = projection.dominantTimer; + final timer = _primaryDisplayTimer(projection); + final secondaryTimers = _visibleSecondaryTimers( + projection, + primaryTimer: timer, + ); final dominantValue = timer == null ? _seriesValue(projection) : _timerText(timer); @@ -705,23 +739,25 @@ final class _ActiveContent extends StatelessWidget { const SizedBox(height: 2), _SmallLabel(dominantLabel), const SizedBox(height: 2), - _DominantValue(dominantValue), - _MainHeartRateLine(sample: state.sensorSample), - if (timer != null) ...[ - const SizedBox(height: 3), - _TimerToggleButton( - runState: timer.runState, + if (timer == null) + _DominantValue(dominantValue) + else + _DominantTimerLine( + value: dominantValue, + timer: timer, pending: state.timerTogglePending, - onPressed: onTogglePause, + onTogglePause: onTogglePause, ), + _MainHeartRateLine(sample: state.sensorSample), + if (timer == null) ...[ + const SizedBox(height: 5), + _StatusLine(projection.statusLabel), ], - const SizedBox(height: 5), - _StatusLine(projection.statusLabel ?? timer?.label), - if (projection.secondaryTimers.isNotEmpty) + if (secondaryTimers.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( - projection.secondaryTimers.map(_compactTimerText).join(' · '), + secondaryTimers.map(_compactTimerText).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -734,6 +770,54 @@ final class _ActiveContent extends StatelessWidget { } } +final class _DominantTimerLine extends StatelessWidget { + const _DominantTimerLine({ + required this.value, + required this.timer, + required this.pending, + required this.onTogglePause, + this.compact = false, + }); + + final String value; + final WatchTimerProjection timer; + final bool pending; + final VoidCallback? onTogglePause; + final bool compact; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: compact ? 34 : 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: compact + ? Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontSize: 26, height: 1), + ) + : _DominantValue(value), + ), + SizedBox(width: compact ? 4 : 6), + _TimerToggleButton( + runState: timer.runState, + pending: pending, + onPressed: onTogglePause, + compact: compact, + ), + ], + ), + ); + } +} + final class _ManualScoreContent extends StatelessWidget { const _ManualScoreContent({ required this.state, @@ -759,7 +843,7 @@ final class _ManualScoreContent extends StatelessWidget { controlsEnabled && score > 0 && (state.scoreCommandPending || projection.canDecrementScore); - final timer = projection.dominantTimer; + final timer = _primaryDisplayTimer(projection); final canToggleTimer = timer != null && controlsEnabled && @@ -850,28 +934,35 @@ final class _CompactTimerLine extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 28, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + height: 45, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Flexible( - child: Text( - _compactTimerText(timer), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), + _SmallLabel(timer.label), + const SizedBox(height: 1), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + _timerText(timer), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontSize: 18, height: 1), + ), + ), + const SizedBox(width: 4), + _TimerToggleButton( + runState: timer.runState, + pending: pending, + onPressed: onTogglePause, + compact: true, + ), + ], ), - if (onTogglePause != null || pending) ...[ - const SizedBox(width: 4), - _TimerToggleButton( - runState: timer.runState, - pending: pending, - onPressed: onTogglePause, - compact: true, - ), - ], ], ), ); @@ -972,40 +1063,37 @@ final class _RestContent extends StatelessWidget { @override Widget build(BuildContext context) { final projection = state.projection; - final timer = projection.dominantTimer; + final timer = _primaryDisplayTimer(projection); return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const _SmallLabel('REPOS'), const SizedBox(height: 2), - _DominantValue(timer == null ? '--:--' : _timerText(timer)), - _MainHeartRateLine(sample: state.sensorSample), + _SmallLabel(_afterSeriesLabel(projection)), if (timer != null) ...[ - const SizedBox(height: 3), - _TimerToggleButton( - runState: timer.runState, + const SizedBox(height: 2), + _DominantTimerLine( + value: _timerText(timer), + timer: timer, pending: state.timerTogglePending, - onPressed: onTogglePause, + onTogglePause: onTogglePause, + compact: true, ), ], - const SizedBox(height: 6), + _MainHeartRateLine(sample: state.sensorSample), + const SizedBox(height: 3), if (projection.nextExerciseName case final next?) ...[ + const _SmallLabel('Ensuite'), Text( - 'Ensuite : $next', + next, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), - const SizedBox(height: 5), ] else ...[ _ExerciseName(projection.exerciseName), - const SizedBox(height: 5), ], - _StatusLine( - projection.statusLabel ?? - 'Série ${projection.seriesIndex}/${projection.seriesTotal}', - ), ], ); } @@ -1398,6 +1486,51 @@ String _exerciseProgress(WatchSessionProjection projection) { return projection.statusLabel ?? ''; } +String _sessionFooterText(WatchSessionUiState state) { + if (state.commandPending) { + return _primaryLabel(state); + } + if (state.staleProjection) { + return 'Dernier état reçu'; + } + final projection = state.projection; + if (projection.phase == WatchSessionPhase.restRunning || + projection.phase == WatchSessionPhase.restPaused) { + return ''; + } + return _exerciseProgress(projection); +} + +String _afterSeriesLabel(WatchSessionProjection projection) { + if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) { + return 'Après série'; + } + return 'Après série ${projection.seriesIndex}/${projection.seriesTotal}'; +} + +WatchTimerProjection? _primaryDisplayTimer(WatchSessionProjection projection) { + final dominantTimer = projection.dominantTimer; + if (dominantTimer != null && dominantTimer.kind != WatchTimerKind.setTimer) { + return dominantTimer; + } + for (final timer in _visibleSecondaryTimers(projection)) { + return timer; + } + return null; +} + +List _visibleSecondaryTimers( + WatchSessionProjection projection, { + WatchTimerProjection? primaryTimer, +}) { + return projection.secondaryTimers + .where( + (timer) => + timer.kind != WatchTimerKind.setTimer && timer != primaryTimer, + ) + .toList(growable: false); +} + String _timerText(WatchTimerProjection timer) { final duration = _displayDuration(timer); final totalSeconds = duration.inSeconds; @@ -1410,6 +1543,23 @@ String _compactTimerText(WatchTimerProjection timer) { return '${timer.label} ${_timerText(timer)}'; } +String _timerHapticKey( + WatchSessionProjection projection, + WatchTimerProjection timer, +) { + return [ + projection.deviceSessionId, + projection.seriesIndex, + projection.passageIndex ?? '-', + projection.stepIndex ?? '-', + timer.kind.name, + timer.label, + timer.referenceEpochMs, + timer.startedAtEpochMs ?? '-', + timer.targetMs ?? '-', + ].join(':'); +} + String? _heartRateLabel(WatchSensorSample? sample) { final bpm = sample?.heartRateBpm; if (bpm == null || bpm <= 0) { diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index e5b26d9..3530115 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime_watch/application/watch_session_view_model.dart'; import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart'; @@ -35,7 +36,10 @@ void main() { expect(find.text('Squat jump'), findsOneWidget); expect(find.text('02:14'), findsOneWidget); + expect(find.text('Chrono étape'), findsOneWidget); expect(find.byTooltip('Pause'), findsOneWidget); + expect(find.text('Séance active'), findsNothing); + expect(find.text('Série 00:45'), findsNothing); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); @@ -83,6 +87,39 @@ void main() { }, ); + testWidgets('hides set timer even when it is projected as dominant', ( + 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(_setTimerDominantProjection()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Chrono étape'), findsOneWidget); + expect(find.text('02:14'), findsOneWidget); + expect(find.text('Série'), findsNothing); + expect(find.text('00:45'), findsNothing); + expect(find.byTooltip('Pause'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + testWidgets('does not offer a start action from the no-session screen', ( tester, ) async { @@ -204,7 +241,8 @@ void main() { expect(find.text('3'), findsOneWidget); expect(find.text('Routine de tir'), findsOneWidget); - expect(find.text('Chrono étape 02:14'), findsOneWidget); + expect(find.text('Chrono étape'), findsOneWidget); + expect(find.text('02:14'), findsOneWidget); expect(find.byTooltip('Pause'), findsOneWidget); expect(tester.takeException(), isNull); @@ -250,7 +288,9 @@ void main() { ); await tester.pump(); - expect(find.text('FC 142 bpm'), findsOneWidget); + expect(find.text('FC 142 bpm'), findsNothing); + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.text('142 bpm'), findsOneWidget); expect(find.text('840 m'), findsNothing); expect(find.text('186 kcal'), findsNothing); expect(find.byTooltip('Stats'), findsOneWidget); @@ -478,6 +518,122 @@ void main() { viewModel.dispose(); }, ); + + testWidgets( + 'renders rest without overlapping series progress and next name', + (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(_restProjection()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('REPOS'), findsOneWidget); + expect(find.text('Après série 2/4'), findsOneWidget); + expect(find.text('Ensuite'), findsOneWidget); + expect(find.text('Gainage latéral'), findsOneWidget); + expect(find.text('Série 2/4'), findsNothing); + expect(find.textContaining('Ensuite :'), findsNothing); + expect(find.text('00:30'), findsOneWidget); + expect(find.byTooltip('Pause'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); + + testWidgets('hides timer controls when no active timer is projected', ( + 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(_manualScoreProjection()); + await tester.pump(); + + expect(find.text('Chrono étape'), findsNothing); + expect(find.text('02:14'), findsNothing); + expect(find.byTooltip('Pause'), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + + testWidgets('vibrates once when a countdown timer reaches zero', ( + tester, + ) async { + final hapticCalls = []; + 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(); + + expect(hapticCalls, hasLength(1)); + expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact'); + + client.emitProjection(_countdownProjection(accumulatedMs: 30000)); + await tester.pump(); + expect(hapticCalls, hasLength(1)); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); } final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { @@ -622,6 +778,31 @@ WatchSessionProjection _secondaryRestActionProjection() { ); } +WatchSessionProjection _setTimerDominantProjection() { + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: 6, + projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 2, + seriesTotal: 4, + exerciseName: 'Squat jump', + statusLabel: 'Séance active', + primaryAction: WatchPrimaryAction.pauseSession, + dominantTimer: const WatchTimerProjection( + kind: WatchTimerKind.setTimer, + label: 'Série', + displayMode: WatchTimerDisplayMode.elapsed, + runState: WatchTimerRunState.running, + referenceEpochMs: 0, + accumulatedMs: 45000, + ), + secondaryTimers: [_runningStepTimer()], + secondaryActions: const [WatchSecondaryAction.finishCurrentSet], + ); +} + void _expectActionsPageVisible(WidgetTester tester) { expect( tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx, @@ -681,6 +862,60 @@ WatchSessionProjection _manualScoreProjectionWithTimer() { ); } +WatchSessionProjection _restProjection() { + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: 5, + projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + phase: WatchSessionPhase.restRunning, + phoneReachable: true, + seriesIndex: 2, + seriesTotal: 4, + exerciseName: 'Squat jump', + nextExerciseName: 'Gainage latéral', + statusLabel: 'Repos', + primaryAction: WatchPrimaryAction.pauseSession, + dominantTimer: WatchTimerProjection( + kind: WatchTimerKind.rest, + label: 'Repos', + displayMode: WatchTimerDisplayMode.countdown, + runState: WatchTimerRunState.running, + referenceEpochMs: DateTime.now() + .toUtc() + .add(const Duration(minutes: 1)) + .millisecondsSinceEpoch, + accumulatedMs: 30000, + startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch, + targetMs: 60000, + ), + ); +} + +WatchSessionProjection _countdownProjection({required int accumulatedMs}) { + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: accumulatedMs, + projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 3, + exerciseName: 'Gainage', + statusLabel: 'Chrono étape', + primaryAction: WatchPrimaryAction.pauseSession, + dominantTimer: WatchTimerProjection( + kind: WatchTimerKind.step, + label: 'Chrono étape', + displayMode: WatchTimerDisplayMode.countdown, + runState: WatchTimerRunState.running, + referenceEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + accumulatedMs: accumulatedMs, + startedAtEpochMs: null, + targetMs: 30000, + ), + ); +} + WatchTimerProjection _runningStepTimer() { return WatchTimerProjection( kind: WatchTimerKind.step, From bc533d6c45ffa6e2d17fa56a3a2cf72bbc54ad74 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 17:59:43 +0200 Subject: [PATCH 2/5] =?UTF-8?q?chore(wip):=20lot=20#165-169=20valid=C3=A9?= =?UTF-8?q?=20QA=20+=20rework=20foreground=20#163=20(KO=20-=20preuve=20dev?= =?UTF-8?q?ice/toolchain=20insuffisante)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/136/carnet.md | 6 +- .ideai/tickets/136/issue.md | 8 +- .ideai/tickets/157/carnet.md | 6 +- .ideai/tickets/157/issue.md | 8 +- .ideai/tickets/160/carnet.md | 4 +- .ideai/tickets/160/issue.md | 6 +- .ideai/tickets/163/carnet.md | 4 +- .ideai/tickets/163/issue.md | 4 +- .ideai/tickets/165/carnet.md | 6 + .ideai/tickets/165/issue.md | 16 ++ .ideai/tickets/166/carnet.md | 67 ++++++ .ideai/tickets/166/issue.md | 16 ++ .ideai/tickets/167/carnet.md | 10 + .ideai/tickets/167/issue.md | 16 ++ .ideai/tickets/169/carnet.md | 10 + .ideai/tickets/169/issue.md | 16 ++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 62 +++++- lib/application/use_cases.dart | 29 ++- lib/presentation/history_screen.dart | 200 ++++++++++++++---- .../lib/src/watch_bridge_contract.dart | 15 ++ .../test/watch_bridge_contract_test.dart | 6 + .../watch_companion_command_handler_test.dart | 50 ++++- .../watch_companion_projection_test.dart | 55 ++++- test/presentation/history_screen_test.dart | 24 ++- .../android/app/src/main/AndroidManifest.xml | 7 +- .../watch/bridge/WatchBridgePlugin.kt | 15 +- .../watch/bridge/WatchHeartRateCollector.kt | 33 --- .../bridge/WatchHeartRateForegroundService.kt | 108 ++++++++++ .../application/watch_session_view_model.dart | 7 +- .../presentation/watch_session_screen.dart | 87 +++++++- .../watch_session_screen_test.dart | 118 +++++++++++ 32 files changed, 898 insertions(+), 123 deletions(-) create mode 100644 .ideai/tickets/165/carnet.md create mode 100644 .ideai/tickets/165/issue.md create mode 100644 .ideai/tickets/166/carnet.md create mode 100644 .ideai/tickets/166/issue.md create mode 100644 .ideai/tickets/167/carnet.md create mode 100644 .ideai/tickets/167/issue.md create mode 100644 .ideai/tickets/169/carnet.md create mode 100644 .ideai/tickets/169/issue.md create mode 100644 watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt diff --git a/.ideai/tickets/136/carnet.md b/.ideai/tickets/136/carnet.md index 35442f0..f0695b0 100644 --- a/.ideai/tickets/136/carnet.md +++ b/.ideai/tickets/136/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#136" -version: 8 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785244375293 +version: 9 +updatedBy: {"kind":"user"} +updatedAt: 1785252649210 --- ## #136 — Cadrage UX (2026-07-28) diff --git a/.ideai/tickets/136/issue.md b/.ideai/tickets/136/issue.md index 5c953e7..13e4cab 100644 --- a/.ideai/tickets/136/issue.md +++ b/.ideai/tickets/136/issue.md @@ -2,15 +2,15 @@ id: "cd125c5f-5e37-4100-8b61-58cbaab5eac1" number: 136 title: "[Bug] \"passer le repos\" sur la montre ne fonctionne pas" -status: "qa" +status: "closed" priority: "medium" sprint: "abc4f969-b169-45f7-988c-daeeab762201" links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785141207792 -updatedAt: 1785244375293 -version: 8 +updatedAt: 1785252649210 +version: 9 --- Le bouton "passer le repos" sur la montre ne fonctionne pas, il me renvoie sur l'écran de la séance sur la montre mais ne passe pas le repos sur le téléphone \ No newline at end of file diff --git a/.ideai/tickets/157/carnet.md b/.ideai/tickets/157/carnet.md index 145c07f..a42b19e 100644 --- a/.ideai/tickets/157/carnet.md +++ b/.ideai/tickets/157/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#157" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859176 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785252668623 --- diff --git a/.ideai/tickets/157/issue.md b/.ideai/tickets/157/issue.md index e5e6bec..a9b386f 100644 --- a/.ideai/tickets/157/issue.md +++ b/.ideai/tickets/157/issue.md @@ -2,15 +2,15 @@ id: "4ba5c5bc-c575-4afc-bb55-93feb7e7ad9c" number: 157 title: "[DevFrontend] Distance live montre sur séance en cours" -status: "qa" +status: "open" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785180656723 -updatedAt: 1785248859176 -version: 4 +updatedAt: 1785252668623 +version: 5 --- Ajouter la distance parcourue live remontée par la montre compatible, avec affichage léger côté téléphone pendant la séance et réutilisation côté montre dans le panneau secondaire. Dégradation silencieuse si donnée indisponible. \ No newline at end of file diff --git a/.ideai/tickets/160/carnet.md b/.ideai/tickets/160/carnet.md index 373f160..c567e9f 100644 --- a/.ideai/tickets/160/carnet.md +++ b/.ideai/tickets/160/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#160" -version: 4 +version: 5 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859257 +updatedAt: 1785254327417 --- diff --git a/.ideai/tickets/160/issue.md b/.ideai/tickets/160/issue.md index 6badb97..689ea0b 100644 --- a/.ideai/tickets/160/issue.md +++ b/.ideai/tickets/160/issue.md @@ -2,7 +2,7 @@ id: "b5b4fe80-952f-4376-9271-de98817add32" number: 160 title: "[DevFrontend] Graphiques historiques statistiques montre" -status: "inProgress" +status: "qa" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785180656778 -updatedAt: 1785248859257 -version: 4 +updatedAt: 1785254327417 +version: 5 --- Implémenter les graphiques et écrans d'historique pour les statistiques montre conservées : fréquence cardiaque avec détail par étape/série/exercice, distance parcourue, calories consommées. S'appuie sur la persistance historique et reste silencieux si certaines séries de données sont absentes. \ No newline at end of file diff --git a/.ideai/tickets/163/carnet.md b/.ideai/tickets/163/carnet.md index 1450a14..b5dfaa6 100644 --- a/.ideai/tickets/163/carnet.md +++ b/.ideai/tickets/163/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#163" -version: 5 +version: 6 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859271 +updatedAt: 1785254327435 --- diff --git a/.ideai/tickets/163/issue.md b/.ideai/tickets/163/issue.md index 9ba1ba8..93cb5f7 100644 --- a/.ideai/tickets/163/issue.md +++ b/.ideai/tickets/163/issue.md @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"} createdBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785242433447 -updatedAt: 1785248859271 -version: 5 +updatedAt: 1785254327435 +version: 6 --- Quand l'écran de la montre est éteint, la fréquence cardiaque n'est aps actualisée (l'affichage est assombris sur le téléphone et la fréquance ne change pas). Pour les statistiques il fauit que la fréquence cardiaque continue d'être enregistrée \ No newline at end of file diff --git a/.ideai/tickets/165/carnet.md b/.ideai/tickets/165/carnet.md new file mode 100644 index 0000000..84ad3ec --- /dev/null +++ b/.ideai/tickets/165/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#165" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785254327333 +--- diff --git a/.ideai/tickets/165/issue.md b/.ideai/tickets/165/issue.md new file mode 100644 index 0000000..a7ce2c2 --- /dev/null +++ b/.ideai/tickets/165/issue.md @@ -0,0 +1,16 @@ +--- +id: "35ff6501-b5fd-4dd3-8b88-dc20e3c6c07d" +number: 165 +title: "[Bug] impossible de lancer un chrono depuis la montre" +status: "qa" +priority: "high" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785250763662 +updatedAt: 1785254327333 +version: 5 +--- +J'ai encore des cas ou je ne peux pas lancer de chrono depuis al montre. Par exemple j'avais deux étapes qui s'enchainaient et qui avaient un chrono, quand j'ai fais passer l'étape depuis la montre, je ne pouvais pas lancer le chrono de l'étape suivante \ No newline at end of file diff --git a/.ideai/tickets/166/carnet.md b/.ideai/tickets/166/carnet.md new file mode 100644 index 0000000..ccf9f0f --- /dev/null +++ b/.ideai/tickets/166/carnet.md @@ -0,0 +1,67 @@ +--- +issueRef: "#166" +version: 8 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785254327361 +--- + +## #166/#167/#169 — Cadrage UX coordonné : slot "donnée dominante sans chrono" (UX, 2026-07-28) + +Statut : **cadrage exploitable, à traiter comme une seule mini-refonte**, pas trois patchs indépendants (voir point 4). Détail complet ici sur #166, #167 et #169 pointent vers ce carnet + leur delta propre. + +Base : écran `Séance active` déjà révisé par #161 (fermé) — un chrono/donnée dominante + un bouton d'action unique sur la même ligne, un seul libellé, FC en icône en pied d'écran. Ce lot traite les **cas où il n'y a pas de chrono actif**, laissés de côté par #161. + +### Constat commun +Quand il n'y a pas de chrono actif, l'écran retombe aujourd'hui sur des solutions par défaut peu utiles : le compteur `SÉRIE X/Y` se répète en donnée dominante alors qu'il est déjà affiché en en-tête (#167), aucune action rapide n'est proposée pour une étape à répétitions alors que c'est justement l'action la plus probable à cet instant (#166), et un sous-titre `Prêt pour la série suivante` occupe une ligne entière pour une information déjà déductible du reste de l'écran (#169). + +### 1. Comportement attendu par ticket + +**#166** : sur une étape à répétitions (sans chrono), un bouton de validation/passage à l'étape suivante est visible directement sur l'écran principal — pas besoin de swiper vers `Actions`. Ce bouton déclenche la même commande que `Étape suivante` (valide l'étape, avance), pas un `Passer l'étape` (qui reste une action de contournement, toujours dans `Actions`). + +**#167** : sur une étape à répétitions (sans chrono), la donnée dominante centrale devient le nombre de répétitions à faire pour cette étape, et non plus le nombre de séries de l'exercice (déjà visible en en-tête `SÉRIE X/Y`, donc redondant). + +**#169** : le sous-titre `Prêt pour la série suivante` est supprimé, sans remplacement — l'écran (nom d'exercice + bouton `Démarrer l'exercice`) reste compréhensible sans lui. + +### 2. Disposition recommandée — écran principal, cas étape reps-only + +Réutilise **exactement** le template ligne chrono+bouton posé par #161, appliqué à la donnée reps au lieu du chrono : + +```text +SÉRIE 2 / 5 +Pompes tempo +Passage 1 / 3 · Étape 2 / 4 + +Répétitions + 10 [✓] + +♥ 132 +``` + +- Légende `Répétitions` une seule fois, au-dessus de la valeur — même règle que le libellé de chrono (#161). +- Bouton icône `[✓]` (valider/étape suivante) à droite de la valeur, même ligne, même cible tactile ≥48dp que le bouton pause/play (#128/#161) — pas un nouveau composant, le même gabarit rejoue avec une icône différente. +- Un seul bouton d'action visible à la fois sur cette ligne : jamais `[✓]` et `[⏸]` en même temps. Si l'étape a aussi un chrono (score chrono d'étape, cas #140), ce cas reste géré par le layout dédié `_ManualScoreContent` existant, non touché par ce lot. +- Cœur FC inchangé, en pied d'écran (#161). + +### 3. Disposition recommandée — écran `Prêt pour la série suivante` + +```text +SÉRIE 3 / 5 +Pompes tempo + + +[Démarrer l'exercice] +``` + +Le sous-titre disparaît, l'espace libéré n'est **pas** réutilisé pour un nouvel élément (contrainte explicite : ne pas ajouter de bruit) — juste un espacement plus respirant entre le nom d'exercice et le bouton. + +### 4. Garde-fous de lisibilité / priorité visuelle +- Un seul élément "action" par écran, toujours à droite de la donnée dominante quand il y en a une (chrono → `[⏸]`, reps → `[✓]`) — jamais empilé en dessous, jamais dupliqué ailleurs. C'est la même règle que #161, appliquée maintenant au cas reps. +- Ne jamais afficher deux fois la même information sous deux formes (le principe qui motive #167) : si une donnée est déjà visible ailleurs sur l'écran (ex. `SÉRIE X/Y` en en-tête), elle n'a pas sa place une seconde fois dans le slot dominant. +- Suppression de texte (#169) : ne jamais compenser par un ajout ailleurs sauf besoin explicite ; le vide contrôlé est préférable au bruit, conformément à la contrainte utilisateur de ce lot. +- Icône `[✓]` distincte visuellement de `[⏸]`/`[▶]` pour éviter toute ambiguïté entre "valider l'étape" et "mettre en pause la séance" — même style (or sur fond `#141824`, anneau crimson au tap, cf. #128) mais pictogramme différent. + +### 5. Coordination des 3 tickets +**Oui, à traiter comme une seule mini-refonte cohérente**, pas trois patchs séparés : +- #166 et #167 modifient la **même zone d'écran** (slot donnée dominante + bouton d'action pour une étape reps-only) — les livrer séparément risquerait qu'un commit écrase la mise en page de l'autre, ou que le bouton `[✓]` soit ajouté sans que la redondance `SÉRIE X/Y` soit corrigée en même temps (ou l'inverse). +- #169 touche un état adjacent du même écran (`Séance active` sans chrono actif) et applique le même principe (pas de texte qui ne porte pas d'action ni de donnée nécessaire) — le traiter isolément ferait revivre exactement la dérive de densité déjà diagnostiquée et corrigée par #161 (accumulation de correctifs isolés qui perdent la cohérence d'ensemble). +- Recommandation : un seul lot Architect/DevFrontend pour les trois, avec cette section comme spec unique de référence. diff --git a/.ideai/tickets/166/issue.md b/.ideai/tickets/166/issue.md new file mode 100644 index 0000000..b803284 --- /dev/null +++ b/.ideai/tickets/166/issue.md @@ -0,0 +1,16 @@ +--- +id: "71f99d03-7ac8-435f-92ef-087e61c0f873" +number: 166 +title: "[UI] mettre un bouton sur l'écran principal de la montre pour passer à l'étape suivante dans le cas de répétitions dans une étape" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785250931982 +updatedAt: 1785254327361 +version: 8 +--- +Quand on a une étape qui ne demande qu'un nombre de répétitions, il faudrait que le bouton pour calider l'étape directement sur l'écran principal de la montre. \ No newline at end of file diff --git a/.ideai/tickets/167/carnet.md b/.ideai/tickets/167/carnet.md new file mode 100644 index 0000000..5c68560 --- /dev/null +++ b/.ideai/tickets/167/carnet.md @@ -0,0 +1,10 @@ +--- +issueRef: "#167" +version: 7 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785254327381 +--- + +## #167 — Cadrage UX (2026-07-28) + +Spec complète et coordination avec #166/#169 : voir carnet #166 (« Cadrage UX coordonné : slot "donnée dominante sans chrono" »). Ce ticket = le remplacement de la donnée dominante (série → répétitions de l'étape) dans le template partagé décrit là-bas. Ne pas traiter séparément de #166 : même zone d'écran, même livraison. diff --git a/.ideai/tickets/167/issue.md b/.ideai/tickets/167/issue.md new file mode 100644 index 0000000..4c5da59 --- /dev/null +++ b/.ideai/tickets/167/issue.md @@ -0,0 +1,16 @@ +--- +id: "a321767d-8f07-45fc-aff6-935540009aa5" +number: 167 +title: "[UI] Afficher le nombre de répétition à faire pour une étape sur la montre" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785251047775 +updatedAt: 1785254327381 +version: 7 +--- +Il est important que l'utilisateur sache ce qu'il a a faire directement depuis sa montre. Dans le cas d'une étape qui demande un nombre de répétitions, il faut que l'utilisateur ai le nombre de répétition à faire sur la montre. Actuellement, si l'étape n'a que des répétition, on affiche le nombre de série de l'exercice, je préfèrerais avoir le nombre de répétition à faire plutot à la place \ No newline at end of file diff --git a/.ideai/tickets/169/carnet.md b/.ideai/tickets/169/carnet.md new file mode 100644 index 0000000..a1a8786 --- /dev/null +++ b/.ideai/tickets/169/carnet.md @@ -0,0 +1,10 @@ +--- +issueRef: "#169" +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785254327400 +--- + +## #169 — Cadrage UX (2026-07-28) + +Spec complète et coordination avec #166/#167 : voir carnet #166 (« Cadrage UX coordonné : slot "donnée dominante sans chrono" »), section 3. Suppression du sous-titre `Prêt pour la série suivante`, sans remplacement — l'espace libéré n'est pas réutilisé (contrainte explicite : pas d'ajout de bruit visuel). Peut être livré avec #166/#167 dans le même lot pour cohérence, mais son changement est indépendant techniquement (écran différent : `Prêt pour la série suivante`, pas l'écran étape reps-only). diff --git a/.ideai/tickets/169/issue.md b/.ideai/tickets/169/issue.md new file mode 100644 index 0000000..fc3d8d0 --- /dev/null +++ b/.ideai/tickets/169/issue.md @@ -0,0 +1,16 @@ +--- +id: "7e5b1ba4-5f5c-46d1-b05a-6d6f5af73f23" +number: 169 +title: "[UI] Enlever \"Prêt pour la série suivante\" sur la montre" +status: "qa" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785251491623 +updatedAt: 1785254327400 +version: 6 +--- +Je pense que le titre "Prêt pour la série suivante" est a enlever de la montre, il prend de la place pour pas grand chose \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 7cf83b1..43d2fbe 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 165 + "nextNumber": 170 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 95c12b7..a08f811 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1461,11 +1461,11 @@ "issueRef": "#136", "path": "136", "title": "[Bug] \"passer le repos\" sur la montre ne fonctionne pas", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], - "updatedAt": 1785244375293 + "updatedAt": 1785252649210 }, { "issueRef": "#137", @@ -1693,11 +1693,11 @@ "issueRef": "#157", "path": "157", "title": "[DevFrontend] Distance live montre sur séance en cours", - "status": "qa", + "status": "open", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], - "updatedAt": 1785248859176 + "updatedAt": 1785252668623 }, { "issueRef": "#158", @@ -1723,11 +1723,11 @@ "issueRef": "#160", "path": "160", "title": "[DevFrontend] Graphiques historiques statistiques montre", - "status": "inProgress", + "status": "qa", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], - "updatedAt": 1785248859257 + "updatedAt": 1785254327417 }, { "issueRef": "#161", @@ -1763,7 +1763,7 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1785248859271 + "updatedAt": 1785254327435 }, { "issueRef": "#164", @@ -1774,6 +1774,54 @@ "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], "updatedAt": 1785244375351 + }, + { + "issueRef": "#165", + "path": "165", + "title": "[Bug] impossible de lancer un chrono depuis la montre", + "status": "qa", + "priority": "high", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785254327333 + }, + { + "issueRef": "#166", + "path": "166", + "title": "[UI] mettre un bouton sur l'écran principal de la montre pour passer à l'étape suivante dans le cas de répétitions dans une étape", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785254327361 + }, + { + "issueRef": "#167", + "path": "167", + "title": "[UI] Afficher le nombre de répétition à faire pour une étape sur la montre", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785254327381 + }, + { + "issueRef": "#169", + "path": "169", + "title": "[UI] Enlever \"Prêt pour la série suivante\" sur la montre", + "status": "qa", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785254327400 } ] } \ No newline at end of file diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 140d61f..bac91f3 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3670,6 +3670,9 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { projection.primaryAction == WatchPrimaryAction.resumeSession, WatchCommandType.startPreparedTimedStep => projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, + WatchCommandType.completeCurrentStep => + projection.stepType == WatchStepType.reps && + projection.stepTargetValue != null, WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( WatchSecondaryAction.skipCurrentStep, ), @@ -3712,6 +3715,7 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep( session, ), + WatchCommandType.completeCurrentStep => _completeCurrentStep(session), WatchCommandType.skipCurrentStep => _skipCurrentStep(session), WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session), WatchCommandType.finishCurrentSet => _finishCurrentSet( @@ -3776,6 +3780,18 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { return WatchCommandAck.accepted; } + Future _completeCurrentStep( + ActiveWorkoutSession session, + ) async { + await _stepUseCases.completeCurrentStep( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + Future _skipCurrentPassage( ActiveWorkoutSession session, ) async { @@ -4151,6 +4167,12 @@ final class WatchSessionProjectionProjector { : (stepState?.currentStepIndex ?? 0) + 1, stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length, stepName: currentStep?.name, + stepType: switch (currentStep?.type) { + ExerciseStepType.time => WatchStepType.time, + ExerciseStepType.reps => WatchStepType.reps, + null => null, + }, + stepTargetValue: currentStep?.defaultTargetValue, dominantTimer: dominantTimer, secondaryTimers: secondaryTimers, primaryAction: _primaryAction(phase), @@ -4389,7 +4411,7 @@ List _secondaryActions({ ]; } -String _statusLabel( +String? _statusLabel( WatchSessionPhase phase, WatchTimerProjection? dominantTimer, ) { @@ -4401,7 +4423,7 @@ String _statusLabel( WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt', WatchSessionPhase.restRunning => 'Repos en cours', WatchSessionPhase.restPaused => 'Repos en pause', - WatchSessionPhase.betweenSetsReady => 'Prêt pour la série suivante', + WatchSessionPhase.betweenSetsReady => null, }; } @@ -4502,8 +4524,7 @@ bool _isNextTimerReady( if (state == null || currentStep == null || state.status != ActiveExerciseStepProgressStatus.stoppedTimer || - currentStep.type != ExerciseStepType.time || - snapshot.autoStartNextTimedStepEffective) { + currentStep.type != ExerciseStepType.time) { return false; } final previous = _previousStep(snapshot, state); diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index b085e0e..e6b630d 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -162,13 +162,9 @@ final class HistoryDetailScreen extends StatelessWidget { Text(_formatDateTime(history.startedAt)), const SizedBox(height: 4), Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'), - if (history.averageHeartRateBpm != null && - history.maxHeartRateBpm != null) ...[ + if (_hasWatchStats(history)) ...[ const SizedBox(height: 16), - _HistoryHeartRateSummary( - averageHeartRateBpm: history.averageHeartRateBpm!, - maxHeartRateBpm: history.maxHeartRateBpm!, - ), + _HistoryWatchStatsSummary(history: history), ], const SizedBox(height: 16), for (final program in detail.programs) ...[ @@ -315,52 +311,163 @@ final class HistoryDetailScreen extends StatelessWidget { } } -final class _HistoryHeartRateSummary extends StatelessWidget { - const _HistoryHeartRateSummary({ - required this.averageHeartRateBpm, - required this.maxHeartRateBpm, - }); +final class _HistoryWatchStatsSummary extends StatelessWidget { + const _HistoryWatchStatsSummary({required this.history}); - final double averageHeartRateBpm; - final int maxHeartRateBpm; + final WorkoutHistory history; @override Widget build(BuildContext context) { + final minHeartRate = history.minHeartRateBpm; + final averageHeartRate = history.averageHeartRateBpm; + final maxHeartRate = history.maxHeartRateBpm; + final distance = history.totalDistanceMeters; + final calories = history.totalCaloriesKcal; return CourtBlazerAccentPanel( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Fréquence cardiaque', - style: Theme.of(context).textTheme.titleSmall, - ), + Text('Stats montre', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _HistoryHeartRateMetric( - label: 'Moyenne', - value: '${averageHeartRateBpm.round()}', - ), - ), - const SizedBox(width: 12), - Expanded( - child: _HistoryHeartRateMetric( - label: 'Max', - value: '$maxHeartRateBpm', - ), + if (minHeartRate != null || + averageHeartRate != null || + maxHeartRate != null) ...[ + Text('Fréquence cardiaque'), + const SizedBox(height: 8), + Row( + children: [ + if (minHeartRate != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Min', + value: '$minHeartRate bpm', + ), + ), + if (minHeartRate != null && averageHeartRate != null) + const SizedBox(width: 8), + if (averageHeartRate != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Moyenne', + value: '${averageHeartRate.round()} bpm', + ), + ), + if ((minHeartRate != null || averageHeartRate != null) && + maxHeartRate != null) + const SizedBox(width: 8), + if (maxHeartRate != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Max', + value: '$maxHeartRate bpm', + ), + ), + ], + ), + if (averageHeartRate != null && maxHeartRate != null) ...[ + const SizedBox(height: 10), + _HistoryWatchStatsBars( + minHeartRateBpm: minHeartRate, + averageHeartRateBpm: averageHeartRate, + maxHeartRateBpm: maxHeartRate, ), ], - ), + ], + if (distance != null || calories != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + if (distance != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Distance', + value: _formatHistoryDistance(distance), + ), + ), + if (distance != null && calories != null) + const SizedBox(width: 8), + if (calories != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Calories', + value: '${calories.round()} kcal', + ), + ), + ], + ), + ], ], ), ); } } -final class _HistoryHeartRateMetric extends StatelessWidget { - const _HistoryHeartRateMetric({required this.label, required this.value}); +final class _HistoryWatchStatsBars extends StatelessWidget { + const _HistoryWatchStatsBars({ + required this.minHeartRateBpm, + required this.averageHeartRateBpm, + required this.maxHeartRateBpm, + }); + + final int? minHeartRateBpm; + final double averageHeartRateBpm; + final int maxHeartRateBpm; + + @override + Widget build(BuildContext context) { + final max = maxHeartRateBpm.toDouble().clamp(1, double.infinity); + final values = [ + if (minHeartRateBpm != null) ('Min', minHeartRateBpm!.toDouble()), + ('Moy', averageHeartRateBpm), + ('Max', maxHeartRateBpm.toDouble()), + ]; + return Semantics( + label: 'Graphique stats montre', + key: const ValueKey('history-watch-stats-graph'), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final item in values) ...[ + Expanded( + child: Column( + children: [ + SizedBox( + height: 52, + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: (item.$2 / max).clamp(0.08, 1), + widthFactor: 0.62, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFC9A24A), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + item.$1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + if (item != values.last) const SizedBox(width: 8), + ], + ], + ), + ); + } +} + +final class _HistoryWatchMetric extends StatelessWidget { + const _HistoryWatchMetric({required this.label, required this.value}); final String label; final String value; @@ -373,14 +480,8 @@ final class _HistoryHeartRateMetric extends StatelessWidget { Text(label, style: Theme.of(context).textTheme.labelMedium), RichText( text: TextSpan( - style: AppTextStyles.scoreNumber(context), - children: [ - TextSpan(text: value), - TextSpan( - text: ' bpm', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + style: AppTextStyles.scoreNumber(context).copyWith(fontSize: 24), + children: [TextSpan(text: value)], ), ), ], @@ -388,6 +489,21 @@ final class _HistoryHeartRateMetric extends StatelessWidget { } } +bool _hasWatchStats(WorkoutHistory history) { + return history.minHeartRateBpm != null || + history.averageHeartRateBpm != null || + history.maxHeartRateBpm != null || + history.totalDistanceMeters != null || + history.totalCaloriesKcal != null; +} + +String _formatHistoryDistance(double meters) { + if (meters >= 1000) { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + return '${meters.round()} m'; +} + final class HistoryDetailData { const HistoryDetailData({required this.programs}); diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index fa31db9..41507a5 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -5,6 +5,7 @@ enum WatchCommandType { pauseSession, resumeSession, startPreparedTimedStep, + completeCurrentStep, skipCurrentStep, skipCurrentPassage, finishCurrentSet, @@ -60,6 +61,8 @@ enum WatchTimerRunState { stopped, running, paused } enum WatchManualScoreScope { series, step } +enum WatchStepType { time, reps } + final class WatchCommandEnvelope { const WatchCommandEnvelope({ this.schemaVersion = watchBridgeSchemaVersion, @@ -150,6 +153,8 @@ final class WatchSessionProjection { this.stepIndex, this.stepTotal, this.stepName, + this.stepType, + this.stepTargetValue, this.dominantTimer, this.secondaryTimers = const [], required this.primaryAction, @@ -190,6 +195,8 @@ final class WatchSessionProjection { stepIndex: _nullableIntFromJson(json['stepIndex']), stepTotal: _nullableIntFromJson(json['stepTotal']), stepName: _nullableStringFromJson(json['stepName']), + stepType: _nullableEnumFromJson(json['stepType'], WatchStepType.values), + stepTargetValue: _nullableIntFromJson(json['stepTargetValue']), dominantTimer: _timerFromJson(json['dominantTimer']), secondaryTimers: _timerListFromJson(json['secondaryTimers']), primaryAction: _enumFromJson( @@ -238,6 +245,8 @@ final class WatchSessionProjection { final int? stepIndex; final int? stepTotal; final String? stepName; + final WatchStepType? stepType; + final int? stepTargetValue; final WatchTimerProjection? dominantTimer; final List secondaryTimers; final WatchPrimaryAction primaryAction; @@ -270,6 +279,8 @@ final class WatchSessionProjection { 'stepIndex': stepIndex, 'stepTotal': stepTotal, 'stepName': stepName, + 'stepType': stepType?.name, + 'stepTargetValue': stepTargetValue, 'dominantTimer': dominantTimer?.toJson(), 'secondaryTimers': secondaryTimers .map((timer) => timer.toJson()) @@ -310,6 +321,8 @@ final class WatchSessionProjection { stepIndex == other.stepIndex && stepTotal == other.stepTotal && stepName == other.stepName && + stepType == other.stepType && + stepTargetValue == other.stepTargetValue && dominantTimer == other.dominantTimer && _listEquals(secondaryTimers, other.secondaryTimers) && primaryAction == other.primaryAction && @@ -344,6 +357,8 @@ final class WatchSessionProjection { stepIndex, stepTotal, stepName, + stepType, + stepTargetValue, dominantTimer, Object.hashAll(secondaryTimers), primaryAction, diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index 0cf6976..90e6a7e 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -161,6 +161,8 @@ void main() { stepIndex: 2, stepTotal: 4, stepName: 'Descente', + stepType: WatchStepType.reps, + stepTargetValue: 12, dominantTimer: _stepTimer(), secondaryTimers: [_scoreStopwatchTimer(), _setTimer()], primaryAction: primaryAction, @@ -203,6 +205,8 @@ void main() { expect(projection.phase, WatchSessionPhase.restPaused); expect(projection.passageIndex, isNull); expect(projection.stepIndex, isNull); + expect(projection.stepType, isNull); + expect(projection.stepTargetValue, isNull); expect(projection.dominantTimer, isNull); expect(projection.secondaryTimers, isEmpty); expect(projection.primaryAction, WatchPrimaryAction.resumeSession); @@ -233,6 +237,8 @@ void main() { expect(projection.exerciseName, ''); expect(projection.primaryAction, WatchPrimaryAction.none); expect(projection.secondaryActions, isEmpty); + expect(projection.stepType, isNull); + expect(projection.stepTargetValue, isNull); expect(projection.hasManualScore, isFalse); expect(projection.currentManualScoreValue, isNull); expect(projection.canDecrementScore, isFalse); diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 55f1118..8f76417 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -98,6 +98,44 @@ void main() { expect(env.repository.stepResults, hasLength(1)); }); + test('routes completeCurrentStep to completed reps step', () async { + final session = _session( + steps: [_step(type: ExerciseStepType.reps, defaultTargetValue: 12)], + ); + final env = _env( + session: session, + projection: _projection( + stepType: WatchStepType.reps, + stepTargetValue: 12, + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.completeCurrentStep); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.stepResults.single.status, SetResultStatus.completed); + expect(env.repository.stepResults.single.actualReps, 12); + }); + + test( + 'rejects completeCurrentStep when current projection is not reps', + () async { + final session = _session(steps: [_step()]); + final env = _env(session: session, projection: _projection()); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.completeCurrentStep); + + expect(ack, WatchCommandAck.rejectedNotApplicable); + expect(env.repository.stepResults, isEmpty); + }, + ); + test('routes skipCurrentPassage to step use case', () async { final session = _session( targetReps: 2, @@ -542,6 +580,8 @@ WatchSessionProjection _projection({ double? currentManualScoreValue, bool canDecrementScore = false, WatchManualScoreScope? manualScoreScope, + WatchStepType? stepType, + int? stepTargetValue, }) { return WatchSessionProjection( deviceSessionId: deviceSessionId, @@ -557,6 +597,8 @@ WatchSessionProjection _projection({ hasManualScore: hasManualScore, currentManualScoreValue: currentManualScoreValue, canDecrementScore: canDecrementScore, + stepType: stepType, + stepTargetValue: stepTargetValue, manualScoreScope: manualScoreScope ?? (hasManualScore ? WatchManualScoreScope.series : null), @@ -617,6 +659,8 @@ ActiveWorkoutSession _session({ ExerciseStep _step({ String id = 'step-1', int position = 0, + ExerciseStepType type = ExerciseStepType.time, + int defaultTargetValue = 1, bool hasScore = false, String? scoreLabel, String? scoreUnit, @@ -626,8 +670,8 @@ ExerciseStep _step({ id: id, position: position, name: 'Step ${position + 1}', - type: ExerciseStepType.time, - defaultTargetValue: 1, + type: type, + defaultTargetValue: defaultTargetValue, hasScore: hasScore, scoreLabel: scoreLabel, scoreUnit: scoreUnit, @@ -701,6 +745,8 @@ final class _FakeProjectionSource implements WatchProjectionSource { seriesIndex: projection.seriesIndex, seriesTotal: projection.seriesTotal, exerciseName: projection.exerciseName, + stepType: projection.stepType, + stepTargetValue: projection.stepTargetValue, primaryAction: projection.primaryAction, secondaryActions: projection.secondaryActions, hasManualScore: projection.hasManualScore, diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index a5830ff..5e8276e 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -40,6 +40,20 @@ void main() { expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); }); + test('projects reps step target for direct watch validation', () async { + final repository = _FakeActiveSessionRepository() + ..session = _session( + steps: [_step(type: ExerciseStepType.reps, defaultTargetValue: 12)], + ); + final projector = _projector(repository, _clock()); + + final projection = await projector.project(revision: 1); + + expect(projection.stepType, WatchStepType.reps); + expect(projection.stepTargetValue, 12); + expect(projection.dominantTimer, isNull); + }); + test('projects running with step timer before stopwatch score', () async { final now = DateTime.utc(2026, 7, 25, 12); final session = _session( @@ -299,6 +313,42 @@ void main() { }, ); + test( + 'projects nextTimerReady for a stopped timed step after watch skip with chaining enabled', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + autoStartNextTimedStepSnapshot: true, + steps: [ + _step(id: 'step-1'), + _step(id: 'step-2', position: 1), + ], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + stepId: 'step-2', + stepIndex: 1, + status: ActiveExerciseStepProgressStatus.stoppedTimer, + lastTransitionAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 4); + + expect(projection.phase, WatchSessionPhase.nextTimerReady); + expect(projection.stepIndex, 2); + expect(projection.stepName, 'Step 2'); + expect(projection.statusLabel, 'Chrono suivant prêt'); + expect( + projection.primaryAction, + WatchPrimaryAction.startPreparedTimedStep, + ); + expect(projection.dominantTimer?.runState, WatchTimerRunState.stopped); + }, + ); + test('projects restRunning after finishing a set with rest', () async { final now = DateTime.utc(2026, 7, 25, 12); final session = _session(currentSetIndex: 1); @@ -406,7 +456,7 @@ void main() { final projection = await projector.project(revision: 6); expect(projection.phase, WatchSessionPhase.betweenSetsReady); - expect(projection.statusLabel, 'Prêt pour la série suivante'); + expect(projection.statusLabel, isNull); expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); }, ); @@ -594,6 +644,7 @@ ActiveWorkoutSession _session({ ExerciseStep _step({ String id = 'step-1', int position = 0, + ExerciseStepType type = ExerciseStepType.time, int defaultTargetValue = 1, bool hasScore = false, String? scoreLabel, @@ -604,7 +655,7 @@ ExerciseStep _step({ id: id, position: position, name: 'Step ${position + 1}', - type: ExerciseStepType.time, + type: type, defaultTargetValue: defaultTargetValue, hasScore: hasScore, scoreLabel: scoreLabel, diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 1e0d25e..83a18b6 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -71,7 +71,7 @@ void main() { ); }); - testWidgets('le détail affiche la fréquence cardiaque quand elle existe', ( + testWidgets('le détail affiche les stats montre quand elles existent', ( tester, ) async { await tester.pumpWidget( @@ -79,8 +79,11 @@ void main() { home: HistoryDetailScreen( history: _history( id: 'history-1', + minHeartRateBpm: 88, averageHeartRateBpm: 126.4, maxHeartRateBpm: 171, + totalDistanceMeters: 1234, + totalCaloriesKcal: 83, ), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), @@ -90,11 +93,22 @@ void main() { ), ); + expect(find.text('Stats montre'), findsOneWidget); expect(find.text('Fréquence cardiaque'), findsOneWidget); + expect(find.text('Min'), findsAtLeastNWidgets(1)); expect(find.text('Moyenne'), findsOneWidget); - expect(find.text('Max'), findsOneWidget); + expect(find.text('Max'), findsAtLeastNWidgets(1)); + expect(find.text('Distance'), findsOneWidget); + expect(find.text('Calories'), findsOneWidget); + expect( + find.byKey(const ValueKey('history-watch-stats-graph')), + findsOneWidget, + ); + expect(find.text('88 bpm', findRichText: true), findsOneWidget); expect(find.text('126 bpm', findRichText: true), findsOneWidget); expect(find.text('171 bpm', findRichText: true), findsOneWidget); + expect(find.text('1.23 km', findRichText: true), findsOneWidget); + expect(find.text('83 kcal', findRichText: true), findsOneWidget); }); testWidgets('le détail affiche le score chrono comme temps réalisé', ( @@ -256,8 +270,11 @@ WorkoutHistory _history({ bool stopwatchScore = false, bool withStepResults = false, bool emptySnapshot = false, + int? minHeartRateBpm, double? averageHeartRateBpm, int? maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, }) { final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); return WorkoutHistory( @@ -268,8 +285,11 @@ WorkoutHistory _history({ startedAt: start, endedAt: start.add(const Duration(minutes: 30)), totalActiveMs: 1800000, + minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, completed: true, historySnapshotJson: jsonEncode({ 'sessionId': 'session-1', diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 466df41..095d192 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ android:name="android.hardware.type.watch" android:required="true" /> - + + + diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 6cb67ec..310b20d 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -55,6 +55,7 @@ object WatchBridgePlugin { private var sensorPermissionRequestInFlight = false private var lastSensorPermissionRequestEpochMs = 0L private var pendingSensorPermissionRequest = false + private var lastSensorProjection: Map? = null fun attachApplicationContext(context: Context) { appContext = context.applicationContext @@ -142,7 +143,13 @@ object WatchBridgePlugin { if (granted) { pendingSensorPermissionRequest = false Log.d(TAG, "sensor permissions granted") - appContext?.let { heartRateCollector.onBodySensorsGranted(it) } + appContext?.let { context -> + val projection = lastSensorProjection + if (projection != null) { + WatchHeartRateForegroundService.start(context, projection) + } + heartRateCollector.onBodySensorsGranted(context) + } } else { val denied = permissions.filterIndexed { index, _ -> grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED @@ -315,11 +322,15 @@ object WatchBridgePlugin { val phase = projection["phase"] as? String ?: "noActiveSession" val sessionId = projection["deviceSessionId"] as? String ?: "" if (phase == "noActiveSession" || sessionId.isBlank()) { + lastSensorProjection = null + WatchHeartRateForegroundService.stop(context) heartRateCollector.finishCurrentSession(context) return } + lastSensorProjection = projection val shouldAggregate = phase == "running" if (!hasRequiredSensorPermissions(context)) { + WatchHeartRateForegroundService.stop(context) heartRateCollector.noteActiveSession( sessionId, shouldAggregate = false, @@ -336,8 +347,10 @@ object WatchBridgePlugin { executionContext = telemetryContext(projection), ) if (shouldAggregate) { + WatchHeartRateForegroundService.start(context, projection) heartRateCollector.start(context) } else { + WatchHeartRateForegroundService.stop(context) heartRateCollector.pause(context) } } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index fd0cca3..a6cc1ef 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -1,8 +1,6 @@ package com.gametime.watch.bridge -import android.annotation.SuppressLint import android.content.Context -import android.os.PowerManager import android.util.Log import androidx.health.services.client.HealthServices import androidx.health.services.client.MeasureClient @@ -39,7 +37,6 @@ internal class WatchHeartRateCollector( private val registeredDataTypes = mutableSetOf>() private var shouldAggregate = false private var appContext: Context? = null - private var wakeLock: PowerManager.WakeLock? = null private val callback = object : MeasureCallback { override fun onAvailabilityChanged( @@ -104,7 +101,6 @@ internal class WatchHeartRateCollector( return } appContext = context.applicationContext - acquireWakeLock(context) val measureClient = HealthServices.getClient(context).measureClient registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) @@ -221,7 +217,6 @@ internal class WatchHeartRateCollector( } registeredDataTypes.clear() appContext = null - releaseWakeLock() } private fun registerMeasureCallbackIfNeeded( @@ -240,34 +235,6 @@ internal class WatchHeartRateCollector( } } - @SuppressLint("WakelockTimeout") - private fun acquireWakeLock(context: Context) { - val existing = wakeLock - if (existing?.isHeld == true) { - return - } - val powerManager = context.applicationContext - .getSystemService(Context.POWER_SERVICE) as? PowerManager - ?: return - wakeLock = powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "GameTime:HeartRateCollection", - ).apply { - setReferenceCounted(false) - acquire() - } - Log.d(TAG, "heart rate collection wake lock acquired sessionId=$sessionId") - } - - private fun releaseWakeLock() { - val lock = wakeLock - wakeLock = null - if (lock?.isHeld == true) { - lock.release() - Log.d(TAG, "heart rate collection wake lock released") - } - } - private fun reset(nextSessionId: String?) { sessionId = nextSessionId sampleCount = 0 diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt new file mode 100644 index 0000000..37ebc9c --- /dev/null +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt @@ -0,0 +1,108 @@ +package com.gametime.watch.bridge + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.gametime.watch.MainActivity +import com.gametime.watch.R + +internal class WatchHeartRateForegroundService : Service() { + companion object { + const val CHANNEL_ID = "gametime_watch_heart_rate" + const val CHANNEL_NAME = "Collecte cardio GameTime" + const val NOTIFICATION_ID = 9102 + const val EXTRA_EXERCISE_NAME = "exerciseName" + + fun start(context: Context, projection: Map) { + val sessionId = projection["deviceSessionId"] as? String ?: "" + val phase = projection["phase"] as? String ?: "noActiveSession" + if (sessionId.isBlank() || phase != "running") { + stop(context) + return + } + val exerciseName = (projection["exerciseName"] as? String) + ?.takeIf { it.isNotBlank() } + ?: "Séance en cours" + ContextCompat.startForegroundService( + context, + Intent(context, WatchHeartRateForegroundService::class.java) + .putExtra(EXTRA_EXERCISE_NAME, exerciseName), + ) + } + + fun stop(context: Context) { + context.stopService(Intent(context, WatchHeartRateForegroundService::class.java)) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + WatchBridgePlugin.attachApplicationContext(applicationContext) + val exerciseName = intent + ?.getStringExtra(EXTRA_EXERCISE_NAME) + ?.takeIf { it.isNotBlank() } + ?: "Séance en cours" + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + buildNotification(exerciseName), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH + } else { + 0 + }, + ) + return START_STICKY + } + + private fun buildNotification(exerciseName: String): Notification { + ensureNotificationChannel() + val touchIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_ongoing_gt) + .setContentTitle("GameTime") + .setContentText(exerciseName) + .setContentIntent(touchIntent) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .build() + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) { + return + } + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + ), + ) + } +} diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index b2ccb57..1606514 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -187,6 +187,10 @@ final class WatchSessionViewModel extends ValueNotifier { return _sendScoreCommand(WatchCommandType.decrementScore, -1); } + Future completeCurrentStep() { + return _sendCommand(WatchCommandType.completeCurrentStep); + } + @override void dispose() { _waitingTimer?.cancel(); @@ -288,8 +292,7 @@ final class WatchSessionViewModel extends ValueNotifier { value = value.copyWith(scoreWaitingForPhone: true); }); _scoreCommandTimeoutTimer = Timer(_commandTimeout, () { - _clearScorePending(recalibrate: true); - unawaited(HapticFeedback.heavyImpact()); + value = value.copyWith(scoreWaitingForPhone: true); unawaited(_nativeClient.requestResync()); }); try { diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index f0c3e26..f86a053 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -77,6 +77,7 @@ final class _WatchSessionScreenState extends State { onTogglePause: widget.viewModel.sendPrimaryAction, onIncrementScore: widget.viewModel.incrementScore, onDecrementScore: widget.viewModel.decrementScore, + onCompleteStep: widget.viewModel.completeCurrentStep, ), ), _RoundScaffold( @@ -399,6 +400,7 @@ final class _SessionMainView extends StatelessWidget { required this.onTogglePause, required this.onIncrementScore, required this.onDecrementScore, + required this.onCompleteStep, }); final WatchSessionUiState state; @@ -407,6 +409,7 @@ final class _SessionMainView extends StatelessWidget { final VoidCallback onTogglePause; final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; + final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { @@ -456,6 +459,7 @@ final class _SessionMainView extends StatelessWidget { onTogglePause: canToggleTimer ? onTogglePause : null, onIncrementScore: onIncrementScore, onDecrementScore: onDecrementScore, + onCompleteStep: onCompleteStep, ), ), ), @@ -703,12 +707,14 @@ final class _ActiveContent extends StatelessWidget { required this.onTogglePause, required this.onIncrementScore, required this.onDecrementScore, + required this.onCompleteStep, }); final WatchSessionUiState state; final VoidCallback? onTogglePause; final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; + final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { @@ -726,10 +732,15 @@ final class _ActiveContent extends StatelessWidget { projection, primaryTimer: timer, ); + final repsTarget = _repsStepTarget(projection); final dominantValue = timer == null ? _seriesValue(projection) : _timerText(timer); final dominantLabel = timer == null ? 'SÉRIE' : timer.label; + final controlsEnabled = + !state.connectionLost && + projection.phoneReachable && + !state.commandPending; return _ScaledContent( child: Column( mainAxisSize: MainAxisSize.min, @@ -737,9 +748,15 @@ final class _ActiveContent extends StatelessWidget { _ExerciseName(projection.exerciseName), _StepNameBand(projection.stepName), const SizedBox(height: 2), - _SmallLabel(dominantLabel), + _SmallLabel(repsTarget == null ? dominantLabel : 'Répétitions'), const SizedBox(height: 2), - if (timer == null) + if (repsTarget != null) + _DominantRepsLine( + value: repsTarget.toString(), + pending: state.commandPending, + onComplete: controlsEnabled ? onCompleteStep : null, + ) + else if (timer == null) _DominantValue(dominantValue) else _DominantTimerLine( @@ -770,6 +787,62 @@ final class _ActiveContent extends StatelessWidget { } } +final class _DominantRepsLine extends StatelessWidget { + const _DominantRepsLine({ + required this.value, + required this.pending, + required this.onComplete, + }); + + final String value; + final bool pending; + final VoidCallback? onComplete; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 52, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: _DominantValue(value)), + const SizedBox(width: 6), + _CompleteStepButton(pending: pending, onPressed: onComplete), + ], + ), + ); + } +} + +final class _CompleteStepButton extends StatelessWidget { + const _CompleteStepButton({required this.pending, required this.onPressed}); + + final bool pending; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 48, + child: IconButton( + onPressed: onPressed, + tooltip: 'Valider l’étape', + visualDensity: VisualDensity.compact, + iconSize: 24, + color: const Color(0xFFC9A24A), + disabledColor: const Color(0xFF414754), + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF141824), + side: const BorderSide(color: Color(0xFFD72638)), + ), + icon: pending + ? const _PendingDot(key: ValueKey('complete-step-pending-dot')) + : const Icon(Icons.check), + ), + ); + } +} + final class _DominantTimerLine extends StatelessWidget { const _DominantTimerLine({ required this.value, @@ -901,7 +974,7 @@ final class _ManualScoreContent extends StatelessWidget { SizedBox( height: 6, child: state.scoreWaitingForPhone - ? const _PendingDot() + ? const _PendingDot(key: ValueKey('score-pending-dot')) : const SizedBox.shrink(), ), _MainHeartRateLine(sample: state.sensorSample), @@ -1465,6 +1538,14 @@ String _seriesValue(WatchSessionProjection projection) { return '${projection.seriesIndex}/${projection.seriesTotal}'; } +int? _repsStepTarget(WatchSessionProjection projection) { + final target = projection.stepTargetValue; + if (projection.stepType != WatchStepType.reps || target == null) { + return null; + } + return target > 0 ? target : null; +} + String _scoreText(double value) { if (value == value.roundToDouble()) { return value.toInt().toString(); diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 3530115..81e0a3c 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -585,6 +585,104 @@ void main() { viewModel.dispose(); }); + testWidgets('shows reps target and direct completion on a reps-only step', ( + 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(_repsStepProjection()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Répétitions'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + expect(find.text('SÉRIE'), findsNothing); + expect(find.text('Prêt pour la série suivante'), findsNothing); + expect(find.byTooltip('Valider l’étape'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.byTooltip('Valider l’étape')); + await tester.pump(); + + expect( + client.sentCommands.single.type, + WatchCommandType.completeCurrentStep, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + + testWidgets( + 'keeps optimistic score visible after timeout until explicit rejection', + (tester) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel( + nativeClient: client, + waitingThreshold: const Duration(milliseconds: 50), + commandTimeout: const Duration(milliseconds: 120), + ); + + 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(_manualScoreProjection()); + await tester.pump(); + + await tester.tap(find.byTooltip('Ajouter')); + await tester.pump(); + + final commandId = client.sentCommands.single.commandId; + expect(find.text('4'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.text('4'), findsOneWidget); + expect(find.byKey(const ValueKey('score-pending-dot')), findsOneWidget); + expect(client.resyncRequests, greaterThanOrEqualTo(2)); + + client.emitProjection(_manualScoreProjection()); + await tester.pump(); + expect(find.text('4'), findsOneWidget); + + client.emitAck( + WatchCommandAckEvent( + commandId: commandId, + status: WatchCommandAck.rejectedNotApplicable, + sessionId: 'session-1', + ), + ); + await tester.pump(); + + expect(find.text('3'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); + testWidgets('vibrates once when a countdown timer reaches zero', ( tester, ) async { @@ -762,6 +860,26 @@ WatchSessionProjection _readyProjection() { ); } +WatchSessionProjection _repsStepProjection() { + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: 7, + projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 3, + exerciseName: 'Pompes', + stepIndex: 1, + stepTotal: 2, + stepName: 'Pompes strictes', + stepType: WatchStepType.reps, + stepTargetValue: 12, + primaryAction: WatchPrimaryAction.pauseSession, + secondaryActions: const [WatchSecondaryAction.skipCurrentStep], + ); +} + WatchSessionProjection _secondaryRestActionProjection() { return WatchSessionProjection( deviceSessionId: 'session-1', From 6f913e4e8dc287fae4c3742076dc0d1322fd7d2b Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 18:17:43 +0200 Subject: [PATCH 3/5] =?UTF-8?q?chore(tickets):=20ajoute=20tickets=20#170-1?= =?UTF-8?q?71=20(affichage=20r=C3=A9p=C3=A9titions=20=C3=A9tape=20score,?= =?UTF-8?q?=20suppression=20scroll=20=C3=A9tape=20r=C3=A9p=C3=A9titions=20?= =?UTF-8?q?seules)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/170/carnet.md | 6 ++++++ .ideai/tickets/170/issue.md | 16 ++++++++++++++++ .ideai/tickets/171/carnet.md | 6 ++++++ .ideai/tickets/171/issue.md | 16 ++++++++++++++++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 24 ++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 .ideai/tickets/170/carnet.md create mode 100644 .ideai/tickets/170/issue.md create mode 100644 .ideai/tickets/171/carnet.md create mode 100644 .ideai/tickets/171/issue.md diff --git a/.ideai/tickets/170/carnet.md b/.ideai/tickets/170/carnet.md new file mode 100644 index 0000000..feba4e1 --- /dev/null +++ b/.ideai/tickets/170/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#170" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785255295122 +--- diff --git a/.ideai/tickets/170/issue.md b/.ideai/tickets/170/issue.md new file mode 100644 index 0000000..edc25aa --- /dev/null +++ b/.ideai/tickets/170/issue.md @@ -0,0 +1,16 @@ +--- +id: "90277a05-bd6a-4360-942e-dd500c2020e0" +number: 170 +title: "[UI] Afficher le nombre de répétitions à faire dans une étape avec score et répétitions" +status: "open" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785255090292 +updatedAt: 1785255295122 +version: 4 +--- +Dans une étape avec score libre + répétition il faut que l'utilisateur ai d'affiché sur sa montre le nombre de répétitions à faire en plus de pouvoir incrémenter ou décrémenter le score. Actuellement, il manque l'affichage du nombre de répétitions \ No newline at end of file diff --git a/.ideai/tickets/171/carnet.md b/.ideai/tickets/171/carnet.md new file mode 100644 index 0000000..eecc58c --- /dev/null +++ b/.ideai/tickets/171/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#171" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785255293774 +--- diff --git a/.ideai/tickets/171/issue.md b/.ideai/tickets/171/issue.md new file mode 100644 index 0000000..f5050e3 --- /dev/null +++ b/.ideai/tickets/171/issue.md @@ -0,0 +1,16 @@ +--- +id: "1bb57b78-7855-4e19-92e6-6d67c9155447" +number: 171 +title: "[UI] enlever le scrolling dans le cas d'une étape avec répétitions seulement" +status: "open" +priority: "medium" +sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" +links: [] +agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1785255221480 +updatedAt: 1785255293774 +version: 4 +--- +Actuellement dans le cas ou une étape contient un nombre de répétitions seulement, le bouton "passer l'étape" est tout en bas et necessite un scrolling. Il faudrait qu'il soit alligné avec le bouton Etape suivante je pense pour éviter le scrolling. Voir avec UX \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 43d2fbe..be9f2a8 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 170 + "nextNumber": 172 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index a08f811..e14bc8a 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1822,6 +1822,30 @@ "57695b92-24d0-4876-837c-76116e70a6ae" ], "updatedAt": 1785254327400 + }, + { + "issueRef": "#170", + "path": "170", + "title": "[UI] Afficher le nombre de répétitions à faire dans une étape avec score et répétitions", + "status": "open", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1785255295122 + }, + { + "issueRef": "#171", + "path": "171", + "title": "[UI] enlever le scrolling dans le cas d'une étape avec répétitions seulement", + "status": "open", + "priority": "medium", + "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", + "assignedAgentIds": [ + "f3408f5d-469c-4f64-9485-d8b218f3ff26" + ], + "updatedAt": 1785255293774 } ] } \ No newline at end of file From 30c6259748b59624250cbfc2b4070b74f4879f57 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 29 Jul 2026 11:22:17 +0200 Subject: [PATCH 4/5] fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157) --- .../gametime/app/watch/WatchBridgePlugin.kt | 4 + lib/application/ports.dart | 2 +- lib/application/use_cases.dart | 40 +++- .../local/drift_repositories.dart | 226 +++++++++++++++++- lib/infrastructure/remote/share_api.dart | 111 +++++++-- .../workout_execution_screen.dart | 207 ++++++++-------- .../lib/src/watch_bridge_contract.dart | 16 +- .../test/watch_bridge_contract_test.dart | 5 + test/application/use_cases_test.dart | 83 ++++++- .../watch_companion_command_handler_test.dart | 41 ++++ .../drift_repositories_test.dart | 219 +++++++++++++++++ .../infrastructure/remote/share_api_test.dart | 181 ++++++++++++++ test/presentation/home_screen_test.dart | 5 +- test/presentation/profile_screen_test.dart | 27 ++- test/presentation/program_screen_test.dart | 27 ++- .../presentation/share_inbox_screen_test.dart | 27 ++- .../workout_execution_screen_test.dart | 6 + .../workout_template_screen_test.dart | 27 ++- .../android/app/src/main/AndroidManifest.xml | 7 +- .../kotlin/com/gametime/watch/MainActivity.kt | 13 + .../watch/bridge/WatchBridgePlugin.kt | 88 +++++++ .../watch/bridge/WatchHeartRateCollector.kt | 195 +++++++++++++-- .../bridge/WatchHeartRateForegroundService.kt | 5 +- .../bridge/WatchOngoingActivityController.kt | 6 +- .../application/watch_session_view_model.dart | 89 ++++++- .../native_watch_bridge_client.dart | 7 + .../presentation/watch_session_screen.dart | 73 +++++- .../watch_session_screen_test.dart | 169 +++++++++++-- 28 files changed, 1658 insertions(+), 248 deletions(-) create mode 100644 test/infrastructure/remote/share_api_test.dart diff --git a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt index f500875..fc14d26 100644 --- a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt +++ b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt @@ -176,6 +176,10 @@ object WatchBridgePlugin { "projectedAtEpochMs", (map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L, ) + dataMap.putLong( + "expiresAtEpochMs", + (map["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L, + ) }.asPutDataRequest().setUrgent() Wearable.getDataClient(context).putDataItem(request) .addOnSuccessListener { result.success(null) } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 90482c2..3f7ba32 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -765,7 +765,7 @@ abstract interface class RemoteShareApi { }); Future> fetchInbox(String token); - Future acceptShare(String shareId, String token); + Future> acceptShare(String shareId, String token); Future declineShare(String shareId, String token); Future revokeShare(String shareId, String token); } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index bac91f3..4c14150 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -632,15 +632,15 @@ final class ShareUseCases { return; } try { - if (cachedItem?.resourceType == ShareResourceType.pack) { - await remoteShareApi.acceptShare(shareId, token); - await _importSharedPayload(cachedItem!); + final createdResources = await remoteShareApi.acceptShare(shareId, token); + final cachedPackItem = cachedItem; + if (cachedPackItem != null && + cachedPackItem.resourceType == ShareResourceType.pack) { + await _importSharedPayload(cachedPackItem); } else { - final createdResource = await remoteShareApi.acceptShare( - shareId, - token, - ); - await localChanges.applyRemoteItem(createdResource); + for (final createdResource in createdResources) { + await localChanges.applyRemoteItem(createdResource); + } } await inboxRepository.markStatus( shareId, @@ -794,11 +794,13 @@ final class ShareUseCases { if (item?.resourceType == ShareResourceType.pack) { await remoteShareApi.acceptShare(action.shareId!, token); } else { - final created = await remoteShareApi.acceptShare( + final createdResources = await remoteShareApi.acceptShare( action.shareId!, token, ); - await localChanges.applyRemoteItem(created); + for (final createdResource in createdResources) { + await localChanges.applyRemoteItem(createdResource); + } } await inboxRepository.markStatus( action.shareId!, @@ -3505,6 +3507,7 @@ bool _hasSameWatchCommandRevisionState( left.deviceSessionId == right.deviceSessionId && left.phase == right.phase && left.phoneReachable == right.phoneReachable && + left.expiresAtEpochMs == right.expiresAtEpochMs && left.seriesIndex == right.seriesIndex && left.seriesTotal == right.seriesTotal && left.exerciseName == right.exerciseName && @@ -3533,6 +3536,7 @@ bool _hasSameWatchCommandRevisionState( left.canDecrementScore == right.canDecrementScore && left.manualScoreTargetValue == right.manualScoreTargetValue && left.manualScoreTargetLabel == right.manualScoreTargetLabel && + left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue && left.manualScoreScope == right.manualScoreScope; } @@ -4055,6 +4059,7 @@ final class WatchSessionProjectionProjector { deviceSessionId: '', revision: revision, projectedAtEpochMs: _epochMs(now), + expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now), phase: WatchSessionPhase.noActiveSession, phoneReachable: true, seriesIndex: 0, @@ -4075,6 +4080,7 @@ final class WatchSessionProjectionProjector { deviceSessionId: session.metadata.id, revision: revision, projectedAtEpochMs: _epochMs(now), + expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now), phase: WatchSessionPhase.noActiveSession, phoneReachable: true, seriesIndex: session.currentSetIndex + 1, @@ -4150,6 +4156,7 @@ final class WatchSessionProjectionProjector { deviceSessionId: session.metadata.id, revision: revision, projectedAtEpochMs: projectedAtEpochMs, + expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now), phase: phase, phoneReachable: true, seriesIndex: session.currentSetIndex + 1, @@ -4202,6 +4209,7 @@ final class WatchSessionProjectionProjector { canDecrementScore: (manualScoreProjection?.value ?? 0) > 0, manualScoreTargetValue: manualScoreProjection?.targetValue, manualScoreTargetLabel: manualScoreProjection?.targetLabel, + manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue, manualScoreScope: manualScoreProjection?.scope, ); } @@ -4274,12 +4282,14 @@ final class _WatchManualScoreProjectionData { required this.value, this.targetValue, this.targetLabel, + this.repsTargetValue, }); final WatchManualScoreScope scope; final double value; final double? targetValue; final String? targetLabel; + final int? repsTargetValue; } _WatchManualScoreProjectionData? _watchManualScoreProjection({ @@ -4304,6 +4314,9 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({ value: result?.actualScore ?? 0, targetValue: step.defaultTargetScore, targetLabel: step.defaultTargetScore == null ? null : 'Cible', + repsTargetValue: step.type == ExerciseStepType.reps + ? step.defaultTargetValue + : null, ); } if (snapshot.scoreEnabled && @@ -4612,6 +4625,13 @@ String? _betweenSetsNextExerciseName( int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch; +int _watchProjectionExpiresAtEpochMs(DateTime projectedAt) { + return projectedAt + .toUtc() + .add(const Duration(seconds: 12)) + .millisecondsSinceEpoch; +} + final class ActiveExerciseStepUseCases { const ActiveExerciseStepUseCases({ required this.sessionRepository, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index cb2364f..2e327ad 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -492,10 +492,14 @@ final class DriftLocalSyncChangeRepository switch (item.resourceType) { case SyncResourceType.exercise: final exercise = _exerciseFromPayload(item); - await database - .into(database.exercises) - .insertOnConflictUpdate(_exerciseCompanion(exercise)); - await _writeExerciseStarterMetadata(database, exercise); + await database.transaction(() async { + await database + .into(database.exercises) + .insertOnConflictUpdate(_exerciseCompanion(exercise)); + await _writeExerciseStarterMetadata(database, exercise); + await _replaceRemoteExerciseImages(exercise); + await _replaceRemoteExerciseSteps(exercise); + }); return true; case SyncResourceType.mediaAsset: await database @@ -542,7 +546,11 @@ final class DriftLocalSyncChangeRepository }); return true; case SyncResourceType.workoutHistory: - return false; + await _replaceRemoteWorkoutHistory( + _workoutHistoryFromLocalBackupPayload(item), + item.clientUpdatedAt, + ); + return true; } } @@ -604,7 +612,7 @@ final class DriftLocalSyncChangeRepository } return _LocalSyncSnapshot.fromMetadata( history.metadata, - _workoutHistoryPayload(history), + _localWorkoutHistoryPayload(history), ); } @@ -627,6 +635,212 @@ final class DriftLocalSyncChangeRepository payload: {'id': id}, ); } + + Future _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 _softDeleteRemoteWorkoutHistoryChildren({ + required String tableName, + required String historyId, + required Set 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(deletedAt.toUtc()), + Variable(deletedAt.toUtc()), + Variable(historyId), + for (final id in keepIds) Variable(id), + ], + ); + } + + Future _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 _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 _softDeleteRemoteRows({ + required String tableName, + required List 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(deletedAt.toUtc()), + Variable(deletedAt.toUtc()), + for (final id in ids) Variable(id), + ], + ); + } } final class DriftShareInboxRepository implements ShareInboxRepository { diff --git a/lib/infrastructure/remote/share_api.dart b/lib/infrastructure/remote/share_api.dart index fbd87f4..b3a5c19 100644 --- a/lib/infrastructure/remote/share_api.dart +++ b/lib/infrastructure/remote/share_api.dart @@ -19,11 +19,11 @@ final class HttpRemoteShareApi implements RemoteShareApi { final response = await client.postJson( '/shares', bearerToken: token, - body: { - 'resourceType': _shareResourceTypeToWire(resourceType), - 'payload': payload, - 'recipientEmails': recipientEmails, - }, + body: _shareRequestBody( + resourceType: resourceType, + payload: payload, + recipientEmails: recipientEmails, + ), expectedStatuses: const {201}, ); return RemoteShareSendResult( @@ -43,12 +43,22 @@ final class HttpRemoteShareApi implements RemoteShareApi { } @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { final response = await client.postJson( '/shares/$shareId/accept', 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 @@ -62,20 +72,91 @@ final class HttpRemoteShareApi implements RemoteShareApi { } } +Map _shareRequestBody({ + required ShareResourceType resourceType, + required Map payload, + required List 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 json) { + final resourceType = _inboxResourceType(json); return ShareInboxItem( shareId: _requiredString(json, 'shareId'), senderUserId: _requiredString(json, 'senderUserId'), - resourceType: _shareResourceTypeFromWire( - _requiredString(json, 'resourceType'), - ), - payloadJson: _jsonObjectString(json['payload']), + resourceType: resourceType, + payloadJson: _payloadJsonString(json, resourceType), status: _shareInboxStatusFromWire(_requiredString(json, 'status')), createdAt: _requiredDateTime(json, 'createdAt'), respondedAt: _optionalDateTime(json, 'respondedAt'), ); } +ShareResourceType _inboxResourceType(Map json) { + final shareKind = json['shareKind']; + if (shareKind == 'pack') { + return ShareResourceType.pack; + } + return _shareResourceTypeFromWire(_requiredString(json, 'resourceType')); +} + +String _payloadJsonString( + Map 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 [], + }); + } + return jsonEncode({ + 'name': json['packName'] as String? ?? 'Pack', + 'workouts': [ + for (final rawItem in rawItems) + if (rawItem is Map && + Map.from(rawItem)['resourceType'] == + 'workoutTemplate') + _map(Map.from(rawItem)['payload']), + ], + }); +} + RemoteSyncedItem _syncedItemFromJson(Map json) { return RemoteSyncedItem( resourceType: _syncResourceTypeFromWire( @@ -163,6 +244,10 @@ String _requiredString(Map json, String key) { ); } +String _stringFromObject(Object? value, String fallback) { + return value is String && value.trim().isNotEmpty ? value : fallback; +} + int _requiredInt(Map json, String key) { final value = json[key]; if (value is int) { @@ -184,7 +269,3 @@ DateTime? _optionalDateTime(Map json, String key) { ? DateTime.parse(value).toUtc() : null; } - -String _jsonObjectString(Object? value) { - return jsonEncode(_map(value)); -} diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index a53a655..4577ba5 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -2570,6 +2570,10 @@ final class _StepSequencePanel extends StatelessWidget { ); } final currentStep = view.currentStep; + final currentStepIsRepsOnly = + currentStep != null && + currentStep.type == ExerciseStepType.reps && + !currentStep.hasScore; final sequenceComplete = view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete; final completedPassages = sequenceComplete @@ -2596,20 +2600,13 @@ final class _StepSequencePanel extends StatelessWidget { ], ), const SizedBox(height: 6), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - 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), - ], - ], + if (currentStepIsRepsOnly) + Row(children: _stepProgressChips(view)) + else + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: _stepProgressChips(view)), ), - ), const SizedBox(height: 8), Expanded( child: sequenceComplete || currentStep == null @@ -2645,6 +2642,15 @@ final class _StepSequencePanel extends StatelessWidget { enum _StepSkipAction { passage, sequence } +List _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 { const _BoundedAccentPanel({required this.child, required this.padding}); @@ -2802,96 +2808,96 @@ final class _CurrentStepPane extends StatelessWidget { ], ); } - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 8), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - step.name, - style: Theme.of(context).textTheme.headlineSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - 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( + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + step.name, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Expanded( + child: step.type == ExerciseStepType.time + ? _TimedStepBody( step: step, - controller: stepScoreController, - elapsedLabel: stepScoreElapsedLabel, - running: stepScoreRunning, - onStart: onStartStepScore, - onStop: onStopStepScore, - onReset: onResetStepScore, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus.runningTimer, + readyToStart: _isNextTimedStepReady(view), + 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( - children: [ - Expanded( - child: OutlinedButton( - onPressed: onSkipStep, - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(44), - ), - child: const Text('Passer l’étape'), - ), + ), + if (step.type == ExerciseStepType.reps) ...[ + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: onCompleteStep, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), ), - 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'), - ), - ], - ), - ], + icon: const Icon(Icons.check), + label: const Text('Étape suivante'), + ), ), ], - ), + ], ), - ), + ], ); }, ); @@ -3241,10 +3247,9 @@ bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) { } final class _RepsStepBody extends StatelessWidget { - const _RepsStepBody({required this.step, required this.onCompleteStep}); + const _RepsStepBody({required this.step}); final ExerciseStep step; - final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { @@ -3257,12 +3262,6 @@ final class _RepsStepBody extends StatelessWidget { ).copyWith(color: Theme.of(context).colorScheme.primary), ), 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'), - ), ], ); } diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index 41507a5..3b0d67d 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -1,4 +1,4 @@ -const int watchBridgeSchemaVersion = 4; +const int watchBridgeSchemaVersion = 5; enum WatchCommandType { startCurrentExercise, @@ -140,6 +140,7 @@ final class WatchSessionProjection { required this.deviceSessionId, required this.revision, required this.projectedAtEpochMs, + this.expiresAtEpochMs = 0, required this.phase, required this.phoneReachable, required this.seriesIndex, @@ -166,6 +167,7 @@ final class WatchSessionProjection { this.canDecrementScore = false, this.manualScoreTargetValue, this.manualScoreTargetLabel, + this.manualScoreRepsTargetValue, this.manualScoreScope, }); @@ -178,6 +180,7 @@ final class WatchSessionProjection { deviceSessionId: _stringFromJson(json['deviceSessionId']), revision: _intFromJson(json['revision'], 0), projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0), + expiresAtEpochMs: _intFromJson(json['expiresAtEpochMs'], 0), phase: _enumFromJson( json['phase'], WatchSessionPhase.values, @@ -221,6 +224,9 @@ final class WatchSessionProjection { manualScoreTargetLabel: _nullableStringFromJson( json['manualScoreTargetLabel'], ), + manualScoreRepsTargetValue: _nullableIntFromJson( + json['manualScoreRepsTargetValue'], + ), manualScoreScope: _nullableEnumFromJson( json['manualScoreScope'], WatchManualScoreScope.values, @@ -232,6 +238,7 @@ final class WatchSessionProjection { final String deviceSessionId; final int revision; final int projectedAtEpochMs; + final int expiresAtEpochMs; final WatchSessionPhase phase; final bool phoneReachable; final int seriesIndex; @@ -258,6 +265,7 @@ final class WatchSessionProjection { final bool canDecrementScore; final double? manualScoreTargetValue; final String? manualScoreTargetLabel; + final int? manualScoreRepsTargetValue; final WatchManualScoreScope? manualScoreScope; Map toJson() { @@ -266,6 +274,7 @@ final class WatchSessionProjection { 'deviceSessionId': deviceSessionId, 'revision': revision, 'projectedAtEpochMs': projectedAtEpochMs, + 'expiresAtEpochMs': expiresAtEpochMs, 'phase': phase.name, 'phoneReachable': phoneReachable, 'seriesIndex': seriesIndex, @@ -296,6 +305,7 @@ final class WatchSessionProjection { 'canDecrementScore': canDecrementScore, 'manualScoreTargetValue': manualScoreTargetValue, 'manualScoreTargetLabel': manualScoreTargetLabel, + 'manualScoreRepsTargetValue': manualScoreRepsTargetValue, 'manualScoreScope': manualScoreScope?.name, }; } @@ -308,6 +318,7 @@ final class WatchSessionProjection { deviceSessionId == other.deviceSessionId && revision == other.revision && projectedAtEpochMs == other.projectedAtEpochMs && + expiresAtEpochMs == other.expiresAtEpochMs && phase == other.phase && phoneReachable == other.phoneReachable && seriesIndex == other.seriesIndex && @@ -334,6 +345,7 @@ final class WatchSessionProjection { canDecrementScore == other.canDecrementScore && manualScoreTargetValue == other.manualScoreTargetValue && manualScoreTargetLabel == other.manualScoreTargetLabel && + manualScoreRepsTargetValue == other.manualScoreRepsTargetValue && manualScoreScope == other.manualScoreScope; } @@ -344,6 +356,7 @@ final class WatchSessionProjection { deviceSessionId, revision, projectedAtEpochMs, + expiresAtEpochMs, phase, phoneReachable, seriesIndex, @@ -370,6 +383,7 @@ final class WatchSessionProjection { canDecrementScore, manualScoreTargetValue, manualScoreTargetLabel, + manualScoreRepsTargetValue, manualScoreScope, ]); } diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index 90e6a7e..08a0f52 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -148,6 +148,7 @@ void main() { deviceSessionId: 'session-${phase.name}-${primaryAction.name}', revision: 4, projectedAtEpochMs: 1710000000100, + expiresAtEpochMs: 1710000012100, phase: phase, phoneReachable: true, seriesIndex: 2, @@ -174,6 +175,7 @@ void main() { canDecrementScore: true, manualScoreTargetValue: 10, manualScoreTargetLabel: 'Cible', + manualScoreRepsTargetValue: 12, manualScoreScope: WatchManualScoreScope.step, ); @@ -220,6 +222,7 @@ void main() { expect(projection.canDecrementScore, isFalse); expect(projection.manualScoreTargetValue, isNull); expect(projection.manualScoreTargetLabel, isNull); + expect(projection.manualScoreRepsTargetValue, isNull); expect(projection.manualScoreScope, isNull); }); @@ -230,6 +233,7 @@ void main() { expect(projection.deviceSessionId, ''); expect(projection.revision, 0); expect(projection.projectedAtEpochMs, 0); + expect(projection.expiresAtEpochMs, 0); expect(projection.phase, WatchSessionPhase.noActiveSession); expect(projection.phoneReachable, false); expect(projection.seriesIndex, 0); @@ -244,6 +248,7 @@ void main() { expect(projection.canDecrementScore, isFalse); expect(projection.manualScoreTargetValue, isNull); expect(projection.manualScoreTargetLabel, isNull); + expect(projection.manualScoreRepsTargetValue, isNull); expect(projection.manualScoreScope, isNull); }); }); diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index a23cf71..12e7d24 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -2611,6 +2611,68 @@ void main() { 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( 'ShareUseCases acceptShare sans token importe une copie locale indépendante', () async { @@ -4559,6 +4621,7 @@ ShareUseCases _shareUseCase({ final class _FakeRemoteShareApi implements RemoteShareApi { Exception? exception; RemoteSyncedItem? acceptResult; + List? acceptResults; List inboxItems = const []; var sendCalls = 0; var acceptCalls = 0; @@ -4568,13 +4631,16 @@ final class _FakeRemoteShareApi implements RemoteShareApi { Map? lastPayload; @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { acceptCalls += 1; final error = exception; if (error != null) { throw error; } - return acceptResult ?? _remoteSharedProgramItem(); + return acceptResults ?? [acceptResult ?? _remoteSharedProgramItem()]; } @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( _FakeExerciseRepository repository, { _FakeProgramRepository? programRepository, diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 8f76417..73c4afa 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -377,6 +377,43 @@ void main() { 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 { final env = _env( session: _session(scoreEnabled: true), @@ -740,6 +777,7 @@ final class _FakeProjectionSource implements WatchProjectionSource { deviceSessionId: projection.deviceSessionId, revision: projection.revision + 1, projectedAtEpochMs: projection.projectedAtEpochMs, + expiresAtEpochMs: projection.expiresAtEpochMs, phase: projection.phase, phoneReachable: projection.phoneReachable, seriesIndex: projection.seriesIndex, @@ -752,6 +790,9 @@ final class _FakeProjectionSource implements WatchProjectionSource { hasManualScore: projection.hasManualScore, currentManualScoreValue: projection.currentManualScoreValue, canDecrementScore: projection.canDecrementScore, + manualScoreTargetValue: projection.manualScoreTargetValue, + manualScoreTargetLabel: projection.manualScoreTargetLabel, + manualScoreRepsTargetValue: projection.manualScoreRepsTargetValue, manualScoreScope: projection.manualScoreScope, ); return projection; diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 74b9695..7cfdb99 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -10,6 +10,7 @@ import 'package:gametime/infrastructure/local/local.dart' as local; void main() { late local.AppDatabase database; + late local.DriftMediaAssetRepository mediaAssetRepository; late local.DriftExerciseRepository exerciseRepository; late local.DriftProgramRepository programRepository; late local.DriftActiveSessionRepository activeRepository; @@ -24,6 +25,7 @@ void main() { setUp(() { database = local.AppDatabase(NativeDatabase.memory()); + mediaAssetRepository = local.DriftMediaAssetRepository(database); exerciseRepository = local.DriftExerciseRepository(database); programRepository = local.DriftProgramRepository(database); activeRepository = local.DriftActiveSessionRepository(database); @@ -482,6 +484,209 @@ CREATE TABLE pending_share_actions ( 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 = { + '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 { final now = DateTime.utc(2026, 7, 22, 11); await syncChangeRepository.applyRemoteItem( @@ -2616,8 +2821,14 @@ WorkoutHistory _history({ required DateTime startedAt, WorkoutHistorySetResult? result, List? results, + List stepResults = const [], bool completed = true, int totalActiveMs = 300000, + int? minHeartRateBpm, + double? averageHeartRateBpm, + int? maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, }) { return WorkoutHistory( metadata: _metadata(id, startedAt), @@ -2628,6 +2839,12 @@ WorkoutHistory _history({ completed: completed, historySnapshotJson: '{"name":"$id"}', results: results ?? [result!], + stepResults: stepResults, + minHeartRateBpm: minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, ); } @@ -2744,6 +2961,7 @@ ExerciseStep _exerciseStep({ String? scoreUnit, double? defaultTargetScore, int? defaultTargetScoreTimeMs, + bool linkedToSeriesScore = false, }) { return ExerciseStep( id: id, @@ -2757,6 +2975,7 @@ ExerciseStep _exerciseStep({ scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore, ); } diff --git a/test/infrastructure/remote/share_api_test.dart b/test/infrastructure/remote/share_api_test.dart new file mode 100644 index 0000000..7ed3039 --- /dev/null +++ b/test/infrastructure/remote/share_api_test.dart @@ -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? 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 [], + }), + 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 _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 _jsonMap(String source) { + final decoded = jsonDecode(source); + return Map.from(decoded as Map); +} diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 599f82b..6ecf485 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -520,7 +520,10 @@ final class _FakeLocalSyncChangeRepository final class _FakeRemoteShareApi implements RemoteShareApi { @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { throw const RemoteAuthException(RemoteAuthFailure.network); } diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index 4c6c42f..9138b71 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -887,17 +887,22 @@ final class _FakeLocalSyncChangeRepository final class _FakeRemoteShareApi implements RemoteShareApi { @override - Future acceptShare(String shareId, String token) async { - return RemoteSyncedItem( - resourceType: SyncResourceType.program, - clientId: 'program-remote', - serverId: 'program-server', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Programme partagé'}, - ); + Future> acceptShare( + String shareId, + String token, + ) async { + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'program-remote', + serverId: 'program-server', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Programme partagé'}, + ), + ]; } @override diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index d5e784f..dd63292 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -1241,17 +1241,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi { List lastRecipientEmails = const []; @override - Future acceptShare(String shareId, String token) async { - return RemoteSyncedItem( - resourceType: SyncResourceType.program, - clientId: 'program-remote', - serverId: 'program-server', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Programme partagé'}, - ); + Future> acceptShare( + String shareId, + String token, + ) async { + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'program-remote', + serverId: 'program-server', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Programme partagé'}, + ), + ]; } @override diff --git a/test/presentation/share_inbox_screen_test.dart b/test/presentation/share_inbox_screen_test.dart index 3a9bcce..c91cd3e 100644 --- a/test/presentation/share_inbox_screen_test.dart +++ b/test/presentation/share_inbox_screen_test.dart @@ -365,19 +365,24 @@ final class _FakeRemoteShareApi implements RemoteShareApi { Map? lastPayload; @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { acceptCalls += 1; _mark(shareId, ShareInboxStatus.accepted); - return RemoteSyncedItem( - resourceType: SyncResourceType.program, - clientId: 'shared-program-1', - serverId: 'server-program-1', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Programme tirs'}, - ); + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'shared-program-1', + serverId: 'server-program-1', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Programme tirs'}, + ), + ]; } @override diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 4349256..5c71526 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -1942,6 +1942,12 @@ void main() { await tester.pump(); 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.pump(); diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 2641a5e..50f2890 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -1272,17 +1272,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi { List lastRecipientEmails = const []; @override - Future acceptShare(String shareId, String token) async { - return RemoteSyncedItem( - resourceType: SyncResourceType.workoutTemplate, - clientId: 'template-remote', - serverId: 'template-server', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Séance partagée'}, - ); + Future> acceptShare( + String shareId, + String token, + ) async { + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.workoutTemplate, + clientId: 'template-remote', + serverId: 'template-server', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Séance partagée'}, + ), + ]; } @override diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 095d192..38fbd1f 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -36,7 +37,7 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:exported="true" android:hardwareAccelerated="true" - android:launchMode="singleTop" + android:launchMode="singleTask" android:taskAffinity="" android:theme="@style/LaunchTheme" android:windowSoftInputMode="adjustResize"> @@ -47,6 +48,10 @@ + + + + ? = null + private var lastActiveProjection: Map? = null + private var lastActiveProjectionReceivedAtEpochMs = 0L + private var activeProjectionExpiryRunnable: Runnable? = null fun attachApplicationContext(context: Context) { appContext = context.applicationContext @@ -123,6 +127,16 @@ object WatchBridgePlugin { 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) { if (this.activity === activity) { this.activity = null @@ -159,7 +173,9 @@ object WatchBridgePlugin { } fun emitProjection(payload: Map): Boolean { + rememberActiveProjection(payload) appContext?.let { + scheduleActiveProjectionExpiry(it, payload) WatchOngoingActivityController.update(it, payload, activity) updateHeartRateCollection(it, payload) } @@ -229,10 +245,24 @@ object WatchBridgePlugin { requestCapabilityRefresh(context) result.success(null) } + "invalidateActiveProjection" -> { + invalidateActiveProjection(context) + result.success(null) + } 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( context: Context, 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) { + 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) { + 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) { val phase = projection["phase"] as? String ?: "noActiveSession" val sessionId = projection["deviceSessionId"] as? String ?: "" @@ -416,6 +503,7 @@ object WatchBridgePlugin { return listOf( heartRatePermission, android.Manifest.permission.ACTIVITY_RECOGNITION, + android.Manifest.permission.ACCESS_FINE_LOCATION, ) } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index a6cc1ef..ba604a9 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -2,6 +2,8 @@ package com.gametime.watch.bridge import android.content.Context 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.MeasureClient 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.DataType 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.Wearable import org.json.JSONObject @@ -35,10 +41,12 @@ internal class WatchHeartRateCollector( private var sampleSequence = 0 private var executionContext: Map = emptyMap() private val registeredDataTypes = mutableSetOf>() + private var exerciseMetricsStarted = false + private var exerciseMetricsStartInFlight = false private var shouldAggregate = false private var appContext: Context? = null - private val callback = object : MeasureCallback { + private val measureCallback = object : MeasureCallback { override fun onAvailabilityChanged( dataType: DeltaDataType<*, *>, availability: Availability, @@ -54,21 +62,11 @@ internal class WatchHeartRateCollector( for (point in data.getData(DataType.HEART_RATE_BPM)) { latestHeartRateBpm = recordHeartRate(point.value) } - var updatedDistance = false - for (point in data.getData(DataType.DISTANCE)) { - if (point.value > 0) { - distanceMeters = (distanceMeters ?: 0.0) + point.value - 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) { + if (latestHeartRateBpm != null) { + Log.d( + TAG, + "heart rate data received sessionId=$sessionId bpm=$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( nextSessionId: String, shouldAggregate: Boolean, @@ -103,17 +152,18 @@ internal class WatchHeartRateCollector( appContext = context.applicationContext val measureClient = HealthServices.getClient(context).measureClient registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) - registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) - registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES) + startExerciseMetrics(context) } fun pause(context: Context) { shouldAggregate = false unregister(context) + stopExerciseMetrics(context) } fun finishCurrentSession(context: Context) { unregister(context) + stopExerciseMetrics(context) val completedSessionId = sessionId if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) { sendSummary(context, completedSessionId) @@ -188,6 +238,8 @@ internal class WatchHeartRateCollector( "minHeartRateBpm" to min, "averageHeartRateBpm" to sampleSum / sampleCount, "maxHeartRateBpm" to max, + "distanceMeters" to distanceMeters, + "caloriesKcal" to caloriesKcal, ), ).toString().toByteArray(StandardCharsets.UTF_8) Wearable.getCapabilityClient(context) @@ -213,10 +265,9 @@ internal class WatchHeartRateCollector( } val measureClient = HealthServices.getClient(context).measureClient for (dataType in registeredDataTypes.toList()) { - measureClient.unregisterMeasureCallbackAsync(dataType, callback) + measureClient.unregisterMeasureCallbackAsync(dataType, measureCallback) } registeredDataTypes.clear() - appContext = null } private fun registerMeasureCallbackIfNeeded( @@ -227,7 +278,7 @@ internal class WatchHeartRateCollector( return } try { - measureClient.registerMeasureCallback(dataType, callback) + measureClient.registerMeasureCallback(dataType, measureCallback) registeredDataTypes.add(dataType) Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId") } 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>() + 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?) { sessionId = nextSessionId sampleCount = 0 diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt index 37ebc9c..6f425e2 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt @@ -13,7 +13,6 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat -import com.gametime.watch.MainActivity import com.gametime.watch.R internal class WatchHeartRateForegroundService : Service() { @@ -71,9 +70,7 @@ internal class WatchHeartRateForegroundService : Service() { val touchIntent = PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - }, + WatchBridgePlugin.openActiveSessionIntent(this), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(this, CHANNEL_ID) diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt index be15e33..51517b9 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt @@ -6,14 +6,12 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.wear.ongoing.OngoingActivity import androidx.wear.ongoing.Status -import com.gametime.watch.MainActivity import com.gametime.watch.R object WatchOngoingActivityController { @@ -47,9 +45,7 @@ object WatchOngoingActivityController { val touchIntent = PendingIntent.getActivity( context, 0, - Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - }, + WatchBridgePlugin.openActiveSessionIntent(context), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val exerciseName = (projection["exerciseName"] as? String) diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index 1606514..db83c58 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -128,6 +128,7 @@ final class WatchSessionViewModel extends ValueNotifier { Timer? _scoreWaitingTimer; Timer? _scoreCommandTimeoutTimer; Timer? _freshnessTimer; + Timer? _projectionExpiryTimer; Timer? _commandFailureClearTimer; WatchCommandEnvelope? _pendingCommand; final _pendingScoreCommandIds = {}; @@ -198,6 +199,7 @@ final class WatchSessionViewModel extends ValueNotifier { _scoreWaitingTimer?.cancel(); _scoreCommandTimeoutTimer?.cancel(); _freshnessTimer?.cancel(); + _projectionExpiryTimer?.cancel(); _commandFailureClearTimer?.cancel(); for (final subscription in _subscriptions) { unawaited(subscription.cancel()); @@ -309,6 +311,7 @@ final class WatchSessionViewModel extends ValueNotifier { void _handleProjection(WatchSessionProjection projection) { final previousProjection = value.projection; _lastProjectionReceivedAt = DateTime.now(); + _scheduleProjectionExpiry(projection); _pendingCommand = null; _clearCommandTimers(); _syncScorePendingFromProjection(projection); @@ -388,7 +391,23 @@ final class WatchSessionViewModel extends ValueNotifier { if (receivedAt == null) { 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 lost = age >= _connectionLostThreshold; if (stale != value.staleProjection || lost != value.connectionLost) { @@ -396,6 +415,53 @@ final class WatchSessionViewModel extends ValueNotifier { } } + 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() { _waitingTimer?.cancel(); _waitingTimer = null; @@ -504,10 +570,29 @@ bool _requiresActiveSession(WatchCommandType type) { } WatchSessionProjection _initialProjection() { + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; return WatchSessionProjection( deviceSessionId: '', 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, phoneReachable: false, seriesIndex: 0, diff --git a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart index 5f71810..2db538e 100644 --- a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart +++ b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart @@ -41,6 +41,8 @@ abstract interface class NativeWatchBridgeClient { Future requestResync(); Future requestCapabilityRefresh(); + + Future invalidateActiveProjection(); } final class MethodChannelNativeWatchBridgeClient @@ -141,6 +143,11 @@ final class MethodChannelNativeWatchBridgeClient Future requestResync() { return _methodChannel.invokeMethod('requestResync'); } + + @override + Future invalidateActiveProjection() { + return _methodChannel.invokeMethod('invalidateActiveProjection'); + } } Map _stringObjectMap(Object? value) { diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index f86a053..7a84008 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -99,7 +99,11 @@ final class _WatchSessionScreenState extends State { ), ); } - return PageView(controller: _pageController, children: pages); + return PageView( + controller: _pageController, + physics: const _WatchPageScrollPhysics(), + children: pages, + ); }, ); } @@ -220,7 +224,21 @@ final class _WatchSessionScreenState extends State { !_completionHapticTimerKeys.add(key)) { return; } + _triggerTimerCompletionHaptic(); + } + + void _triggerTimerCompletionHaptic() { unawaited(HapticFeedback.heavyImpact()); + unawaited( + Future.delayed(const Duration(milliseconds: 140), () { + return HapticFeedback.heavyImpact(); + }), + ); + unawaited( + Future.delayed(const Duration(milliseconds: 320), () { + return HapticFeedback.heavyImpact(); + }), + ); } Future _confirm({ @@ -242,6 +260,47 @@ final class _WatchSessionScreenState extends State { } } +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 { const _RoundScaffold({required this.child, this.notice, super.key}); @@ -927,17 +986,21 @@ final class _ManualScoreContent extends StatelessWidget { ); final target = projection.manualScoreTargetValue; 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( child: Column( mainAxisSize: MainAxisSize.min, children: [ _ExerciseName(projection.exerciseName), _StepNameBand(projection.stepName), - if (target != null && - targetLabel != null && - targetLabel.isNotEmpty) ...[ + if (captionSegments.isNotEmpty) ...[ Text( - '$targetLabel : ${_scoreText(target)}', + captionSegments.join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 81e0a3c..98ed1d1 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -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', ( tester, ) 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 = []; + 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, ) async { - final hapticCalls = []; - 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); @@ -714,20 +785,18 @@ void main() { ), ); - client.emitProjection(_countdownProjection(accumulatedMs: 29000)); + client.emitProjection( + _expiringProjection(expiresIn: const Duration(seconds: 1)), + ); await tester.pump(); - expect(hapticCalls, isEmpty); + expect(find.text('Squat jump'), findsOneWidget); - client.emitProjection(_countdownProjection(accumulatedMs: 30000)); - await tester.pump(); - await tester.pump(); + await tester.pump(const Duration(seconds: 2)); - expect(hapticCalls, hasLength(1)); - expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact'); - - client.emitProjection(_countdownProjection(accumulatedMs: 30000)); - await tester.pump(); - expect(hapticCalls, hasLength(1)); + expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession); + expect(viewModel.value.connectionLost, isTrue); + expect(client.invalidatedProjectionCount, 1); + expect(find.text('Téléphone indisponible'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); @@ -745,6 +814,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { var resyncRequests = 0; var capabilityRefreshRequests = 0; + var invalidatedProjectionCount = 0; final sentCommands = []; @override @@ -782,6 +852,11 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { capabilityRefreshRequests += 1; } + @override + Future invalidateActiveProjection() async { + invalidatedProjectionCount += 1; + } + @override Future requestResync() async { resyncRequests += 1; @@ -794,10 +869,12 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { } WatchSessionProjection _runningProjection() { + final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch; return WatchSessionProjection( deviceSessionId: 'session-1', revision: 1, - projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + projectedAtEpochMs: projectedAt, + expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds, phase: WatchSessionPhase.running, phoneReachable: true, 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}) { return WatchSessionProjection( 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() { return WatchSessionProjection( deviceSessionId: 'session-1', From dd0c12481804b2e622fd748fe66595bb2abd554f Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 29 Jul 2026 11:22:17 +0200 Subject: [PATCH 5/5] chore(ideai): synchronise etat de suivi (tickets, agents) --- .ideai/agents/architect.md | 20 ++ .ideai/agents/devbackend.md | 21 ++ .ideai/agents/devfrontend.md | 16 + .ideai/agents/main.md | 7 + .ideai/agents/qa.md | 15 + .ideai/tickets/157/carnet.md | 63 +++- .ideai/tickets/157/issue.md | 6 +- .ideai/tickets/159/carnet.md | 6 +- .ideai/tickets/159/issue.md | 8 +- .ideai/tickets/163/carnet.md | 54 ++- .ideai/tickets/163/issue.md | 8 +- .ideai/tickets/164/carnet.md | 18 +- .ideai/tickets/164/issue.md | 8 +- .ideai/tickets/166/carnet.md | 29 +- .ideai/tickets/166/issue.md | 6 +- .ideai/tickets/169/carnet.md | 6 +- .ideai/tickets/169/issue.md | 8 +- .ideai/tickets/170/carnet.md | 39 ++- .ideai/tickets/170/issue.md | 8 +- .ideai/tickets/171/carnet.md | 42 ++- .ideai/tickets/171/issue.md | 8 +- .ideai/tickets/172/carnet.md | 103 ++++++ .ideai/tickets/172/issue.md | 20 ++ .ideai/tickets/173/carnet.md | 101 ++++++ .ideai/tickets/173/issue.md | 20 ++ .ideai/tickets/174/carnet.md | 64 ++++ .ideai/tickets/174/issue.md | 16 + .ideai/tickets/175/carnet.md | 86 +++++ .ideai/tickets/175/issue.md | 16 + .ideai/tickets/85/carnet.md | 4 +- .ideai/tickets/85/issue.md | 6 +- .ideai/tickets/index.json | 658 ++++++++++++++++++++++++++++++++++- 32 files changed, 1415 insertions(+), 75 deletions(-) create mode 100644 .ideai/tickets/172/carnet.md create mode 100644 .ideai/tickets/172/issue.md create mode 100644 .ideai/tickets/173/carnet.md create mode 100644 .ideai/tickets/173/issue.md create mode 100644 .ideai/tickets/174/carnet.md create mode 100644 .ideai/tickets/174/issue.md create mode 100644 .ideai/tickets/175/carnet.md create mode 100644 .ideai/tickets/175/issue.md diff --git a/.ideai/agents/architect.md b/.ideai/agents/architect.md index 9b870f2..1456323 100644 --- a/.ideai/agents/architect.md +++ b/.ideai/agents/architect.md @@ -117,3 +117,23 @@ Tu interviens **au début** du cycle, et **en arbitrage** ensuite : - Avec **UX** : la forme conditionne les contrats. Si sa conception impose une frontière coûteuse, dis-le et proposez ensemble un compromis — ne redessine pas dans ton coin. - Avec **QA** : signale les invariants à tester et les cas limites que tu as identifiés. + +--- + +## 6. Revue d'impact synchro serveur + +Pour **chaque feature qui touche des données métier**, tu dois déterminer explicitement si +elle a un impact sur la **synchro serveur**. + +- Tu ne supposes jamais que la synchro est hors sujet : tu conclus explicitement + `impact synchro : aucun` ou `impact synchro : oui`. +- Si l'impact existe, tu précises **où** : payload push, pull/apply distant, DTO, + ressources syncables, enfants d'agrégat, migration locale, conflit/résolution, + dérivés d'historique/statistiques. +- Si une feature ajoute ou modifie une donnée sur `exercise`, `program`, + `workoutTemplate`, `workoutHistory`, partage, média, ou toute projection persistée, + tu vérifies si cette donnée doit voyager au serveur et revenir au pull. +- Si tu conclus `aucun impact synchro`, tu donnes la raison en une phrase. +- Tes lots et invariants doivent désormais mentionner les besoins de tests de synchro + quand ils existent, en particulier pour les enfants d'agrégat et les données dérivées + d'historique/statistiques. \ No newline at end of file diff --git a/.ideai/agents/devbackend.md b/.ideai/agents/devbackend.md index ce3debf..c811604 100644 --- a/.ideai/agents/devbackend.md +++ b/.ideai/agents/devbackend.md @@ -96,3 +96,24 @@ avant d'écrire** — c'est un arbitrage d'Architect, pas une décision d'implé solution, ne l'impose pas. - Avec **QA** : signale les cas limites que tu sais fragiles. Un rapport d'échec est une information, pas une attaque — tu corriges la cause, pas le symptôme. + +--- + +## 6. Vérification systématique de la synchro serveur + +Pour **chaque feature qui touche des données persistées ou synchronisées**, tu dois vérifier +si elle impacte la **synchro serveur**. + +- Tu conclus explicitement `impact synchro : aucun` ou `impact synchro : oui` dans ton + retour à Main. +- Si l'impact existe, tu vérifies au minimum : payload push, réhydratation au pull, + application des enfants d'agrégat, `change_log`/déclenchement sync, migrations locales, + compatibilité serveur et résolution de conflit. +- Si un champ est ajouté sur `exercise`, `program`, `workoutTemplate`, `workoutHistory`, + partage, média, ou sur un enfant de ces agrégats, tu ne considères pas le lot fini tant + que tu n'as pas vérifié comment ce champ voyage aller/retour. +- Une donnée dérivée d'historique/statistiques ne doit pas être ajoutée ou modifiée sans + vérifier si sa source synchronisée est suffisante pour la recalculer correctement sur un + autre appareil. +- Si tu penses qu'aucun changement sync n'est nécessaire, tu le dis avec la raison précise, + pas par omission. \ No newline at end of file diff --git a/.ideai/agents/devfrontend.md b/.ideai/agents/devfrontend.md index 75fd4b6..41cbfa6 100644 --- a/.ideai/agents/devfrontend.md +++ b/.ideai/agents/devfrontend.md @@ -91,3 +91,19 @@ cœur du produit. - Avec **Architect** : tout écart de contrat remonte pour arbitrage. - Avec **QA** : un rapport d'échec est une information. Tu corriges la cause, pas le symptôme. + +--- + +## 6. Signalement des impacts de synchro serveur + +Même sur un lot UI, tu dois vérifier si la feature manipule une donnée qui vit dans une +**ressource synchronisée**. + +- Si la surface crée, édite, affiche ou dépend d'une donnée persistée/synchronisée, tu + signales explicitement à Main si le contrat actuel suffit ou si un impact synchro doit + être traité par Architect/DevBackend. +- Tu ne supposes jamais que l'existant sync transporte déjà la donnée : si un champ est + nouveau ou si un enfant d'agrégat devient visible/éditable, tu le remontes. +- Si tu relies une UI à une donnée absente du pull distant ou du payload sync, tu le dis + avant d'implémenter plutôt que de contourner localement. +- Si tu conclus `aucun impact synchro`, tu le mentionnes explicitement avec la raison. \ No newline at end of file diff --git a/.ideai/agents/main.md b/.ideai/agents/main.md index bc90943..11a3536 100644 --- a/.ideai/agents/main.md +++ b/.ideai/agents/main.md @@ -20,6 +20,7 @@ feature ou correction applicative, tu passes par les agents spécialisés : - **DevBackend** pour le code côté serveur/domaine/données. - **DevFrontend** pour le code d'interface. - **QA** pour écrire et exécuter les tests, et produire les rapports d'échec. +- **Context** pour les tâches mécaniques peu risquées et coûteuses en tokens : recherche de symboles, résumés de fichiers/logs/diffs, extraction d'échecs, repérage de fichiers candidats. Context n'arbitre rien et ne remplace jamais UX/Architect/Git/QA/dev. **Exception limitée** : tu peux modifier toi-même les fichiers de contexte, de mémoire, la documentation de pilotage et la configuration d'orchestration quand la demande porte @@ -64,6 +65,10 @@ tomber par défaut dans les mains d'un dev. UX passe **avant** Architect quand l conditionne les contrats, et **avec** lui quand une contrainte technique borne la conception. UX ne bloque pas les lots sans surface utilisateur. +**Quand solliciter Git — la règle.** Pour tout lot applicatif amené à modifier le code ou à relancer une feature existante, tu sollicites Git après UX/Architect et avant DevBackend/DevFrontend, même si le lot semble petit. Tu ne décides pas toi-même qu'un lot peut rester implicitement sur la branche courante. + +**Quand solliciter Context — la règle.** Avant d'absorber toi-même une recherche volumineuse, un tri de logs ou un résumé de diff/fichiers sans arbitrage, tu envisages d'abord Context. Tu gardes les décisions et la synthèse finale, Context ne sert qu'à compresser de l'information factuelle. + --- ## 4. Répartition des responsabilités @@ -82,6 +87,8 @@ spécialisé dans son domaine** : l'utilisateur s'il faut brancher, committer ou merger** : sollicite Git, qui tranche. Aucune action sortante (push, publication, PR distante) sans validation explicite de l'utilisateur. +- **Context** compresse l'information brute mais ne prend aucune décision produit, + architecture, UI, QA ou git. --- diff --git a/.ideai/agents/qa.md b/.ideai/agents/qa.md index 868f9df..acece31 100644 --- a/.ideai/agents/qa.md +++ b/.ideai/agents/qa.md @@ -93,3 +93,18 @@ un diagnostic, pas une excuse pour passer au vert. Remonte les frontières qui rendent le test impossible. - Avec les **devs** : ton rapport vise le code, jamais la personne. Donne de quoi reproduire, pas de quoi deviner. + +--- + +## 6. Vérifications de synchro serveur + +Pour toute feature qui touche des **données synchronisées** ou susceptibles de l'être, tu +vérifies si un lot de **tests de synchro serveur** est nécessaire. + +- Tu conclus explicitement `tests sync requis : oui/non` dans ton retour. +- Si oui, tu couvres au minimum les comportements critiques : push, pull, réhydratation + locale, enfants d'agrégat, multi-appareil si pertinent, et cohérence des historiques/ + statistiques dérivées. +- Une feature data n'est pas considérée terminée si son impact synchro identifié par + Architect/DevBackend n'a pas été validé par des commandes réelles. +- Si tu juges qu'aucun test sync n'est nécessaire, tu donnes la raison précise. \ No newline at end of file diff --git a/.ideai/tickets/157/carnet.md b/.ideai/tickets/157/carnet.md index a42b19e..5de6a0f 100644 --- a/.ideai/tickets/157/carnet.md +++ b/.ideai/tickets/157/carnet.md @@ -1,6 +1,63 @@ --- issueRef: "#157" -version: 5 -updatedBy: {"kind":"user"} -updatedAt: 1785252668623 +version: 7 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1785256180427 --- +## Cadrage UX — agent UX (2026-07-28) + +Statut : **cadrage exploitable**, aucune nouvelle surface à créer. Ce ticket branche une donnée (distance live) sur des emplacements déjà spécifiés et déjà construits par [[gametime-ux-online-client]]-adjacent (#155 cadrage transverse) et #159 (surface montre, fermé). Ne pas concevoir de nouvel écran ni de nouveau composant : la distance vient simplement remplir un slot déjà réservé, aujourd'hui silencieusement absent. + +### Rappel des deux emplacements concernés (déjà cadrés par #155, à ne pas rouvrir) + +**Téléphone, barre stats live compacte sous le bloc d'exécution :** +``` +FC 142 bpm · 0,84 km · 186 kcal +``` +- ordre fixe **FC → Distance → Calories**, la distance est toujours au milieu ; +- aujourd'hui la barre affiche seulement `FC 142 bpm` (distance et calories absentes) — c'est le comportement silencieux attendu, pas un bug ; +- dès que la distance devient disponible, elle s'insère à sa place dans l'ordre, sans changement de layout, sans réanimation d'apparition particulière au-delà du refresh discret déjà en place pour la FC. + +**Montre, écran secondaire `Stats` (glissement inverse d'`Actions`, cf. #159) :** +- une ligne `Distance` vient s'ajouter aux côtés de `FC` (icône cœur, cf. #161) et `Calories`, toujours dans l'ordre FC / Distance / Calories ; +- pas d'icône dédiée à inventer pour la distance : libellé texte `Distance` en Archivo au-dessus de la valeur, même traitement typographique que `Calories` (aucune des deux n'est la donnée dominante de l'écran montre, contrairement à la FC sur l'écran principal) ; +- écran principal montre : **la distance n'y apparaît jamais**, seule la FC y a droit (règle #155/#161 déjà actée, ce ticket ne la touche pas). + +### Format d'affichage +- Unité `km`, séparateur décimal virgule (locale FR), deux décimales : `0,84 km` (cohérent avec l'exemple historique `2,84 km` déjà utilisé dans le cadrage #155/carte historique). +- Pas de conversion m/km dynamique à ce stade (pas de "840 m" sous 1km) — rester simple, un seul format, cohérent partout où la distance est affichée (téléphone, montre, futur après-séance/historique #158/#160). + +### Garde-fous +- **Silence total si la donnée est absente** (pas de capteur GPS/distance sur la montre, pas de fix GPS encore acquis, montre non compatible) : la ligne/le segment disparaît, jamais de `--` ni de placeholder. Le comportement actuel (barre réduite à `FC` seule) est déjà la bonne référence. +- **Perte temporaire de mesure live** : garder la dernière valeur connue plutôt que de faire disparaître puis réapparaître le segment en boucle (cf. règle #155 "perte temporaire → dernière valeur connue si la session l'utilise déjà"). +- Ne pas transformer la distance en donnée dominante nulle part sur ce lot : elle reste secondaire au téléphone (barre compacte) et secondaire à la montre (écran Stats uniquement, jamais écran principal). +- Aucun message bloquant, aucune demande de permission spécifique visible à l'utilisateur au-delà de ce qui est déjà géré par les tickets permissions capteurs montre déjà livrés (cf. commits f71a1551/58272e35). + +### Compatibilité avec #155/#159/#161 déjà traités +Ce ticket ne modifie aucune décision de #155 (surfaces), #159 (navigation montre par glissement inverse) ni #161 (icône cœur FC, dédoublonnage écran principal). Il alimente uniquement les emplacements distance déjà prévus et actuellement vides faute de donnée. + +--- + +## Cadrage Architect (2026-07-28) + +Statut : **surprise majeure — ce ticket n'a rien de "Frontend" restant à faire**. Vérifié en lecture directe : + +- `lib/presentation/workout_execution_screen.dart` (`_LiveSensorBar`, ~L2221) affiche déjà FC + Distance + Calories en pills conditionnelles (`if (sensorState.latestHeartRateBpm case final heartRate?)`, `if (_distanceLabel(sensorState) case final distanceLabel?)`, `if (_caloriesLabel(sensorState) case final caloriesLabel?)`) — silence total déjà implémenté exactement comme cadré par UX. +- `watch_app/lib/presentation/watch_session_screen.dart` (~L1187-1196) affiche déjà `Distance`/`Calories` dans l'écran secondaire `Stats`, via `_distanceLabel`/`_caloriesLabel` sur `WatchSensorSample`. +- Le contrat `packages/watch_bridge_contract/.../watch_bridge_contract.dart` porte déjà `distanceMeters` et `caloriesKcal` sur `WatchSensorSample`/`WatchSensorSummary` (schemaVersion 4), sérialisation JSON incluse. +- Donc l'intégralité de la chaîne UI téléphone + UI montre + contrat de transport est **déjà livrée**. Le titre "[DevFrontend] Distance live montre" ne correspond plus à l'état réel du code. + +### Root cause probable du "distance toujours vide" — risque architecture réel côté watch natif +`WatchHeartRateCollector.kt:106-107` enregistre `DataType.DISTANCE` et `DataType.CALORIES` via **`MeasureClient.registerMeasureCallback`**, le même client que pour la FC. Or Health Services `MeasureClient` est documenté par Google comme ne supportant en pratique de façon fiable que quelques types passifs (FC, et sur certains devices SpO2) : **la distance et les calories nécessitent normalement un `ExerciseClient`** (session d'exercice Health Services explicite, avec `ExerciseType`, capacités GPS le cas échéant), pas le mode passif "measure" utilisé ici. + +Conséquence probable : sur device réel, `registerMeasureCallback(DataType.DISTANCE, ...)` échoue ou ne remonte simplement jamais de données — et comme `onRegistrationFailed` n'est que loggé (jamais remonté), ce échec est **indiscernable du fallback silencieux voulu par l'UX** ("pas de donnée = rien ne s'affiche"). C'est très probablement pour cela que le ticket reste ouvert malgré une UI déjà terminée : la donnée source ne remonte jamais côté device réel, mais aucun signal ne le révèle sans instrumentation ciblée. + +### Plan exécutable +1. **Vérification (watch bridge natif, DevBackend/watch)** : appeler `measureClient.getCapabilities()` sur un device Wear OS réel compatible et confirmer si `DataType.DISTANCE`/`DataType.CALORIES` sont listés comme supportés en mode `MeasureClient`. C'est un test de quelques lignes, pas un chantier. +2. **Si non supporté (cas attendu)** : cadrer un sous-lot dédié `ExerciseClient` pour distance/calories, distinct du pipeline FC actuel — cycle de vie propre (start/pause/stop de session d'exercice aligné sur la séance GameTime), permissions supplémentaires possibles (`ACCESS_FINE_LOCATION` selon device pour la distance GPS-based). Ne pas le faire rentrer de force dans `WatchHeartRateCollector` : il mérite son propre collecteur (`WatchExerciseMetricsCollector` ou équivalent) branché sur le même pipeline de sample/summary existant (mêmes champs `WatchSensorSample.distanceMeters/caloriesKcal`, pas de nouveau contrat nécessaire). +3. **Si supporté mais silencieux pour une autre raison** (permission refusée, capability disponible mais jamais déclenchée) : corriger l'enregistrement/l'octroi de permission ciblé, toujours sans toucher au contrat ni à l'UI. +4. **Aucun travail Frontend Dart/Flutter requis** dans les deux cas — l'UI est prête et attend juste que le sample porte une valeur non nulle. +5. Reclassement recommandé : ce ticket devrait passer de `[DevFrontend]` à `[DevBackend]`/watch bridge natif dans son titre, pour éviter qu'il soit repris à tort comme un chantier Flutter. + +### Risque transverse +Si le sous-lot `ExerciseClient` s'avère nécessaire, il ajoute une complexité (permission localisation, gestion double session Health Services FC+Exercise en parallèle) non anticipée par le cadrage initial #156, qui supposait implicitement une seule source de collecte. À valider avec Main si le produit veut assumer cette complexité maintenant ou accepter un dégradé silencieux permanent (distance/calories jamais peuplées) sur les devices où seul `MeasureClient` HR fonctionne. diff --git a/.ideai/tickets/157/issue.md b/.ideai/tickets/157/issue.md index a9b386f..fc3a08a 100644 --- a/.ideai/tickets/157/issue.md +++ b/.ideai/tickets/157/issue.md @@ -8,9 +8,9 @@ sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} createdAt: 1785180656723 -updatedAt: 1785252668623 -version: 5 +updatedAt: 1785256180427 +version: 7 --- Ajouter la distance parcourue live remontée par la montre compatible, avec affichage léger côté téléphone pendant la séance et réutilisation côté montre dans le panneau secondaire. Dégradation silencieuse si donnée indisponible. \ No newline at end of file diff --git a/.ideai/tickets/159/carnet.md b/.ideai/tickets/159/carnet.md index 1986ace..1fb2c48 100644 --- a/.ideai/tickets/159/carnet.md +++ b/.ideai/tickets/159/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#159" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859208 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785255482109 --- diff --git a/.ideai/tickets/159/issue.md b/.ideai/tickets/159/issue.md index 4d2f511..db8edfa 100644 --- a/.ideai/tickets/159/issue.md +++ b/.ideai/tickets/159/issue.md @@ -2,15 +2,15 @@ id: "35022a8a-a4ef-4ffc-99d7-cd0a0e925fde" number: 159 title: "[DevFrontend] Surface montre statistiques live (FC principale + panneau secondaire)" -status: "qa" +status: "closed" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [{"target":"#106","kind":"relatesTo"},{"target":"#144","kind":"dependsOn"},{"target":"#145","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785180656758 -updatedAt: 1785248859208 -version: 4 +updatedAt: 1785255482109 +version: 5 --- Faire évoluer l'UI montre pour afficher la fréquence cardiaque live sur l'écran principal de séance, puis un écran/panneau secondaire accessible par glissement inverse du menu action avec fréquence cardiaque live, distance parcourue et calories live depuis le début de la séance. Rien n'est affiché pour les données indisponibles ; aucun message bloquant. \ No newline at end of file diff --git a/.ideai/tickets/163/carnet.md b/.ideai/tickets/163/carnet.md index b5dfaa6..8d7827d 100644 --- a/.ideai/tickets/163/carnet.md +++ b/.ideai/tickets/163/carnet.md @@ -1,6 +1,54 @@ --- issueRef: "#163" -version: 6 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785254327435 +version: 12 +updatedBy: {"kind":"user"} +updatedAt: 1785315994265 --- +## Cadrage UX — agent UX (2026-07-28) + +Statut : **impact UX cadré, mais le cœur du ticket reste technique** (Architect/DevBackend/QA — cf. tentative précédente KO faute de preuve device/toolchain, commit bc533d6c). Cette note ne prétend pas résoudre le problème de collecte en arrière-plan ; elle fixe ce que l'utilisateur doit voir (ou ne jamais voir) une fois la collecte réparée, et les critères d'acceptation QA côté surface. + +### Principe directeur +La collecte de FC en écran éteint est un problème de **continuité de donnée**, pas un problème de surface. Aucune nouvelle UI montre ou téléphone n'est nécessaire pour ce ticket. Conformément à [[gametime-online-layer-philosophy]] et aux garde-fous déjà actés en #155 ("silence par défaut", "aucun message bloquant"), la correction doit être **invisible** pour l'utilisateur : il ne doit rien avoir à faire, rien à valider, rien à lire de nouveau. + +### Comportement attendu +- La FC continue d'être **échantillonnée et enregistrée** pendant toute la séance, y compris quand l'écran de la montre est éteint/assombri (mode ambient) — l'écran éteint est un état d'affichage, pas un état de séance en pause. +- À l'écran rallumé (montre ou téléphone), la valeur affichée doit reprendre normalement, sans artefact visuel (pas de valeur figée depuis avant l'extinction affichée comme si elle était "live", pas de saut brutal non plausible). +- Aucun changement de placement/libellé : la FC reste affichée exactement comme cadré par #161 (icône cœur, écran principal montre) et #155 (barre téléphone) — ce ticket ne touche aucune de ces décisions. + +### Garde-fous spécifiques à ce ticket +- **Ne jamais afficher de FC en direct correspondant à des données non capturées** : mieux vaut un trou silencieux dans les statistiques d'après-séance (cf. règle #155 "en cas de donnée partielle : afficher seulement ce qui existe") qu'une valeur interpolée ou dupliquée artificiellement pendant l'écran éteint — l'intégrité des agrégats min/moyenne/max par étape/série/exercice (#106/#144) en dépend directement. Une valeur fabriquée pendant le trou fausserait ces agrégats plus qu'elle ne les compléterait. +- **Aucune sollicitation utilisateur nouvelle** : si la résolution technique nécessite un service de fond (foreground service, exemption de mise en veille), l'autorisation doit rester dans le flux de permissions déjà débloqué par les tickets précédents (f71a1551/58272e35) — pas de nouvelle popup dédiée à ce ticket, pas d'explication technique exposée à l'utilisateur ("service en arrière-plan requis", batterie, etc.). +- Si la contrainte plateforme rend la collecte écran éteint réellement impossible sur certains appareils : dégrader **silencieusement** (la portion de séance concernée manque à l'historique, sans message), jamais bloquer la séance ni avertir l'utilisateur en cours de séance — cohérent avec la philosophie transverse "aucun message bloquant" déjà actée sur tout le chantier stats montre (#106/#144/#145/#157/#155). + +### Ce qui reste hors du périmètre UX +- Le choix technique (listener passif vs foreground service vs API plateforme spécifique) appartient à Architect/DevBackend. +- La preuve de fonctionnement sur device réel / toolchain (bloquant de la tentative précédente) reste un sujet QA/Architect, pas un sujet de conception de surface. + +--- + +## Cadrage Architect (2026-07-28) + +Statut : **l'architecture cible existe déjà dans le code, ce n'est pas un chantier de conception nouvelle**. Vérifié en lecture directe (pas de supposition) : + +- `WatchHeartRateCollector.kt` utilise Health Services `MeasureClient.registerMeasureCallback(DataType.HEART_RATE_BPM, ...)` — API passive recommandée par Google pour la FC en arrière-plan, pas un `SensorEventListener` classique fragile à l'ambient mode. +- `WatchHeartRateForegroundService.kt` démarre un vrai foreground service typé `FOREGROUND_SERVICE_TYPE_HEALTH` (API 34+) / type 0 sinon, avec notification ongoing — exactement le pattern Wear OS attendu pour survivre à l'écran éteint et au Doze. +- `AndroidManifest.xml` déclare déjà `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_HEALTH`, `BODY_SENSORS`, **`BODY_SENSORS_BACKGROUND`**, `health.READ_HEART_RATE` — le triplet permission+foreground service+MeasureClient est le combo correct pour la collecte écran éteint. +- `WatchBridgePlugin.updateHeartRateCollection()` pilote `start()/pause()` uniquement sur changement de `phase` (running vs pas running), pas sur un signal d'écran — donc une fois démarré, rien ne l'arrête sur extinction d'écran par construction. +- `MainActivity.getAmbientCallback()` est un callback vide (aucun override `onEnterAmbient`/`onExitAmbient`), sans wake lock — mais ce n'est pas nécessairement un défaut : le pipeline HR ne dépend pas de l'Activity ni de l'ambient callback, il dépend du foreground service + du singleton `WatchBridgePlugin`/`WatchHeartRateCollector` attaché à l'`applicationContext`. + +**Conclusion** : sur le papier, l'architecture respecte déjà le pattern Wear OS standard pour la collecte FC hors écran. Le commit bc533d6c ("KO - preuve device/toolchain insuffisante") indique que le blocage précédent n'était probablement **pas un défaut de conception** mais une **impossibilité de valider sur device réel** (les émulateurs Wear OS ne simulent pas fidèlement l'ambient mode/Doze ni le comportement Health Services par OEM). + +### Risques identifiés (à couvrir avant de reclore le ticket) +1. **Comportement par OEM** : Health Services et la gestion de Doze/App Standby varient entre fabricants Wear OS (Samsung Galaxy Watch, Pixel Watch, TicWatch...). Un test sur un seul device ne suffit pas à conclure. Il faut au moins 2 modèles distincts. +2. **`onRegistrationFailed` est seulement loggé** (`Log.w`), jamais remonté au bridge ni au téléphone. Si l'enregistrement `MeasureClient` échoue silencieusement sur un device donné (capability non supportée), le symptôme observé sera identique à "FC qui ne s'actualise plus écran éteint" — indiscernable du bug réel sans logs device. Recommandation QA : instrumenter une capture logcat pendant le test écran éteint, pas seulement observer le résultat UI. +3. Aucune preuve dans le code que le process/l'engine Flutter reste vivant assez longtemps en arrière-plan pour que `WatchBridgePlugin` (qui vit dans le process app, pas dans le Service) continue de transmettre les échantillons au téléphone via `Wearable.getMessageClient` — le foreground service maintient le process vivant, ce qui devrait suffire, mais c'est un point à vérifier explicitement sur device (voir si des samples sont bien reçus côté téléphone pendant la fenêtre écran éteint, pas seulement collectés côté montre). + +### Plan exécutable +- **Pas de nouveau contrat, pas de nouveau port, pas de nouvelle classe** : ce ticket ne rouvre pas #156. +- Lot unique, scope **watch bridge natif + QA device** (pas de Frontend Dart/Flutter a priori) : + 1. Instrumentation temporaire (logs) sur `onDataReceived`/`onRegistrationFailed`/`onAvailabilityChanged` si absente en debug build, pour obtenir une preuve device exploitable. + 2. Test protocolé QA : séance réelle, écran éteint manuellement (bouton physique) pendant >2 min, sur au moins 2 modèles de montre compatibles, vérifier réception continue des samples côté téléphone (pas seulement côté montre). + 3. Si le test confirme une perte réelle (pas juste un artefact d'émulateur) : revenir vers Architect avec les logs device pour trancher un correctif ciblé (probable candidat : `WAKE_LOCK` partiel côté foreground service si l'OEM suspend le CPU malgré le foreground service typé health — actuellement absent du manifest). + 4. Si le test confirme que ça fonctionne déjà : reclasser le ticket en clôture QA, la "régression" perçue par l'utilisateur était probablement liée à l'ancien état du code avant f71a1551/58272e35, pas à l'état actuel. +- **Frontend (téléphone/montre Flutter)** : aucun changement requis. L'affichage FC (icône cœur, barre téléphone) consomme déjà les samples tels qu'ils arrivent ; il n'y a rien à modifier côté présentation pour ce ticket. diff --git a/.ideai/tickets/163/issue.md b/.ideai/tickets/163/issue.md index 93cb5f7..d1de715 100644 --- a/.ideai/tickets/163/issue.md +++ b/.ideai/tickets/163/issue.md @@ -2,15 +2,15 @@ id: "2c794bcc-09c1-47c7-a7ed-1e2f0e408ffd" number: 163 title: "Frequence cardiaque qui ne semble plus calculée si la montre à l'écran éteint" -status: "inProgress" +status: "qa" priority: "medium" sprint: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785242433447 -updatedAt: 1785254327435 -version: 6 +updatedAt: 1785315994265 +version: 12 --- Quand l'écran de la montre est éteint, la fréquence cardiaque n'est aps actualisée (l'affichage est assombris sur le téléphone et la fréquance ne change pas). Pour les statistiques il fauit que la fréquence cardiaque continue d'être enregistrée \ No newline at end of file diff --git a/.ideai/tickets/164/carnet.md b/.ideai/tickets/164/carnet.md index d6f7552..4a82ae1 100644 --- a/.ideai/tickets/164/carnet.md +++ b/.ideai/tickets/164/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#164" -version: 5 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785244375351 +version: 8 +updatedBy: {"kind":"user"} +updatedAt: 1785315994240 --- ## #164 — Cadrage UX (2026-07-28) @@ -20,3 +20,15 @@ updatedAt: 1785244375351 - Ne pas inventer un nouveau pattern haptique montre sans vérifier d'abord si le double-impulsion déjà spécifié pour "fin de chrono" couvre déjà ce cas. **Libellé/navigation** : aucune évolution nécessaire, correctif de feedback sonore/haptique uniquement. + +## Complément Main (2026-07-28) + +Le besoin utilisateur est désormais plus précis que le ticket initial : +- le son de fin de chrono manque toujours ; +- la vibration montre doit être **beaucoup plus perceptible en course/sauts** ; +- il existe un doute fort sur l'absence de vibration quand l'écran montre est éteint ; +- la préférence produit est de **retirer encore de la logique métier de la montre**. + +Conséquence : +- `#164` reste le ticket bug de feedback utilisateur observable ; +- le cadrage d'architecture correspondant est désormais extrait dans `#173` pour trancher proprement le pilotage téléphone -> montre des alertes chrono. diff --git a/.ideai/tickets/164/issue.md b/.ideai/tickets/164/issue.md index dc56954..2691141 100644 --- a/.ideai/tickets/164/issue.md +++ b/.ideai/tickets/164/issue.md @@ -2,15 +2,15 @@ id: "879caaa3-2658-4ad8-bfa0-a7444cffc1b0" number: 164 title: "[Bug] Plus de son en fin de chrono" -status: "inProgress" +status: "qa" priority: "medium" sprint: "abc4f969-b169-45f7-988c-daeeab762201" links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785243194849 -updatedAt: 1785244375351 -version: 5 +updatedAt: 1785315994240 +version: 8 --- Il n'y a plus de bips en fin de chrono. Sur les 3 derniere seconde il doit y avoir un big court et un bip plus long à 0. J'aiemrais que la montre vibre quand le chrono arrive à 0 \ No newline at end of file diff --git a/.ideai/tickets/166/carnet.md b/.ideai/tickets/166/carnet.md index ccf9f0f..7c700bb 100644 --- a/.ideai/tickets/166/carnet.md +++ b/.ideai/tickets/166/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#166" -version: 8 +version: 12 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785254327361 +updatedAt: 1785311597894 --- ## #166/#167/#169 — Cadrage UX coordonné : slot "donnée dominante sans chrono" (UX, 2026-07-28) @@ -65,3 +65,28 @@ Le sous-titre disparaît, l'espace libéré n'est **pas** réutilisé pour un no - #166 et #167 modifient la **même zone d'écran** (slot donnée dominante + bouton d'action pour une étape reps-only) — les livrer séparément risquerait qu'un commit écrase la mise en page de l'autre, ou que le bouton `[✓]` soit ajouté sans que la redondance `SÉRIE X/Y` soit corrigée en même temps (ou l'inverse). - #169 touche un état adjacent du même écran (`Séance active` sans chrono actif) et applique le même principe (pas de texte qui ne porte pas d'action ni de donnée nécessaire) — le traiter isolément ferait revivre exactement la dérive de densité déjà diagnostiquée et corrigée par #161 (accumulation de correctifs isolés qui perdent la cohérence d'ensemble). - Recommandation : un seul lot Architect/DevFrontend pour les trois, avec cette section comme spec unique de référence. + +--- + +## Cadrage Architect (2026-07-28) + +Statut : **ticket déjà implémenté dans le code actuel, conforme au cadrage UX ci-dessus**. Vérifié en lecture directe sur `watch_app/lib/presentation/watch_session_screen.dart` : + +- `_ActiveContent.build()` (~L704-788) calcule `repsTarget` et bascule déjà sur `_DominantRepsLine` quand `repsTarget != null` (L753-758), au lieu de la donnée `SÉRIE` — #167 déjà appliqué. +- `_DominantRepsLine` (~L790-815) affiche la valeur reps + `_CompleteStepButton` sur la même ligne, gabarit `SizedBox(height: 52)` + `Row` — conforme au template ligne unique posé par #161. +- `_CompleteStepButton` (~L817-844) : `SizedBox.square(dimension: 48)` (cible tactile 48dp respectée), icône `Icons.check`, couleur or `#C9A24A` sur fond `#141824` avec liseré crimson `#D72638` — conforme au style #128/#161, visuellement distinct de pause/play. +- Câblage commande : le bouton appelle `onCompleteStep` → `WatchSessionViewModel.completeCurrentStep()` (`watch_session_view_model.dart:190-191`) → `WatchCommandType.completeCurrentStep`, une commande **distincte** de `WatchCommandType.skipCurrentStep` (utilisée par `Passer l'étape` dans `Actions`) — la séparation validation/contournement exigée par le cadrage UX est bien respectée, pas de confusion de commande. +- Libellé `Répétitions` déjà affiché une seule fois au-dessus de la valeur (L751, `_SmallLabel(repsTarget == null ? dominantLabel : 'Répétitions')`) — #167 confirmé. +- Recherche du texte `Prêt pour la série suivante` dans tout le repo Dart : **aucune occurrence** — le sous-titre visé par #169 n'existe déjà plus. +- Aucun double bouton `[✓]`/`[⏸]` possible : `_ActiveContent.build()` structure les branches `repsTarget != null` / `timer == null` / chrono en `if/else if/else` mutuellement exclusifs (L753-767). + +### Conclusion +**Aucun développement restant.** #166 (et vraisemblablement #167/#169 associés dans ce même carnet) sont fonctionnellement livrés dans le code présent sur la branche. Il n'y a ni contrat, ni port, ni frontière hexagonale à faire évoluer ici — pure présentation Flutter déjà en place. + +### Recommandation +- Ne pas relancer de lot DevFrontend sur ce ticket : le repasser directement en validation **QA** sur device/émulateur Wear OS réel, avec ces points de contrôle précis : + 1. Étape reps-only sans chrono → bouton `[✓]` visible et fonctionnel sur l'écran principal (pas besoin de swiper vers `Actions`). + 2. Étape avec score chrono manuel (`_ManualScoreContent`) → bouton `[✓]` absent de ce layout dédié, pas de régression croisée. + 3. Écran `Prêt pour la série suivante` → absence confirmée du sous-titre, pas de nouvel élément parasite. + 4. Cible tactile et absence d'ambiguïté visuelle avec le bouton pause/play sur écran chrono. +- Si le ticket reste "open" c'est vraisemblablement un oubli de requalification après une implémentation antérieure (probablement dans le même lot que #161/#159, cf. commit bc533d6c "lot #165-169 validé QA") plutôt qu'un travail réellement en attente. À confirmer avec Main avant clôture pure, mais aucune architecture nouvelle n'est à cadrer. diff --git a/.ideai/tickets/166/issue.md b/.ideai/tickets/166/issue.md index b803284..8d12c91 100644 --- a/.ideai/tickets/166/issue.md +++ b/.ideai/tickets/166/issue.md @@ -2,7 +2,7 @@ id: "71f99d03-7ac8-435f-92ef-087e61c0f873" number: 166 title: "[UI] mettre un bouton sur l'écran principal de la montre pour passer à l'étape suivante dans le cas de répétitions dans une étape" -status: "qa" +status: "closed" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"} createdBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785250931982 -updatedAt: 1785254327361 -version: 8 +updatedAt: 1785311597894 +version: 12 --- Quand on a une étape qui ne demande qu'un nombre de répétitions, il faudrait que le bouton pour calider l'étape directement sur l'écran principal de la montre. \ No newline at end of file diff --git a/.ideai/tickets/169/carnet.md b/.ideai/tickets/169/carnet.md index a1a8786..a3f338c 100644 --- a/.ideai/tickets/169/carnet.md +++ b/.ideai/tickets/169/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#169" -version: 6 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785254327400 +version: 7 +updatedBy: {"kind":"user"} +updatedAt: 1785255487215 --- ## #169 — Cadrage UX (2026-07-28) diff --git a/.ideai/tickets/169/issue.md b/.ideai/tickets/169/issue.md index fc3d8d0..48a73b3 100644 --- a/.ideai/tickets/169/issue.md +++ b/.ideai/tickets/169/issue.md @@ -2,15 +2,15 @@ id: "7e5b1ba4-5f5c-46d1-b05a-6d6f5af73f23" number: 169 title: "[UI] Enlever \"Prêt pour la série suivante\" sur la montre" -status: "qa" +status: "closed" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785251491623 -updatedAt: 1785254327400 -version: 6 +updatedAt: 1785255487215 +version: 7 --- Je pense que le titre "Prêt pour la série suivante" est a enlever de la montre, il prend de la place pour pas grand chose \ No newline at end of file diff --git a/.ideai/tickets/170/carnet.md b/.ideai/tickets/170/carnet.md index feba4e1..0eb1ad7 100644 --- a/.ideai/tickets/170/carnet.md +++ b/.ideai/tickets/170/carnet.md @@ -1,6 +1,39 @@ --- issueRef: "#170" -version: 4 -updatedBy: {"kind":"user"} -updatedAt: 1785255295122 +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785311597912 --- + +## #170 — Cadrage UX (2026-07-28) + +Statut : **nouveau lot, cadrage exploitable, indépendant de #166/#167/#169.** + +### Pourquoi ce n'est pas un doublon de #166/#167/#169 +Ces trois tickets couvrent le cas **reps-only sans score manuel** sur la montre (branche `_ActiveContent` / `_DominantRepsLine` dans `watch_session_screen.dart`, déjà livrée). #170 décrit un **autre écran** : une étape qui combine `type = reps` **et** `hasScore` en mode manuel (`ScoreInputMode.manual`) — cette étape bascule sur `projection.hasManualScore == true` et rend `_ManualScoreContent` (watch_session_screen.dart:894), un layout entièrement différent qui n'a jamais affiché le nombre de répétitions cible. Vérifié en lecture directe : + +- `_watchManualScoreProjection` (lib/application/use_cases.dart:4285-4318) ne connaît que la **cible de score** (`targetValue`/`targetLabel: 'Cible'`), jamais `step.defaultTargetValue` (le nombre de répétitions). Le champ répétitions n'existe nulle part dans cette branche de données. +- `_ManualScoreContent.build()` (watch_session_screen.dart:907-993) affiche : nom d'exercice, bande étape, puis **si `target != null`** une ligne caption `"$targetLabel : $target"` (ex. `Cible : 500`), puis le label `SCORE` et la ligne +/- score. Rien n'y référence `step.type` ni les répétitions. + +Donc le constat utilisateur est exact : sur une étape score-libre + répétitions, la montre affiche le score (incrémentable) mais **jamais** le nombre de répétitions à réaliser pendant ce score — contrairement au cas reps-only (#166/#167) qui, lui, a bien sa donnée dominante dédiée. + +### Comportement attendu +Réutiliser le gabarit caption déjà en place (`_ManualScoreContent`, juste au-dessus du label `SCORE`), sans ajouter de nouvelle ligne ni de nouveau composant : + +```text +Pompes tempo +Répétitions : 10 ← nouvelle info, même style que "Cible : 500" + +SCORE + − 42 + + +♥ 118 +``` + +- Si l'étape a **aussi** une cible de score (`targetValue` non nul), joindre les deux informations sur la **même ligne**, séparées par `·` — même règle de mutualisation que les chronos secondaires (`secondaryTimers.join(' · ')`, déjà dans ce fichier) : `Répétitions : 10 · Cible : 500`. Ne jamais empiler deux lignes de caption : une étape à la fois ne doit montrer qu'une seule ligne d'info secondaire au-dessus du score, pour ne pas reproduire la densité déjà corrigée par #161. +- Si l'étape n'a **pas** de cible de score, la ligne devient simplement `Répétitions : 10` (remplace l'espace aujourd'hui vide quand `target == null`). +- Le score reste la donnée manipulable (boutons −/+ inchangés) : les répétitions ici sont une **cible informative**, pas un compteur actionnable — pas de bouton de validation à ajouter sur cette ligne (l'étape se termine via le flux existant de fin de score/étape, non modifié par ce ticket). +- Style : reprendre exactement `Theme.of(context).textTheme.bodySmall` + couleur `#A7ADBA` + `fontSize: 11`, `maxLines: 1`, `overflow: ellipsis` — déjà le style de la ligne `Cible`, aucune nouvelle charte à définir. + +### Ce qu'il faut pour Architect +Donnée manquante à faire remonter dans `_WatchManualScoreProjectionData` / `WatchSessionProjection` : le nombre de répétitions cible de l'étape courante (`step.defaultTargetValue` quand `step.type == ExerciseStepType.reps`), à côté du `targetValue`/`targetLabel` de score existant. Recommandation : soit un champ dédié (`repsTargetValue`), soit généraliser la caption en une liste de segments texte assemblés côté UI — au choix d'Architect selon ce qui est le plus cohérent avec le modèle de projection existant. Le rendu attendu (label unique, jointure `·`, une seule ligne) est fixé ci-dessus et ne doit pas varier selon l'implémentation choisie. diff --git a/.ideai/tickets/170/issue.md b/.ideai/tickets/170/issue.md index edc25aa..9a635c6 100644 --- a/.ideai/tickets/170/issue.md +++ b/.ideai/tickets/170/issue.md @@ -2,15 +2,15 @@ id: "90277a05-bd6a-4360-942e-dd500c2020e0" number: 170 title: "[UI] Afficher le nombre de répétitions à faire dans une étape avec score et répétitions" -status: "open" +status: "closed" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785255090292 -updatedAt: 1785255295122 -version: 4 +updatedAt: 1785311597912 +version: 6 --- Dans une étape avec score libre + répétition il faut que l'utilisateur ai d'affiché sur sa montre le nombre de répétitions à faire en plus de pouvoir incrémenter ou décrémenter le score. Actuellement, il manque l'affichage du nombre de répétitions \ No newline at end of file diff --git a/.ideai/tickets/171/carnet.md b/.ideai/tickets/171/carnet.md index eecc58c..4b44a60 100644 --- a/.ideai/tickets/171/carnet.md +++ b/.ideai/tickets/171/carnet.md @@ -1,6 +1,42 @@ --- issueRef: "#171" -version: 4 -updatedBy: {"kind":"user"} -updatedAt: 1785255293774 +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785311597930 --- + +## #171 — Cadrage UX (2026-07-28) + +Statut : **nouveau lot, cadrage exploitable, sans lien avec #166/#167/#169** (ces trois tickets portent sur l'app montre `watch_app/`, celui-ci porte sur l'app téléphone, `lib/presentation/workout_execution_screen.dart` — écran d'exécution de séance, panneau étape courante). + +### Constat, vérifié en lecture directe +Dans `_CurrentStepPane.build()` (workout_execution_screen.dart:2711-2898), deux mises en page coexistent pour l'étape courante : + +- Cas **reps + score chrono** (`hasRepsWithStopwatchScore`, L2723-2803) : layout en `Column` de hauteur fixe, pas de scroll, boutons `Passer l'étape` / `Étape suivante` **côte à côte en bas**, toujours visibles. +- Cas **reps seules** (sans score), branche `else` (L2805-2895) : layout enveloppé dans `SingleChildScrollView` + `IntrinsicHeight`, avec : + 1. nom de l'étape, + 2. `Expanded` → `_RepsStepBody` (valeur reps + bouton `Étape suivante`, aligné en haut du `Expanded`), + 3. ligne `Passer l'étape` + menu `...` (passer passage/séquence), **après** le `Expanded`. + +Comme `_RepsStepBody` ne remplit pas toute la hauteur du `Expanded` (son contenu est aligné en haut via `Align(topCenter)` + `FittedBox`), la ligne `Passer l'étape` se retrouve repoussée en bas du contenu scrollable, potentiellement hors de la zone visible du panneau → scroll nécessaire pour l'atteindre. C'est exactement le symptôme décrit par le ticket, et le bouton `Étape suivante` existe déjà (dans `_RepsStepBody`) mais **pas au même endroit** que `Passer l'étape`. + +### Comportement attendu +Aligner le cas reps-seules sur le gabarit déjà validé du cas reps+score chrono (L2769-2793), pour cohérence et sans scroll : + +```text +Pompes tempo + + 10 + RÉPÉTITIONS + +[ Passer l'étape ] [...] [ ✓ Étape suivante ] +``` + +- Supprimer le `SingleChildScrollView` + `IntrinsicHeight` de la branche reps-seules : la hauteur du panneau doit se calculer normalement (nom étape + valeur reps + score input optionnel + une seule ligne de boutons), sans scroll, comme le fait déjà la branche voisine. +- Retirer le bouton `Étape suivante` de `_RepsStepBody` (workout_execution_screen.dart:3243-3269) — il ne doit plus être dupliqué à mi-écran. `_RepsStepBody` n'affiche plus que la valeur + le libellé `RÉPÉTITIONS`. +- La ligne de boutons en bas devient : `Passer l'étape` (Expanded, comme aujourd'hui) + menu `...` (inchangé, passage/séquence) + `Étape suivante` (nouveau, `FilledButton.icon` avec check, même style que la branche `hasRepsWithStopwatchScore` L2782-2791). Trois éléments sur une seule ligne — largeur téléphone suffisante, pas de contrainte watch ici. +- Garder `Passer l'étape` et `Étape suivante` comme deux commandes distinctes (contournement vs validation), même principe que sur la montre (#166) : ne pas les fusionner en un seul bouton. +- Si l'étape a aussi un score (`step.hasScore`, saisie manuelle), le `_StepScoreInput` reste affiché entre la valeur reps et la ligne de boutons, comme aujourd'hui (L2843-2854) — ce ticket ne touche pas cette sous-partie, seulement le retrait du scroll et le repositionnement du bouton de validation. + +### Ce qu'il faut pour Architect / DevFrontend +Pur remaniement de présentation Flutter dans un seul fichier (`workout_execution_screen.dart`), pas de nouvelle donnée ni de nouveau contrat : il s'agit de réutiliser un layout déjà existant (celui de la branche `hasRepsWithStopwatchScore`) pour la branche reps-seules, et de déplacer un bouton existant (`onCompleteStep`) d'un widget à l'autre. Point de vigilance dev : vérifier que le panneau (`_BoundedAccentPanel`, hauteur contrainte par son parent) a bien assez de place pour ce layout à hauteur fixe sur les plus petites tailles d'écran supportées — sinon réduire les paddings/tailles de police via `FittedBox`/`Theme` déjà utilisés ailleurs dans ce fichier, sans réintroduire de scroll. diff --git a/.ideai/tickets/171/issue.md b/.ideai/tickets/171/issue.md index f5050e3..bd3979d 100644 --- a/.ideai/tickets/171/issue.md +++ b/.ideai/tickets/171/issue.md @@ -2,15 +2,15 @@ id: "1bb57b78-7855-4e19-92e6-6d67c9155447" number: 171 title: "[UI] enlever le scrolling dans le cas d'une étape avec répétitions seulement" -status: "open" +status: "closed" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [] agentRefs: [{"agentId":"f3408f5d-469c-4f64-9485-d8b218f3ff26","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785255221480 -updatedAt: 1785255293774 -version: 4 +updatedAt: 1785311597930 +version: 6 --- Actuellement dans le cas ou une étape contient un nombre de répétitions seulement, le bouton "passer l'étape" est tout en bas et necessite un scrolling. Il faudrait qu'il soit alligné avec le bouton Etape suivante je pense pour éviter le scrolling. Voir avec UX \ No newline at end of file diff --git a/.ideai/tickets/172/carnet.md b/.ideai/tickets/172/carnet.md new file mode 100644 index 0000000..979e3c3 --- /dev/null +++ b/.ideai/tickets/172/carnet.md @@ -0,0 +1,103 @@ +--- +issueRef: "#172" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785272880017 +--- + +## #172 — Cadrage Main (2026-07-28) + +### Décision produit +Direction validée : +- la montre reste un **capteur + télécommande** ; +- le téléphone reste **source de vérité métier** ; +- on optimise le trafic et la chauffe par **batching côté montre**, pas par déplacement de logique métier. + +### 1. Télémétrie stats montre +Deux modes à cadrer : +- **écran montre allumé** : cadence plus fréquente pour un live crédible ; +- **écran montre éteint / ambient** : flush agrégé toutes les ~30s, inspiré du comportement observé sur Heavy. + +La montre ne calcule pas les agrégats métier finaux. Elle bufferise seulement des échantillons ou micro-agrégats transport : +- FC : min/max/moy locale de fenêtre + latest utile à l'affichage ; +- distance/calories : latest cumulée de fenêtre, jamais recalcul métier final ; +- timestamps de capture et contexte d'exécution. + +Le téléphone : +- persiste ; +- fusionne ; +- construit les agrégats de séance / étape / série / exercice / historique ; +- résout les conflits et les trous. + +### 2. Score manuel montre +Décision : **oui au debounce 500ms, non au "score final sans contexte"**. + +Le flux cible minimal : +- la montre met à jour le score **optimistement** à chaque tap ; +- chaque tap reset un timer de 500ms ; +- au silence de 500ms, la montre envoie au téléphone une intention de type `setManualScore` contextualisée ; +- le téléphone valide, persiste, reprojette ; +- la montre se recale silencieusement sur la projection confirmée. + +### 3. Garde-fous architecture +- Pas de logique métier durable sur la montre. +- Pas de calcul d'historique ni de règle d'invariants sur la montre. +- Chaque message montre -> téléphone doit embarquer le contexte minimal : `sessionId`, indices d'exécution, `baseRevision`, horodatage. +- Rejet/conflit côté téléphone : la projection confirmée gagne toujours. +- Crash montre avant flush : perte limitée à la fenêtre non flushée, acceptable pour le score seulement si la montre ne prétend jamais avoir persisté côté téléphone. + +### 4. Contrat à cadrer ensuite +Ticket attendu après celui-ci : +- extension ou adaptation du contrat watch bridge pour batches télémétrie ; +- ajout d'une commande contextualisée `setManualScore` ou équivalent ; +- stratégie de flush explicite selon état écran / foreground / ambient ; +- QA device sur chauffe, batterie, cohérence de resync. + +## Cadrage Architect (2026-07-28) + +Statut : **cadrage exploitable, prêt pour découpage dev**. + +### Architecture cible minimale +- Garder `phone = source de vérité`, `watch = capteur + télécommande`. +- Conserver `WatchCompanionCommandHandler` comme point d'entrée unique des intentions montre. +- Ajouter un flux applicatif téléphone dédié pour la télémétrie batchée : `WatchTelemetryBatchIngress` -> validation -> appel de `WorkoutTelemetryUseCases.recordTelemetrySample(...)` pour chaque sample -> `WorkoutHistoryUseCases.updateHeartRateSummary(...)` au flush final. +- La montre ne calcule ni score final ni agrégats métier ; elle ne bufferise que du transport. + +### Télémétrie batchée watch -> phone +- Recommandation : nouveau DTO `WatchTelemetryBatch`, `schemaVersion = 5`, nouveau path message `/gametime/watch/telemetry-batch`. +- Le téléphone déduplique au niveau **sample** (`sampleId` stable), pas au niveau batch. +- `WatchSensorSummary` est conservé pour le résumé final de séance, pas remplacé. + +### Stratégie interactive vs ambient/screenOff +- `interactive` : flush toutes les `5s` max. +- `ambient/screenOff` : flush toutes les `30s` max. +- Flush immédiat aussi sur : fin de séance, pause explicite si la collecte s'arrête, reconnexion téléphone, changement d'état écran, buffer plein. +- Cap de sécurité : couper les batches trop gros (~60 samples). +- Une petite outbox locale de télémétrie côté montre est acceptable pour survivre à un crash : c'est du transport, pas du métier. + +### Score manuel debounce +- Décision Architect : **ne pas faire de `setManualScore(value)` comme contrat principal**. +- Recommandation : un batch d'intentions delta, type `applyManualScoreDeltaBatch`. +- La montre accumule les taps `+/-` pendant `500 ms`, puis envoie un seul delta net avec `sessionId`, position courante, `baseRevision`, horodatage. +- Le téléphone applique le delta seulement si la cible métier est toujours la même ; sinon rejet et resync. + +### Invariants et risques +- La montre ne calcule jamais la valeur finale du score. +- Les écritures d'historique et agrégats durables restent côté téléphone. +- Une intention score batchée n'est applicable que si `sessionId + scope + position courante` correspondent encore. +- Les retries de télémétrie ne doivent jamais dupliquer les samples : `sampleId` stable obligatoire. +- En cas de crash montre : + - score pending perdu puis recalage sur projection téléphone ; + - télémétrie non flushée reprise depuis l'outbox local. +- En reconnexion : + - resync projection immédiat ; + - flush immédiat de l'outbox télémétrie ; + - aucun replay d'anciennes intentions score. + +### Lots recommandés +1. `B1 [DevBackend] Contrat bridge v5 et ingress télémétrie batchée` +2. `B2 [DevBackend] Application téléphone score debounce + validation de cible` +3. `B3 [DevBackend] Déduplication, retries, flush final` +4. `F1 [DevFrontend] Buffer montre télémétrie et stratégie interactive vs ambient` +5. `F2 [DevFrontend] Score optimiste debounce 500 ms` +6. `QA1 [QA] Matrice de cohérence et robustesse` diff --git a/.ideai/tickets/172/issue.md b/.ideai/tickets/172/issue.md new file mode 100644 index 0000000..a0b69e3 --- /dev/null +++ b/.ideai/tickets/172/issue.md @@ -0,0 +1,20 @@ +--- +id: "7536c1ce-3683-4fb6-989f-7a6d3365cc69" +number: 172 +title: "[Architect] Cadrage batching stats montre et debounce score montre -> téléphone" +status: "closed" +priority: "high" +sprint: "2c0dd1ea-8809-49ff-adc1-aa8820815ee7" +links: [{"target":"#119","kind":"relatesTo"},{"target":"#155","kind":"relatesTo"},{"target":"#156","kind":"relatesTo"},{"target":"#158","kind":"relatesTo"},{"target":"#163","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785257860728 +updatedAt: 1785272880017 +version: 2 +--- +Formaliser une architecture montre/téléphone plus économe en batterie et en requêtes : +- statistiques montre collectées localement puis envoyées au téléphone par batch/flush périodique, notamment écran montre éteint ; +- téléphone propriétaire des agrégats métier, de l'historique et des décisions ; +- montre limitée aux capteurs qu'elle seule connaît et aux commandes utilisateur ; +- score manuel montre avec UI optimiste locale et envoi debounce au téléphone, sans déplacer la logique métier sur la montre. diff --git a/.ideai/tickets/173/carnet.md b/.ideai/tickets/173/carnet.md new file mode 100644 index 0000000..edf266b --- /dev/null +++ b/.ideai/tickets/173/carnet.md @@ -0,0 +1,101 @@ +--- +issueRef: "#173" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785272880037 +--- + +## #173 — Cadrage Main (2026-07-28) + +### Décision produit +La direction proposée est retenue : +- **le téléphone décide** qu'un chrono arrive à 3, 2, 1, 0 ; +- **la montre ne dérive pas seule** un événement métier "chrono terminé" ; +- la montre reçoit une alerte à jouer et exécute l'haptique localement, même écran éteint si possible. + +### Périmètre exact +Ce ticket ne remplace pas `#164`, il le complète architecturalement. + +Il faut cadrer : +- le contrat téléphone -> montre d'alerte chrono ; +- la robustesse quand l'écran montre est éteint / ambient ; +- la force/pattern haptique suffisant pour activité physique réelle ; +- l'absence de doublon agressif ou de logique divergente entre téléphone et montre. + +### Recommandation cible +- téléphone : source des événements `countdownTick(3|2|1)` et `countdownFinished` ; +- montre : simple exécution d'un pattern reçu, idempotent et court ; +- support minimal hors écran : foreground/ongoing/session state suffisamment vivant pour que la montre puisse encore vibrer ; +- si une alerte est ratée faute de connectivité, la projection confirmée doit rester cohérente et ne pas faire vibrer en retard plusieurs secondes après. + +### Risques à cadrer +- latence si l'on transporte chaque tick individuellement ; +- duplication si la montre déclenche aussi localement ; +- vibration faible selon OEM si on reste sur un impact standard ; +- absence de vibration écran éteint si le process n'est pas au bon niveau de foreground/ongoing. + +### Attendu suite +Créer ensuite le lot d'architecture/implémentation qui tranche : +- événements instantanés vs petits batches d'alertes planifiées ; +- pattern haptique fort mais court ; +- comportement dégradé sans connexion ; +- QA sur montre réelle en mouvement. + +## Cadrage Architect (2026-07-28) + +Statut : **cadrage exploitable, prêt pour découpage dev**. + +### Architecture cible minimale +- Le téléphone reste l'autorité unique des événements d'alerte chrono. +- La montre n'infère plus seule une fin de chrono pour décider une vibration ; elle exécute une alerte explicite reçue du téléphone. +- La projection d'état actuelle est conservée pour l'affichage, avec un **flux séparé phone -> watch d'alertes éphémères**. +- Côté téléphone, une façade `WatchAlertPublisher` suffit au point où la logique chrono sait déjà qu'un `3/2/1/0/fin` se produit. + +### Répartition téléphone / montre +- Téléphone : + - détecte `3`, `2`, `1`, `0`, `fin chrono`, `fin repos` si retenu ; + - joue le son téléphone ; + - publie une alerte structurée vers la montre ; + - décide si une alerte est encore pertinente ou périmée. +- Montre : + - reçoit l'alerte ; + - déclenche l'haptique locale forte ; + - peut réveiller brièvement l'écran si faisable natif Wear ; + - ne décide jamais seule qu'un `0` est atteint. + +### Contrat phone -> watch recommandé +- Nouveau DTO `WatchAlertEnvelope`, `schemaVersion = 5`, transporté via `MessageClient` sur un path type `/gametime/phone/alert`. +- Champs clés : `alertId`, `sessionId`, `revision`, `kind`, `timerKind`, position courante, `scheduledForEpochMs`, `expiresAtEpochMs`, `pattern`. +- `alertId` sert à la déduplication. +- `scheduledForEpochMs` + `expiresAtEpochMs` servent à ignorer une alerte arrivée trop tard. + +### Latence / duplication / retard +- Envoi au moment de l'événement métier, pas à intervalle fixe. +- La montre ignore : + - tout `alertId` déjà joué ; + - tout `sessionId` non cohérent avec la séance projetée courante ; + - toute alerte reçue après son expiration. +- TTL recommandé : + - `3/2/1` : ~700 ms ; + - `0/fin` : ~2000 ms. +- Pas d'ACK obligatoire en v1 : une alerte manquée n'est pas rejouée après resync. + +### Foreground / ambient / ongoing +- S'appuyer sur l'existant : + - `WatchOngoingActivityController` ; + - `WatchHeartRateForegroundService` pendant `running`. +- Ne pas créer un second service d'alerte. +- Si un réveil écran bref est faisable, le réserver à `timerZero|timerFinished`, jamais à `3/2/1`. +- En ambient, l'alerte doit rester haptique-first. + +### Articulation avec #164 +- `#164` reste le ticket observable audio/alertes chrono. +- `#173` ajoute la prolongation architecturale vers la montre. +- Le haptique montre actuel basé sur transition de projection devient au mieux un fallback transitoire ; la cible propre est le nouveau flux d'alertes explicites. + +### Lots recommandés +1. `B1 [DevBackend] Contrat bridge alertes chrono + publisher téléphone` +2. `B2 [DevBackend] Déduplication / TTL / filtrage session` +3. `F1 [DevFrontend] Exécution locale alerte montre` +4. `F2 [DevFrontend] Intégration foreground/ambient` +5. `QA1 [QA] Validation alertes chrono phone/watch` diff --git a/.ideai/tickets/173/issue.md b/.ideai/tickets/173/issue.md new file mode 100644 index 0000000..1007d0a --- /dev/null +++ b/.ideai/tickets/173/issue.md @@ -0,0 +1,20 @@ +--- +id: "7b5853b0-61c2-427e-bc07-e8c662f35ec4" +number: 173 +title: "[Architect] Pilotage téléphone -> montre des alertes chrono (son + haptique forte + écran éteint)" +status: "closed" +priority: "high" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [{"target":"#164","kind":"relatesTo"},{"target":"#127","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785257860729 +updatedAt: 1785272880037 +version: 2 +--- +Cadrer le pilotage des alertes de chrono pour respecter la règle "un maximum de logique métier côté téléphone" : +- le téléphone décide quand une alerte de chrono doit partir ; +- la montre exécute une vibration suffisamment forte pour être sentie en course/sauts ; +- l'alerte doit continuer à fonctionner écran montre éteint si la séance est toujours active ; +- le son téléphone de fin de chrono reste requis sans couper la musique. diff --git a/.ideai/tickets/174/carnet.md b/.ideai/tickets/174/carnet.md new file mode 100644 index 0000000..5abdf81 --- /dev/null +++ b/.ideai/tickets/174/carnet.md @@ -0,0 +1,64 @@ +--- +issueRef: "#174" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785311597948 +--- + +## #174 — Cadrage Main (2026-07-28) + +### Symptôme utilisateur +Parfois, au lancement d'un chrono, l'affichage ne reste pas sur GameTime et retourne à l'accueil de la montre. + +### Position +Ce ticket est un **suivi plus précis** de `#127`, mais avec un déclencheur confirmé supplémentaire : le lancement/changement d'état du chrono. + +### Attendu +- tant qu'une séance est active, le retour gestuel ou le réveil écran doit favoriser le retour à GameTime ; +- un changement de projection de séance ou de chrono ne doit pas éjecter l'utilisateur hors de l'app ; +- l'app montre doit rester le point de focus naturel pendant la séance, sans demander de relancer manuellement l'app. + +### Axes à instruire ensuite +- ongoing activity / reprise d'activité ; +- task affinity / launch mode / intent de retour ; +- interaction entre foreground service, écran ambient et navigation Flutter ; +- différence entre "écran éteint puis réveil" et "lancement d'un chrono". + +## Cadrage Architect (2026-07-28) + +Statut : **cadrage exploitable, prêt pour lot frontend natif montre**. + +### Causes techniques plausibles +- `MainActivity` Wear est trop passive : pas de `onResume`, pas de `onNewIntent`, pas de logique de reentry/resync. +- La reprise dépend surtout du flux Flutter `EventChannel` et de `requestLatestProjection(...)`, sans ré-ancrage explicite de l'activity. +- Les intents ongoing/notification utilisent seulement `SINGLE_TOP | CLEAR_TOP`, sans action dédiée "ouvrir la séance active". +- Si `POST_NOTIFICATIONS` manque, l'ongoing activity peut ne pas fournir de point d'ancrage système. +- Le foreground service watch ne tourne que pour `phase == running`, donc l'UI peut perdre son point d'appui natif pendant certaines transitions alors que la séance existe encore. + +### Correctif minimal recommandé +- Corriger côté watch natif, sans changement de contrat métier. +- Ajouter une vraie voie de réentrée locale : + - intent explicite `ACTION_OPEN_ACTIVE_SESSION` ; + - même intent réutilisé par ongoing activity et foreground notification ; + - `onNewIntent` et `onResume` dans `MainActivity` qui appellent `requestLatestProjection(...)` + `requestCapabilityRefresh(...)`. +- Garder côté natif la dernière projection utile reçue ; si elle indique une séance active **encore fraîche ou rapidement reconfirmée**, la réouverture doit retomber sur GameTime, pas sur l'accueil système. +- Si la séance n'est plus confirmée, ne pas forcer de reopen : cela doit rester distingué de `#175`. + +### Impacts +- Contrats bridge : aucun changement obligatoire. +- Natif watch : + - action/extra de reentry ; + - cache de dernière projection utile ; + - resync explicite sur resume/new intent. +- UI Flutter montre : pas de changement structurel, au plus un resync plus agressif au retour foreground. +- DevBackend : non requis pour le correctif minimal. + +### Garde-fous vs #175 +- `#174` : le téléphone est vivant, la séance existe, mais l'activity montre décroche ou ne se ré-ancre pas. +- `#175` : la projection doit expirer quand la source téléphone disparaît durablement. +- Donc le reopen forcé de `#174` ne doit se faire que si la projection locale est encore fraîche ou si un resync confirme rapidement la séance. + +### Lots recommandés +1. `F1 [DevFrontend] Reentry natif montre` +2. `F2 [DevFrontend] Robustesse réveil/ambient` +3. `QA1 [QA] Validation montre ancrée` diff --git a/.ideai/tickets/174/issue.md b/.ideai/tickets/174/issue.md new file mode 100644 index 0000000..1a42b4d --- /dev/null +++ b/.ideai/tickets/174/issue.md @@ -0,0 +1,16 @@ +--- +id: "f2db430b-48cf-4626-beae-611ffb352043" +number: 174 +title: "[Bug] La montre quitte l'application ou revient à l'accueil pendant une séance/au lancement d'un chrono" +status: "qa" +priority: "high" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [{"target":"#127","kind":"relatesTo"},{"target":"#165","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1785257860730 +updatedAt: 1785311597948 +version: 2 +--- +Corriger le cas où, pendant une séance, le lancement d'un chrono ou certains changements d'état renvoient la montre vers l'écran d'accueil système au lieu de rester sur l'application GameTime. Le comportement attendu est que la montre reste ancrée sur la séance active tant qu'une séance est en cours. diff --git a/.ideai/tickets/175/carnet.md b/.ideai/tickets/175/carnet.md new file mode 100644 index 0000000..d2c0e52 --- /dev/null +++ b/.ideai/tickets/175/carnet.md @@ -0,0 +1,86 @@ +--- +issueRef: "#175" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1785315994252 +--- + +## #175 — Cadrage Main (2026-07-28) + +### Symptôme utilisateur +Si l'utilisateur tue l'app téléphone sans quitter d'abord la séance, la montre conserve une séance active apparente et continue d'ouvrir l'ancien écran de séance. + +### Gravité +Critique côté cohérence : +- la montre affirme un état actif sans source de vérité confirmée ; +- l'utilisateur peut croire que la séance continue alors que le téléphone a disparu du cycle ; +- cela contredit le principe "logique métier côté téléphone". + +### Décision de cadrage +La montre ne doit pas garder indéfiniment un état "séance active" orphelin. + +Comportement cible à trancher techniquement : +- soit le téléphone envoie explicitement un état terminal/deconnexion avant extinction contrôlée ; +- soit la montre invalide l'état de séance après une fenêtre d'absence de heartbeat/projection ; +- soit combinaison des deux, avec dégradation vers un état clair "Téléphone indisponible" ou retour hors séance. + +### Garde-fous +- ne pas perdre une vraie séance lors d'une micro-coupure normale ; +- ne pas laisser une séance fantôme persister après fermeture forcée ; +- distinguer reconnexion brève et disparition durable du téléphone ; +- l'écran montre doit refléter un état confirmé ou explicitement dégradé, jamais un faux "tout va bien". + +### Attendu suite +Ticket d'architecture/implémentation à sortir de celui-ci : +- stratégie heartbeat / TTL projection ; +- comportement UI montre si la source téléphone disparaît ; +- tests kill téléphone / reprise / relance app. + +## Cadrage Architect (2026-07-28) + +Statut : **cadrage exploitable, prêt pour découpage dev**. + +### Diagnostic +Le bug vient du mécanisme de reprise montre : au démarrage ou à la reconnexion, la montre relit le dernier `DataItem` de projection et continue de l'afficher même si le téléphone a disparu. Le `WatchSessionViewModel` peut marquer la projection `stale` puis `connectionLost`, mais n'invalide jamais la projection elle-même. + +### Décision d'architecture +- Le téléphone reste l'unique source de vérité. +- La projection montre doit être traitée comme un **cache temporaire**, jamais comme une preuve durable qu'une séance est encore active. +- Une projection phone -> watch devient **expirable**. +- Sans rafraîchissement explicite du téléphone dans une fenêtre courte, la montre doit considérer la projection invalide et revenir à un état `téléphone indisponible / aucune séance confirmée`. + +### Mécanisme recommandé +- Étendre `WatchSessionProjection` avec un TTL explicite, par exemple `expiresAtEpochMs`. +- À chaque projection publiée, le téléphone renseigne `expiresAtEpochMs = now + ttl`. +- Recommandation : TTL de `12s`, cohérent avec le refresh périodique existant toutes les `2s`. +- Si l'app téléphone est killée, le heartbeat s'arrête ; la montre laisse expirer la projection. +- Si le téléphone sait explicitement qu'il n'y a plus de séance, il publie `phase = noActiveSession` immédiatement comme aujourd'hui ; le TTL couvre le cas brutal. + +### Comportement montre attendu +- Avant expiration : garder l'écran courant, avec l'état `stale` existant si utile. +- Après expiration durable : + - remplacer localement la projection active par une projection synthétique `noActiveSession` ; + - vider `sensorSample`, commandes pending, score optimiste ; + - arrêter ongoing / foreground liés à la séance ; + - afficher un état type `Téléphone indisponible / rouvre GameTime sur le téléphone`, pas l'ancienne séance. +- Si le téléphone republie ensuite une projection valide, la montre se recale normalement. + +### Garde-fous micro-coupures +- Ne pas invalider sur simple `connectionLost` capability. +- Invalider seulement quand aucune projection fraîche n'est reçue au-delà du TTL et qu'aucun ack/commande retour ne prouve une communication en cours. +- Fenêtres recommandées : + - refresh téléphone : `2s` inchangé ; + - seuil `stale` : `6s` actuel conservé ; + - expiration dure : `12s` à `15s`. + +### Impacts +- Contrat : `WatchSessionProjection` gagne `expiresAtEpochMs`, `schemaVersion` incrémentée. +- Téléphone : renseigne ce champ à chaque publish. +- Bridge natif watch : pas de refonte structurelle ; la lecture du dernier `DataItem` peut rester, mais ce snapshot sera auto-invalidé par TTL. +- UI montre : ajouter l'invalidation locale dans `_syncFreshnessState()` ; injecter une projection synthétique `noActiveSession` après expiration. + +### Lots recommandés +1. `B1 [DevBackend] Projection expirable côté téléphone` +2. `F1 [DevFrontend] Invalidation locale montre sur TTL expiré` +3. `F2 [DevFrontend] Ajustements natifs reprise/ongoing` +4. `QA1 [QA] Validation disparition source téléphone` diff --git a/.ideai/tickets/175/issue.md b/.ideai/tickets/175/issue.md new file mode 100644 index 0000000..2c59414 --- /dev/null +++ b/.ideai/tickets/175/issue.md @@ -0,0 +1,16 @@ +--- +id: "3528d6d9-1b1a-4b77-ac36-6ab37470eba0" +number: 175 +title: "[Bug] Séance fantôme sur la montre après fermeture forcée de l'application téléphone" +status: "qa" +priority: "critical" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [{"target":"#100","kind":"relatesTo"},{"target":"#108","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} +createdAt: 1785257860731 +updatedAt: 1785315994252 +version: 4 +--- +Quand l'application téléphone est fermée de force pendant qu'une séance est encore active, la montre continue de croire qu'une séance est en cours : logo de séance toujours présent sur l'écran principal et retour vers le dernier écran de séance au tap. Il faut cadrer puis corriger la stratégie de vérité/déconnexion pour qu'une montre ne garde pas une séance fantôme sans source téléphone vivante. diff --git a/.ideai/tickets/85/carnet.md b/.ideai/tickets/85/carnet.md index fd05b39..0f38bb4 100644 --- a/.ideai/tickets/85/carnet.md +++ b/.ideai/tickets/85/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#85" -version: 4 +version: 5 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859243 +updatedAt: 1785311597729 --- ## #85 — Cadrage UX V1 packs partageables (UX, 2026-07-28) diff --git a/.ideai/tickets/85/issue.md b/.ideai/tickets/85/issue.md index f226e51..0316023 100644 --- a/.ideai/tickets/85/issue.md +++ b/.ideai/tickets/85/issue.md @@ -2,7 +2,7 @@ id: "78ce00f0-b70c-4e21-a22a-42a78f31fa48" number: 85 title: "Packs de séances partageables (au-delà du partage ciblé existant)" -status: "inProgress" +status: "closed" priority: "low" sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822" links: [] @@ -10,8 +10,8 @@ agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784650728867 -updatedAt: 1785248859243 -version: 4 +updatedAt: 1785311597729 +version: 5 --- Constat Commercial : certains concurrents s'appuient sur des contenus créés par des coachs/communauté. GameTime a déjà une base de partage ciblé de programmes/séances entre comptes (#51, #66, #69, en QA) ; l'étape suivante serait des "packs" de séances plus larges (coach ou communauté), mais uniquement après arbitrage explicite de Main sur le positionnement (éviter de dériver vers un réseau social ou une marketplace). diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index e14bc8a..0298eb5 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -11,6 +11,10 @@ "assignedAgentIds": [ "8f065f64-ef6e-4a00-af9c-d00be079e3cc" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784301585782 }, { @@ -23,6 +27,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784304100048 }, { @@ -35,6 +43,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784305026425 }, { @@ -47,6 +59,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784305595841 }, { @@ -59,6 +75,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784305875016 }, { @@ -71,6 +91,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784306547848 }, { @@ -83,6 +107,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784307099507 }, { @@ -95,6 +123,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784307587164 }, { @@ -107,6 +139,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784308091100 }, { @@ -119,6 +155,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784308662161 }, { @@ -131,6 +171,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784309299325 }, { @@ -143,6 +187,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784328818712 }, { @@ -155,6 +203,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784319234004 }, { @@ -167,6 +219,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784319492849 }, { @@ -179,6 +235,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784319755572 }, { @@ -191,6 +251,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784324772157 }, { @@ -201,6 +265,9 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784378420355 }, { @@ -213,6 +280,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784325166560 }, { @@ -225,6 +296,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784327055822 }, { @@ -237,6 +312,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784326729918 }, { @@ -249,6 +328,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784327426815 }, { @@ -261,6 +344,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784327862613 }, { @@ -273,6 +360,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482464083 }, { @@ -285,6 +375,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784329799370 }, { @@ -297,6 +391,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784358479742 }, { @@ -309,6 +407,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784378061444 }, { @@ -321,6 +423,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784378419582 }, { @@ -331,6 +437,9 @@ "priority": "critical", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482456784 }, { @@ -341,6 +450,9 @@ "priority": "critical", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784362687149 }, { @@ -353,6 +465,9 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784329139605 }, { @@ -365,6 +480,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482442930 }, { @@ -375,6 +493,9 @@ "priority": "medium", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482438271 }, { @@ -387,6 +508,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784363909962 }, { @@ -399,6 +523,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784364636506 }, { @@ -411,6 +538,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784376975122 }, { @@ -423,6 +553,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784363570119 }, { @@ -435,6 +568,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784377509677 }, { @@ -447,6 +583,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784362685685 }, { @@ -459,6 +599,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784362686414 }, { @@ -471,6 +615,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482434770 }, { @@ -483,6 +630,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482429394 }, { @@ -495,6 +645,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482421672 }, { @@ -507,6 +660,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482417515 }, { @@ -519,6 +675,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784482408550 }, { @@ -531,6 +690,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784566354283 }, { @@ -541,6 +703,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1784448362531 }, { @@ -551,6 +717,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243107564 }, { @@ -561,6 +731,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243113748 }, { @@ -571,6 +745,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243117713 }, { @@ -581,6 +759,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243124727 }, { @@ -591,6 +773,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243127394 }, { @@ -601,6 +787,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243133172 }, { @@ -613,6 +803,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784566315824 }, { @@ -625,6 +818,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243136634 }, { @@ -637,6 +834,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784488107329 }, { @@ -649,6 +849,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243140603 }, { @@ -661,6 +865,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243143729 }, { @@ -673,6 +881,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243146698 }, { @@ -685,6 +897,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243150941 }, { @@ -697,6 +913,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243154694 }, { @@ -709,6 +929,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243102711 }, { @@ -721,6 +944,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243158451 }, { @@ -733,6 +960,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243160967 }, { @@ -745,6 +976,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243165428 }, { @@ -757,6 +992,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243168334 }, { @@ -769,6 +1008,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243170813 }, { @@ -781,6 +1024,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243173715 }, { @@ -793,6 +1040,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243176716 }, { @@ -805,6 +1056,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243094965 }, { @@ -817,6 +1071,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243091609 }, { @@ -829,6 +1086,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243086218 }, { @@ -841,6 +1101,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243081689 }, { @@ -853,6 +1116,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243075122 }, { @@ -865,6 +1132,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243070204 }, { @@ -877,6 +1148,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243066150 }, { @@ -889,6 +1164,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243056992 }, { @@ -901,6 +1180,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "f8f40941-ecf7-4830-b9de-8818a099f448" + }, "updatedAt": 1785243052684 }, { @@ -911,6 +1194,10 @@ "priority": "high", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784698823269 }, { @@ -921,6 +1208,10 @@ "priority": "high", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784700197992 }, { @@ -931,6 +1222,10 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784705972459 }, { @@ -941,17 +1236,25 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784718420341 }, { "issueRef": "#85", "path": "85", "title": "Packs de séances partageables (au-delà du partage ciblé existant)", - "status": "inProgress", + "status": "closed", "priority": "low", "sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822", "assignedAgentIds": [], - "updatedAt": 1785248859243 + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785311597729 }, { "issueRef": "#86", @@ -961,6 +1264,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784650732823 }, { @@ -971,6 +1278,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784650736449 }, { @@ -981,6 +1292,9 @@ "priority": "critical", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243048210 }, { @@ -993,6 +1307,9 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243041954 }, { @@ -1003,6 +1320,9 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785230763779 }, { @@ -1015,6 +1335,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784704108458 }, { @@ -1027,6 +1350,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784705595811 }, { @@ -1037,6 +1363,9 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1784936913063 }, { @@ -1049,6 +1378,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784991949110 }, { @@ -1061,6 +1394,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784992086885 }, { @@ -1073,6 +1410,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784992086886 }, { @@ -1085,6 +1426,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784992086886 }, { @@ -1097,6 +1442,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784992086885 }, { @@ -1109,6 +1458,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785002671405 }, { @@ -1121,6 +1474,10 @@ "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1784992086885 }, { @@ -1133,6 +1490,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785217009929 }, { @@ -1145,6 +1505,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785217006191 }, { @@ -1157,6 +1520,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785217003099 }, { @@ -1169,6 +1535,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216998986 }, { @@ -1181,6 +1550,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785221560663 }, { @@ -1193,6 +1565,10 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785134675226 }, { @@ -1205,6 +1581,10 @@ "assignedAgentIds": [ "f8f40941-ecf7-4830-b9de-8818a099f448" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785134675243 }, { @@ -1217,6 +1597,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785142184934 }, { @@ -1229,6 +1613,10 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785134675260 }, { @@ -1241,6 +1629,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785142176024 }, { @@ -1253,6 +1645,10 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785134675276 }, { @@ -1265,6 +1661,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785142171875 }, { @@ -1277,6 +1677,10 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785137390315 }, { @@ -1289,6 +1693,10 @@ "assignedAgentIds": [ "f8f40941-ecf7-4830-b9de-8818a099f448" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785137408716 }, { @@ -1301,6 +1709,10 @@ "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785137390334 }, { @@ -1313,6 +1725,10 @@ "assignedAgentIds": [ "7efa512f-3b3a-47b5-ade0-a2dd13073055" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785142162842 }, { @@ -1325,6 +1741,10 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785137390347 }, { @@ -1337,6 +1757,10 @@ "assignedAgentIds": [ "f8f40941-ecf7-4830-b9de-8818a099f448" ], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785137390360 }, { @@ -1349,6 +1773,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216995347 }, { @@ -1361,6 +1788,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216992076 }, { @@ -1373,6 +1803,9 @@ "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216987980 }, { @@ -1385,6 +1818,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785217016893 }, { @@ -1397,6 +1833,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785243026193 }, { @@ -1407,6 +1846,9 @@ "priority": "critical", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216979125 }, { @@ -1419,6 +1861,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785141275322 }, { @@ -1431,6 +1876,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216975714 }, { @@ -1443,6 +1891,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785141267072 }, { @@ -1455,6 +1906,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785151136170 }, { @@ -1465,6 +1919,9 @@ "priority": "medium", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785252649210 }, { @@ -1477,6 +1934,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785147020900 }, { @@ -1489,6 +1949,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216971861 }, { @@ -1501,6 +1964,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785160475878 }, { @@ -1513,6 +1979,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785160464016 }, { @@ -1523,6 +1992,9 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216922010 }, { @@ -1533,6 +2005,9 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785221553966 }, { @@ -1545,6 +2020,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785143072501 }, { @@ -1555,6 +2033,10 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785221565015 }, { @@ -1565,6 +2047,10 @@ "priority": "low", "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785248859123 }, { @@ -1575,6 +2061,10 @@ "priority": "low", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785143531526 }, { @@ -1585,6 +2075,9 @@ "priority": "high", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785242960861 }, { @@ -1597,6 +2090,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216966035 }, { @@ -1609,6 +2105,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785160491348 }, { @@ -1621,6 +2120,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785216960781 }, { @@ -1633,6 +2135,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785179756629 }, { @@ -1643,6 +2148,9 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785179756654 }, { @@ -1655,6 +2163,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785179756676 }, { @@ -1667,6 +2178,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785179756697 }, { @@ -1677,6 +2191,10 @@ "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785248859142 }, { @@ -1687,6 +2205,10 @@ "priority": "medium", "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785248859161 }, { @@ -1697,7 +2219,11 @@ "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], - "updatedAt": 1785252668623 + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785256180427 }, { "issueRef": "#158", @@ -1707,17 +2233,25 @@ "priority": "medium", "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785248859190 }, { "issueRef": "#159", "path": "159", "title": "[DevFrontend] Surface montre statistiques live (FC principale + panneau secondaire)", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], - "updatedAt": 1785248859208 + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785255482109 }, { "issueRef": "#160", @@ -1727,6 +2261,10 @@ "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, "updatedAt": 1785254327417 }, { @@ -1739,6 +2277,9 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785241912171 }, { @@ -1751,29 +2292,38 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785248859225 }, { "issueRef": "#163", "path": "163", "title": "Frequence cardiaque qui ne semble plus calculée si la montre à l'écran éteint", - "status": "inProgress", + "status": "qa", "priority": "medium", "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1785254327435 + "createdBy": { + "kind": "user" + }, + "updatedAt": 1785315994265 }, { "issueRef": "#164", "path": "164", "title": "[Bug] Plus de son en fin de chrono", - "status": "inProgress", + "status": "qa", "priority": "medium", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], - "updatedAt": 1785244375351 + "createdBy": { + "kind": "user" + }, + "updatedAt": 1785315994240 }, { "issueRef": "#165", @@ -1785,19 +2335,25 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785254327333 }, { "issueRef": "#166", "path": "166", "title": "[UI] mettre un bouton sur l'écran principal de la montre pour passer à l'étape suivante dans le cas de répétitions dans une étape", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1785254327361 + "createdBy": { + "kind": "user" + }, + "updatedAt": 1785311597894 }, { "issueRef": "#167", @@ -1809,43 +2365,111 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], + "createdBy": { + "kind": "user" + }, "updatedAt": 1785254327381 }, { "issueRef": "#169", "path": "169", "title": "[UI] Enlever \"Prêt pour la série suivante\" sur la montre", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1785254327400 + "createdBy": { + "kind": "user" + }, + "updatedAt": 1785255487215 }, { "issueRef": "#170", "path": "170", "title": "[UI] Afficher le nombre de répétitions à faire dans une étape avec score et répétitions", - "status": "open", + "status": "closed", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1785255295122 + "createdBy": { + "kind": "user" + }, + "updatedAt": 1785311597912 }, { "issueRef": "#171", "path": "171", "title": "[UI] enlever le scrolling dans le cas d'une étape avec répétitions seulement", - "status": "open", + "status": "closed", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [ "f3408f5d-469c-4f64-9485-d8b218f3ff26" ], - "updatedAt": 1785255293774 + "createdBy": { + "kind": "user" + }, + "updatedAt": 1785311597930 + }, + { + "issueRef": "#172", + "path": "172", + "title": "[Architect] Cadrage batching stats montre et debounce score montre -> téléphone", + "status": "closed", + "priority": "high", + "sprint": "2c0dd1ea-8809-49ff-adc1-aa8820815ee7", + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785272880017 + }, + { + "issueRef": "#173", + "path": "173", + "title": "[Architect] Pilotage téléphone -> montre des alertes chrono (son + haptique forte + écran éteint)", + "status": "closed", + "priority": "high", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785272880037 + }, + { + "issueRef": "#174", + "path": "174", + "title": "[Bug] La montre quitte l'application ou revient à l'accueil pendant une séance/au lancement d'un chrono", + "status": "qa", + "priority": "high", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785311597948 + }, + { + "issueRef": "#175", + "path": "175", + "title": "[Bug] Séance fantôme sur la montre après fermeture forcée de l'application téléphone", + "status": "qa", + "priority": "critical", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "57695b92-24d0-4876-837c-76116e70a6ae" + }, + "updatedAt": 1785315994252 } ] } \ No newline at end of file