Compare commits

...

208 commits
v0.1.0 ... main

Author SHA1 Message Date
fy59
9a8708c63b release: prepare v0.1.6 2026-09-25 04:57:10 +02:00
fy59
bb20aa44ef docs: redesign README with Catppuccin identity 2026-09-25 04:43:41 +02:00
fy59
45bb44e45d fix(cardio): truncate recovered session capture at factual end 2026-09-24 18:36:02 +02:00
fy59
bd795495df fix(sync): recover divergent sleep revision identities 2026-09-24 14:31:53 +02:00
fy59
a652644e24 fix(android): recover continuous session finalization 2026-09-24 13:29:30 +02:00
fy59
8646422bef fix(web): anchor sleep rows to factual timezone 2026-09-24 08:42:31 +02:00
fy59
77b5bbca85 fix(web): center sleep hours and refine date controls 2026-09-24 08:35:13 +02:00
fy59
ffcdc3efcf fix(web): align sleep axis and range PDF export 2026-09-24 08:18:53 +02:00
fy59
23717ba6da fix(sleep): record wake taps as timed intervals 2026-09-24 07:54:29 +02:00
fy59
de4b77d59b fix(web): synchronize sleep selection and PDF projection 2026-09-24 07:16:47 +02:00
fy59
4cb911d5c4 docs: record sleep persistence and agenda evidence 2026-09-24 06:54:11 +02:00
fy59
a60e1aa056 fix(web): reuse sleep projection in agenda timeline 2026-09-24 06:52:10 +02:00
fy59
696dac82a3 test(android): prove sleep intake lifecycle retention 2026-09-24 06:52:03 +02:00
fy59
fe79c44e51 docs(web): record sleep overlay private deployment 2026-09-24 06:31:34 +02:00
fy59
2ce458d286 feat(web): enrich sleep heart-rate timeline overlays 2026-09-24 06:29:57 +02:00
fy59
3a51211f01 fix(sync): restore automatic sleep bluetooth synchronization 2026-09-24 06:00:29 +02:00
fy59
2755d29f2d docs(web): record post-review validation and private deployment 2026-09-24 05:10:33 +02:00
fy59
713238fd0e refactor(web): finish post-review layout and visual corrections 2026-09-24 05:09:54 +02:00
fy59
d7ca6a1924 fix(sync): recover sleep sync and canonical web mutations 2026-09-24 05:05:38 +02:00
fy59
e7fbd6ff86 refactor(web): refine dashboard analysis and settings UI 2026-09-23 22:14:25 +02:00
fy59
7adeb59b06 feat(web): add factual equipment merge and analysis aggregates 2026-09-23 22:14:25 +02:00
fy59
f20e5c4d39 chore: open 0.1.6 development 2026-09-23 21:20:38 +02:00
fy59
f2747b357b release: prepare v0.1.5 2026-09-23 21:10:11 +02:00
fy59
f2fd610b4f feat(sleep): complete pre-bed capture and multi-night cardio review 2026-09-23 20:48:06 +02:00
fy59
c8bfb7a759 test(cardio): prove multi-occurrence program heart-rate ownership 2026-09-23 19:02:59 +02:00
fy59
21f56e3986 test(cardio): prove program session heart-rate timeline readiness 2026-09-23 18:54:29 +02:00
fy59
5a7f80049a fix(cardio): keep live capture coherent across session type changes 2026-09-23 18:14:45 +02:00
fy59
41d0f1ff96 fix(sync): carry ack recovery over transports 2026-09-23 16:40:20 +02:00
fy59
0750a47cef fix(sync): recover lost generation acknowledgements 2026-09-23 16:19:17 +02:00
fy59
8f8b361915 docs(release): record 0.1.5 validation 2026-09-23 15:58:09 +02:00
fy59
246e47ee8f feat(web): visualize synchronized heart rate 2026-09-23 15:24:16 +02:00
fy59
ad493a72aa feat(cardio): guide phases from measured heart rate 2026-09-23 14:42:59 +02:00
fy59
8371d5e16c feat(cardio): add measured calibration profile 2026-09-23 13:20:00 +02:00
fy59
3ded6ee425 feat(cardio): add cardio session type 2026-09-23 12:34:01 +02:00
fy59
070a29298f feat(android): add one-tap sleep capture 2026-09-23 11:47:32 +02:00
fy59
dd39c85f94 feat(training): timestamp exercise execution 2026-09-23 11:32:15 +02:00
fy59
1ca354af53 feat(android): add persistent heart rate capture 2026-09-23 10:33:02 +02:00
fy59
5bf90899bd test(cardio): validate CYCPLUS H2 BLE profile 2026-09-23 10:13:26 +02:00
fy59
984b1e058e feat(android): add heart rate BLE diagnostics 2026-09-23 10:07:24 +02:00
fy59
14c3ac294e feat(cardio): add heart rate persistence and sync 2026-09-23 09:40:05 +02:00
fy59
c13549338c docs(cardio): freeze v1 implementation contract 2026-09-23 09:26:14 +02:00
fy59
e1d6372338 chore: open 0.1.5 development 2026-09-22 18:45:49 +02:00
fy59
5d58071601 release: prepare v0.1.4 2026-09-22 18:28:12 +02:00
fy59
b4fd753ac5 feat(web): chart body measurement trends 2026-09-22 18:07:12 +02:00
fy59
8a334613f8 fix(web): refresh next session after sync 2026-09-22 17:54:05 +02:00
fy59
26d24d2ed7 docs(sync): freeze bluetooth transport v1 2026-09-22 16:53:25 +02:00
fy59
1d8450ad1c fix(android): repair bluetooth bond on startup 2026-09-22 16:25:40 +02:00
fy59
d5b4584ae2 feat(sync): add automatic bluetooth transport 2026-09-22 16:02:46 +02:00
fy59
b580b63886 fix(sync): serialize auto mtp probe 2026-09-22 14:10:47 +02:00
fy59
066a3a44a7 fix(sync): arbitrate shared mtp transport 2026-09-22 14:06:29 +02:00
fy59
87931ca1ec fix(sync): serialize daemon mtp discovery 2026-09-22 14:00:58 +02:00
fy59
c11ea07a19 fix(web): refresh stale sync csrf token 2026-09-22 13:57:09 +02:00
fy59
3347de3dbb fix(sync): reject boolean worker timeout 2026-09-22 13:34:39 +02:00
fy59
0a35ddacbf fix(sync): carry sleep revision ancestry 2026-09-22 13:34:28 +02:00
fy59
a88e85c235 fix(sync): ignore terminal legacy generation capacity 2026-09-22 13:15:49 +02:00
fy59
0f7d793960 fix(sync): ignore stale sleep snapshot ancestors 2026-09-22 13:00:23 +02:00
fy59
dd6d2a180d fix(programs): recover historical program provenance 2026-09-21 22:27:56 +02:00
fy59
891c09179e fix(programs): reflect completed sessions in progress 2026-09-21 21:36:25 +02:00
fy59
e6e5e76f68 fix(web): align sleep agenda and isolate active nights 2026-09-21 17:34:14 +02:00
fy59
9652b35189 fix(report): localize sleep diary pdf 2026-09-21 17:09:24 +02:00
fy59
a04e41d252 test(web): parameterize deployed sleep diary review 2026-09-21 16:21:41 +02:00
fy59
20fe562cd8 fix(web): refresh sleep agenda from persisted revisions 2026-09-21 16:19:39 +02:00
fy59
a98a4440ed docs(review): record sleep diary private deployment 2026-09-21 15:41:53 +02:00
fy59
915728cec4 fix(web): stabilize and refine sleep diary capture 2026-09-21 15:38:51 +02:00
fy59
47ee128807 fix(android): complete active equipment workflow 2026-09-21 15:15:16 +02:00
fy59
e320767516 fix(android): create equipment from active editor 2026-09-21 14:25:19 +02:00
fy59
c5e62c2e5e fix(android): render active exercise editor 2026-09-21 13:35:05 +02:00
fy59
e2284be1e3 fix(android): start mixed session preparations 2026-09-21 13:34:43 +02:00
fy59
f48be35f3b fix(web): expose sleep diary capture workflow 2026-09-21 08:03:46 +02:00
fy59
0cf53228da feat(sleep): add daily draft publication lifecycle 2026-09-21 07:30:55 +02:00
fy59
afd1fcc92f feat(sleep): add structured medication intakes 2026-09-21 07:01:20 +02:00
fy59
53f15e1354 fix(packaging): declare sleep diary schemas 2026-09-21 06:25:05 +02:00
fy59
1096b13939 docs: document sleep diary v1 2026-09-21 06:24:17 +02:00
fy59
bf27e05e7a feat(android): add sleep diary capture 2026-09-21 06:24:13 +02:00
fy59
36a7e651ac feat(web): add sleep diary workspace and pdf 2026-09-21 06:24:10 +02:00
fy59
30df3030e6 feat(sync): synchronize sleep diary 2026-09-21 06:24:05 +02:00
fy59
2565d0eafa feat(core): add sleep diary domain 2026-09-21 06:23:58 +02:00
fy59
6948e0bbd5 refactor(web): refine analysis visual presentation 2026-09-20 21:06:10 +02:00
fy59
c31c394e4f refactor(web): improve dashboard summaries 2026-09-20 20:38:39 +02:00
fy59
109eb6fcd4 docs: document 0.1.4 analysis foundation 2026-09-20 20:35:16 +02:00
fy59
2bd68100e9 feat(web): implement analysis read models and workspace 2026-09-20 20:35:16 +02:00
fy59
451d0a36a0 chore: open 0.1.4 development 2026-09-20 20:19:54 +02:00
fy59
bedf392542 release: prepare v0.1.3 2026-09-20 20:17:13 +02:00
fy59
0262433615 docs(sync): document decoupled generation admission 2026-09-20 19:22:19 +02:00
fy59
ab3986200e feat(sync): separate full-generation Android trigger 2026-09-20 19:22:19 +02:00
fy59
eb6d70fb62 docs(sync): document Android conversation ownership 2026-09-20 17:54:47 +02:00
fy59
38d304d014 fix(android): prioritize explicit sync conversations 2026-09-20 17:54:43 +02:00
fy59
ca2ea9379b docs(sync): document MTP visibility guarantees 2026-09-20 15:30:06 +02:00
fy59
0e13c3947a fix(android): keep body metrics two-column on normal phones 2026-09-20 15:30:06 +02:00
fy59
60b08f225a fix(sync): throttle MTP polling and preserve timeout cause 2026-09-20 15:30:06 +02:00
fy59
a2eaf224e8 fix(sync): make Android generation publication visible to MTP 2026-09-20 15:30:00 +02:00
fy59
ba5021b436 fix(sync): retain strict V3 fallback publication 2026-09-20 14:40:59 +02:00
fy59
99e428c558 docs(sync): reconcile rollout readiness 2026-09-20 14:37:07 +02:00
fy59
2a68e5a505 docs(sync): document full-generation daily routing 2026-09-20 14:35:42 +02:00
fy59
1e9c8aa741 fix(sync): route Android requests through full generations 2026-09-20 14:35:42 +02:00
fy59
f27ef5af5f refactor(android): compact drafts and body measurement screens 2026-09-20 14:26:34 +02:00
fy59
53baa7ad24 chore(android): keep private candidates on 0.1.3 2026-09-20 13:29:58 +02:00
fy59
e41949557b docs(ui): document unified capture interface 2026-09-20 13:23:08 +02:00
fy59
d56bd3b54f feat(web): refine program progress presentation 2026-09-20 13:23:08 +02:00
fy59
cb4010dbd5 feat(android): unify capture-first mobile interface 2026-09-20 13:23:08 +02:00
fy59
88e0eb0fb7 feat(web): add exercise catalog administration 2026-09-20 12:11:45 +02:00
fy59
0a9639bb94 docs: define 0.1.3 development roadmap 2026-09-20 08:55:08 +02:00
fy59
5701e2a541 chore: start 0.1.3 development 2026-09-20 08:27:56 +02:00
fy59
fdf4301502 release: v0.1.2 2026-09-20 08:23:30 +02:00
fy59
799054ee2c feat(sync): add USB and Drive Android synchronization 2026-09-20 08:03:25 +02:00
fy59
3ad50be247 feat(web): add active program calendar 2026-09-20 08:03:03 +02:00
fy59
ca45683813 fix(programs): preserve integer target weights 2026-09-19 08:05:48 +02:00
fy59
4ad7ccc9c0 Document history deletion private rollout 2026-09-19 07:20:26 +02:00
fy59
a8c50a9512 Prioritize recent synchronization acknowledgements 2026-09-19 00:00:20 +02:00
fy59
736d209675 Honor session tombstones in association companions 2026-09-18 23:33:23 +02:00
fy59
457abc0a51 Normalize recoverable rejection acknowledgements 2026-09-18 23:25:43 +02:00
fy59
59673a0739 Recover terminal generation acknowledgements 2026-09-18 23:22:35 +02:00
fy59
2c0fa714c3 Honor causal tombstones in full generation snapshots 2026-09-18 23:18:01 +02:00
fy59
68af06b470 Fix causal deletion of Program sessions 2026-09-18 23:04:01 +02:00
fy59
894c944636 docs: record program execution rollout 2026-09-18 22:09:33 +02:00
fy59
949ace6301 Add durable program session execution flow 2026-09-18 21:30:14 +02:00
fy59
db722588f8 docs: record Programs Android rollout 2026-09-18 20:45:13 +02:00
fy59
f431b6ec9c test(programs): close migration and sync gates 2026-09-18 20:01:42 +02:00
fy59
20fc427c2f feat(programs): add deletion and Android projection 2026-09-18 19:17:34 +02:00
fy59
4d6ee07ac5 docs(web): record grouped private rollout 2026-09-18 17:53:55 +02:00
fy59
a0fc48f082 docs(web): document deletion and program v1 2026-09-18 17:15:19 +02:00
fy59
4dffb05210 feat(web): complete session deletion and programs 2026-09-18 17:15:19 +02:00
fy59
c8a292326d refactor(programs): add readable program service 2026-09-18 16:18:54 +02:00
fy59
a4d7f5d350 docs(style): enforce changed-file readability gate 2026-09-18 16:18:40 +02:00
fy59
0cff443893 style(web): separate programs interaction flows 2026-09-18 16:18:40 +02:00
fy59
ed9395366e refactor(programs): clarify persistence and transaction flow 2026-09-18 16:18:34 +02:00
fy59
679ed6184a Document Web Sessions withdrawal rollout 2026-09-18 15:29:02 +02:00
fy59
4573fe9a42 Refresh MTP peer identity before validation 2026-09-18 15:20:58 +02:00
fy59
4f0a5fee31 Add durable Web preparation withdrawal 2026-09-18 15:05:51 +02:00
fy59
0e7fb720c8 Use current HTTP 422 status name 2026-09-18 08:32:43 +02:00
fy59
4b76848c54 Fix Web Sessions test response ownership 2026-09-18 08:29:52 +02:00
fy59
823184d99e Test terminal generation resume boundary 2026-09-18 07:59:43 +02:00
fy59
1fc15d6214 Document controlled Sessions V1 deployment 2026-09-18 07:52:53 +02:00
fy59
1a373cb3bb Complete acknowledged generation conversations 2026-09-18 07:40:02 +02:00
fy59
c4074f4732 Resume interrupted Android generations 2026-09-18 07:34:24 +02:00
fy59
1be4ab0397 Fix Android generation sync retry trigger 2026-09-18 05:42:48 +02:00
fy59
189307b548 Accept integral preparation weight targets 2026-09-18 05:32:39 +02:00
fy59
ed3301217f Track Sessions frontend assets in Meson 2026-09-18 05:30:02 +02:00
fy59
9cdd9c2313 Serialize Web preparation commands strictly 2026-09-18 05:29:23 +02:00
fy59
f57682790d Fix proposal preparation derivation transaction 2026-09-18 05:27:47 +02:00
fy59
a856451875 Declare session preparation package protocol 2026-09-18 05:17:37 +02:00
fy59
c99493fa54 Document Web Sessions V1 evidence 2026-09-18 05:10:50 +02:00
fy59
bbcb487491 Complete Web Sessions pagination and browser flow 2026-09-18 05:10:50 +02:00
fy59
e0a0397d2a Deliver prepared sessions through generation sync 2026-09-17 20:47:15 +02:00
fy59
47385c7a86 Implement Web Sessions Core API and interface 2026-09-17 20:27:41 +02:00
fy59
ec17fca45d Define Web Sessions preparation persistence contract 2026-09-17 20:13:21 +02:00
fy59
a5cdbdf2b9 Document controlled generation archive rollout 2026-09-17 19:47:03 +02:00
fy59
d87d7cd302 Publish desktop generation before its MTP reference 2026-09-17 19:37:43 +02:00
fy59
908c38546c Use Android-compatible archive durability barrier 2026-09-17 19:32:36 +02:00
fy59
4c330be8c3 Implement durable sync generation archival 2026-09-17 19:23:01 +02:00
fy59
6f3ad45cb4 Bound generation MTP pull to current objects 2026-09-17 18:26:24 +02:00
fy59
f7b556cc37 Fix sync MTP timeout and prepared-item visibility 2026-09-17 18:06:56 +02:00
fy59
56ac1cdfd2 docs(sync): record operational private rollout 2026-09-17 17:24:55 +02:00
fy59
e64ea836f7 fix(android): report completed generation sync 2026-09-17 17:18:05 +02:00
fy59
bad79ed926 fix(android): ignore stale sync coordination objects 2026-09-17 17:11:35 +02:00
fy59
d94ff495e3 fix(sync): package every generation exporter 2026-09-17 17:08:14 +02:00
fy59
09ada28c6b fix(sync): compare feedback values across row modes 2026-09-17 17:05:40 +02:00
fy59
72c30ba7e0 fix(sync): package generation module closure 2026-09-17 17:01:15 +02:00
fy59
99e6c0b073 fix(web): generate sync ids on loopback HTTP 2026-09-17 16:56:59 +02:00
fy59
05c93777d6 fix(sync): resolve versioned launcher symlinks 2026-09-17 16:09:58 +02:00
fy59
1552dcac1f test(android): validate real bridge backups 2026-09-17 16:08:36 +02:00
fy59
0018edff26 fix(android): preserve rollout update chain 2026-09-17 15:56:47 +02:00
fy59
8aa3c20954 docs(sync): record rollout candidate provenance 2026-09-17 15:47:48 +02:00
fy59
dd8c91a946 fix(sync): package user service helpers 2026-09-17 15:47:15 +02:00
fy59
cc87f26b98 docs(sync): record native candidate evidence 2026-09-17 15:26:14 +02:00
fy59
2c5a8be282 fix(sync): reject truncated native artifacts 2026-09-17 15:19:59 +02:00
fy59
d29532a735 docs(style): record readability validation 2026-09-17 14:51:56 +02:00
fy59
3a740eb95d docs(style): enforce readable source formatting 2026-09-17 14:47:05 +02:00
fy59
7d29dc83a2 refactor(c): clarify control flow and MTP callbacks 2026-09-17 14:46:23 +02:00
fy59
7b769076c1 style(sync): expand Python and Kotlin workflows 2026-09-17 14:45:00 +02:00
fy59
6cd6219ac5 style(c): expand compact synchronization code 2026-09-17 14:43:24 +02:00
fy59
34c6c0b369 feat(sync): add generation MTP and Android backup 2026-09-17 14:38:23 +02:00
fy59
86b317c5bd fix(sync): reject unavailable physical transport 2026-09-17 13:51:09 +02:00
fy59
dd991830ac build(sync): add candidate and recovery readiness tools 2026-09-17 13:50:08 +02:00
fy59
7b34ca15d8 feat(sync): wire bounded generation coordination 2026-09-17 13:50:08 +02:00
fy59
aa8ab7ae69 docs(sync): close web end-to-end validation 2026-09-17 13:07:06 +02:00
fy59
ae4e493253 feat(web): add full-generation sync control 2026-09-17 13:06:59 +02:00
fy59
ef435148f8 feat(sync): add durable web orchestration API 2026-09-17 13:06:54 +02:00
fy59
a6fe9be296 feat(sync): add generation acknowledgements 2026-09-17 12:29:14 +02:00
fy59
444508b8b9 test(sync): prove remaining causal deletion domains 2026-09-17 11:14:53 +02:00
fy59
70cb7a116b fix(sync): close causal deletion guarantees 2026-09-17 10:40:26 +02:00
fy59
98cb40749f feat(sync): add causal deletion protection 2026-09-17 10:02:16 +02:00
fy59
4b8f5b02b8 test(sync): close lifecycle validation evidence 2026-09-17 09:24:23 +02:00
fy59
3db84bbfb0 docs(sync): freeze data lifecycle slice 2026-09-17 08:19:18 +02:00
fy59
99f3f46d13 feat(sync): implement data lifecycle staging 2026-09-17 08:19:12 +02:00
fy59
77c15172e1 test(sync): freeze current synchronization behavior 2026-09-17 07:16:36 +02:00
fy59
9b2da4e9e8 docs: reconcile current state and v0.1.1 notes 2026-09-17 06:48:07 +02:00
fy59
14b863eb0f test(sync): add isolated validation harness 2026-09-17 06:31:07 +02:00
fy59
32877f11e9 docs(sync): freeze complete synchronization gap contract 2026-09-16 22:07:55 +02:00
fy59
c04fa76f0b fix(web): complete dashboard v1 final review 2026-09-16 21:26:43 +02:00
fy59
86af874de5 feat(web): add dashboard visualizations 2026-09-16 21:09:01 +02:00
fy59
f290286122 style(web): improve dashboard typography 2026-09-16 17:25:07 +02:00
fy59
ecec81f077 feat(web): deliver factual dashboard tiles 2026-09-16 14:35:37 +02:00
fy59
799c2e0a52 feat(web): persist dashboard layout 2026-09-16 14:19:23 +02:00
fy59
3973a60233 feat(web): freeze interactive dashboard grid 2026-09-16 13:56:21 +02:00
fy59
6f34233751 test(web): cover dashboard failure states 2026-09-16 13:39:20 +02:00
fy59
f894f744f3 feat(web): freeze dashboard data contract 2026-09-16 13:37:35 +02:00
fy59
6443221327 feat(web): add user service integration 2026-09-16 12:50:32 +02:00
fy59
188aba2df7 feat(web): embed frontend shell 2026-09-16 10:07:18 +02:00
fy59
7597a167f8 feat(web): add local HTTP infrastructure 2026-09-16 09:37:48 +02:00
fy59
456713586b refactor(core): extract dashboard read model 2026-09-16 08:26:48 +02:00
fy59
3e06039890 test: cover dashboard legacy weight edges 2026-09-16 08:10:09 +02:00
fy59
af86895c2e test: freeze TUI dashboard behavior 2026-09-16 08:07:37 +02:00
fy59
7ea6022eac chore: open development for v0.1.2 2026-09-16 07:49:14 +02:00
fy59
7a12dd3728 release: prepare Trainlog 0.1.1 2026-09-16 07:46:11 +02:00
fy59
d4b56a3aa5 feat(tui): add bilingual presentation 2026-09-16 07:46:01 +02:00
fy59
1674059203 feat(android): refine field workflows and bilingual UI 2026-09-16 07:45:54 +02:00
fy59
3200358641 docs: add official release mirror links 2026-09-15 09:38:19 +02:00
476 changed files with 102355 additions and 7691 deletions

19
.clang-format Normal file
View file

@ -0,0 +1,19 @@
---
BasedOnStyle: LLVM
Language: Cpp
Standard: c++17
ColumnLimit: 100
IndentWidth: 4
InsertBraces: true
ContinuationIndentWidth: 4
UseTab: Never
BreakBeforeBraces: Attach
AllowShortBlocksOnASingleLine: Never
AllowShortCaseLabelsOnASingleLine: false
AllowShortEnumsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
BinPackArguments: false
BinPackParameters: false
SortIncludes: Never

8
.gitignore vendored
View file

@ -3,11 +3,19 @@ build/
builddir/ builddir/
meson-logs/ meson-logs/
meson-private/ meson-private/
/web/node_modules/
/web/dist/
/web/coverage/
# Android / Gradle # Android / Gradle
.gradle/ .gradle/
local.properties local.properties
**/build/ **/build/
*.jks
*.keystore
/android/release-signing.properties
/android/release-signing.env
/android/keystore.properties
# Editors # Editors
*.swp *.swp

View file

@ -6,15 +6,21 @@ Trainlog consists of:
- a native Android application for fast workout and body-data capture; - a native Android application for fast workout and body-data capture;
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis, - a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
visualization, and manual synchronization. The current authorized visualization, and manual synchronization, using Notcurses as its completed
`TUI_NOTCURSES_V1` infrastructure tranche migrates the active rendering/input active rendering/input backend;
backend from legacy ncursesw to Notcurses without changing product semantics; - a loopback-only local Web adapter whose Dashboard is implemented on the
0.1.2 development branch; its Analyse, Programmes, Sessions, and Exercises
routes remain placeholders pending separate contracts;
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered - a small user-session PC agent, `trainlog-syncd`, for Android-triggered
synchronization; synchronization;
- versioned JSON synchronization artifacts exchanged over direct MTP. - versioned JSON synchronization artifacts exchanged over direct MTP.
The desktop SQLite database is the canonical long-term history. The desktop SQLite database is the canonical long-term history.
The full-generation orchestrator and Web sync surface are implemented behind a
trusted local opt-in. Default deployed automatic exchange remains V3 until the
separate controlled-rollout decision.
Android has an independent local SQLite store for offline capture. SQLite Android has an independent local SQLite store for offline capture. SQLite
database files are never synchronized directly. database files are never synchronized directly.
@ -164,7 +170,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name The Android UI is driven by exercise metadata, never by exercise-name
heuristics. heuristics.
Android local SQLite schema v17 retains the exactly-one durable active-session Android local SQLite schema v24 retains the exactly-one durable active-session
draft introduced in v4. draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back, Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit backgrounding and process death never delete the draft. Home offers explicit
@ -178,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs. durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v18 and Android migrations reach v17 without changing desktop migrations reach v26 and Android migrations reach v24 without changing
that identity contract. The active that identity contract. The active
completed-session exchange is the separate strict completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -215,9 +221,12 @@ trainlog-sync-receipt-v1.json
Android -> PC data uses: Android -> PC data uses:
```text ```text
trainlog-mobile-export-v1.json trainlog-mobile-export-v3.json
``` ```
V3 is the active completed-session snapshot. V1/V2 remain readable legacy
inputs and `TRAINLOG_FORMAT_V1` remains a separate frozen contract.
PC -> Android catalog data uses: PC -> Android catalog data uses:
```text ```text
@ -232,11 +241,14 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version; PRAGMA user_version;
``` ```
The current desktop schema is v18. Schema v9 added an occurrence-owned explicit The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state. None changes `TRAINLOG_FORMAT_V1`. storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and Every incompatible schema evolution requires an explicit migration and
regression coverage. regression coverage.
@ -264,6 +276,30 @@ User-facing code must not intentionally return placeholders such as
## 10. Documentation ## 10. Documentation
Repository documentation and publication language is:
```text
README and canonical documentation: English
CHANGELOG and release notes: English
New or modified source comments: English
User interface: preserve its actual localization
```
Exact UI strings, user data, identifiers, commands, paths, protocol fields,
version/status markers, citations, and historical evidence are not translated
merely to satisfy the prose-language rule.
Production code, tests, fixtures expressed as source, and repository tooling
must remain readable by a human reviewer. Do not compress functions, control
flow, declarations, assertions, or cleanup onto one line to reduce line or
token count. Grouped missions may and should use small comprehensible commits.
Perform an explicit readability review before the final commit.
Readable source is a blocking acceptance criterion. Every new or modified C/H
file must pass the changed-file `clang-format` check; tests and fixtures follow
the same rule. Run `tools/check_changed_c_format.sh <base-revision>` without
formatting unrelated historical files.
Canonical documents: Canonical documents:
- `README.md`; - `README.md`;
@ -289,6 +325,14 @@ Checkpoint history belongs in Git history and `docs/reviews`; canonical
documents describe the current state rather than accumulating obsolete documents describe the current state rather than accumulating obsolete
`NEXT` sections. `NEXT` sections.
Every functional change identifies the affected documentation. When relevant,
reconcile `docs/current_state.md`, `docs/roadmap.md`, and `CHANGELOG.md`; the
completion report lists updated documents or explains why none were affected.
Release publication verifies both GitHub and Forgejo notes, their English
language, and their correspondence with the exact tag independently of later
development. A requested status in prose is never sufficient evidence for a
`PASS` or `FROZEN` marker.
## 11. Validation ## 11. Validation
Before every meaningful push: Before every meaningful push:

View file

@ -1,10 +1,818 @@
# Changelog # Changelog
- Documented stable-release distribution through GitHub Releases, including ## 0.1.6 — 2026-09-25
- Corrected delayed session finalization so a heart-rate capture cannot extend
beyond the explicit factual session end. Android now transactionally removes
strict post-end BPM/RR tails while retaining an exact-cutoff sample. For an
already acknowledged capture, the separately versioned
`trainlog-heart-rate-corrections` V1 companion carries an immutable
`capture_id|cutoff` correction through the existing causal ledger without
widening frozen causal-deletions V1. Old Heart Rate V1 generation replay
applies that cutoff before identity comparison and cannot resurrect removed
measurements. A guarded dry-run recovery tool reports before/after counts and
statistics without rewriting published manifests or ACKs.
- Completed the paired-device recovery for session
`se_fa845379-794d-4bb6-8cd3-48187ebbd134`: Android and desktop now retain
3,429 BPM samples and 3,429 RR values through the inclusive 11:16 cutoff,
and two successive full MTP generation exchanges completed with durable
consumed ACKs. Replaying the original 4,919-sample artifact cannot restore
the removed tail. Generation parent selection now excludes archived or
legacy acknowledgement rows lacking durable ACK evidence, while consumers
accept the producer's declared parent when it is one of multiple retained
current tips. Android Sleep V2 persistence also names medication-intake
columns explicitly, preserving `quantity`, note, and creation time across
the additive v33 layout.
- Recovered a real divergent Sleep revision identity without choosing an
arbitrary winner. The unpublished desktop branch was re-identified with its
exact content, the immutable Android revision retained its published ID and
parent, and a fresh successor preserved the non-conflicting qualities,
events and medication occurrences. The original immutable generation now
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
session is available through Desktop and Web with its timeline, feedback,
cardio and Program provenance.
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
revision payloads. Both importers now reject a known historical revision ID
whose parent or canonical content differs, even when that revision is below
the current tip. A dedicated transactional recovery utility and regressions
cover exact replay, parent/content collisions, medication quantities,
rollback, immutable generation retry, and long UUID/parent chains.
- Fixed Android finalization of Program-derived sessions containing a completed
target-only continuous occurrence. The repository now materializes the
performed duration from the persisted exercise timeline, including preserved
drafts closed by an older build, while keeping planned set counts independent
from valid actual sets, repetitions and loads. A strictly identity-bound
private recovery build may supply the factual session end; strict late
heart-rate BPM/RR tails are now removed transactionally at that boundary.
- Hardened desktop Sleep generation consumption when an identical durable
revision has no local publication row. Content comparison now reads the
durable revision directly, acknowledges an exact match, and reports a real
revision-identity conflict instead of an empty rejection diagnostic when the
same ID carries different content.
Development opened after the stable v0.1.5 release. The Web refinement tranche
adds persistent French/English settings, denser shared surfaces, ISO-week
Dashboard activity, factual exercise-count overview charts, measured-timeline
muscle-group drill-down, normalized measurement summaries, one shared refined
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
lists factual references and transactionally absorbs a custom duplicate into a
canonical identity while retaining history/MAX context and recording a causal
deletion marker; immutable embedded drafts cause an explicit conflict.
The post-review correction keeps acknowledged-generation capacity recoverable
for legacy Android rows without ACK-ledger evidence, proves two successive
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
generation. Web can now causally remove only an orphaned zero-occurrence active
draft while retaining protection for real active drafts. Mutation UUIDs use
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
Dashboard sizing, Program progress contrast, chart proportions, duration
coverage messaging, and the right-aligned Settings/Sync action cluster are
also refined.
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
trace exposed several restart and first-import gaps. The user-service
installer now removes only its obsolete `syncd` ExecStart override and
installs the RFCOMM agent;
Android resumes the same unsuperseded immutable generation across a new
conversation run and accepts its exact generation/digest ACK even though
the immutable ACK retains the original run identity. Ledger-proven
superseded branches remain retained without exhausting admission. Desktop
progress validation accepts the existing ACK-recovery phase, and a fresh
desktop can seed a non-root Sleep V2 current snapshot when its validated
ancestry is present. Regression
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
through import, ACK, replay and a second Sleep-only generation with no
Android workout session.
- Enriched the selected Sleep night's heart-rate chart with declared sleep
ranges, explicit awakening ranges and point events, medication markers that
preserve unit dose and quantity, exact start/end bounds, and every
intervening hourly tick. When no declared sleep interval exists, the Web
presentation alone shows the conservative bedtime +45 minutes to final
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
writes it to Sleep data. Nights without synchronized HR retain these factual
and estimated timeline overlays without inventing BPM samples. The private
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
real HR night and the latest real night containing medications from code
commit `2ce458d`.
- Reused that same read-only fallback projection in the multi-night Sleep
agenda, with a visibly estimated hatched interval and no Sleep write. The
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
two-hour labels on ordinary desktop widths, expands to every hour when wide,
and reduces to six-hour anchors on narrow screens. A read-only real-device
investigation of the 2026-09-23 night found no medication intake in any
retained local revision and established that later synchronization only
published the already-empty current revision. Android regression coverage
now proves two pre-bed intakes, including quantity two, through repeated
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
replay, stale-ancestor input and a subsequent revision. The private service
serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real
night confirms the identical 22:50–04:04 agenda/detail projection and the
responsive 13/25/5-label axis densities.
- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one
temporal projection. Every structured long-awake interval remains a distinct
band, row selection updates the detail dates and content without persistence,
and preview/download export exactly the active night with observations
recomputed for that subset. Sleep duration now consistently sums factual
sleep intervals or, only when none exist, uses the displayed Couché +45 min
to Levé -10 min estimate minus structured awakenings; the estimate is never
written to SQLite.
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
facts to structured `long_awake` windows under the user-approved 30-minute
default. A repeated tap inside the window extends its end to 30 minutes after
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
The policy is revisioned, undoable, and independent of BPM/RR inference.
- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid
and added an inclusive night-start date range for PDF preview and export.
The duplicate terminal `18` is removed; detail/HR night selection remains
independent from the report range, whose date fields now use a grouped
Trainlog form treatment.
- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but
factual events used the user's local offset: the row now anchors its complete
18:00 axis to one factual night offset. Agenda night headings use French
`DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI.
## 0.1.5 — 2026-09-23
- Completed the Sleep Tonight readiness delta and its non-destructive private
vc47 rollout. Pre-bed medication now creates or
reuses one durable pending 18:00-to-18:00 Sleep entry; Couché appends bedtime
to that same stable identity, while Réveil/Levé remain unavailable before
bedtime. Pending quick facts are publishable through a distinct marker and
are not day validation.
- Added structured per-intake medication quantity (`1..99`) in Android schema
v32 and desktop schema v35. The active `trainlog-sleep-diary` V2 companion
(`sleep-diary-v2.json`) carries quantity; frozen V1 remains readable and
interprets missing quantity as one. Per-unit dose/unit remain separate from
quantity, which is rendered in Android, Web and PDF as appropriate (for
example `75 mg ×2`).
- Kept sleep HR capture factual and sample-driven: pre-bed medication alone
creates no capture; the first real post-Couché sample opens/reuses the
entry-owned capture at bedtime; Réveil does not split it; and Levé closes it
exactly at final get-up. A no-sensor night fabricates neither capture nor
sample.
- Updated the Web Sleep agenda to render every returned night in the selected
period on one shared 18:00-to-18:00 axis. Row selection is navigation only
and shows the selected entry’s full-width HR detail. Relative measured HR
rises/falls use a trailing five-minute reference excluding the current
60-second window, thresholds of `max(10 BPM, 15%)`, and ignore gaps over
three seconds; labels remain descriptive and make no sleep-stage, medical,
or causal inference.
- Added the Cardio V1 persistence/synchronization foundation. Android schema
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
transactionally. Exact generation membership is retained until a correlated
consumed ACK, after which those stopped captures remain local history but are
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
only; Bluetooth Classic Trainlog synchronization is unchanged.
- Added the Android Heart Rate Service diagnostic foundation. The independent
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
strict malformed-payload rejection. A settings diagnostic provides bounded
HRS-filtered and expanded BLE scans, GATT service discovery and notification
subscription without turning desktop/Web into sensor clients. Android 12+
scan/connect permissions and the Android 11-and-older scan-location boundary
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
indicator. A validated sensor is remembered, a dedicated connected-device
foreground service reconnects across app restarts and Bluetooth loss, stale
measurements are cleared instead of displayed indefinitely, and the heart
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
recovery on the private 0.1.5 versionCode 40 installation.
- Added the training execution timeline used for cardio correlation. Android
schema v28 records explicit start/end actions for stable exercise occurrences
while preserving the session lifecycle `started_at`/`ended_at` as canonical
bounds. Only one exercise can be active; session finalization refuses an
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
rest intervals remain unowned, and the optional `trainlog-session-timeline`
V1 companion imports idempotently into desktop schema v31.
- Added Android one-tap Sleep capture on top of the existing Sleep Diary V1:
Couché, active medication buttons, point Réveil events and Levé each record
the current timestamp in one action. A guarded short-lived Annuler creates a
causal compensating revision, while Corriger la nuit retains the complete
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
- Added the true `cardio` session type without widening frozen session
codecs. Android schema v29 and desktop schema v32 introduce canonical
`session_kind=training|max_test|cardio` while the historical
`session_type` compatibility column remains `training|max_test`.
Completed cardio sessions are excluded from `history-v4` and move through
the separate optional `trainlog-cardio-sessions` V1 generation companion;
exact replay is idempotent and identity collision with non-cardio history is
rejected.
- Added the first versioned Cardio calibration workflow. Android schema v30 creates one reserved system exercise outside the normal exercise catalogue, starts a real cardio session and HR capture, records only measured observed peak BPM, and requires at least three minutes of recovery. +1/+2/+3 minute recovery facts accept only real samples within a bounded ten-second window; missing signal therefore remains missing instead of being backfilled from a later reconnect. Completed immutable profiles synchronize Android→desktop through `trainlog-cardio-calibrations` V1 into desktop schema v33, where the importer proves peak and recovery points against the synchronized raw HR capture before accepting them.
- Added fresh-signal BPM-guided cardio phases. Android schema v31 stores
ordered phase runs against the active cardio occurrence, immutable resolved
BPM targets, optional exact calibration provenance, exit conditions and
instruction changes. The engine uses a 2 BPM hysteresis band and a 3-second
confirmation interval to avoid boundary oscillation; stale signal suspends
guidance immediately and never reuses an old BPM. Android exposes live
Accélère / Maintiens / Ralentis guidance during the active cardio exercise.
Completed runs synchronize once through `trainlog-cardio-guidance` V1 into
desktop schema v34. Desktop verifies cardio-session ownership, exercise
timeline, calibration provenance and exact raw HR evidence for instruction
events carrying BPM before accepting them.
- Fixed live Cardio guidance after changing an already-started manual draft
from Training to Cardio. Draft persistence now atomically reconciles the
same active heart-rate capture from `session` to `cardio` without replacing
its identity or losing existing BPM/RR samples and sequence. A transition
away from Cardio remains possible before Cardio-only facts exist, but is
rejected once durable guidance or calibration facts would make it
ambiguous. Repository coverage reproduces the real ordering through an
active exercise and guidance event. The private vc46 re-smoke retained the
original capture and more than 31 existing samples across the transition,
then displayed a measured 92 BPM and `ACCÉLÈRE`; the Sommeil and Calibration
cardio screens also rendered without creating persistent data.
- Added one end-to-end tomorrow-session readiness regression. A synchronized
Program session retains its stable session and Program identities through
edits, two ordered Program-derived occurrence intervals,
pre-exercise/inter-exercise/active heart-rate ownership, finalization,
full-generation publication, desktop import, replay and correlated ACK. Raw
observation timestamps, BPM, RR, nullable stable exercise ownership and
capture/session bounds are compared on both platforms without inferring
ownership from time overlap.
- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added.
Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22
- Added the frozen Bluetooth Classic RFCOMM synchronization transport without
changing full-generation business semantics. The paired deployment selects
Bluetooth before MTP, authenticates the configured bonded Android peer with
the persisted Trainlog peer identity, rate-limits reconnect noise, and keeps
MTP as the wired recovery transport. This transport is for Trainlog data
synchronization only; heart-rate sensor ingestion remains future 0.1.5 work.
- Refreshed the Web next-session projection after synchronization and added
factual body-measurement trend charts through the existing bounded analysis
read model without introducing health interpretation or estimated metrics.
- Fixed Program progress for sessions started from a delivered manual
preparation. The preparation companion now carries its stable Program and
Program-session provenance through Android start, finalization, restart and
the existing execution companion; legacy stored deliveries are safely
enriched only when every pre-existing field and execution identity matches.
Android and Web consequently present the completed session and progress from
the same persisted identity without fabricating history.
Historical acknowledged deliveries now complete that recovery on the
original session even when the preparation contained planned continuous
activities, and current sleep snapshots can initialize a fresh peer from a
non-root revision without discarding causal identity.
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
the previous entry immediately, adopts only an exact-date result and rejects
stale out-of-order reads; an absent night remains unpersisted and empty until
the first real edit. The editor presents local completion as Draft,
Day validated or Modified without exposing synchronization transport state.
- Fully localized the French Sleep Diary PDF without changing its canonical
data source or event semantics. Column headings, timeline labels, legend,
factual summary, draft warning, dates and plurals are now French, accented
text uses the PDF WinAnsi encoding, and durations use human-readable units.
Long awakenings remain proportional intervals between independent sleep
ranges, while medication intake snapshots retain localized dose and unit.
The English report keeps an independent English vocabulary.
- Made the Sleep Diary Agenda a live projection of the latest locally
persisted entry revision. Event and intake mutations now re-read and adopt
the canonical Core snapshot, serialized autosaves carry the returned
revision forward, and stale asynchronous reads cannot regress the visible
state. The Agenda shows factual bedtime, final get-up, declared-sleep and
time-in-bed durations, long awakenings, naps, sleepiness and grouped
medication markers without requiring reload or publication. Medication
catalog entries and selectors now include their usual localized dose and
unit, keep same-name/different-dose records distinct, and prefill editable
intake doses. Schemas, domain semantics, synchronization and PDF generation
are unchanged.
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
event or medication intake on the loopback-routed HTTP origin. Sleep event
and intake UUIDv4 identities now use secure random bytes without depending
on the secure-context-only `crypto.randomUUID()` API, and a section-local
error boundary preserves the Trainlog shell if an unrelated render failure
occurs. The capture workspace now reuses Trainlog cards, controls, status,
primary/secondary/danger actions and responsive spacing for a structured
night header, appreciations, event timeline, medication catalog and intake
editor. Domain data, schemas, revisions, synchronization and PDF semantics
are unchanged.
- Fixed equipment creation from the Android active-session exercise editor.
The selector now exposes the complete catalogue in a bounded scrollable
list, reuses the catalogue creation route, returns to the same stable
occurrence with all draft fields intact, and selects the explicitly created
equipment. Target-only prepared occurrences can save that equipment in
place without fabricating performed sets or appending a duplicate occurrence.
- Fixed the Android active-session exercise editor for target-only continuous
preparation entries. Opening an occurrence now renders its prefilled form,
preserves its independent plan while editing performed values, updates only
the selected stable occurrence, and reports persistence failures instead of
displaying a false editor-opened confirmation.
- Fixed Android activation of mixed session preparations containing continuous
duration targets and set-based exercises. Continuous target metadata now
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
performed continuous activity or set is created before user capture.
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value
appreciation scale and user-authored notes; no medical interpretation or
heart-rate data is introduced. Sleep Diary also includes a revisioned local
medication catalog and timestamped intake snapshots with optional positive
dose/unit, synchronized and rendered factually in the agenda and PDF without
efficacy, interaction, causality, dosage, or clinical advice.
Progressive daily capture now persists every edit locally while keeping
draft, ready, synchronized, and post-sync modified states distinct. Explicit
validation publishes only the guarded current revision through the existing
full-generation/ACK path; validation never makes a day immutable.
Development opened after stable v0.1.3 release.
- Implemented the loopback-only Web Analyse V1 workspace and shared bounded
Core read model. The 7/30/90-day and all-history filters, factual activity,
active-Program progress, compatible exercise metrics, explicit persisted
MAX results, primary BODY ZONE exposures, and fourteen body measurements are
calculated by the C17 backend. Dashboard summaries consume the same model
while preserving all existing tile identities and layout behavior. Deep
links, safe query fallbacks, null/empty states, French/English presentation,
keyboard focus, and responsive charts are covered without estimating 1RM,
readiness, fatigue, recovery, body fat, or performance.
- Refined the Analyse V1 and Dashboard presentation without changing their
read models or API contracts. Period controls now share the section toolbar;
durations use the shared human-readable formatter; incompatible exercise
metrics remain hidden; activity, exercise, and measurement charts expose
chronological dates, units, and factual tooltips; BODY ZONE silhouettes and
exposure comparisons are more legible; and active-Program summaries use a
compact structured progression layout. Persisted Dashboard tile identities
and layout behavior remain unchanged.
## 0.1.3 — 2026-09-20
- Decoupled the Android full-generation request from the strict legacy V3
compatibility signal. One explicit intent now publishes the dedicated
`trainlog-sync-full-generation-request-v1.json` channel first; the legacy
request is published with the same identity only after a fresh V3 snapshot
succeeds. The desktop daemon prioritizes this channel and durably
deduplicates a bounded set of request identities across both channels.
Development opened after the stable v0.1.2 release.
- Made explicit Android synchronization intent authoritative over automatic
background resumption. A process-wide conversation arbiter now yields an
older background wait before the foreground publishes its fresh request
UUID, and `NEW_EXPLICIT` ignores the request/run that existed before the
click. Background USB and Drive workers classify new requests, new
correlated remote evidence, legitimate handoff, and stale resumable state;
terminal runs are no longer retried every five seconds without new proof.
Durable generations are retained across yield and handoff, and desktop
request-ID deduplication remains strict. No schema, protocol, causal rule or
exchange format changed.
- Hardened direct-MTP full-generation conversations after a private Android
run was frozen before generation capture. Android now emits bounded
run/phase metadata, refreshes the user-enabled background listener before a
foreground request, and confirms MediaProvider/MTP visibility for immutable
generation objects before publishing their reference. Desktop MTP pulls are
throttled to one complete refresh every three seconds, keep a separate
30-second operation budget, and report expiration of the conversation as a
correlated protocol timeout instead of a misleading final one-second
transport timeout. Explicit requests continue to receive fresh UUIDs.
Mensurations now retains two compact columns at the deployed phone's 312 dp
content width, with a narrow/large-font fallback. Schemas, causal semantics,
generation formats, ACK formats and standalone V3 strictness are unchanged.
- Routed Android foreground and daemon-triggered daily synchronization through
the existing full-generation orchestrator whenever its trusted local
configuration is present. The standalone mobile V3 importer remains strict
and still rejects causally protected live replay; complete generations apply
tombstones before filtering dominated snapshot facts. No schema, exchange
format, causal rule, or synchronization protocol changed. Android backup
manifests now obtain `product_version` from installed package metadata
instead of retaining the historical `0.1.2` literal.
- Unified the Android capture interface around compact dark cards, action
tiles, restrained-radius button variants, wrapping selection grids, and
two-column performed-set entry. Home now focuses on session capture, recent
BODY ZONES exposure, synchronization, and measurements; mobile Statistics
navigation was removed without changing stored data or exchange contracts.
Programs now show compact session metadata, completed progress, and explicit
start/resume/completed states using the existing schema-v25 durable Program
execution provenance. Web Program administration gained adaptive cards, a
visible progress bar, and green completed-session treatment while retaining
its revision-guarded trash confirmation and causal deletion behavior.
Android full lint remains at the exact ten-error `main` baseline (one
`NewApi`, one `LocalContextConfigurationRead`, and eight bilingual
`StringFormatMatches` diagnostics); this lot adds no lint error and disables
no rule. Private daily candidates retain development `versionName=0.1.3` and
may override only an increasing `versionCode` at build time.
- Added the loopback-only `/exercices` desktop catalogue administration
surface with bounded search/profile/BODY ZONES filtering, detail and
read-only equipment presentation, Core-owned creation and editing, complete
optimistic concurrency, and protected causal retirement. Profile changes
preserve stable exercise IDs and all historical occurrence snapshots.
Mutations remain desktop-local until the existing USB-priority, Drive
mirror/fallback synchronization publishes the existing catalogue companions;
no Android code, schema, exchange format, or automatic synchronization was
added.
## 0.1.2 — 2026-09-20
- Added USB-priority full-generation synchronization with a separately
configured private Drive mirror/fallback transport, verified manifest-last
publication, shared multi-transport identities/ACKs, and explicit per-
transport Web status without ever transporting SQLite.
- Added an Android user-enabled foreground USB listener, private Drive folder
connection with bounded WorkManager checks, and one shared concurrency-
guarded generation coordinator for UI and background triggers.
- Added an accessible preparation-list send action with separate editing and
delivery states, durable retry behavior, duplicate-delivery prevention and
stable execution-identity transition from Preparation to History/Program
completion.
- Implemented `TRAINLOG_PROGRAM_CALENDAR_V1` as the operational top-level
`/programmes` daily active-Program calendar. It consumes typed Core reads and
preparation creation, derives Monday–Sunday weeks from Program bounds and
real planned dates without inventing rest days, keeps undated sessions
separate, and rereads Core state after Prepare. Sessions → Programmes remains
the administration/import/list/detail/archive/delete surface; this adds no
Core, API, schema, format, synchronization, or Android changes.
- Fixed Program-to-preparation creation for Program V1 target weights written
as integer JSON numbers. Imports now preserve both integer and real number
spellings instead of persisting integer weights as zero. Existing affected
Programs remain unchanged; when deriving an editable draft, an impossible
legacy zero weight is represented as an unspecified target rather than
blocking the entire preparation. Mixed continuous-duration, unloaded-reps,
and externally loaded reps coverage verifies order, targets, provenance,
zero delivery/execution side effects, readable detail, and request replay.
- Fixed the Web history deletion failure for Program-origin completed
sessions. The history detail query now reads explicit MAX results from
`max_results`; mobile history import seeds a deterministic causal live
revision when one is missing; and desktop schema v28 gives durable Program
execution provenance an explicit terminal `deleted` state. Deletion remains
revision-guarded, atomic and replay-safe. Retained Program-execution
generations cannot resurrect a causally deleted session, and Web errors now
distinguish stale, absent, invalid and internal failures without exposing
database details. The private rollout additionally fixed complete-generation
dominance for occurrence equipment companions and made bounded ACK recovery
prioritize recent terminal evidence. Two correlated direct-MTP exchanges,
component restart and exact replay of a retained pre-deletion generation
preserved the single tombstone without duplication or resurrection.
- Added the Program execution flow. Android schema v25 can start or resume one
Program session through the existing durable singleton, preserves stable
Program provenance on completion, and presents explicit `À faire`, `En cours`
and `Effectuée` states. Desktop schema v27 stores the corresponding unique
execution link. The optional `trainlog-program-executions` V1 full-generation
companion advances Web Program sessions from persisted facts without changing
mobile export V3, `programs-v1`, or `TRAINLOG_FORMAT_V1`. Web also exposes
`À faire`, `Préparée`, `En cours`, and `Effectuée` session states, while the
existing logical Program deletion and confirmation semantics remain intact.
The coordinated private deployment advanced the daily Android installation
from versionCode 16/schema v24 to versionCode 17/schema v25 and desktop from
schema v26 to v27 without uninstalling, clearing, or losing user data. A
disposable Program session was started, resumed, completed, synchronized and
displayed as completed on Android and Web. Correlated ACKs, a second
idempotence exchange, store/component restart, exact old-generation replay,
causal Program deletion, tombstone acknowledgement and non-resurrection all
passed while the real 24-session/164-entry Program remained unchanged.
- Completed the private coordinated deployment of
`TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. Desktop schema v26 adds
terminal logical Program deletion with
request/deleted revisions, durable response replay, generation linkage and
acknowledgment state; source definitions and derived preparations remain,
while deleted Programs cannot resurrect or prepare. The responsive Sessions
Programs cards/timeline now use imported/session/usage/provenance data and
protect a discrete accessible trash confirmation with focus restoration and
Escape/Cancel no-op behavior. Android schema v24 adds the read-only synced
Programs list/detail projection. The optional staged controlled-generation
`trainlog-programs` V1 (`programs-v1.json`) publishes nondeleted Programs and
unacknowledged tombstones under capability `programs-v1`; it changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations. Real
Firefox Programs screenshots and real rollout evidence are recorded in
`docs/reviews/programs_presentation_android_delete_v1_evidence.md`. The
signed private Android update advanced versionCode 15 to 16 without
uninstall or data clearing. A real disposable Program tombstone was consumed
and acknowledged, and a final correlated exchange after component restart
proved persistence, idempotence, and non-resurrection.
- Completed the private grouped rollout of Web Sessions deletion and Program
V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`.
Desktop migrated from schema v24 to v25 after backup, restoration, and
copy-migration verification. Android was updated in place to private
versionCode 15 with no uninstall or data clearing; the installed APK is
non-debuggable and retains the daily-installation certificate. Exact
pre/post-update logical comparison preserved all 46 Android tables and 1,023
rows. A preparation created explicitly as disposable was withdrawn through
the real 390×844 confirmation UI. Two full-generation exchanges around a
Trainlog-only restart retained exactly one Android withdrawal result, no
matching delivery or performed session, and no resurrection or duplication.
- Completed Web Sessions deletion and Program V1 on desktop schema v25. Separate
revision-guarded Core commands now withdraw manual preparations and AI
proposals or causally delete inactive execution drafts and selected completed
sessions. The accessible trash confirmation preserves derived preparations,
active/concurrent drafts and unrelated performed history; proposal withdrawals
use the bounded `trainlog-ai-session-drafts` V2 tombstone extension to prevent
legacy replay resurrection on Android.
- Added Programs as the fourth Sessions subtab with complete paged listing,
search/state filtering, stable detail links, archival, strict bounded
`trainlog-program` V1 import preview and transactional commit. An explicit
program-session action creates a distinct provenance-bearing manual
preparation without automatic Android delivery or performed data.
- Completed the controlled Web Sessions presentation/withdrawal rollout on the
paired private installation. Desktop migrated from schema v23 to v24 and
Android from v22 to v23 without clearing application data. The requested
validation preparation was withdrawn once; Android reported
`execution_preserved` because its delivery had already started. Two
post-withdrawal direct-MTP generations around component restart proved no
resurrection or duplication while preserving the source proposal and all
actual workout data.
- Fixed a rollout-discovered stale peer-advertisement failure in the
full-generation MTP worker. Each MTP run now requires one successful pull
from the expected phone before validating peer identity and capabilities, so
a retained pre-upgrade advertisement cannot reject a compatible upgraded
Android peer.
- Corrected Web Sessions presentation with one descending cross-type order,
strict civil-date and zoned-timestamp formatting, contextual French states,
readable persisted proposal provenance, and an installation-local versioned
French/ISO date preference. The Dashboard prepared-item list, Footer, charts
and synchronization timestamps use the same presentation helpers without
changing machine values.
- Added revision-guarded, idempotent **Delete preparation** through the Core
service and protected loopback API. Desktop schema v24 retains an immutable
withdrawal ledger and all preparation/delivery evidence. The separate
`trainlog-session-preparations` V2 generation participant propagates exact
withdrawals; Android schema v23 cancels only pending deliveries, preserves
started executions and permanently prevents older delivery replay from
resurrecting a withdrawn preparation. V1 remains readable and
`TRAINLOG_FORMAT_V1` is unchanged.
- Implemented `TRAINLOG_WEB_SESSIONS_V1` with paged preparation, resume and
history views, stable details, optimistic/idempotent manual preparation,
explicit proposal derivation, desktop schema v23, Android schema v22 and the
separate `trainlog-session-preparations` V1 generation participant.
- Completed the controlled Sessions V1 deployment and delivered a six-entry
validation preparation to Android without starting it or replacing an active
draft. Foreground generation retries now emit a fresh daemon request and
resume the exact interrupted generation until both peer halves are durable,
preventing false transport timeouts and duplicate recapture.
- Added compatible non-destructive generation archival on desktop schema v22
and Android schema v21. Only exactly acknowledged generations older than the
two active recovery tips leave admission after a verified durable payload
copy and audit record; all identities, manifests, ACKs, lineage and causal
evidence remain stored. Older Android stores can recover exact missing
producer-side ACK rows from the desktop consumer's durable ledger, with
strict run, peer, generation and manifest revalidation. A correlated Android
capacity error now reaches the Web without waiting for a transport timeout,
and the prepared-items tile can inspect every proposal rather than only its
first compact summary.
- Completed the controlled schema-v22/schema-v21 rollout without uninstalling
or clearing Android data. The production Android archive uses a supported
post-rename file durability barrier on emulated external storage, and the
desktop publishes each complete MTP generation before its correlated
reference. Two real post-migration bidirectional exchanges, separated by a
Trainlog-service restart, completed with durable ACKs and no session
duplication. The expected AI proposal retained its identity, six entries,
and `pending` state on Android and remained independently visible in Web.
- Fixed a production full-generation timeout caused by recursively uploading
retained staging and unrelated transport history on every MTP push. Each
phase now publishes a private bounded outbox containing only its request,
ACK, and current referenced generation, while preserving every retained
generation, tombstone, and causal proof. Transport expiry has a stable
browser-facing code and action instead of a truncated traceback.
The reciprocal pull now reads only current coordination objects and the
exactly referenced Android generation instead of recursively mirroring
retained Android and desktop history.
- Replaced the Web Next Session placeholder with a separate read-only Core
projection that distinguishes AI proposals from active/pending execution
drafts without redefining the frozen Dashboard field or counting targets as
performed work. The browser preserves the last successful durable view
across refresh failures and uses one abortable, per-run ordered sync poller.
- Completed the authorized private full-generation rollout over production
direct MTP: verified Android and desktop backups, signing-preserving Android
updates, schema migrations, persistent peer pairing, bidirectional durable
ACKs, restart and idempotent replay. This did not publish a release or modify
v0.1.1.
- Fixed rollout-discovered feedback idempotence across SQLite row modes,
packaged the complete generation exporter closure, ignored stale Android
coordination objects until their run/generation correlation matches, and
made the Android screen report completed generation conversations instead of
waiting for a legacy receipt.
- Packaged `trainlog-sync-once` and `trainlog-syncd` with the relocatable sync
candidate so user services cannot mix a versioned install with live-checkout
helpers during rollout.
- Added a bounded Android package-version override and made the schema-v17
backup bridge produce a signed release APK, allowing a strictly increasing
private update chain without changing Trainlog schemas or protocols.
- Generalized bridge restore/migration validation to preserve every historical
SQLite value whether a real backup contains an active draft or legitimately
contains none.
- Made relocatable launchers resolve stable user-bin symlinks to their physical
versioned bundle before loading matching binaries and helpers.
- Made the Web sync button generate cryptographic UUIDv4 request IDs on the
loopback HTTP hostname where `crypto.randomUUID()` is unavailable.
- Included the exercise-name and training-knowledge modules required by the
packaged generation worker, with an isolated runtime-import regression.
- Closed the native candidate warning gate with checked path and HTTP response
bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android
and non-installed schema-v17 bridge candidates with recorded digests; no
service, device, user database, signing secret or deployment was touched.
- Normalized the recent synchronization, generation-MTP, Android backup,
Web Dashboard serialization, tests, I/O doubles and packaging code for human
readability. Added scoped C formatting rules and review guidance without
changing synchronization protocols, assertions, deployment state or runtime
behavior.
- Added the production libudev/libmtp generation adapter with bounded peer
selection, object transfer and manifest-last publication; wired the trusted
orchestrator mode and proved the full Firefox/Android/desktop conversation
with an I/O-boundary object double. Added complete versioned Android
backup/verify/restore and a reproducible, non-installed schema-v17 bridge
candidate with v17 round-trip and v17-to-v20 migration tests. Physical MTP,
signing, device installation, user-data operations and rollout remain
pending.
- Added opt-in foreground generation coordination, peer-evidenced capabilities,
truthful bounded Web progress, descendant cleanup, an integrated Android ↔
desktop Firefox proof, and synthetic candidate/SQLite recovery tooling on the
development branch. Physical MTP and deployment remain pending.
- Completed the grouped `TRAINLOG_SYNC_WEB_END_TO_END_V1` mission: the opt-in
full-generation orchestrator, protected asynchronous Web API, global sync
control, durable reconnect reporting, Dashboard refresh and read-only draft
summary pass isolated C, Python, Android, frontend and Firefox validation.
Default V3, physical MTP, Drive and deployment remain unchanged.
- Completed `TRAINLOG_SYNC_GENERATION_ACK_V1` with desktop schema v21 and
Android schema v20. Added coherent per-platform capture, strict bounded
manifest V1, immutable manifest-last publication, whole-generation SQLite
consumption, pair-scoped lineage, durable consumed/rejected ACK V1 replay,
and a separate causal-operation publication ledger. Real isolated Android →
desktop and desktop → Android chains use production entry points and retain
operation identity across generations. Automatic V3/MTP selection and all
operational services remain unchanged.
- Completed the residual causal-deletion evidence with real Android and
desktop producers for exercise, body-observation, custom-equipment, feedback
and BODY ZONE relation targets, including production replica exchange,
retained-history/current-reader checks, reopen/replay and stale-companion
refusal.
- Closed the causal-deletion correctness review: deletion predecessors now
cover affected session children, measurements, links, notes and immutable
feedback revisions; supported corrections preserve an intervening mutation
marker even after a content revert. Imported operations share built-in
authorization with local deletion, execution drafts use their stable
cross-platform revision identity, protected legacy companions refuse unsafe
replay, and causal artifact reads/exports enforce count and byte bounds
before destructive effects.
- Completed `TRAINLOG_SYNC_CAUSAL_DELETE_V1` with desktop schema v20 and
Android schema v19. Added a strict bounded staged causal-deletion artifact,
durable operation/state records, domain-specific deletion effects, causal
conflict and replay handling, and legacy snapshot non-resurrection gates.
Android and desktop both produce and consume the artifact in isolated tests;
automatic transport, generation acknowledgement and deployment remain
inactive.
- Completed `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` with desktop schema v19 and
Android schema v18. Added explicit staged V4 enriched-history codecs,
causal notes, body-observation session links, stable execution-draft
identities, bounded pending drafts, explicit activation and atomic,
idempotent finalization with stale-replay protection. Active synchronization
remains V3; no service, user database, phone, tag or release artifact was
changed or deployed.
- Closed the lifecycle validation evidence with real Android/desktop V4
history and execution-draft round trips in both producer directions. Fixed
Android V4 import so validated target, load, and rest fields are persisted,
reconciled the 73/75 native count to the two `web=auto` frontend tests, and
documented identical pre-existing GCC 16 `-Wmisleading-indentation`
failures on baseline and branch while retaining Clang 22 strict green
builds. V4 remains staged and inactive.
- Completed `TRAINLOG_SYNC_CHARACTERIZATION_V1` without changing production
synchronization, schemas, or formats. Added a real Android V3 exporter →
desktop importer/exporter → fresh Android importer round trip, and completed
current-behavior evidence for stable identities, occurrence/set ordering,
exact replay versus correction, local-only field handling, inter-artifact and
delete-before-send partial failure, request/receipt replay and marking, and
contention on the real private-XDG synchronization lock. V4, draft transport,
general tombstones, coherent generations, causal merge, and durable peer
consumption acknowledgement remain future work.
- Reconciled canonical documentation with the implemented 0.1.2 development
state, kept v0.1.1 and future synchronization scope explicit, established
English as the repository documentation/release-note language, and added a
reusable tag-scoped English source for the v0.1.1 public notes. No production
behavior, schema, format, service, tag, or release asset changed.
- Completed `TRAINLOG_SYNC_TEST_ENV_V1` without changing production behavior,
schemas or formats. `tools/validate_sync_isolated.py` now validates JDK 17 and
the effective Gradle JVMs, creates unique private HOME/XDG/native/JVM tmp,
exchange, database and report paths, runs bounded preflight/smoke/full suites,
counts Android XML results, propagates failures and interruptions, and cleans
only its owned successful run. Unit and Robolectric coverage proves bounded
cleanup, distinct runs, process termination, no default real transport and
effective `java.io.tmpdir` propagation.
- Froze `TRAINLOG_SYNC_GAP_CONTRACT_V1` without changing production sync,
schemas or published formats. The contract distinguishes current evidence
from future behavior and defines domain ownership, active-draft lifecycle,
lost fields, causal deletion, conflicts, coherent generations, verified
consumption/acknowledgement, compatibility, reporting, a future Web service
boundary and dependency-ordered implementation lots. Added isolated
production-path characterization of V3 field loss, snapshot resurrection and
per-artifact rollback; existing Android coverage remains the proof of draft
exclusion followed by finalized-session export.
- Development opened after the stable `v0.1.1` release. The complete Web
Dashboard is now `WEB_DASHBOARD_V1=PASS/FROZEN`; Analyse, Programmes,
Sessions, and Exercises remain placeholders. The current operational cursor
is `TRAINLOG_SYNC_CHARACTERIZATION_V1`; its future protocol work has not
started.
## 0.1.1 — 2026-09-16
- Completed `TRAINLOG_ANDROID_FIELD_INPUT_REFINEMENT_V1`: active and completed
occurrence handles now keep one stable `entry_id`-keyed pointer gesture alive,
accumulate total drag distance, jump directly across multiple positions, show
residual pointer-following motion, restore the origin on cancellation, and
commit only once at completion where persistence applies. Decimal distance,
speed, MAX, target-load, and set-load editors use decimal keyboards, accept
both `.` and `,`, preserve incomplete typing states, and parse finite
locale-independent values only at commit. Units, schemas, formats, identities,
active/completed ownership, and version 0.1.1 remain unchanged.
- Completed `TRAINLOG_ANDROID_COMPLETED_SESSION_REORDER_V1`: the Android
completed-session correction form reuses the dedicated localized right-side
drag handle and accessibility move actions. Reordering remains transient until
Save; Cancel/Back writes nothing, while Save atomically replaces positions and
any factual corrections under the same session/entry/exercise identities.
Stable Compose keys keep editor state attached to `entry_id`; equipment,
targets, actuals, MAX, feedback revisions, and J+1 ownership survive. Existing
V3 synchronization reconciles the same desktop session without duplication.
No schema, exchange format, active-session behavior, or version changed.
- Completed `TRAINLOG_ANDROID_FIELD_WORKFLOW_REFINEMENT_V1`: Android now uses a
full-width semantic-success **Add to session** action, provides a dedicated
accessible right-side drag handle for durable active-draft reordering, and
supports atomic completed-session factual correction. Reorder and correction
retain stable session, occurrence, exercise, equipment, plan, actual, feedback,
and follow-up ownership; corrected facts continue through the existing V3
synchronization identity. Continuous values reject non-finite input and show
explicit km, km/h, and seconds units. No SQLite schema, exchange format,
desktop/TUI behavior, or product version changed.
- Documented stable-release distribution through GitHub and Forgejo Releases,
including
prebuilt Android and architecture-specific dynamically linked Linux assets, prebuilt Android and architecture-specific dynamically linked Linux assets,
SHA-256 verification, runtime/build/optional dependencies, and the release SHA-256 verification, runtime/build/optional dependencies, and the release
signing and exact-tag requirements. No v0.1.0 release is claimed until a signing and exact-tag requirements. The Android artifact is built with the
signed APK and authenticated GitHub publication path are available. durable Trainlog release identity and is verified before publication.
- Completed `TRAINLOG_I18N_V0_1_1`: Trainlog 0.1.1 is one synchronized Android
and desktop product version, with French as the default interface language
and English selectable through **Settings → Language** on both surfaces.
The preference is local presentation state (dedicated Android
SharedPreferences; desktop XDG `trainlog/presentation.conf`) and successful
selection updates the interface immediately. Localized Trainlog-owned text,
BODY ZONE labels selected by stable IDs, and visible number/date formatting
do not change training data, user exercise/catalogue names, IDs, SQLite,
schemas, exchange/protocol artifacts, AI, MAX, or feedback. Desktop and
Android synchronization views render local typed status/counters; raw
core/receipt/history summaries remain opaque protocol/operational bytes and
are never injected as cross-device French text. Final validation passed
60/60 normal and 60/60 ASan/UBSan desktop tests, Android 202 total tests
(198 passed, 4 skipped, 0 failed), both validators, source-derived
`TRANSLATABLE_UI=0`, and Android resource parity. Real device/manual visual
switching remains an explicit smoke validation.
- Completed `TRAINLOG_FULL_REPOSITORY_DOCUMENTATION_AUDIT_V2`: added the - Completed `TRAINLOG_FULL_REPOSITORY_DOCUMENTATION_AUDIT_V2`: added the
canonical documentation index, made README a concise entry point, separated canonical documentation index, made README a concise entry point, separated

389
README.md
View file

@ -1,195 +1,124 @@
# Trainlog <p align="center">
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
</p>
Trainlog is a local-first workout and body-data system. Its native Android app <p align="center">
is the lightweight field companion for fast capture and glanceable summaries; <img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
its C17/Notcurses desktop TUI is the detailed surface for correction, </p>
catalogue management, analytics, graphs, and long-term follow-up.
Android and desktop each own a local SQLite database. The desktop database is Trainlog is a **local-first system for recording, synchronizing, and examining
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts training and body data**. Its Android companion handles capture in the field;
over direct MTP; it never copies SQLite database files between devices. the C17 desktop core keeps the canonical long-term history; a keyboard-first
Notcurses TUI and a loopback-only Web application provide complementary ways
to inspect and manage it.
## Product split The system is built around explicit facts, stable identities, versioned JSON
exchanges, and user-controlled storage. Plans never become performed work until
they are actually recorded, continuous activities are not disguised as sets,
and estimates are kept distinct from measurements.
| Surface | Primary responsibility | > **Release line:** The latest stable release is
|---|---| > [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
| Android | Capture sets, repetitions, loads, durations, continuous activities, feedback, J+1 follow-ups, body measurements, active drafts, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Consult and correct canonical history; manage exercises and equipment; analyze body data, MAX results, training statistics, body zones, calendar periods, and longitudinal graphs. |
In short: **Android captures and summarizes. The TUI analyzes and tracks over ## ✦ Showcase
time.** Android intentionally remains lightweight for analytics.
## Release links <p align="center">
<img src="docs/reviews/evidence/sleep-diary-alignment-firefox.png" alt="Trainlog Web Analyse workspace showing the Sleep Diary agenda and factual heart-rate timeline" width="100%">
</p>
- [Download the latest release](https://github.com/labfytools/trainlog/releases/latest) <p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
- [Documentation](docs/README.md)
- [Changelog](CHANGELOG.md)
## Installation The repository currently tracks real-browser evidence for the Web interface.
Its Android overview asset is a clearly labelled static design study with
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
Those assets are therefore not presented here as product captures.
### Download a prebuilt release ## ✦ Features
Stable releases publish an Android APK, an x86-64 Linux TUI executable, and a | Area | Available in the current development tree |
`SHA256SUMS` file on [GitHub Releases](https://github.com/labfytools/trainlog/releases). | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Verify the downloaded binary against `SHA256SUMS` before installing it. | **Training capture** | Metadata-driven sets, repetitions, durations and continuous activities; heterogeneous actual sets; durable active draft; exercise timelines; explicit measured MAX results. |
| **Planning** | Manual preparations, imported Programs, Android delivery, execution provenance and strict separation between targets and facts. |
| **Context** | Exercise and equipment catalogues, BODY ZONES, body measurements, immediate feedback, wording revisions and J+1 follow-ups. |
| **Cardio** | Android BLE heart-rate acquisition, factual BPM/RR history, cardio sessions, measured calibration and fresh-signal phase guidance. |
| **Sleep** | One-tap Android capture, causal Sleep Diary revisions, medication occurrences, synchronized factual timelines and local vector PDF export. |
| **Desktop** | C17 Core, canonical SQLite history, keyboard-first Notcurses administration, correction, statistics, graphs and synchronization diagnostics. |
| **Local Web** | Loopback-only Dashboard, Analyse, Programmes, Sessions, Exercises, Equipment, Settings and synchronization surfaces backed by typed Core services. |
| **Synchronization** | One generation/ACK business engine over authenticated Bluetooth Classic RFCOMM, direct USB/MTP recovery and a separately configured private Drive workflow; SQLite files are never exchanged. |
The Linux TUI artifact is architecture-specific and dynamically linked. It is ### Boundaries and work in progress
not a universally portable Linux binary: the host must provide compatible
runtime libraries listed under [Dependencies](#dependencies).
```bash - `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
chmod +x trainlog-tui-linux-x86_64-v0.1.0 and V2 remain readable, while V4 codecs are staged but not selected by the
./trainlog-tui-linux-x86_64-v0.1.0 active transport.
``` - Full-generation synchronization is deployed only behind a trusted local
opt-in; the default automatic exchange remains V3.
- AI history export is active. AI session proposals still have a pending real
Drive plus Android-triggered bidirectional smoke test.
- Session Generator V1 is implemented but hidden while the V2 planning model
is developed.
- Web Analyse is implemented; its recorded visual-review gate remains open.
The executable can remain in the download directory. Moving it into a directory The canonical status and limitations live in
on `PATH`, such as a user-managed `~/.local/bin`, is optional. [`docs/current_state.md`](docs/current_state.md); future work belongs in the
[`roadmap`](docs/roadmap.md).
For Android, download the release APK, allow installation from the browser or ## ✦ Architecture
file manager when Android requests it, and install the package. Android 8.0
(API 26) or later is required. Grant Trainlog all-files access only when direct
`Documents/Trainlog` synchronization is needed. Desktop synchronization also
requires the Linux side and its MTP dependencies to be installed and configured.
Android and the TUI in one GitHub Release share the same Trainlog product
version. Database schemas and JSON protocol versions are independent.
### Build from source
Desktop:
```bash
meson setup build
meson compile -C build
meson test -C build --print-errorlogs
```
Android:
```bash
cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
```
`assembleRelease` produces a distributable stable APK only when release signing
is configured by the release operator. The repository does not contain signing
credentials and does not silently generate them.
## Dependencies
### Linux runtime dependencies
The published x86-64 TUI is dynamically linked. The current build directly
requires compatible versions of:
- glibc and the GCC support runtime;
- SQLite 3;
- libuuid;
- utf8proc;
- libudev;
- libmtp;
- Notcurses Core;
- the standard math library.
Distribution packages may pull additional transitive libraries, including
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
Package names and ABI versions vary by distribution; inspect the release's
`ldd` evidence and install packages from the target distribution rather than
assuming one universal package command.
### Optional runtime and integration dependencies
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
requires the Android companion, libudev, libmtp, and an unlocked connected
device.
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
synchronization does not require the user service.
- `rclone` is required only for automatic AI history upload and Drive
inbox/archive workflows. Core capture, local history, analytics, and direct
MTP synchronization work without `rclone`.
### Build dependencies
Desktop builds require:
- Meson and Ninja;
- a C17 compiler toolchain;
- `pkg-config`;
- Notcurses Core development headers;
- SQLite, libuuid, utf8proc, libudev, and libmtp development headers;
- Python 3 for generated sources, validators, import/export helpers, and tests.
Android builds require JDK 17, an Android SDK supporting the configured API
levels, and the Gradle wrapper committed in this repository. People installing
the APK do not need Java, Gradle, or the Android SDK.
## Documentation
| Topic | Document | Purpose |
|---|---|---|
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
## Architecture overview
```text ```text
Android local SQLite ┌──────────────────────────────┐
| │ Android field companion │
| versioned JSON snapshots and requests │ private SQLite capture store │
v └──────────────┬───────────────┘
Documents/Trainlog on Android storage │ versioned JSON generations + acknowledgements
| │ Bluetooth RFCOMM primary · direct USB/MTP recovery
| direct MTP / libmtp ▼
v ┌──────────────────────────────┐ optional, separately configured
trainlog_sync_run() <---- trainlog-syncd or desktop TUI │ Synchronization orchestration│◄───── private Drive workflow
| └──────────────┬───────────────┘
+---- import Android snapshot into desktop SQLite │ typed imports, commands and read models
+---- publish desktop catalog companions to Android ▼
+---- write a synchronization receipt ┌──────────────────────────────┐
│ Trainlog Core · C17 │
desktop SQLite = canonical long-term history │ desktop SQLite = canonical │
│ long-term history │
└──────────────┬───────────────┘
│
┌──────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
└──────────────┘ └──────────────────────┘
``` ```
The optional desktop AI flow exports read-only history and exchanges session The TUI, Web application, and Android client are sibling adapters with
proposals through Google Drive via the external `rclone` process. Android does different responsibilities. Business rules, calculations, persistence, and
not contain cloud credentials and does not run `rclone`. typed read models belong to Core; presentation code does not reconstruct them
from raw database tables. See the full
[`architecture contract`](docs/architecture.md).
## Current status ## ✦ Your data, your system
As of 2026-09-15: - Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
account or proprietary cloud service is required for core capture, history,
analytics, or direct synchronization.
- Android and desktop each keep a local SQLite database; the desktop database
is the canonical long-term history.
- Devices exchange bounded, versioned JSON artifacts—not database files.
- The local Web server binds to `127.0.0.1` by default and never silently falls
back to a LAN address or another port.
- Direct Android storage uses `Documents/Trainlog`; desktop USB access uses
`libudev` and `libmtp` without requiring a GVFS/FUSE mount.
- The optional Drive path is operator-configured through external `rclone`.
Android holds no cloud credentials.
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`; ## ✦ Build and run
- desktop SQLite schema v18 and Android SQLite schema v17;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
- mobile export V3 is active; V1/V2 remain readable legacy inputs;
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
The latest executable result belongs in ### Desktop and TUI
[current state](docs/current_state.md), not in multiple README narratives.
## Desktop development quick start The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev, Notcurses Core, GNU libmicrohttpd, and yyjson.
libmtp, and Notcurses.
```bash ```bash
meson setup build meson setup build
@ -198,13 +127,36 @@ meson test -C build --print-errorlogs
./build/tui/trainlog ./build/tui/trainlog
``` ```
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or The desktop database is stored at
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset. `~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
## Android development quick start ### Embedded local Web application
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked Node and npm are build-time dependencies only. Meson does not download npm
`android/local.properties` file. packages.
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build-web -Dweb=enabled
meson compile -C build-web
./build-web/tui/trainlog --web
```
The Web application listens on `127.0.0.1:8080` by default. Use
`--port <port>` for an explicit alternative. For an existing build directory,
use `meson configure build -Dweb=enabled` instead of running `meson setup`
again.
### Android
Android builds use the committed Gradle wrapper, JDK 17, and an Android SDK for
the configured API levels (`minSdk 26`, `targetSdk 36`, `compileSdk 37`).
```bash ```bash
cd android cd android
@ -212,38 +164,81 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
``` ```
Instrumented tests belong on an emulator. Do not run Instrumented tests belong on an emulator; the primary personal phone is
`connectedDebugAndroidTest` on the primary personal phone. reserved for manual, non-destructive installation and synchronization checks.
Release APK signing is intentionally operator-local and is not configured with
credentials from this repository.
## Synchronization quick start <details>
<summary>Optional user-session services</summary>
After a desktop build, the repository provides an installer for the private
automatic synchronization services:
```bash ```bash
meson compile -C build
bash tools/install_syncd_user.sh bash tools/install_syncd_user.sh
systemctl --user is-active trainlog-syncd.service systemctl --user is-active trainlog-syncd.service
``` ```
On Android, grant all-files access for the fixed `Documents/Trainlog` This modifies the current user's `~/.local/bin` and systemd user configuration.
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP Bluetooth generation transport starts only when its separate Trainlog
validation requires an unlocked connected device and remains an explicit manual configuration explicitly enables it. The loopback Web development unit in
check. `systemd/trainlog-web.service` is another optional integration; neither service
is required for manual TUI use.
## Development principles </details>
- Preserve frozen, versioned compatibility boundaries. Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
- Use stable IDs; display names are not identities. official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
- Keep performed work separate from plans and continuous activity separate and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
from fake sets. downloaded artifacts against the accompanying `SHA256SUMS`.
- Keep Android capture, desktop analytics, persistence, transport, and
rendering responsibilities distinct.
- Prefer explicit failure to silent data loss or corruption.
- Treat migrations, idempotence, tests, and documentation as part of feature
completion.
- Keep scientific catalogs separate from runtime user data and preserve stated
uncertainty.
## License and project notes ## ✦ Repository layout
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror. ```text
Repository layout, detailed contracts, and historical review records are linked trainlog/
from [docs/README.md](docs/README.md). ├── android/ Kotlin + Jetpack Compose field companion
├── tui/ C17 Core, Notcurses TUI, Web adapter and native tests
├── web/ React + TypeScript + Vite frontend
├── catalog/ versioned scientific and product knowledge
├── format/ JSON schemas and frozen format material
├── contracts/ machine-readable presentation contracts
├── tools/ validators, import/export and sync utilities
├── tests/ cross-surface and protocol regression tests
├── systemd/ optional user-session integration
└── docs/ canonical documentation and review evidence
```
## ✦ Documentation
| Start here | Scope |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| [`docs/current_state.md`](docs/current_state.md) | Implemented capabilities, versions, validation evidence and active limitations. |
| [`docs/architecture.md`](docs/architecture.md) | Component, storage, process, transport and ownership boundaries. |
| [`docs/roadmap.md`](docs/roadmap.md) | Current cursor and future work. |
| [`docs/android.md`](docs/android.md) | Android capture, persistence and synchronization behavior. |
| [`docs/tui.md`](docs/tui.md) | Notcurses workflows, correction, analytics and technical tools. |
| [`docs/sync_exchange.md`](docs/sync_exchange.md) | Active synchronization flow, replay and acknowledgement semantics. |
| [`docs/exchange_format.md`](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| [`docs/exercise_data_model.md`](docs/exercise_data_model.md) | Exercise, occurrence, planning, load and MAX semantics. |
| [`docs/database.md`](docs/database.md) | Desktop SQLite schema and migration contracts. |
| [`docs/tests.md`](docs/tests.md) | Canonical validation commands and coverage strategy. |
The complete ownership map is in [`docs/README.md`](docs/README.md), with
chronological detail in the [`CHANGELOG`](CHANGELOG.md).
## ✦ Project status
| Boundary | Current state |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Product | `0.1.6` stable |
| Desktop persistence | SQLite schema v36 |
| Android persistence | SQLite schema v33 |
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
| Desktop terminal | C17 + Notcurses, active backend |
| Local Web | Dashboard and operational Analyse, Programmes, Sessions, Exercises, Equipment, Settings and sync surfaces |
| Interface language | French by default; English selectable on Android and desktop |
| Compatibility | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
Forgejo is the primary repository; GitHub is its release mirror. Trainlog is
licensed under the [GNU General Public License v3](LICENSE).

View file

@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
LOCAL_PERSISTENCE=IMPLEMENTED LOCAL_PERSISTENCE=IMPLEMENTED
DIRECT_STORAGE=IMPLEMENTED DIRECT_STORAGE=IMPLEMENTED
MTP_SYNC=IMPLEMENTED MTP_SYNC=IMPLEMENTED
ANDROID_SCHEMA=17 ANDROID_SCHEMA=33
``` ```
## Visual contract ## Visual contract

View file

@ -1,8 +1,82 @@
import java.time.OffsetDateTime
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
/*
* CONTRACT: release-signing material is machine-local. Environment variables
* take precedence over the private properties file so CI and release hosts can
* inject secrets without copying them into the source tree.
*/
val releaseSigningProperties = Properties()
val releaseSigningPropertiesPath =
providers.environmentVariable("TRAINLOG_RELEASE_CREDENTIALS_FILE").orNull
?: "${System.getProperty("user.home")}/.config/trainlog/release-signing.properties"
val releaseSigningPropertiesFile = file(releaseSigningPropertiesPath)
if (releaseSigningPropertiesFile.isFile) {
releaseSigningPropertiesFile.inputStream().use(releaseSigningProperties::load)
}
fun releaseSigningValue(environmentName: String, propertyName: String): String? =
providers.environmentVariable(environmentName).orNull
?.takeIf { it.isNotBlank() }
?: releaseSigningProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
val releaseStoreFile = releaseSigningValue("TRAINLOG_RELEASE_STORE_FILE", "storeFile")
val releaseStorePassword = releaseSigningValue("TRAINLOG_RELEASE_STORE_PASSWORD", "storePassword")
val releaseKeyAlias = releaseSigningValue("TRAINLOG_RELEASE_KEY_ALIAS", "keyAlias")
val releaseKeyPassword = releaseSigningValue("TRAINLOG_RELEASE_KEY_PASSWORD", "keyPassword")
val releaseSigningComplete = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
/* CONTRACT: a private rollout may need an update-only version code above the
* repository default while preserving versionName, schemas and protocols.
* INVARIANT: the override remains a positive Android package version code and
* never changes the source default used by ordinary builds. */
val trainlogVersionCode =
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 7
/*
* WHY: a user may need to finish one preserved draft after a completion bug
* has been repaired, while its factual end time predates the repair build.
* CONTRACT: both values are private-build inputs, must be supplied together,
* and are embedded only after strict identity/timestamp validation.
* INVARIANT: ordinary builds use empty values and every non-matching future
* session continues to finalize at the live clock time.
*/
val recoverySessionId =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
val recoveryEndedAt =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
throw GradleException(
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
)
}
if (recoverySessionId.isNotEmpty()) {
val sessionPattern =
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
if (!sessionPattern.matches(recoverySessionId)) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
}
try {
OffsetDateTime.parse(recoveryEndedAt)
} catch (_: Exception) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
}
}
android { android {
namespace = "com.labfytools.trainlog" namespace = "com.labfytools.trainlog"
compileSdk = 37 compileSdk = 37
@ -12,12 +86,33 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = trainlogVersionCode
versionName = "0.1.0" versionName = "0.1.6"
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
testInstrumentationRunner = testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner" "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
if (releaseSigningComplete) {
create("release") {
storeFile = file(requireNotNull(releaseStoreFile))
storePassword = requireNotNull(releaseStorePassword)
keyAlias = requireNotNull(releaseKeyAlias)
keyPassword = requireNotNull(releaseKeyPassword)
}
}
}
buildTypes {
getByName("release") {
if (releaseSigningComplete) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
@ -25,6 +120,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
sourceSets { sourceSets {
@ -53,6 +149,27 @@ android {
} }
} }
/* WHY: Android Gradle otherwise emits app-release-unsigned.apk successfully,
* which is build evidence but can be mistaken for a publishable artifact.
* INVARIANT: every task that packages a release fails before packaging unless
* all four external values exist and the configured keystore is a regular
* file. Gradle/apksigner still perform the cryptographic validation. */
tasks.matching { it.name == "packageRelease" }.configureEach {
doFirst {
if (!releaseSigningComplete) {
throw GradleException(
"Trainlog release signing is not configured. Set the four " +
"TRAINLOG_RELEASE_* variables or provide the private " +
"release-signing.properties file.",
)
}
val configuredStore = file(requireNotNull(releaseStoreFile))
if (!configuredStore.isFile) {
throw GradleException("Trainlog release keystore does not exist: $configuredStore")
}
}
}
dependencies { dependencies {
val composeBom = val composeBom =
platform("androidx.compose:compose-bom:2026.08.00") platform("androidx.compose:compose-bom:2026.08.00")
@ -64,6 +181,9 @@ dependencies {
implementation( implementation(
"androidx.activity:activity-compose:1.13.0" "androidx.activity:activity-compose:1.13.0"
) )
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.work:work-runtime-ktx:2.10.5")
implementation( implementation(
"androidx.compose.foundation:foundation" "androidx.compose.foundation:foundation"

View file

@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
sessionVisible = false sessionVisible = false
}, },
onCreateExercise = {}, onCreateExercise = {},
onCreateEquipment = { _ -> },
/* CONTRACT: this host never writes shared export data. */ /* CONTRACT: this host never writes shared export data. */
onSessionSaved = {}, onSessionSaved = {},
) )
@ -82,8 +83,6 @@ class DraftUiTestActivity : ComponentActivity() {
?: (loaded as? ?: (loaded as?
ActiveDraftLoadResult.Loaded) ActiveDraftLoadResult.Loaded)
?.warning, ?.warning,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = repository.getBodyZoneHomeOverview(), bodyZoneOverview = repository.getBodyZoneHomeOverview(),
onSession = { onSession = {
when ( when (
@ -102,9 +101,7 @@ class DraftUiTestActivity : ComponentActivity() {
} }
}, },
onOpenExercises = {}, onOpenExercises = {},
onOpenStatistics = {},
onBody = {}, onBody = {},
onOpenLatestSession = {},
onSync = {}, onSync = {},
) )
} }

View file

@ -5,6 +5,25 @@
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one <!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
user-visible directory, Documents/Trainlog, without a SAF provider. --> user-visible directory, Documents/Trainlog, without a SAF provider. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE cardio discovery does not derive physical location. Android 11 and
lower still require fine location for direct BLE scans. -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@ -26,5 +45,14 @@
android:name="android.intent.category.LAUNCHER" /> android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".data.HeartRateSensorService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application> </application>
</manifest> </manifest>

View file

@ -9,20 +9,45 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.runtime.CompositionLocalProvider
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.TrainlogApp import com.labfytools.trainlog.ui.TrainlogApp
import com.labfytools.trainlog.ui.TrainlogAppState import com.labfytools.trainlog.ui.TrainlogAppState
import com.labfytools.trainlog.ui.LanguagePresentation
import com.labfytools.trainlog.ui.LanguageSettingsOwner
import com.labfytools.trainlog.ui.LocalLanguagePresentation
import com.labfytools.trainlog.ui.theme.TrainlogTheme import com.labfytools.trainlog.ui.theme.TrainlogTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val appState by viewModels<TrainlogAppState>() private val appState by viewModels<TrainlogAppState>()
private lateinit var languageSettings: LanguageSettingsOwner
override fun onCreate( override fun onCreate(
savedInstanceState: Bundle? savedInstanceState: Bundle?
) { ) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
languageSettings = LanguageSettingsOwner(applicationContext)
if (
BackgroundSyncSettings(applicationContext).enabled &&
hasBluetoothConnectPermission(applicationContext)
) {
SyncBackgroundService.start(applicationContext)
}
if (
HeartRateSensorPreferences(applicationContext).selected() != null &&
hasHeartRateBleRuntimePermissions(applicationContext)
) {
HeartRateSensorService.start(applicationContext)
}
val repository = val repository =
TrainlogRepository( TrainlogRepository(
@ -47,15 +72,22 @@ class MainActivity : ComponentActivity() {
) )
setContent { setContent {
TrainlogTheme { /* CONTRACT: reading this observable property makes a language
TrainlogApp( * selection recompose the existing activity immediately. */
val language = languageSettings.language
CompositionLocalProvider(
LocalLanguagePresentation provides LanguagePresentation(language, languageSettings::select),
) {
TrainlogTheme {
TrainlogApp(
repository = repository, repository = repository,
exporter = exporter, exporter = exporter,
inbox = inbox, inbox = inbox,
requestOutbox = requestOutbox =
requestOutbox, requestOutbox,
appState = appState, appState = appState,
) )
}
} }
} }
} }

View file

@ -0,0 +1,235 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import org.json.JSONObject
internal const val AI_EXPORT_FILENAME = "trainlog_ai_export_v1.json"
internal sealed interface AiExportDriveResult {
data object NotConfigured : AiExportDriveResult
data object Missing : AiExportDriveResult
data object Unchanged : AiExportDriveResult
data class Published(val sha256: String) : AiExportDriveResult
data class Error(val message: String) : AiExportDriveResult
}
internal class AiExportDriveSettings(private val context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val treeUri: Uri?
get() = preferences.getString(TREE, null)?.let(Uri::parse)
val connected: Boolean
get() = treeUri != null
fun connect(uri: Uri) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences.edit().putString(TREE, uri.toString()).apply()
schedule(context)
}
fun disconnect() {
treeUri?.let { uri ->
runCatching {
context.contentResolver.releasePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
}
preferences.edit()
.remove(TREE)
.remove(LAST_SHA256)
.remove(LAST_SIZE)
.remove(LAST_MODIFIED)
.apply()
WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK)
WorkManager.getInstance(context).cancelUniqueWork(IMMEDIATE_WORK)
}
fun alreadyPublished(source: File, sha256: String): Boolean =
preferences.getString(LAST_SHA256, null) == sha256 &&
preferences.getLong(LAST_SIZE, -1L) == source.length() &&
preferences.getLong(LAST_MODIFIED, -1L) == source.lastModified()
fun needsPublication(source: File): Boolean =
connected &&
source.isFile &&
(
preferences.getString(LAST_SHA256, null) == null ||
preferences.getLong(LAST_SIZE, -1L) != source.length() ||
preferences.getLong(LAST_MODIFIED, -1L) != source.lastModified()
)
fun recordPublished(source: File, sha256: String) {
preferences.edit()
.putString(LAST_SHA256, sha256)
.putLong(LAST_SIZE, source.length())
.putLong(LAST_MODIFIED, source.lastModified())
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-ai-export-drive"
private const val TREE = "tree_uri"
private const val LAST_SHA256 = "last_sha256"
private const val LAST_SIZE = "last_size"
private const val LAST_MODIFIED = "last_modified"
private const val PERIODIC_WORK = "trainlog-ai-export-drive-periodic-v1"
private const val IMMEDIATE_WORK = "trainlog-ai-export-drive-now-v1"
fun sourceFile(): File = File(canonicalExchangeDirectory(), AI_EXPORT_FILENAME)
fun schedule(context: Context) {
val constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val periodic =
PeriodicWorkRequestBuilder<AiExportDriveWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK,
ExistingPeriodicWorkPolicy.UPDATE,
periodic,
)
enqueueImmediate(context)
}
fun enqueueImmediate(context: Context) {
val settings = AiExportDriveSettings(context)
val source = sourceFile()
if (!settings.needsPublication(source)) return
val request =
OneTimeWorkRequestBuilder<AiExportDriveWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
IMMEDIATE_WORK,
ExistingWorkPolicy.KEEP,
request,
)
}
}
}
internal class AiExportDrivePublisher(private val context: Context) {
private val settings = AiExportDriveSettings(context)
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun validateSource(source: File) {
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) {
"Export IA local indisponible."
}
require(source.length() in 1..64L * 1024L * 1024L) {
"Export IA local invalide."
}
val root =
source.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
JSONObject(reader.readText())
}
require(
root.optString("format") == "TRAINLOG_AI_EXPORT" &&
root.optInt("version", -1) == 1
) { "Format d’export IA invalide." }
}
fun publishPending(): AiExportDriveResult {
val uri = settings.treeUri ?: return AiExportDriveResult.NotConfigured
val source = AiExportDriveSettings.sourceFile()
if (!source.isFile) return AiExportDriveResult.Missing
return try {
validateSource(source)
val sourceSha = sha256(source)
if (settings.alreadyPublished(source, sourceSha)) {
return AiExportDriveResult.Unchanged
}
val root =
DocumentFile.fromTreeUri(context, uri)
?: return AiExportDriveResult.Error("Dossier Drive IA indisponible.")
require(root.isDirectory) { "Dossier Drive IA invalide." }
val destination =
root.findFile(AI_EXPORT_FILENAME)
?: root.createFile("application/json", AI_EXPORT_FILENAME)
?: throw IllegalStateException("Impossible de créer l’export IA sur Drive.")
require(destination.isFile) { "Destination Drive IA invalide." }
context.contentResolver.openOutputStream(destination.uri, "wt")?.use { output ->
source.inputStream().use { input -> input.copyTo(output, 64 * 1024) }
output.flush()
} ?: throw IllegalStateException("Impossible d’écrire l’export IA sur Drive.")
val verification =
File.createTempFile("trainlog-ai-drive-verify-", ".json", context.cacheDir)
try {
context.contentResolver.openInputStream(destination.uri)?.use { input ->
FileOutputStream(verification).use { output ->
input.copyTo(output, 64 * 1024)
output.fd.sync()
}
} ?: throw IllegalStateException("Impossible de relire l’export IA Drive.")
require(
verification.length() == source.length() &&
sha256(verification) == sourceSha
) { "Vérification de l’export IA Drive échouée." }
} finally {
verification.delete()
}
settings.recordPublished(source, sourceSha)
AiExportDriveResult.Published(sourceSha)
} catch (error: Exception) {
AiExportDriveResult.Error(error.message ?: error.javaClass.simpleName)
}
}
}
class AiExportDriveWorker(
context: Context,
parameters: WorkerParameters,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result =
when (AiExportDrivePublisher(applicationContext).publishPending()) {
AiExportDriveResult.NotConfigured,
AiExportDriveResult.Missing,
AiExportDriveResult.Unchanged,
is AiExportDriveResult.Published -> Result.success()
is AiExportDriveResult.Error -> Result.retry()
}
}

View file

@ -0,0 +1,351 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import org.json.JSONArray
import org.json.JSONObject
internal sealed interface AndroidBackupResult {
data class Success(val bytes: Long) : AndroidBackupResult
data class Error(val message: String) : AndroidBackupResult
}
internal data class VerifiedAndroidBackup(
val directory: File,
val database: File,
val preferences: File,
)
/** Complete same-installation backup container; plaintext is explicit by design. */
internal class AndroidBackupService(
private val context: Context,
private val productVersion: String =
checkNotNull(
context.packageManager.getPackageInfo(context.packageName, 0).versionName,
),
) {
companion object {
const val FORMAT = "trainlog-android-backup"
const val VERSION = 1
const val MAX_ENTRY_BYTES = 768L * 1024L * 1024L
const val MAX_TOTAL_BYTES = 1024L * 1024L * 1024L
private val PREFERENCES = listOf("trainlog-sync", "trainlog_presentation_settings")
}
private fun digest(file: File): String {
val value = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
value.update(buffer, 0, count)
}
}
return value.digest().joinToString("") { "%02x".format(it) }
}
private fun preferencesJson(): String =
JSONObject()
.apply {
put("format", "trainlog-android-preferences")
put("version", 1)
put(
"stores",
JSONObject().also { stores ->
PREFERENCES.forEach { name ->
stores.put(
name,
JSONObject().also { values ->
context
.getSharedPreferences(name, Context.MODE_PRIVATE)
.all
.toSortedMap()
.forEach { (key, value) ->
when (value) {
null -> values.put(key, JSONObject.NULL)
is String,
is Boolean,
is Int,
is Long,
is Float -> values.put(key, value)
is Set<*> ->
values.put(
key,
JSONArray(
value
.filterIsInstance<String>()
.sorted()
),
)
else -> error("Unsupported preference type")
}
}
},
)
}
},
)
}
.toString()
fun createPrivate(repository: TrainlogRepository): Result<File> = runCatching {
val directory = File(context.filesDir, "backups").apply { mkdirs() }
val archive = File(directory, "trainlog-backup-${java.util.UUID.randomUUID()}.tlbackup")
archive.outputStream().use { destination ->
val result = writeArchive(repository, destination)
if (result is AndroidBackupResult.Error) error(result.message)
}
archive
}
fun create(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult {
val archive =
createPrivate(repository).getOrElse {
return AndroidBackupResult.Error(it.message ?: "Backup failed.")
}
return try {
archive.inputStream().use { it.copyTo(destination) }
destination.flush()
val bytes = archive.length()
archive.delete()
AndroidBackupResult.Success(bytes)
} catch (error: Exception) {
AndroidBackupResult.Error(
"Export failed; private backup retained at ${archive.name}: ${error.message}"
)
}
}
private fun writeArchive(
repository: TrainlogRepository,
destination: OutputStream,
): AndroidBackupResult {
val stage = File(context.cacheDir, "backup-${java.util.UUID.randomUUID()}")
if (!stage.mkdir())
return AndroidBackupResult.Error("Cannot create private backup staging.")
val database = File(stage, "database.sqlite")
val preferences = File(stage, "preferences.json")
return try {
repository.backupDatabaseTo(database)
preferences.writeText(preferencesJson())
val schema = validateDatabase(database)
val files = listOf("database.sqlite" to database, "preferences.json" to preferences)
val manifest =
JSONObject()
.put("format", FORMAT)
.put("version", VERSION)
.put("package", context.packageName)
.put("schema", schema)
.put("product_version", productVersion)
.put("plaintext", true)
.put("created_at", OffsetDateTime.now().toString())
.put(
"entries",
JSONArray(
files.map { (name, file) ->
JSONObject()
.put("path", name)
.put("size", file.length())
.put("sha256", digest(file))
}
),
)
var written = 0L
ZipOutputStream(destination).use { zip ->
files.forEach { (name, file) ->
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { input -> written += input.copyTo(zip) }
zip.closeEntry()
}
zip.putNextEntry(ZipEntry("manifest.json"))
val raw = manifest.toString().toByteArray()
zip.write(raw)
written += raw.size
zip.closeEntry()
}
AndroidBackupResult.Success(written)
} catch (error: Exception) {
AndroidBackupResult.Error(error.message ?: "Backup failed.")
} finally {
stage.deleteRecursively()
}
}
fun verify(input: InputStream): Result<VerifiedAndroidBackup> {
val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}")
if (!stage.mkdir())
return Result.failure(IllegalStateException("Cannot create restore staging."))
return runCatching {
var total = 0L
val names = mutableSetOf<String>()
ZipInputStream(input).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
require(
!entry.isDirectory &&
entry.name in
setOf("database.sqlite", "preferences.json", "manifest.json") &&
names.add(entry.name)
) {
"Invalid backup entry."
}
val target = File(stage, entry.name)
target.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var entryBytes = 0L
while (true) {
val count = zip.read(buffer)
if (count < 0) break
entryBytes += count
total += count
require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) {
"Backup exceeds bounds."
}
output.write(buffer, 0, count)
}
}
}
}
require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) {
"Backup entries are incomplete."
}
val manifest = JSONObject(File(stage, "manifest.json").readText())
require(
manifest.getString("format") == FORMAT &&
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..33
) {
"Unsupported backup."
}
val expected = manifest.getJSONArray("entries")
require(expected.length() == 2)
val manifestPaths = mutableSetOf<String>()
for (index in 0 until expected.length()) {
val item = expected.getJSONObject(index)
val path = item.getString("path")
require(
path in setOf("database.sqlite", "preferences.json") &&
manifestPaths.add(path)
) {
"Invalid manifest entry."
}
val file = File(stage, path)
require(
file.canonicalFile.parentFile == stage.canonicalFile &&
file.length() == item.getLong("size") &&
digest(file) == item.getString("sha256")
) {
"Backup digest mismatch."
}
}
require(manifestPaths == setOf("database.sqlite", "preferences.json")) {
"Backup manifest is incomplete."
}
require(
validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema")
)
val preferences = JSONObject(File(stage, "preferences.json").readText())
require(
preferences.getString("format") == "trainlog-android-preferences" &&
preferences.getInt("version") == 1
)
VerifiedAndroidBackup(
stage,
File(stage, "database.sqlite"),
File(stage, "preferences.json"),
)
}
.onFailure { stage.deleteRecursively() }
}
fun restore(
repository: TrainlogRepository,
verified: VerifiedAndroidBackup,
databaseName: String = "trainlog-android.db",
): AndroidBackupResult {
val destination = context.getDatabasePath(databaseName)
val previous =
File(
destination.parentFile,
"${destination.name}.pre-restore-${java.util.UUID.randomUUID()}",
)
val previousPreferences =
File(context.cacheDir, "preferences-pre-restore-${java.util.UUID.randomUUID()}.json")
var moved = false
return try {
previousPreferences.writeText(preferencesJson())
repository.close()
destination.parentFile?.mkdirs()
if (destination.exists()) {
check(destination.renameTo(previous))
moved = true
}
File("${destination.path}-wal").delete()
File("${destination.path}-shm").delete()
verified.database.copyTo(destination, overwrite = false)
validateDatabase(destination)
restorePreferences(verified.preferences)
previous.delete()
previousPreferences.delete()
verified.directory.deleteRecursively()
AndroidBackupResult.Success(destination.length())
} catch (error: Exception) {
destination.delete()
if (moved) previous.renameTo(destination)
if (previousPreferences.isFile) runCatching { restorePreferences(previousPreferences) }
previousPreferences.delete()
AndroidBackupResult.Error(error.message ?: "Restore failed.")
}
}
private fun validateDatabase(file: File): Int {
val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
try {
require(
db.rawQuery("PRAGMA integrity_check", null).use {
it.moveToFirst() && it.getString(0) == "ok"
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()
}
}
private fun restorePreferences(file: File) {
val stores = JSONObject(file.readText()).getJSONObject("stores")
PREFERENCES.forEach { name ->
val editor = context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear()
val values = stores.getJSONObject(name)
values.keys().forEach { key ->
when (val value = values.get(key)) {
is String -> editor.putString(key, value)
is Boolean -> editor.putBoolean(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is Double -> editor.putFloat(key, value.toFloat())
is JSONArray ->
editor.putStringSet(
key,
(0 until value.length()).map { value.getString(it) }.toSet(),
)
}
}
check(editor.commit())
}
}
}

View file

@ -0,0 +1,562 @@
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Duration
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import org.json.JSONObject
internal const val TRAINLOG_BLUETOOTH_SERVICE_UUID =
"f0d1c0de-7a11-4f62-9b7c-545241494e4c"
internal const val TRAINLOG_BLUETOOTH_FILE_PROTOCOL = "trainlog-bt-files-v1"
private const val BLUETOOTH_FRAME_FORMAT = "trainlog-bt-frame"
private const val BLUETOOTH_FRAME_VERSION = 1
private const val MAX_BT_HEADER_BYTES = 64 * 1024
private const val MAX_BT_PAYLOAD_BYTES = 256L * 1024L * 1024L
private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L
private const val MAX_BT_FILES = 128
private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L
internal val BLUETOOTH_ANDROID_PULL_COORDINATION =
listOf(
"android-peer-v1.json",
FULL_GENERATION_REQUEST_NAME,
LEGACY_SYNC_REQUEST_NAME,
"android-generation-v1.json",
"android-consumption-ack-v1.json",
"android-archive-acknowledgements-v1.json",
"android-generation-error-v1.json",
)
internal data class BondedBluetoothDesktop(
val name: String,
val address: String,
)
internal class BluetoothSyncSettings(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val desktopAddress: String?
get() = preferences.getString(DESKTOP_ADDRESS, null)
val configured: Boolean
get() = desktopAddress != null
fun selectDesktop(address: String) {
require(BLUETOOTH_ADDRESS.matches(address.uppercase())) {
"Adresse Bluetooth invalide."
}
preferences.edit().putString(DESKTOP_ADDRESS, address.uppercase()).apply()
}
fun disconnect() {
preferences.edit().remove(DESKTOP_ADDRESS).apply()
}
fun shouldPublishAutomaticRequest(nowMillis: Long): Boolean {
val previous = preferences.getLong(LAST_AUTO_REQUEST, 0L)
return previous <= 0L || nowMillis - previous >= AUTO_REQUEST_COOLDOWN_MS
}
fun recordAutomaticRequest(nowMillis: Long) {
preferences.edit().putLong(LAST_AUTO_REQUEST, nowMillis).apply()
}
companion object {
private const val PREFERENCES = "trainlog-bluetooth-sync"
private const val DESKTOP_ADDRESS = "desktop_address"
private const val LAST_AUTO_REQUEST = "last_auto_request_ms"
private val BLUETOOTH_ADDRESS =
Regex("^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
}
}
internal fun hasBluetoothConnectPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
/**
* Decide whether a newly connected RFCOMM peer may publish an automatic request.
*
* WHY: Sleep-only changes have no workout event to wake the desktop path, so
* Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and
* the existing cooldown are both mandatory. INVARIANT: reconnection never
* bypasses the bounded request cadence or requires an explicit UI action.
*/
internal fun shouldPublishBluetoothArrivalRequest(
autoSyncEnabled: Boolean,
cooldownReady: Boolean,
): Boolean = autoSyncEnabled && cooldownReady
@SuppressLint("MissingPermission")
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
if (!hasBluetoothConnectPermission(context)) return emptyList()
val adapter =
context.getSystemService(BluetoothManager::class.java)?.adapter
?: return emptyList()
return adapter.bondedDevices
.asSequence()
.filter { device ->
device.bluetoothClass?.majorDeviceClass == BluetoothClass.Device.Major.COMPUTER
}
.map { device ->
BondedBluetoothDesktop(
name = device.name?.takeIf(String::isNotBlank) ?: device.address,
address = device.address.uppercase(),
)
}
.sortedWith(compareBy<BondedBluetoothDesktop> { it.name }.thenBy { it.address })
.toList()
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private data class BluetoothFrame(
val header: JSONObject,
val payload: File?,
) : AutoCloseable {
override fun close() {
payload?.delete()
}
}
private class BluetoothFrameCodec(private val cacheDirectory: File) {
fun read(input: DataInputStream): BluetoothFrame {
val headerSize = input.readInt()
if (headerSize !in 1..MAX_BT_HEADER_BYTES)
throw IllegalStateException("En-tête Bluetooth invalide.")
val rawHeader = ByteArray(headerSize)
input.readFully(rawHeader)
val header = JSONObject(rawHeader.toString(Charsets.UTF_8))
require(
header.optString("format") == BLUETOOTH_FRAME_FORMAT &&
header.optInt("version", -1) == BLUETOOTH_FRAME_VERSION
) { "Trame Bluetooth non supportée." }
val payloadSize =
if (header.has("payload_size")) header.getLong("payload_size") else 0L
require(payloadSize in 0..MAX_BT_PAYLOAD_BYTES) {
"Charge utile Bluetooth invalide."
}
if (payloadSize == 0L) {
require(!header.has("payload_sha256") || header.optString("payload_sha256").isEmpty())
return BluetoothFrame(header, null)
}
val expected = header.getString("payload_sha256")
require(expected.matches(Regex("^[0-9a-f]{64}$"))) {
"Empreinte Bluetooth invalide."
}
val temporary =
File.createTempFile("trainlog-bt-frame-", ".bin", cacheDirectory)
try {
val digest = MessageDigest.getInstance("SHA-256")
FileOutputStream(temporary).use { output ->
var remaining = payloadSize
val buffer = ByteArray(64 * 1024)
while (remaining > 0L) {
val wanted = minOf(buffer.size.toLong(), remaining).toInt()
val count = input.read(buffer, 0, wanted)
if (count < 0) throw IllegalStateException("Connexion Bluetooth interrompue.")
output.write(buffer, 0, count)
digest.update(buffer, 0, count)
remaining -= count.toLong()
}
output.fd.sync()
}
val actual = digest.digest().joinToString("") { "%02x".format(it) }
require(actual == expected) { "Empreinte Bluetooth incorrecte." }
return BluetoothFrame(header, temporary)
} catch (error: Exception) {
temporary.delete()
throw error
}
}
fun write(output: DataOutputStream, header: JSONObject, payload: File? = null) {
val payloadSize = payload?.length() ?: 0L
require(payloadSize in 0..MAX_BT_PAYLOAD_BYTES)
val value =
JSONObject(header.toString())
.put("format", BLUETOOTH_FRAME_FORMAT)
.put("version", BLUETOOTH_FRAME_VERSION)
.put("payload_size", payloadSize)
if (payload != null) {
require(payload.isFile)
value.put("payload_sha256", sha256(payload))
}
val raw = value.toString().toByteArray(Charsets.UTF_8)
require(raw.size in 1..MAX_BT_HEADER_BYTES)
output.writeInt(raw.size)
output.write(raw)
if (payload != null) {
FileInputStream(payload).use { input -> input.copyTo(output, 64 * 1024) }
}
output.flush()
}
}
private class BluetoothExchangeArchive(
private val context: Context,
) {
private val exchangeDirectory = canonicalExchangeDirectory()
private fun safeRelative(relative: String): List<String> {
require(relative.isNotBlank() && '\\' !in relative)
val parts = relative.split('/')
require(parts.size in 1..6 && parts.all { it.isNotBlank() && it != "." && it != ".." })
return parts
}
private fun addFile(
archive: ZipOutputStream,
file: File,
relative: String,
seen: MutableSet<String>,
totals: LongArray,
) {
if (!file.isFile || Files.isSymbolicLink(file.toPath())) return
safeRelative(relative)
require(seen.add(relative)) { "Chemin Bluetooth dupliqué." }
val size = file.length()
require(size in 0..MAX_BT_FILE_BYTES) { "Fichier Bluetooth trop volumineux." }
totals[0] += 1
totals[1] = Math.addExact(totals[1], size)
require(totals[0] <= MAX_BT_FILES && totals[1] <= MAX_BT_PAYLOAD_BYTES) {
"Archive Bluetooth trop volumineuse."
}
archive.putNextEntry(ZipEntry(relative).also { it.size = size })
FileInputStream(file).use { input -> input.copyTo(archive, 64 * 1024) }
archive.closeEntry()
}
fun buildAndroidPullArchive(): File {
require(exchangeDirectory.isDirectory) { "Dossier d'échange Trainlog indisponible." }
val output = File.createTempFile("trainlog-bt-pull-", ".zip", context.cacheDir)
val seen = linkedSetOf<String>()
val totals = longArrayOf(0L, 0L)
try {
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
BLUETOOTH_ANDROID_PULL_COORDINATION.forEach { name ->
addFile(zip, File(exchangeDirectory, name), name, seen, totals)
}
val reference = File(exchangeDirectory, "android-generation-v1.json")
if (reference.isFile && reference.length() <= MAX_BT_HEADER_BYTES) {
val value = JSONObject(reference.readText())
val relative = value.optString("relative_path")
val generationId = value.optString("generation_id")
val expected = "android-objects/generations/$generationId"
require(relative == expected) { "Référence de génération Android invalide." }
val directory = File(exchangeDirectory, relative)
require(directory.isDirectory && !Files.isSymbolicLink(directory.toPath()))
directory.listFiles().orEmpty().sortedBy(File::getName).forEach { file ->
addFile(zip, file, "$relative/${file.name}", seen, totals)
}
}
}
require(output.length() <= MAX_BT_PAYLOAD_BYTES)
return output
} catch (error: Exception) {
output.delete()
throw error
}
}
fun applyDesktopPushArchive(archiveFile: File) {
require(archiveFile.isFile && archiveFile.length() in 1..MAX_BT_PAYLOAD_BYTES)
if (!exchangeDirectory.exists()) require(exchangeDirectory.mkdirs())
require(exchangeDirectory.isDirectory)
val root = exchangeDirectory.canonicalFile
val seen = linkedSetOf<String>()
val published = mutableListOf<File>()
var count = 0L
var total = 0L
ZipFile(archiveFile).use { archive ->
val entries = archive.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.isDirectory) continue
val parts = safeRelative(entry.name)
require(seen.add(entry.name)) { "Chemin Bluetooth dupliqué." }
require(entry.size in 0..MAX_BT_FILE_BYTES) { "Fichier Bluetooth trop volumineux." }
count += 1
total = Math.addExact(total, entry.size)
require(count <= MAX_BT_FILES && total <= MAX_BT_PAYLOAD_BYTES)
val destination =
parts.fold(root) { parent, name -> File(parent, name) }
val parent = requireNotNull(destination.parentFile)
require(parent.mkdirs() || parent.isDirectory)
require(parent.canonicalFile.toPath().startsWith(root.toPath())) {
"Chemin Bluetooth hors du dossier Trainlog."
}
val temporary =
File(parent, ".${destination.name}.bt-${UUID.randomUUID()}.tmp")
try {
archive.getInputStream(entry).use { input ->
FileOutputStream(temporary).use { output ->
var copied = 0L
val buffer = ByteArray(64 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
copied += read.toLong()
require(copied <= entry.size)
output.write(buffer, 0, read)
}
require(copied == entry.size)
output.fd.sync()
}
}
try {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
}
published += destination
} catch (error: Exception) {
temporary.delete()
throw error
}
}
}
AndroidMtpPublicationVisibility(context).confirm(published)
if (AI_EXPORT_FILENAME in seen) {
AiExportDriveSettings.enqueueImmediate(context)
}
}
}
internal class BluetoothSyncClient(
private val context: Context,
private val repository: TrainlogRepository,
) {
companion object {
private const val TAG = "TrainlogBluetoothSync"
}
@Volatile private var activeSocket: BluetoothSocket? = null
private val settings = BluetoothSyncSettings(context)
private val codec = BluetoothFrameCodec(context.cacheDir)
private val archive = BluetoothExchangeArchive(context)
@SuppressLint("MissingPermission")
fun run(stopped: AtomicBoolean) {
while (!stopped.get()) {
if (!settings.configured || !hasBluetoothConnectPermission(context)) {
sleep(stopped, Duration.ofSeconds(10))
continue
}
val adapter =
context.getSystemService(BluetoothManager::class.java)?.adapter
if (adapter == null || !adapter.isEnabled) {
sleep(stopped, Duration.ofSeconds(10))
continue
}
val address = settings.desktopAddress ?: continue
val device =
runCatching { adapter.getRemoteDevice(address) }
.onFailure { Log.w(TAG, "Configured Bluetooth desktop is invalid", it) }
.getOrNull()
if (device == null || !ensureBond(device, stopped)) {
sleep(stopped, Duration.ofSeconds(5))
continue
}
val socket =
runCatching {
device.createRfcommSocketToServiceRecord(
UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID)
)
}
.onFailure { Log.w(TAG, "Unable to create RFCOMM socket", it) }
.getOrNull()
if (socket == null) {
sleep(stopped, Duration.ofSeconds(10))
continue
}
activeSocket = socket
try {
socket.connect()
Log.i(TAG, "RFCOMM connected to selected Trainlog desktop")
serve(socket, stopped)
} catch (error: Exception) {
Log.w(TAG, "RFCOMM connection ended: ${error.message}", error)
} finally {
activeSocket = null
runCatching { socket.close() }
}
if (!stopped.get()) sleep(stopped, Duration.ofSeconds(5))
}
}
fun shutdown() {
runCatching { activeSocket?.close() }
activeSocket = null
}
@SuppressLint("MissingPermission")
private fun ensureBond(device: BluetoothDevice, stopped: AtomicBoolean): Boolean {
if (device.bondState == BluetoothDevice.BOND_BONDED) return true
if (device.bondState == BluetoothDevice.BOND_NONE) {
Log.i(TAG, "Bluetooth bond missing; requesting one-time repair")
if (!runCatching { device.createBond() }.getOrDefault(false)) {
Log.w(TAG, "Bluetooth bond request was rejected by Android")
return false
}
}
val deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos()
while (!stopped.get() && System.nanoTime() < deadline) {
when (device.bondState) {
BluetoothDevice.BOND_BONDED -> {
Log.i(TAG, "Bluetooth bond ready")
return true
}
BluetoothDevice.BOND_NONE -> {
Log.w(TAG, "Bluetooth bond repair failed")
return false
}
}
sleep(stopped, Duration.ofMillis(250))
}
return device.bondState == BluetoothDevice.BOND_BONDED
}
private fun sleep(stopped: AtomicBoolean, duration: Duration) {
val deadline = System.nanoTime() + duration.toNanos()
while (!stopped.get() && System.nanoTime() < deadline) {
try {
Thread.sleep(250)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return
}
}
}
private fun serve(socket: BluetoothSocket, stopped: AtomicBoolean) {
val input = DataInputStream(BufferedInputStream(socket.inputStream))
val output = DataOutputStream(BufferedOutputStream(socket.outputStream))
val peer = SyncGenerationService(repository).peerId()
codec.write(
output,
JSONObject()
.put("type", "hello")
.put("protocol", TRAINLOG_BLUETOOTH_FILE_PROTOCOL)
.put("peer_id", peer),
)
codec.read(input).use { response ->
require(
response.payload == null &&
response.header.optString("type") == "hello_ack" &&
response.header.optString("protocol") == TRAINLOG_BLUETOOTH_FILE_PROTOCOL &&
response.header.optString("peer_id") == peer
) { "Accusé Bluetooth Trainlog invalide." }
}
publishArrivalRequest()
while (!stopped.get()) {
codec.read(input).use { frame ->
val type = frame.header.optString("type")
val requestId = frame.header.optString("request_id")
require(requestId.startsWith("bt_")) { "Requête Bluetooth non corrélée." }
when (type) {
"pull" -> {
require(frame.payload == null)
val payload = archive.buildAndroidPullArchive()
try {
codec.write(
output,
JSONObject()
.put("type", "result")
.put("request_id", requestId)
.put("result", "ok"),
payload,
)
} finally {
payload.delete()
}
}
"push" -> {
val payload = requireNotNull(frame.payload) {
"Archive Bluetooth desktop absente."
}
archive.applyDesktopPushArchive(payload)
codec.write(
output,
JSONObject()
.put("type", "result")
.put("request_id", requestId)
.put("result", "ok"),
)
}
else -> throw IllegalStateException("Opération Bluetooth non supportée.")
}
}
}
}
private fun publishArrivalRequest() {
val now = System.currentTimeMillis()
if (
!shouldPublishBluetoothArrivalRequest(
BackgroundSyncSettings(context).enabled,
settings.shouldPublishAutomaticRequest(now),
)
) {
return
}
val directory = canonicalExchangeDirectory()
if (!directory.isDirectory) return
val coordinator =
SyncGenerationCoordinator(repository, AndroidMtpPublicationVisibility(context))
coordinator.publishPeer(directory)
when (SyncRequestOutbox(context).requestFullGeneration()) {
is SyncRequestResult.Requested,
is SyncRequestResult.Completed -> settings.recordAutomaticRequest(now)
else -> Unit
}
}
}

View file

@ -0,0 +1,190 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import kotlin.math.roundToInt
internal object CardioTargetResolver {
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
when (definition) {
is CardioTargetDefinition.AbsoluteBpm -> {
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
CardioTargetSnapshot(
minimumBpm = definition.minimumBpm,
maximumBpm = definition.maximumBpm,
)
}
is CardioTargetDefinition.CalibrationPercent -> {
if (
definition.calibrationId.isBlank() ||
definition.observedPeakBpm !in 1..65535 ||
definition.minimumPercent !in 1..100 ||
definition.maximumPercent !in 1..100 ||
definition.minimumPercent > definition.maximumPercent
) return null
val minimum =
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
.roundToInt()
val maximum =
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
.roundToInt()
if (!validBounds(minimum, maximum)) return null
CardioTargetSnapshot(
minimumBpm = minimum,
maximumBpm = maximum,
calibrationId = definition.calibrationId,
calibrationObservedPeakBpm = definition.observedPeakBpm,
minimumPercent = definition.minimumPercent,
maximumPercent = definition.maximumPercent,
)
}
}
private fun validBounds(minimum: Int, maximum: Int): Boolean =
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
}
/**
* Pure state machine for BPM guidance.
*
* Hysteresis and confirmation time are UI/control stability parameters, not
* physiological zones. The engine never extrapolates a stale measurement.
*/
internal object CardioPhaseCompletion {
fun isComplete(
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
elapsedSeconds: Int,
bpm: Int?,
fresh: Boolean,
): Boolean {
if (elapsedSeconds < 0) return false
return when (val exit = phase.exitCondition) {
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
elapsedSeconds >= exit.seconds
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
val target = phase.target ?: return false
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
}
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
fresh && bpm != null && bpm <= exit.bpm
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
elapsedSeconds >= exit.maximumSeconds ||
(fresh && bpm != null && bpm <= exit.bpm)
}
}
}
internal class CardioGuidanceEngine(
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
) {
private var current =
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
?: CardioGuidanceInstruction.MAINTAIN
private var candidate: CardioGuidanceInstruction? = null
private var candidateSinceMs: Long? = null
init {
require(hysteresisBpm in 0..20)
require(confirmationMs in 0L..30_000L)
}
fun reset() {
current = CardioGuidanceInstruction.MAINTAIN
candidate = null
candidateSinceMs = null
}
fun evaluate(
target: CardioTargetSnapshot?,
bpm: Int?,
fresh: Boolean,
nowMs: Long,
): CardioGuidanceOutput {
require(nowMs >= 0L)
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = target,
)
}
require(target.minimumBpm in 1..65535)
require(target.maximumBpm in target.minimumBpm..65535)
val desired = desiredInstruction(target, bpm)
if (desired == current) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(current, bpm, target)
}
if (candidate != desired) {
candidate = desired
candidateSinceMs = nowMs
}
val since = checkNotNull(candidateSinceMs)
if (nowMs - since >= confirmationMs) {
current = desired
candidate = null
candidateSinceMs = null
}
return CardioGuidanceOutput(
instruction = current,
bpm = bpm,
target = target,
pendingInstruction = candidate,
)
}
private fun desiredInstruction(
target: CardioTargetSnapshot,
bpm: Int,
): CardioGuidanceInstruction {
// A target entered from either side immediately trends toward MAINTAIN.
if (bpm in target.minimumBpm..target.maximumBpm) {
return CardioGuidanceInstruction.MAINTAIN
}
return when (current) {
CardioGuidanceInstruction.ACCELERATE ->
if (bpm >= target.minimumBpm + hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.ACCELERATE
}
CardioGuidanceInstruction.SLOW_DOWN ->
if (bpm <= target.maximumBpm - hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.SLOW_DOWN
}
CardioGuidanceInstruction.MAINTAIN,
CardioGuidanceInstruction.SUSPENDED ->
when {
bpm < target.minimumBpm - hysteresisBpm ->
CardioGuidanceInstruction.ACCELERATE
bpm > target.maximumBpm + hysteresisBpm ->
CardioGuidanceInstruction.SLOW_DOWN
else -> CardioGuidanceInstruction.MAINTAIN
}
}
}
companion object {
const val DEFAULT_HYSTERESIS_BPM = 2
const val DEFAULT_CONFIRMATION_MS = 3_000L
}
}

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import java.util.concurrent.CopyOnWriteArraySet
internal data class CardioGuidanceLiveSnapshot(
val phaseId: String? = null,
val phaseKind: CardioPhaseKind? = null,
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
val bpm: Int? = null,
val target: CardioTargetSnapshot? = null,
val startedAt: String? = null,
)
internal object CardioGuidanceLiveState {
private val listeners =
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = CardioGuidanceLiveSnapshot()
fun current(): CardioGuidanceLiveSnapshot = snapshot
fun publish(next: CardioGuidanceLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun clear() {
publish(CardioGuidanceLiveSnapshot())
}
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
}

View file

@ -0,0 +1,378 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Duration
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import org.json.JSONObject
internal class DriveSyncSettings(private val context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val treeUri: Uri? get() = preferences.getString(TREE, null)?.let(Uri::parse)
val connected: Boolean get() = treeUri != null
fun connect(uri: Uri) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences.edit().putString(TREE, uri.toString()).apply()
schedule(context)
}
fun disconnect() {
treeUri?.let { uri ->
runCatching {
context.contentResolver.releasePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
}
preferences.edit().remove(TREE).apply()
WorkManager.getInstance(context).cancelUniqueWork(WORK)
}
companion object {
private const val PREFERENCES = "trainlog-drive-sync"
private const val TREE = "private_tree_uri"
private const val WORK = "trainlog-drive-sync-v1"
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<DriveSyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<DriveSyncWorker>().build())
}
}
}
/** Byte-only adapter over one user-selected private Drive folder. */
internal class DriveDocumentTransport(private val context: Context, treeUri: Uri) {
private val root = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalStateException("Drive folder is unavailable")
private val resolver = context.contentResolver
private val directories = mutableMapOf("" to root)
private fun segments(relative: String): List<String> {
val parts = relative.split('/')
require(parts.size in 1..4 && parts.all { it.isNotEmpty() && it != "." && it != ".." && '\\' !in it })
return parts
}
private fun find(relative: String): DocumentFile? {
val parts = segments(relative)
var parent = root
var prefix = ""
parts.forEachIndexed { index, name ->
if (index == parts.lastIndex) return parent.findFile(name)
prefix = if (prefix.isEmpty()) name else "$prefix/$name"
parent =
directories[prefix]
?: parent.findFile(name)?.also {
if (it.isDirectory) directories[prefix] = it
}
?: return null
}
return null
}
private fun ensureParent(parts: List<String>): DocumentFile {
var parent = root
var prefix = ""
for (name in parts) {
prefix = if (prefix.isEmpty()) name else "$prefix/$name"
parent =
directories[prefix]
?: parent.findFile(name)
?: parent.createDirectory(name)
?: throw IllegalStateException("Cannot create Drive folder")
require(parent.isDirectory)
directories[prefix] = parent
}
return parent
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { stream ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = stream.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun downloadDocument(source: DocumentFile, destination: File): Boolean {
if (!source.isFile || source.length() > 64L * 1024L * 1024L) return false
destination.parentFile?.mkdirs()
val temporary = File(destination.parentFile, ".${destination.name}.drive.tmp-${UUID.randomUUID()}")
resolver.openInputStream(source.uri)?.use { input ->
FileOutputStream(temporary).use { output -> input.copyTo(output); output.fd.sync() }
} ?: return false
/* WHY: coordination files are deliberately replaced across runs, and
* File.renameTo() does not reliably replace an existing destination.
* CONTRACT: consumers observe the old complete file or the newly
* downloaded complete file, never a partial provider stream.
* INVARIANT: the temporary is unique and on the destination filesystem. */
try {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
} catch (error: Exception) {
temporary.delete()
throw error
}
return true
}
private fun download(relative: String, destination: File): Boolean =
find(relative)?.let { downloadDocument(it, destination) } ?: false
private fun upload(relative: String, source: File) {
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath()))
val parts = segments(relative)
val parent = ensureParent(parts.dropLast(1))
val destination = parent.findFile(parts.last())
?: parent.createFile("application/json", parts.last())
?: throw IllegalStateException("Cannot create Drive object")
resolver.openOutputStream(destination.uri, "wt")?.use { output ->
source.inputStream().use { it.copyTo(output) }
output.flush()
} ?: throw IllegalStateException("Cannot write Drive object")
val verification = File.createTempFile("trainlog-drive-verify-", ".json", context.cacheDir)
try {
/* WHY: some SAF providers do not make a newly created child
* discoverable by name immediately. CONTRACT: verification reads
* the exact object returned by createFile/findFile above.
* INVARIANT: size and SHA-256 are checked before publication can
* advance to a manifest or coordination reference. */
require(downloadDocument(destination, verification))
require(verification.length() == source.length() && sha256(verification) == sha256(source))
} finally {
verification.delete()
}
}
fun pull(localRoot: File) {
localRoot.mkdirs()
val coordination = listOf(
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
"request-v1.json",
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
)
coordination.forEach { download(it, File(localRoot, it)) }
listOf("android-generation-v1.json", "desktop-generation-v1.json").forEach { name ->
val reference = File(localRoot, name)
if (!reference.isFile || reference.length() > 64 * 1024) return@forEach
val value = JSONObject(reference.readText())
val relative = value.getString("relative_path")
val generationParts = relative.split('/')
require(
generationParts.size == 3 &&
generationParts[0] in setOf("android-objects", "desktop-objects") &&
generationParts[1] == "generations" &&
generationParts[2] == value.getString("generation_id")
)
val manifestPath = "$relative/manifest.json"
val manifest = File(localRoot, manifestPath)
if (!download(manifestPath, manifest)) return@forEach
val artifacts = JSONObject(manifest.readText()).getJSONArray("artifacts")
require(artifacts.length() <= 32)
val generationRelative = "${generationParts[1]}/${generationParts[2]}"
for (index in 0 until artifacts.length()) {
val filename = artifacts.getJSONObject(index).getString("filename")
require(filename.substringBeforeLast('/', "") == generationRelative)
val artifactPath = "${generationParts[0]}/$filename"
require(download(artifactPath, File(localRoot, artifactPath)))
}
}
}
fun pushPeer(localRoot: File) {
upload("android-peer-v1.json", File(localRoot, "android-peer-v1.json"))
}
fun pushGeneration(localRoot: File) {
val reference = File(localRoot, "android-generation-v1.json")
require(reference.isFile && reference.length() <= 64 * 1024)
val value = JSONObject(reference.readText())
val relative = value.getString("relative_path")
val parts = relative.split('/')
require(
parts.size == 3 &&
parts[0] == "android-objects" &&
parts[1] == "generations" &&
parts[2] == value.getString("generation_id")
)
val generation = File(localRoot, relative)
require(generation.isDirectory && !java.nio.file.Files.isSymbolicLink(generation.toPath()))
val files =
generation.listFiles()?.filter { it.isFile }?.sortedWith(
compareBy<File> { it.name == "manifest.json" }
.thenBy { it.name },
) ?: throw IllegalStateException("Drive generation is unavailable")
require(files.lastOrNull()?.name == "manifest.json")
/* WHY: a Drive tree may contain downloaded desktop objects, stale
* generations, and local staging. Uploading that cache made a single
* phase exceed WorkManager's execution window and crossed transport
* ownership boundaries. CONTRACT: Android publishes only the current
* immutable Android generation. INVARIANT: artifacts precede the
* manifest commit marker, and the correlated reference is last. */
files.forEach { upload("$relative/${it.name}", it) }
upload("android-generation-v1.json", reference)
}
fun pushRecoveryAcknowledgements(localRoot: File) {
upload(
"android-archive-acknowledgements-v1.json",
File(localRoot, "android-archive-acknowledgements-v1.json"),
)
}
fun pushAcknowledgement(localRoot: File) {
upload(
"android-consumption-ack-v1.json",
File(localRoot, "android-consumption-ack-v1.json"),
)
}
}
class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
CoroutineWorker(context, parameters) {
companion object {
/* WHY: reconnecting Drive updates periodic work and also enqueues an
* immediate pass; Android may start both before either reaches the
* Core conversation lock. CONTRACT: only one Drive byte conversation
* runs in this process. INVARIANT: a duplicate trigger invents no
* generation and the active worker remains the durable authority. */
private val running = AtomicBoolean(false)
}
override suspend fun doWork(): Result {
val uri = DriveSyncSettings(applicationContext).treeUri ?: return Result.success()
if (!running.compareAndSet(false, true)) return Result.success()
val local = File(applicationContext.filesDir, "sync-drive-v1")
val transport = DriveDocumentTransport(applicationContext, uri)
val repository = TrainlogRepository(applicationContext)
var stage = "pull"
return try {
transport.pull(local)
stage = "coordinate"
val coordinator = SyncGenerationCoordinator(repository)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
coordinator.publishPeer(local)
stage = "peer-publication"
transport.pushPeer(local)
val actionability = coordinator.classifyBackgroundRequest(local, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NOT_ACTIONABLE ||
actionability == BackgroundRequestActionability.RESUMABLE_BUT_STALE
) {
if (coordinator.hasReplayableAcknowledgement(local)) {
stage = "acknowledgement-replay"
transport.pushAcknowledgement(local)
}
return Result.success()
}
var lastPull = System.nanoTime()
val boundedPull = {
val now = System.nanoTime()
if (now - lastPull >= Duration.ofSeconds(5).toNanos()) {
transport.pull(local)
lastPull = System.nanoTime()
}
}
val result = coordinator.run(
local,
Duration.ofMinutes(10),
afterPeerPublication = { transport.pushPeer(local) },
afterRecoveryAcknowledgements = {
stage = "recovery-acknowledgement"
transport.pushRecoveryAcknowledgements(local)
},
afterPublication = {
stage = "generation-publication"
transport.pushGeneration(local)
},
pollTransport = boundedPull,
afterAcknowledgement = {
stage = "acknowledgement"
transport.pushAcknowledgement(local)
},
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
when (result) {
is ForegroundGenerationResult.Completed -> {
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(local, result.runId),
)
Result.success()
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Result.success()
is ForegroundGenerationResult.Superseded -> {
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
}
Result.success()
}
is ForegroundGenerationResult.Failed -> {
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
}
Result.retry()
}
}
} catch (error: Exception) {
/* SECURITY: stage and exception class are fixed application
* metadata. Provider messages may contain private URIs and are
* deliberately never written to logs. */
Log.w("TrainlogDriveSync", "Drive sync failed at $stage (${error.javaClass.simpleName})")
Result.retry()
} finally {
repository.close()
running.set(false)
}
}
}

View file

@ -39,6 +39,7 @@ internal fun canonicalExchangeDirectory(): File =
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) { internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES" "trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
"trainlog-sync-request-v1.json" -> "SYNC_REQUEST" "trainlog-sync-request-v1.json" -> "SYNC_REQUEST"
"trainlog-sync-full-generation-request-v1.json" -> "FULL_GENERATION_SYNC_REQUEST"
"trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT" "trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT"
"trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS" "trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS" "trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"

View file

@ -0,0 +1,482 @@
/*
* Android-only BLE Heart Rate diagnostics.
*
* This owner is intentionally bounded and screen-scoped. Long-running capture
* belongs to the later foreground acquisition service, not this diagnostic.
*/
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import androidx.core.content.ContextCompat
import java.util.UUID
internal val HEART_RATE_SERVICE_UUID: UUID =
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
internal val BATTERY_SERVICE_UUID: UUID =
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
internal enum class HeartRateDiagnosticPhase {
IDLE,
SCANNING,
CONNECTING,
DISCOVERING,
SUBSCRIBING,
RECEIVING,
DISCONNECTED,
ERROR,
}
internal data class HeartRateDiagnosticDevice(
val address: String,
val name: String?,
val rssi: Int,
val advertisesHeartRate: Boolean,
)
internal data class HeartRateDiagnosticSnapshot(
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
val selectedAddress: String? = null,
val serviceFound: Boolean = false,
val measurementFound: Boolean = false,
val batteryServiceFound: Boolean = false,
val notificationCount: Int = 0,
val latestMeasurement: ParsedHeartRateMeasurement? = null,
val diagnostic: String? = null,
)
internal fun heartRateBleRuntimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
heartRateBleRuntimePermissions().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
internal class HeartRateBleDiagnostics(
context: Context,
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
) : AutoCloseable {
private val appContext = context.applicationContext
private val main = Handler(Looper.getMainLooper())
private val bluetoothManager =
appContext.getSystemService(BluetoothManager::class.java)
private val adapter get() = bluetoothManager?.adapter
private val devices = linkedMapOf<String, BluetoothDevice>()
private var snapshot = HeartRateDiagnosticSnapshot()
private var scannerCallback: ScanCallback? = null
private var scanStop: Runnable? = null
private var gatt: BluetoothGatt? = null
private var closed = false
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
if (closed) return
if (Looper.myLooper() != Looper.getMainLooper()) {
main.post { update(transform) }
return
}
snapshot = transform(snapshot)
onSnapshot(snapshot)
}
@SuppressLint("MissingPermission")
fun startScan(includeAllBle: Boolean = false) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val currentAdapter = adapter
if (currentAdapter == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
return
}
if (!currentAdapter.isEnabled) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
return
}
val scanner = currentAdapter.bluetoothLeScanner
if (scanner == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
return
}
stopScan()
devices.clear()
update {
HeartRateDiagnosticSnapshot(
phase = HeartRateDiagnosticPhase.SCANNING,
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
)
}
val callback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach(::handleScanResult)
}
override fun onScanFailed(errorCode: Int) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "scan_failed:" + errorCode,
)
}
}
}
scannerCallback = callback
val filters =
if (includeAllBle) {
emptyList()
} else {
listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
.build(),
)
}
scanner.startScan(
filters,
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
callback,
)
val stopper =
Runnable {
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
stopScan()
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
}
}
scanStop = stopper
main.postDelayed(stopper, SCAN_DURATION_MS)
}
@SuppressLint("MissingPermission")
private fun handleScanResult(result: ScanResult) {
val record = result.scanRecord
val advertised =
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
val device = result.device
devices[device.address] = device
val row =
HeartRateDiagnosticDevice(
address = device.address,
name = record?.deviceName,
rssi = result.rssi,
advertisesHeartRate = advertised,
)
update { old ->
val rows =
(old.devices.filterNot { it.address == row.address } + row)
.sortedWith(
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
.thenByDescending { it.rssi },
)
old.copy(devices = rows)
}
}
@SuppressLint("MissingPermission")
fun connect(address: String) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val device =
devices[address]
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
?: run {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
return
}
stopScan()
closeGatt()
update {
it.copy(
phase = HeartRateDiagnosticPhase.CONNECTING,
selectedAddress = address,
serviceFound = false,
measurementFound = false,
batteryServiceFound = false,
notificationCount = 0,
latestMeasurement = null,
diagnostic = "connecting",
)
}
@Suppress("DEPRECATION")
runCatching {
device.connectGatt(
appContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess { gatt = it }
.onFailure { error ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "connect_failed:" + error.javaClass.simpleName,
)
}
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCOVERING,
diagnostic = "connected",
)
}
if (!callbackGatt.discoverServices()) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "discover_start_failed",
)
}
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCONNECTED,
diagnostic = "disconnected:" + status,
)
}
closeGatt()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "gatt_status:" + status,
)
}
closeGatt()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "service_discovery:" + status,
)
}
return@post
}
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
update {
it.copy(
serviceFound = service != null,
measurementFound = measurement != null,
batteryServiceFound = battery,
)
}
if (measurement == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "heart_rate_measurement_missing",
)
}
return@post
}
subscribe(callbackGatt, measurement)
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
update {
if (status == BluetoothGatt.GATT_SUCCESS) {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
diagnostic = "notifications_enabled",
)
} else {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write:" + status,
)
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
handleMeasurement(characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
handleMeasurement(characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
) {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "notification_enable_failed",
)
}
return
}
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
if (descriptor == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "ccc_descriptor_missing",
)
}
return
}
update {
it.copy(
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
diagnostic = "enabling_notifications",
)
}
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
if (!started) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write_start_failed",
)
}
}
}
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
when (val parsed = HeartRateMeasurementParser.parse(value)) {
is HeartRateMeasurementParseResult.Invalid ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "measurement:" + parsed.reason,
)
}
is HeartRateMeasurementParseResult.Parsed ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
notificationCount = it.notificationCount + 1,
latestMeasurement = parsed.measurement,
diagnostic = "measurement",
)
}
}
}
@SuppressLint("MissingPermission")
fun stopScan() {
val callback = scannerCallback ?: return
scanStop?.let(main::removeCallbacks)
scanStop = null
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
scannerCallback = null
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun close() {
if (closed) return
stopScan()
closeGatt()
closed = true
}
private companion object {
const val SCAN_DURATION_MS = 10_000L
}
}

View file

@ -0,0 +1,80 @@
package com.labfytools.trainlog.data
import java.util.concurrent.CopyOnWriteArraySet
internal enum class HeartRateLivePhase {
DISCONNECTED,
CONNECTING,
CONNECTED,
STALE,
}
internal data class HeartRateLiveSnapshot(
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
val bpm: Int? = null,
val rrIntervals1024: List<Int> = emptyList(),
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val notificationCount: Long = 0,
)
internal object HeartRateLiveState {
private val listeners =
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = HeartRateLiveSnapshot()
fun current(): HeartRateLiveSnapshot = snapshot
fun publish(next: HeartRateLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
fun disconnected() {
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
}
fun connecting() {
publish(
snapshot.copy(
phase = HeartRateLivePhase.CONNECTING,
bpm = null,
rrIntervals1024 = emptyList(),
sensorContactDetected = null,
energyExpended = null,
),
)
}
fun measurement(measurement: ParsedHeartRateMeasurement) {
publish(
HeartRateLiveSnapshot(
phase = HeartRateLivePhase.CONNECTED,
bpm = measurement.bpm,
rrIntervals1024 = measurement.rrIntervals1024,
sensorContactDetected = measurement.sensorContactDetected,
energyExpended = measurement.energyExpended,
notificationCount = snapshot.notificationCount + 1,
),
)
}
fun stale() {
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
publish(
snapshot.copy(
phase = HeartRateLivePhase.STALE,
bpm = null,
),
)
}
}
}

View file

@ -0,0 +1,103 @@
/*
* Bluetooth Heart Rate Measurement parser.
*
* This file deliberately has no Android Bluetooth dependency so protocol
* decoding can be exhaustively unit-tested independently of GATT transport.
*/
package com.labfytools.trainlog.data
internal data class ParsedHeartRateMeasurement(
val bpm: Int,
val sensorContactDetected: Boolean?,
val energyExpended: Int?,
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
val rrIntervals1024: List<Int>,
)
internal sealed interface HeartRateMeasurementParseResult {
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
HeartRateMeasurementParseResult
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
}
internal object HeartRateMeasurementParser {
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
private const val FLAG_CONTACT_DETECTED = 1 shl 1
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
private const val FLAG_ENERGY_PRESENT = 1 shl 3
private const val FLAG_RR_PRESENT = 1 shl 4
private const val KNOWN_FLAGS =
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
val flags = payload[0].toInt() and 0xff
if ((flags and KNOWN_FLAGS.inv()) != 0) {
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
}
var offset = 1
fun uint8(): Int? =
payload.getOrNull(offset)?.let {
offset += 1
it.toInt() and 0xff
}
fun uint16(): Int? {
if (offset + 2 > payload.size) return null
val value =
(payload[offset].toInt() and 0xff) or
((payload[offset + 1].toInt() and 0xff) shl 8)
offset += 2
return value
}
val bpm =
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
} else {
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
}
val contact =
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
null
} else {
(flags and FLAG_CONTACT_DETECTED) != 0
}
val energy =
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
uint16()
?: return HeartRateMeasurementParseResult.Invalid(
"truncated energy-expended value",
)
} else {
null
}
val rr = mutableListOf<Int>()
if ((flags and FLAG_RR_PRESENT) != 0) {
if (offset == payload.size) {
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
}
if ((payload.size - offset) % 2 != 0) {
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
}
while (offset < payload.size) {
rr += checkNotNull(uint16())
}
} else if (offset != payload.size) {
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
}
return HeartRateMeasurementParseResult.Parsed(
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = contact,
energyExpended = energy,
rrIntervals1024 = rr,
),
)
}
}

View file

@ -0,0 +1,46 @@
package com.labfytools.trainlog.data
import android.content.Context
internal data class SelectedHeartRateSensor(
val address: String,
val name: String?,
)
internal class HeartRateSensorPreferences(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun selected(): SelectedHeartRateSensor? {
if (!preferences.getBoolean(ENABLED, false)) return null
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
return SelectedHeartRateSensor(
address = address,
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
)
}
fun select(address: String, name: String?) {
require(address.isNotBlank())
preferences.edit()
.putBoolean(ENABLED, true)
.putString(ADDRESS, address)
.putString(NAME, name?.takeIf { it.isNotBlank() })
.apply()
}
fun clear() {
preferences.edit()
.putBoolean(ENABLED, false)
.remove(ADDRESS)
.remove(NAME)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-heart-rate-sensor"
private const val ENABLED = "enabled"
private const val ADDRESS = "address"
private const val NAME = "name"
}
}

View file

@ -0,0 +1,561 @@
package com.labfytools.trainlog.data
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.R
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import java.time.Duration
import java.time.OffsetDateTime
class HeartRateSensorService : Service() {
private val main = Handler(Looper.getMainLooper())
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
private val bluetoothManager by lazy {
applicationContext.getSystemService(BluetoothManager::class.java)
}
private var selected: SelectedHeartRateSensor? = null
private var gatt: BluetoothGatt? = null
private var reconnectAttempt = 0
private var lastMeasurementElapsed = 0L
private var lastNotificationBpm: Int? = null
private var repository: TrainlogRepository? = null
private var guidancePhaseId: String? = null
private var guidanceEngine: CardioGuidanceEngine? = null
private fun repository(): TrainlogRepository =
repository ?: TrainlogRepository(applicationContext).also { repository = it }
private val reconnectRunnable = Runnable { connectSelected() }
private val connectionTimeoutRunnable =
Runnable {
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
closeGatt()
scheduleReconnect()
}
}
private val staleRunnable =
object : Runnable {
override fun run() {
val last = lastMeasurementElapsed
if (last > 0L) {
val age = SystemClock.elapsedRealtime() - last
if (
age >= STALE_AFTER_MS &&
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
) {
HeartRateLiveState.stale()
suspendCardioGuidance()
updateForegroundNotification()
}
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
lastMeasurementElapsed = 0L
closeGatt()
scheduleReconnect()
}
}
main.postDelayed(this, STALE_CHECK_MS)
}
}
override fun onCreate() {
super.onCreate()
createChannel()
promoteForeground()
main.post(staleRunnable)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
selected = preferences.selected()
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return START_NOT_STICKY
}
if (bluetoothManager.adapter?.isEnabled != true) {
scheduleReconnect()
return START_STICKY
}
promoteForeground()
if (gatt == null) connectSelected()
return START_STICKY
}
@SuppressLint("MissingPermission")
@Suppress("DEPRECATION")
private fun connectSelected() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
val sensor = preferences.selected()
selected = sensor
val adapter = bluetoothManager.adapter
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return
}
if (adapter?.isEnabled != true) {
scheduleReconnect()
return
}
closeGatt()
lastMeasurementElapsed = 0L
HeartRateLiveState.connecting()
updateForegroundNotification()
val device =
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
?: run {
HeartRateLiveState.disconnected()
scheduleReconnect()
return
}
runCatching {
device.connectGatt(
applicationContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess {
gatt = it
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
.onFailure {
scheduleReconnect()
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (callbackGatt !== gatt) return@post
if (
status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
reconnectAttempt = 0
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
if (!callbackGatt.discoverServices()) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
failConnection()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
return@post
}
val measurement =
callbackGatt
.getService(HEART_RATE_SERVICE_UUID)
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
if (measurement == null) {
failConnection()
return@post
}
main.removeCallbacks(connectionTimeoutRunnable)
if (!subscribe(callbackGatt, measurement)) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
} else {
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
): Boolean {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
}
private fun onHeartRateNotification(
callbackGatt: BluetoothGatt,
uuid: java.util.UUID,
value: ByteArray,
) {
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
val parsed = HeartRateMeasurementParser.parse(value)
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
lastMeasurementElapsed = SystemClock.elapsedRealtime()
HeartRateLiveState.measurement(parsed.measurement)
val observedAt = OffsetDateTime.now().toString()
val repository = repository()
val sessionRecorded =
repository.recordLiveHeartRateForActiveSession(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
if (sessionRecorded != null) {
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
} else {
repository.recordLiveHeartRateForActiveSleep(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
clearCardioGuidanceEngine()
}
if (lastNotificationBpm != parsed.measurement.bpm) {
lastNotificationBpm = parsed.measurement.bpm
updateForegroundNotification()
}
}
private fun processCardioGuidance(
repository: TrainlogRepository,
observedAt: String,
bpm: Int,
) {
val phase = repository.activeCardioGuidancePhase()
if (phase == null) {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val engine = checkNotNull(guidanceEngine)
val output =
engine.evaluate(
target = phase.phase.target,
bpm = bpm,
fresh = true,
nowMs = SystemClock.elapsedRealtime(),
)
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = output.instruction,
bpm = output.bpm,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
}
private fun suspendCardioGuidance() {
val repository = repository ?: return
val phase = repository.activeCardioGuidancePhase() ?: run {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val output =
checkNotNull(guidanceEngine).evaluate(
target = phase.phase.target,
bpm = null,
fresh = false,
nowMs = SystemClock.elapsedRealtime(),
)
val observedAt = OffsetDateTime.now().toString()
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
}
private fun maybeFinishCardioGuidancePhase(
repository: TrainlogRepository,
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
observedAt: String,
bpm: Int?,
fresh: Boolean,
) {
val elapsedSeconds =
runCatching {
Duration.between(
OffsetDateTime.parse(phase.startedAt),
OffsetDateTime.parse(observedAt),
).seconds
}
.getOrNull()
?.coerceIn(0L, Int.MAX_VALUE.toLong())
?.toInt()
?: return
if (
CardioPhaseCompletion.isComplete(
phase.phase,
elapsedSeconds,
bpm,
fresh,
)
) {
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
clearCardioGuidanceEngine()
}
}
private fun clearCardioGuidanceEngine() {
guidancePhaseId = null
guidanceEngine = null
CardioGuidanceLiveState.clear()
}
private fun failConnection() {
main.removeCallbacks(connectionTimeoutRunnable)
closeGatt()
lastMeasurementElapsed = 0L
lastNotificationBpm = null
scheduleReconnect()
}
private fun scheduleReconnect() {
if (preferences.selected() == null) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
return
}
HeartRateLiveState.connecting()
updateForegroundNotification()
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
val delay =
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
main.removeCallbacks(reconnectRunnable)
main.postDelayed(reconnectRunnable, delay)
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun onDestroy() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
main.removeCallbacks(staleRunnable)
closeGatt()
repository?.close()
repository = null
HeartRateLiveState.disconnected()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(
CHANNEL,
getString(R.string.heart_rate_service_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun promoteForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateForegroundNotification() {
runCatching {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification())
}
}
private fun buildNotification(): android.app.Notification {
val live = HeartRateLiveState.current()
val text =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
?: getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.CONNECTING ->
getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.STALE ->
getString(R.string.heart_rate_service_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
getString(R.string.heart_rate_service_disconnected)
}
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.heart_rate_service_title))
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
companion object {
private const val CHANNEL = "trainlog_heart_rate"
private const val NOTIFICATION_ID = 1402
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
private const val CONNECTION_TIMEOUT_MS = 12_000L
private const val STALE_AFTER_MS = 3_000L
private const val STALE_RECONNECT_AFTER_MS = 8_000L
private const val STALE_CHECK_MS = 1_000L
private const val RECONNECT_BASE_MS = 2_000L
private const val RECONNECT_MAX_MS = 15_000L
fun start(context: Context) {
if (
HeartRateSensorPreferences(context).selected() == null ||
!hasHeartRateBleRuntimePermissions(context)
) {
HeartRateLiveState.disconnected()
return
}
ContextCompat.startForegroundService(
context,
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
)
}
fun stop(context: Context) {
context.startService(
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
)
}
fun forget(context: Context) {
HeartRateSensorPreferences(context).clear()
stop(context)
HeartRateLiveState.disconnected()
}
}
}

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.media.MediaScannerConnection
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Confirms that durable external-storage files have reached Android's MTP index. */
internal fun interface MtpPublicationVisibility {
fun confirm(files: List<File>)
}
internal object ImmediateMtpPublicationVisibility : MtpPublicationVisibility {
override fun confirm(files: List<File>) = Unit
}
internal class AndroidMtpPublicationVisibility(context: Context) : MtpPublicationVisibility {
private val applicationContext = context.applicationContext
override fun confirm(files: List<File>) {
val paths = files.asSequence().filter(File::isFile).map { it.absolutePath }.distinct().toList()
if (paths.isEmpty()) return
val completed = CountDownLatch(paths.size)
/* WHY: direct File writes are durable before MediaProvider necessarily
* advertises them through Samsung's MTP database. CONTRACT: every
* immutable artifact is scanned before its mutable reference, and a
* publication is not reported complete until every callback arrives.
* INVARIANT: scanning changes transport visibility only; canonical
* names, bytes, generation identities and commit markers are intact. */
MediaScannerConnection.scanFile(
applicationContext,
paths.toTypedArray(),
Array(paths.size) { "application/json" },
) { _, _ -> completed.countDown() }
if (!completed.await(15, TimeUnit.SECONDS)) {
throw SyncGenerationException("timeout confirming MTP publication visibility")
}
}
}

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.data
import android.content.Context
internal enum class BackgroundRequestActionability {
NEW_REQUEST,
NEW_REMOTE_EVIDENCE,
ACTIVE_HANDOFF,
RESUMABLE_BUT_STALE,
NOT_ACTIONABLE,
}
internal data class BackgroundTerminalRun(
val runId: String,
val remoteEvidence: String,
)
/** One-entry durable suppression ledger for the current background request. */
internal class BackgroundSyncRunLedger(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun terminal(): BackgroundTerminalRun? {
val runId = preferences.getString(RUN_ID, null) ?: return null
return BackgroundTerminalRun(runId, preferences.getString(REMOTE_EVIDENCE, "").orEmpty())
}
fun recordTerminal(runId: String, remoteEvidence: String) {
/* CONTRACT: only the request currently present on the transport needs
* suppression. A replacement request has a distinct run_id and is
* immediately actionable, so this ledger remains permanently bounded
* to one record and is not a second synchronization database. */
preferences.edit()
.putString(RUN_ID, runId)
.putString(REMOTE_EVIDENCE, remoteEvidence)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-background-sync-terminal"
private const val RUN_ID = "run_id"
private const val REMOTE_EVIDENCE = "remote_evidence"
}
}

View file

@ -0,0 +1,197 @@
package com.labfytools.trainlog.data
import android.app.NotificationChannel
import android.app.NotificationManager
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.content.ContextCompat
import com.labfytools.trainlog.R
import java.time.Duration
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
internal class BackgroundSyncSettings(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
var enabled: Boolean
get() = preferences.getBoolean(ENABLED, false)
private set(value) { preferences.edit().putBoolean(ENABLED, value).apply() }
fun enable() { enabled = true }
fun disable() { enabled = false }
companion object {
private const val PREFERENCES = "trainlog-background-sync"
private const val ENABLED = "enabled"
}
}
/**
* User-enabled foreground listener for automatic Bluetooth and MTP synchronization.
*
* WHY: the phone must reconnect to the paired desktop while Compose is not
* visible, but modern Android does not permit an invisible permanent loop.
* CONTRACT: the service shows a mandatory notification, owns the RFCOMM client,
* invokes the same generation coordinator as SyncScreen, and never starts an
* exercise. MTP remains a filesystem fallback.
* INVARIANT: transport visibility only triggers protocol validation; stable
* run/generation/ACK identities remain the sole business authority.
*/
class SyncBackgroundService : Service() {
private val stopped = AtomicBoolean(false)
private val launched = AtomicBoolean(false)
private val executor = Executors.newFixedThreadPool(2)
private var repository: TrainlogRepository? = null
private var bluetoothClient: BluetoothSyncClient? = null
override fun onCreate() {
super.onCreate()
createChannel()
promoteForeground()
repository = TrainlogRepository(applicationContext)
bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP || !BackgroundSyncSettings(this).enabled) {
stopSelf()
return START_NOT_STICKY
}
promoteForeground()
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
executor.execute {
bluetoothClient?.run(stopped)
}
executor.execute {
val coordinator = SyncGenerationCoordinator(
requireNotNull(repository),
AndroidMtpPublicationVisibility(applicationContext),
)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
val directory = canonicalExchangeDirectory()
/* WHY: waiting inside run() owns the process-wide conversation
* lock and would starve Drive while USB is idle. CONTRACT: a
* filesystem observation only schedules bounded validation;
* the coordinator still validates all protocol evidence.
* INVARIANT: no request means no business work and no lock. */
coordinator.publishPeer(directory)
val actionability =
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NEW_REQUEST ||
actionability == BackgroundRequestActionability.NEW_REMOTE_EVIDENCE ||
actionability == BackgroundRequestActionability.ACTIVE_HANDOFF
) {
when (
val result = coordinator.run(
directory,
Duration.ofMinutes(5),
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
) {
is ForegroundGenerationResult.Completed ->
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(directory, result.runId),
)
is ForegroundGenerationResult.Failed ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
is ForegroundGenerationResult.Superseded ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Unit
}
}
AiExportDriveSettings.enqueueImmediate(applicationContext)
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}
stopSelf()
}
return START_NOT_STICKY
}
override fun onDestroy() {
stopped.set(true)
bluetoothClient?.shutdown()
bluetoothClient = null
executor.shutdownNow()
repository?.close()
repository = null
super.onDestroy()
}
override fun onTimeout(startId: Int, fgsType: Int) {
/* Android 15 bounds dataSync foreground time across the application.
* Stop immediately rather than risking an ANR; durable generations
* and ACKs make a later explicit restart safe. */
stopped.set(true)
stopSelf(startId)
}
override fun onBind(intent: Intent?): IBinder? = null
private fun promoteForeground() {
val notification =
NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.background_sync_notification_title))
.setContentText(getString(R.string.background_sync_notification_text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (Build.VERSION.SDK_INT >= 29) {
val serviceType =
if (hasBluetoothConnectPermission(this)) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
startForeground(NOTIFICATION_ID, notification, serviceType)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL,
getString(R.string.background_sync_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
companion object {
private const val CHANNEL = "trainlog_sync"
private const val NOTIFICATION_ID = 1401
private const val ACTION_START = "com.labfytools.trainlog.sync.START"
private const val ACTION_STOP = "com.labfytools.trainlog.sync.STOP"
fun start(context: Context) {
BackgroundSyncSettings(context).enable()
ContextCompat.startForegroundService(
context,
Intent(context, SyncBackgroundService::class.java).setAction(ACTION_START),
)
}
fun stop(context: Context) {
BackgroundSyncSettings(context).disable()
context.startService(
Intent(context, SyncBackgroundService::class.java).setAction(ACTION_STOP),
)
}
}
}

View file

@ -0,0 +1,116 @@
package com.labfytools.trainlog.data
import java.time.Duration
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
internal enum class SyncConversationOwner {
FOREGROUND_EXPLICIT,
BACKGROUND_AUTOMATIC,
}
internal class SyncConversationYieldedException : Exception("conversation superseded by explicit sync")
/** Process-wide ownership authority for Android generation conversations. */
internal class SyncConversationArbiter {
private val lock = ReentrantLock()
private val ownershipChanged = lock.newCondition()
private var active: Lease? = null
private var pendingExplicit = 0
/**
* Announces user intent before the foreground service is refreshed.
*
* WHY: starting the service first allowed it to acquire the old binary
* lock and silently absorb the click. CONTRACT: an announcement prevents
* new background ownership and cooperatively supersedes an existing
* background lease. INVARIANT: ownership is still exclusive until that
* lease observes yield and releases in its finally block.
*/
fun announceExplicit(): ExplicitClaim = lock.withLock {
pendingExplicit += 1
active?.takeIf { it.owner == SyncConversationOwner.BACKGROUND_AUTOMATIC }
?.requestYield()
ownershipChanged.signalAll()
ExplicitClaim(this)
}
fun tryAcquireBackground(): Lease? = lock.withLock {
if (active != null || pendingExplicit > 0) return null
Lease(this, SyncConversationOwner.BACKGROUND_AUTOMATIC).also { active = it }
}
private fun acquireExplicit(claim: ExplicitClaim, timeout: Duration): Lease? {
return lock.withLock {
check(claim.announced)
var remaining = timeout.toNanos()
while (active != null) {
if (remaining <= 0L) {
releaseAnnouncement(claim)
return@withLock null
}
remaining = ownershipChanged.awaitNanos(remaining)
}
releaseAnnouncement(claim)
Lease(this, SyncConversationOwner.FOREGROUND_EXPLICIT).also { active = it }
}
}
private fun releaseAnnouncement(claim: ExplicitClaim) {
if (!claim.announced) return
claim.announced = false
pendingExplicit -= 1
check(pendingExplicit >= 0)
ownershipChanged.signalAll()
}
private fun release(lease: Lease) = lock.withLock {
if (active === lease) {
active = null
ownershipChanged.signalAll()
}
}
internal class ExplicitClaim internal constructor(private val arbiter: SyncConversationArbiter) :
AutoCloseable {
internal var announced = true
private var acquired = false
fun acquire(timeout: Duration): Lease? {
check(!acquired)
val lease = arbiter.acquireExplicit(this, timeout)
acquired = lease != null
return lease
}
override fun close() = arbiter.lock.withLock {
if (!acquired) arbiter.releaseAnnouncement(this)
}
}
internal class Lease internal constructor(
private val arbiter: SyncConversationArbiter,
val owner: SyncConversationOwner,
) : AutoCloseable {
@Volatile private var yieldRequested = false
@Volatile private var closed = false
internal fun requestYield() {
yieldRequested = true
}
fun throwIfYieldRequested() {
if (yieldRequested) throw SyncConversationYieldedException()
}
override fun close() {
if (closed) return
closed = true
arbiter.release(this)
}
}
companion object {
val process = SyncConversationArbiter()
}
}

View file

@ -0,0 +1,701 @@
package com.labfytools.trainlog.data
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Duration
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult
data object Busy : ForegroundGenerationResult
data class Superseded(val runId: String?) : ForegroundGenerationResult
data class Cancelled(val runId: String?) : ForegroundGenerationResult
data class Failed(val message: String, val runId: String?) : ForegroundGenerationResult
}
internal enum class SyncConversationIntent {
NEW_EXPLICIT,
RESUME_BACKGROUND,
}
internal fun interface SyncGenerationTrace {
fun record(runId: String?, phase: String, artifact: String?, generationId: String?, result: String?)
}
internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
override fun record(
runId: String?, phase: String, artifact: String?, generationId: String?, result: String?
) {
Log.i(
"TrainlogSyncGeneration",
listOfNotNull(
"run_id=${runId ?: "pending"}",
"phase=$phase",
artifact?.let { "artifact=$it" },
generationId?.let { "generation_id=$it" },
result?.let { "result=$it" },
).joinToString(" "),
)
}
}
internal fun acknowledgementMatchesCapturedGeneration(
acknowledgement: JSONObject,
captured: CapturedSyncGeneration,
): Boolean =
acknowledgement.optString("generation_id") == captured.generationId &&
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository,
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
private val arbiter: SyncConversationArbiter = SyncConversationArbiter.process,
) {
private val service = SyncGenerationService(repository)
private companion object {
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
const val MAX_COORDINATION_BYTES = 65_536
}
private fun phase(
runId: String?,
phase: String,
artifact: String? = null,
generationId: String? = null,
result: String? = null,
) = trace.record(runId, phase, artifact, generationId, result)
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
sql,
arguments,
).use { cursor ->
if (!cursor.moveToFirst()) null
else {
val captured =
CapturedSyncGeneration(
cursor.getString(0),
runId,
cursor.getString(1),
File(cursor.getString(2)),
)
if (cursor.moveToNext())
throw SyncGenerationException("ambiguous resumable generation")
captured
}
}
query(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
) ?: if (consumerPeerId == null) null else query(
/* WHY: an RFCOMM reset creates a new conversation run while
* the immutable generation still awaits its correlated ACK.
* CONTRACT: resume only the unsuperseded generation for the
* same peer; the reference adopts the new run while manifest
* and generation identity remain unchanged. */
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
"ORDER BY g.generated_at DESC LIMIT 2",
arrayOf(consumerPeerId),
)
}
private fun request(directory: File): JSONObject? {
val path = File(directory, "request-v1.json")
if (!path.isFile || path.length() !in 1..64 * 1024) return null
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
val runId = request.optString("run_id")
val desktopPeerId = request.optString("desktop_peer_id")
if (
request.optString("format") != "trainlog-sync-generation-request" ||
request.optInt("version", -1) != 1 ||
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
request.optString("android_peer_id") != service.peerId()
) return null
return request
}
private fun hasGeneration(runId: String): Boolean =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
internal fun remoteEvidence(directory: File, runId: String): String {
val evidence =
listOf(
Triple("desktop-consumption-ack-v1.json", "trainlog-sync-ack", 1),
Triple("desktop-generation-v1.json", "trainlog-sync-generation-reference", 1),
)
.mapNotNull { (name, format, version) ->
val path = File(directory, name)
if (!path.isFile || path.length() !in 1..64 * 1024) return@mapNotNull null
val raw = runCatching { path.readBytes() }.getOrNull() ?: return@mapNotNull null
val value = runCatching { JSONObject(raw.toString(Charsets.UTF_8)) }.getOrNull()
?: return@mapNotNull null
if (
value.optString("format") != format ||
value.optInt("version", -1) != version ||
value.optString("run_id") != runId
) return@mapNotNull null
"$name:${java.security.MessageDigest.getInstance("SHA-256").digest(raw)
.joinToString("") { "%02x".format(it) }}"
}
return evidence.sorted().joinToString("|")
}
internal fun classifyBackgroundRequest(
directory: File,
terminal: BackgroundTerminalRun?,
): BackgroundRequestActionability {
val request = request(directory) ?: return BackgroundRequestActionability.NOT_ACTIONABLE
val runId = request.getString("run_id")
val desktopPeerId = request.getString("desktop_peer_id")
val resumable = resumableGeneration(runId, desktopPeerId) != null
if (terminal?.runId == runId) {
val currentEvidence = remoteEvidence(directory, runId)
return if (currentEvidence.isNotEmpty() && currentEvidence != terminal.remoteEvidence)
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE
else if (resumable) BackgroundRequestActionability.RESUMABLE_BUT_STALE
else BackgroundRequestActionability.NOT_ACTIONABLE
}
if (!hasGeneration(runId)) return BackgroundRequestActionability.NEW_REQUEST
if (resumable) return BackgroundRequestActionability.ACTIVE_HANDOFF
return BackgroundRequestActionability.NOT_ACTIONABLE
}
internal fun currentRequestRunId(directory: File): String? =
request(directory)?.optString("run_id")
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
val request =
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
?: return false
val reference =
runCatching { JSONObject(File(directory, "desktop-generation-v1.json").readText()) }
.getOrNull() ?: return false
val acknowledgement =
runCatching { JSONObject(File(directory, "android-consumption-ack-v1.json").readText()) }
.getOrNull() ?: return false
/* WHY: a network failure may occur after Core commits consumption but
* before Drive receives its ACK. CONTRACT: only the exact durable ACK
* correlated to the current request and desktop reference is replayed.
* INVARIANT: replay transports existing evidence and never recaptures
* a generation or repeats a domain mutation. */
return request.optString("format") == "trainlog-sync-generation-request" &&
request.optInt("version", -1) == 1 &&
request.optString("android_peer_id") == service.peerId() &&
reference.optString("format") == "trainlog-sync-generation-reference" &&
reference.optInt("version", -1) == 1 &&
acknowledgement.optString("format") == "trainlog-sync-ack" &&
acknowledgement.optInt("version", -1) == 1 &&
acknowledgement.optString("run_id") == request.optString("run_id") &&
reference.optString("run_id") == request.optString("run_id") &&
acknowledgement.optString("generation_id") == reference.optString("generation_id") &&
acknowledgement.optString("manifest_sha256") == reference.optString("manifest_sha256") &&
acknowledgement.optString("producer_peer_id") == request.optString("desktop_peer_id") &&
acknowledgement.optString("consumer_peer_id") == service.peerId() &&
acknowledgement.optString("result") in setOf("consumed", "rejected")
}
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
val actual = value.keys().asSequence().toSet()
if (actual != expected) throw SyncGenerationException("invalid $label fields")
}
private fun requestSupports(request: JSONObject, capability: String): Boolean {
val capabilities = request.optJSONArray("capabilities") ?: return false
for (index in 0 until capabilities.length()) {
if (capabilities.optString(index) == capability) return true
}
return false
}
private fun recoveryAcknowledgements(
desktopPeerId: String,
androidPeerId: String,
): JSONArray =
repository.inSyncGenerationTransaction { db ->
val acknowledgements = JSONArray()
db.rawQuery(
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? " +
"AND consumer_peer_id=? AND result IN('consumed','rejected') " +
"ORDER BY consumed_at DESC,generation_id DESC LIMIT 32",
arrayOf(desktopPeerId, androidPeerId),
)
.use { cursor ->
while (cursor.moveToNext()) {
val acknowledgement = JSONObject(cursor.getString(0))
/* ACKs written before cross-runtime rejection diagnostics
* were normalized can hash '/' differently in Android
* and Python JSON canonicalizers. They remain immutable
* evidence, but must not block recovery of newer,
* compatible ACKs. Consumed ACKs are unaffected. */
if (
acknowledgement.optString("result") != "rejected" ||
!acknowledgement.optString("diagnostic").contains("/")
) {
acknowledgements.put(acknowledgement)
}
}
}
acknowledgements
}
private fun reconcileArchiveAcknowledgements(
raw: ByteArray,
runId: String,
androidPeerId: String,
desktopPeerId: String,
) {
/* WHY: releases before archive-v1 advanced generation status without
* retaining the consumer's ACK locally. The desktop consumer may
* return its exact durable ACK so the Android producer can repair
* that evidence gap without fabricating or weakening proof.
* CONTRACT: the envelope is strictly correlated and contains at most
* 32 ACKs; acceptAcknowledgement revalidates every ACK against the
* immutable local manifest before any archive can become eligible.
* INVARIANT: a missing, rejected, or ambiguous ACK leaves its
* generation active and protected. */
val envelope = JSONObject(raw.toString(Charsets.UTF_8))
requireExactKeys(
envelope,
setOf(
"format",
"version",
"run_id",
"android_peer_id",
"desktop_peer_id",
"acknowledgements",
),
"archive acknowledgement envelope",
)
if (
envelope.getString("format") != "trainlog-sync-archive-acknowledgements" ||
envelope.getInt("version") != 1 ||
envelope.getString("run_id") != runId ||
envelope.getString("android_peer_id") != androidPeerId ||
envelope.getString("desktop_peer_id") != desktopPeerId
) {
throw SyncGenerationException("archive acknowledgement envelope is not correlated")
}
val acknowledgements = envelope.getJSONArray("acknowledgements")
if (acknowledgements.length() > 32)
throw SyncGenerationException("too many archive acknowledgements")
for (index in 0 until acknowledgements.length()) {
service.acceptAcknowledgement(
acknowledgements.getJSONObject(index).toString().toByteArray(Charsets.UTF_8)
)
}
}
private fun publish(path: File, value: String) {
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
FileOutputStream(temporary).use { stream ->
stream.write(value.toByteArray())
stream.flush()
stream.fd.sync()
}
/* WHY: Drive conversations reuse coordination filenames, and
* File.renameTo() does not reliably replace an existing destination
* on Android private storage. CONTRACT: readers observe either the
* previous complete JSON document or its complete replacement.
* INVARIANT: the temporary file is in the destination directory, so
* the preferred move is same-filesystem and atomic when supported. */
try {
Files.move(
temporary.toPath(),
path.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
path.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: Exception) {
temporary.delete()
throw SyncGenerationException("cannot publish ${path.name}")
}
}
/* WHY: transports must advertise a stable installation before a desktop
* can address its first request. CONTRACT: discovery publishes no domain
* facts and does not acquire the conversation lock. INVARIANT: run() uses
* this exact descriptor, so idle discovery cannot fork capabilities. */
internal fun publishPeer(directory: File): String {
if (!directory.isDirectory)
throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
val path = File(directory, "android-peer-v1.json")
val descriptor =
JSONObject()
.put("format", "trainlog-sync-peer")
.put("version", 1)
.put("peer_id", peer)
.put(
"capabilities",
JSONArray(
listOf(
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
"generation-archive-v1",
ACK_RECOVERY_CAPABILITY,
"session-preparations-v2",
"programs-v1",
)
),
)
.toString()
/* WHY: the foreground listener probes every five seconds, while some
* Android MediaProvider implementations cannot atomically replace an
* unchanged file that is simultaneously exposed over MTP. CONTRACT:
* an identical canonical descriptor is already a complete durable
* publication. INVARIANT: changed or unreadable bytes still use the
* crash-safe replacement path above. */
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) {
visibility.confirm(listOf(path))
return peer
}
publish(path, descriptor)
visibility.confirm(listOf(path))
return peer
}
private fun await(path: File, deadline: Long, pump: (() -> Unit)? = null): File {
while (!path.isFile) {
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for ${path.name}")
pump?.invoke()
Thread.sleep(50)
}
return path
}
private fun awaitCorrelated(
path: File,
deadline: Long,
predicate: (JSONObject) -> Boolean,
pump: (() -> Unit)? = null,
lease: SyncConversationArbiter.Lease,
): ByteArray {
while (true) {
lease.throwIfYieldRequested()
if (path.isFile && path.length() <= 64 * 1024) {
try {
val raw = path.readBytes()
if (predicate(JSONObject(raw.toString(Charsets.UTF_8)))) return raw
} catch (_: Exception) {
// Publication replaces the file atomically, but an old or
// concurrently observed object is not evidence of failure.
}
}
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for correlated ${path.name}")
pump?.invoke()
Thread.sleep(50)
}
}
fun run(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterRecoveryAcknowledgements: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null,
intent: SyncConversationIntent = SyncConversationIntent.RESUME_BACKGROUND,
explicitClaim: SyncConversationArbiter.ExplicitClaim? = null,
): ForegroundGenerationResult {
val claim =
if (intent == SyncConversationIntent.NEW_EXPLICIT)
explicitClaim ?: arbiter.announceExplicit()
else null
val lease = try {
claim?.acquire(timeout) ?: if (claim == null) arbiter.tryAcquireBackground() else null
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
claim?.close()
return ForegroundGenerationResult.Cancelled(null)
}
if (lease == null) {
claim?.close()
return ForegroundGenerationResult.Busy
}
var currentRunId: String? = null
return try {
val peer = publishPeer(directory)
phase(null, "peer_published", "android-peer-v1.json")
val baselineRunId = request(directory)?.optString("run_id")
/* WHY: trainlog-syncd is intentionally driven by the established
* Android request artifact, not by polling generation internals.
* CONTRACT: each explicit foreground attempt publishes the peer
* identity before its caller emits one fresh request signal.
* INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.let {
it.invoke()
phase(
null,
"full_generation_trigger_published",
FULL_GENERATION_REQUEST_NAME,
)
}
val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") && when (intent) {
SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
SyncConversationIntent.RESUME_BACKGROUND ->
resumableGeneration(runId) != null || !hasGeneration(runId)
}
}, pollTransport, lease)
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
val runId = request.getString("run_id")
currentRunId = runId
phase(runId, "generation_request_observed", "request-v1.json")
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val archiveAckRaw =
awaitCorrelated(
File(directory, "desktop-archive-acknowledgements-v1.json"),
deadline,
{
it.optString("run_id") == request.getString("run_id") &&
it.optString("android_peer_id") == peer
},
pollTransport,
lease,
)
reconcileArchiveAcknowledgements(
archiveAckRaw,
request.getString("run_id"),
peer,
request.getString("desktop_peer_id"),
)
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
if (requestSupports(request, ACK_RECOVERY_CAPABILITY)) {
/* WHY: Android may commit consumption after the desktop
* producer has already published its immutable generation, then
* transport can fail before the ACK reaches that producer.
* CONTRACT: return only exact durable consumer ACK evidence,
* bounded to the newest 32 rows, before either side captures a
* new generation. INVARIANT: recovery never fabricates lineage
* and never rewrites Android consumption history. */
val recoveryPath =
File(directory, "android-archive-acknowledgements-v1.json")
val recoveryEnvelope =
JSONObject()
.put("format", "trainlog-sync-archive-acknowledgements")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", peer)
.put("desktop_peer_id", request.getString("desktop_peer_id"))
.put(
"acknowledgements",
recoveryAcknowledgements(
request.getString("desktop_peer_id"),
peer,
),
)
.toString()
if (recoveryEnvelope.toByteArray(Charsets.UTF_8).size > MAX_COORDINATION_BYTES)
throw SyncGenerationException(
"Android archive acknowledgement envelope exceeds bound"
)
publish(recoveryPath, recoveryEnvelope)
visibility.confirm(listOf(recoveryPath))
afterRecoveryAcknowledgements?.invoke()
phase(
runId,
"android_archive_ack_published",
recoveryPath.name,
)
}
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
* mutable application state under an existing identity.
* INVARIANT: a completed inbound ledger makes the run ineligible;
* an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */
phase(runId, "generation_capture_started")
lease.throwIfYieldRequested()
val captured =
resumableGeneration(
request.getString("run_id"),
request.getString("desktop_peer_id"),
) ?: service.capture(
directory,
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
lease.throwIfYieldRequested()
phase(runId, "generation_captured", generationId = captured.generationId)
val published = service.publish(captured, File(directory, "android-objects"))
visibility.confirm(
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
)
lease.throwIfYieldRequested()
phase(
runId,
"generation_objects_published",
published.relativeTo(directory).path,
captured.generationId,
)
val generationReference = File(directory, "android-generation-v1.json")
publish(
generationReference,
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
.put("run_id", captured.runId)
.put("generation_id", captured.generationId)
.put("manifest_sha256", captured.manifestSha256)
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
visibility.confirm(listOf(generationReference))
phase(
runId,
"generation_reference_published",
generationReference.name,
captured.generationId,
)
afterPublication?.invoke()
lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. A cross-run retry preserves the
// immutable manifest's original run_id, so correlate its ACK by
// generation identity and manifest digest. acceptAcknowledgement
// then revalidates the complete durable ACK against SQLite.
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
// ignored; an exact replay is deliberately accepted.
val desktopAck =
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
acknowledgementMatchesCapturedGeneration(it, captured)
}, pollTransport, lease)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId
}, pollTransport, lease)
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
phase(
runId,
"desktop_generation_observed",
"desktop-generation-v1.json",
reference.optString("generation_id"),
)
/* WHY: non-atomic transports can expose a newly downloaded
* reference locally before the corresponding immutable objects
* have reached this process. CONTRACT: correlation validates the
* reference first, then one transport refresh stages its committed
* generation before Core validates hashes and imports it.
* INVARIANT: the refresh moves bytes only; service.consume remains
* the sole validation and mutation authority. */
pollTransport?.invoke()
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
phase(
runId,
"desktop_generation_consumed",
generationId = reference.optString("generation_id"),
)
val acknowledgementPath = File(directory, "android-consumption-ack-v1.json")
publish(acknowledgementPath, acknowledgement)
visibility.confirm(listOf(acknowledgementPath))
phase(
runId,
"android_ack_published",
acknowledgementPath.name,
reference.optString("generation_id"),
)
afterAcknowledgement?.invoke()
phase(runId, "completed", result = "completed")
ForegroundGenerationResult.Completed(captured.runId)
} catch (_: SyncConversationYieldedException) {
phase(currentRunId, "superseded", result = "foreground_priority")
ForegroundGenerationResult.Superseded(currentRunId)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
phase(currentRunId, "cancelled", result = "interrupted")
ForegroundGenerationResult.Cancelled(currentRunId)
} catch (error: Exception) {
Log.e(
"TrainlogSyncGeneration",
"run_id=${currentRunId ?: "pending"} phase=error " +
"type=${error.javaClass.name} message=${error.message}",
error,
)
if (error.message == "generation retention capacity exhausted") {
try {
val request = JSONObject(File(directory, "request-v1.json").readText())
publish(
File(directory, "android-generation-error-v1.json"),
JSONObject()
.put("format", "trainlog-sync-generation-error")
.put("version", 1)
.put("run_id", request.getString("run_id"))
.put("android_peer_id", service.peerId())
.put("code", "peer_capacity_exhausted")
.put("action", "Archive acknowledged generations, then retry explicitly.")
.toString(),
)
visibility.confirm(listOf(File(directory, "android-generation-error-v1.json")))
} catch (_: Exception) {
// Preserve the original local failure when error reporting
// cannot itself be durably published.
}
}
phase(
runCatching {
JSONObject(File(directory, "request-v1.json").readText()).optString("run_id")
}.getOrNull(),
"error",
result = error.javaClass.simpleName,
)
ForegroundGenerationResult.Failed(
error.message ?: "generation synchronization failed",
currentRunId,
)
} finally {
lease.close()
claim?.close()
}
}
}
/* Compatibility name for focused tests and callers outside the coordinator
* migration. New triggers should depend on SyncGenerationCoordinator. */
internal typealias SyncGenerationForegroundCoordinator = SyncGenerationCoordinator

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,29 @@ import java.util.UUID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
internal const val FULL_GENERATION_REQUEST_NAME =
"trainlog-sync-full-generation-request-v1.json"
internal const val LEGACY_SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"
internal data class SyncRequestIntent(
val requestId: String,
val requestedAt: String,
) {
fun payload(): String =
JSONObject()
.put("format", "trainlog-sync-request")
.put("version", 1)
.put("request_id", requestId)
.put("requested_at", requestedAt)
.toString()
}
sealed interface SyncRequestResult { sealed interface SyncRequestResult {
/** The full-generation coordinator completed both durable peer ACKs. */
data class Completed(
val runId: String,
) : SyncRequestResult
data class Requested( data class Requested(
val requestId: String, val requestId: String,
) : SyncRequestResult ) : SyncRequestResult
@ -28,13 +50,16 @@ sealed interface SyncRequestResult {
class SyncRequestOutbox private constructor( class SyncRequestOutbox private constructor(
private val publisher: DirectExchangePublisher, private val publisher: DirectExchangePublisher,
private val visibility: MtpPublicationVisibility,
) { ) {
constructor(context: Context) : this( constructor(context: Context) : this(
DirectExchangePublisher { directExchangeDirectory(context) }, DirectExchangePublisher { directExchangeDirectory(context) },
AndroidMtpPublicationVisibility(context),
) )
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this( internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
DirectExchangePublisher(directoryProvider), DirectExchangePublisher(directoryProvider),
ImmediateMtpPublicationVisibility,
) )
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) { suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
@ -44,6 +69,16 @@ class SyncRequestOutbox private constructor(
} }
} }
internal fun requestFullGeneration(): SyncRequestResult {
return when (val opened = publisher.snapshot()) {
is DirectExchangeSnapshotResult.Ready -> {
val intent = newIntent()
publishFullGeneration(opened.snapshot, intent)
}
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
}
}
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult { internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
if ( if (
Build.VERSION.SDK_INT < Build.VERSION.SDK_INT <
@ -52,37 +87,42 @@ class SyncRequestOutbox private constructor(
return SyncRequestResult.Unsupported return SyncRequestResult.Unsupported
} }
val requestId = val intent = newIntent()
"sr_" + return publishLegacy(snapshot, intent)
UUID.randomUUID() }
.toString()
val payload = internal fun newIntent(): SyncRequestIntent =
JSONObject() SyncRequestIntent(
.put( requestId = "sr_${UUID.randomUUID()}",
"format", requestedAt = OffsetDateTime.now().toString(),
"trainlog-sync-request", )
)
.put(
"version",
1,
)
.put(
"request_id",
requestId,
)
.put(
"requested_at",
OffsetDateTime.now()
.toString(),
)
.toString()
val displayName = internal fun publishFullGeneration(
"trainlog-sync-request-v1.json" snapshot: DirectExchangeSnapshot,
val error = snapshot.writeJson(displayName, payload) intent: SyncRequestIntent,
): SyncRequestResult = publish(snapshot, FULL_GENERATION_REQUEST_NAME, intent)
internal fun publishLegacy(
snapshot: DirectExchangeSnapshot,
intent: SyncRequestIntent,
): SyncRequestResult = publish(snapshot, LEGACY_SYNC_REQUEST_NAME, intent)
private fun publish(
snapshot: DirectExchangeSnapshot,
displayName: String,
intent: SyncRequestIntent,
): SyncRequestResult {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return SyncRequestResult.Unsupported
}
val error = snapshot.writeJson(displayName, intent.payload())
return if (error == null) { return if (error == null) {
SyncRequestResult.Requested(requestId) try {
visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName)))
SyncRequestResult.Requested(intent.requestId)
} catch (error: Exception) {
SyncRequestResult.Error(error.message ?: "Publication MTP impossible.")
}
} else { } else {
SyncRequestResult.Error(error) SyncRequestResult.Error(error)
} }

View file

@ -0,0 +1,37 @@
package com.labfytools.trainlog.model
enum class CardioCalibrationPhase(val wireValue: String) {
WARMUP("warmup"),
PROGRESSIVE("progressive"),
HIGH_EFFORT("high_effort"),
RECOVERY("recovery"),
COMPLETED("completed"),
ABORTED("aborted");
companion object {
fun fromWire(value: String): CardioCalibrationPhase =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown cardio calibration phase: $value")
}
}
data class CardioCalibrationRecoveryPoint(
val targetOffsetSeconds: Int,
val observedAt: String,
val bpm: Int,
)
data class CardioCalibrationProfile(
val calibrationId: String,
val protocolVersion: Int,
val sessionId: String,
val entryId: String,
val phase: CardioCalibrationPhase,
val startedAt: String,
val phaseStartedAt: String,
val effortEndAt: String?,
val endedAt: String?,
val heartRateCaptureId: String?,
val observedPeakBpm: Int?,
val recovery: List<CardioCalibrationRecoveryPoint>,
)

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.model
enum class CardioPhaseKind(val wireValue: String) {
WARMUP("warmup"),
WORK("work"),
RECOVERY("recovery"),
COOLDOWN("cooldown"),
}
sealed interface CardioTargetDefinition {
data class AbsoluteBpm(
val minimumBpm: Int,
val maximumBpm: Int,
) : CardioTargetDefinition
data class CalibrationPercent(
val calibrationId: String,
val observedPeakBpm: Int,
val minimumPercent: Int,
val maximumPercent: Int,
) : CardioTargetDefinition
}
/**
* Immutable target actually used by one phase.
*
* The calibration identity and percentages are provenance only. Runtime
* guidance consumes the resolved BPM bounds so later calibration changes
* cannot rewrite an already-started session.
*/
data class CardioTargetSnapshot(
val minimumBpm: Int,
val maximumBpm: Int,
val calibrationId: String? = null,
val calibrationObservedPeakBpm: Int? = null,
val minimumPercent: Int? = null,
val maximumPercent: Int? = null,
)
sealed interface CardioPhaseExitCondition {
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
data object EnterTarget : CardioPhaseExitCondition
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
data class DurationOrRecoverBelow(
val maximumSeconds: Int,
val bpm: Int,
) : CardioPhaseExitCondition
}
data class CardioGuidedPhase(
val phaseId: String,
val kind: CardioPhaseKind,
val target: CardioTargetSnapshot?,
val exitCondition: CardioPhaseExitCondition,
)
enum class CardioGuidanceInstruction {
ACCELERATE,
MAINTAIN,
SLOW_DOWN,
SUSPENDED,
}
data class CardioGuidanceOutput(
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val target: CardioTargetSnapshot?,
val pendingInstruction: CardioGuidanceInstruction? = null,
)
data class CardioGuidancePhaseRecord(
val runId: String,
val sessionId: String,
val entryId: String,
val phase: CardioGuidedPhase,
val position: Int,
val startedAt: String,
val endedAt: String?,
val currentInstruction: CardioGuidanceInstruction,
)
data class CardioGuidanceEvent(
val sequence: Long,
val observedAt: String,
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val targetMinimumBpm: Int?,
val targetMaximumBpm: Int?,
)
data class CardioGuidanceRun(
val runId: String,
val sessionId: String,
val startedAt: String,
val endedAt: String?,
val phases: List<CardioGuidancePhaseRecord>,
)

View file

@ -0,0 +1,46 @@
/*
* Android HeartRateModels model.
*
* Android owns sensor acquisition. Desktop/Web only consume synchronized
* measured facts and never connect to the heart-rate sensor.
*/
package com.labfytools.trainlog.model
enum class HeartRateContextKind(val wireValue: String) {
SESSION("session"),
CARDIO("cardio"),
SLEEP("sleep");
companion object {
fun fromWire(value: String): HeartRateContextKind =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown heart-rate context: $value")
}
}
data class HeartRateRrInterval(
val index: Int,
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
val value1024: Int,
)
data class HeartRateSample(
val sequence: Long,
val observedAt: String,
val bpm: Int,
val exerciseEntryId: String? = null,
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
)
data class HeartRateCapture(
val captureId: String,
val contextKind: HeartRateContextKind,
val contextId: String,
val startedAt: String,
val endedAt: String?,
val activeExerciseEntryId: String?,
val sensorName: String?,
val samples: List<HeartRateSample> = emptyList(),
)

View file

@ -0,0 +1,52 @@
package com.labfytools.trainlog.model
/** Read-only desktop-owned program projection exposed to Android presentation. */
data class SyncedProgramSummary(
val programId: String,
val title: String,
val state: String,
val startDate: String?,
val endDate: String?,
val sessionCount: Int,
)
data class SyncedProgramDetail(
val programId: String,
val title: String,
val note: String?,
val state: String,
val startDate: String?,
val endDate: String?,
val sessions: List<SyncedProgramSession>,
)
data class SyncedProgramSession(
val programSessionId: String,
val title: String,
val sessionType: String,
val plannedFor: String?,
val note: String?,
val executionState: ProgramSessionExecutionState,
val executionSessionId: String?,
val occurrences: List<SyncedProgramOccurrence>,
)
enum class ProgramSessionExecutionState {
TODO,
IN_PROGRESS,
COMPLETED,
}
data class SyncedProgramOccurrence(
val entryId: String,
val exerciseId: String,
val exerciseName: String,
val equipmentId: String?,
val loadMode: String,
val restSeconds: Int,
val targetSets: Int?,
val targetReps: Int?,
val targetDurationSeconds: Int?,
val targetWeightKg: Double?,
val notes: String?,
)

View file

@ -9,17 +9,14 @@ enum class SessionType(
val wireValue: String, val wireValue: String,
) { ) {
TRAINING("training"), TRAINING("training"),
MAX_TEST("max_test"); MAX_TEST("max_test"),
CARDIO("cardio");
companion object { companion object {
fun fromWire( fun fromWire(
value: String, value: String,
): SessionType = ): SessionType =
if (value == "max_test") { entries.firstOrNull { it.wireValue == value } ?: TRAINING
MAX_TEST
} else {
TRAINING
}
} }
} }
@ -79,6 +76,24 @@ data class SessionDraft(
val sessionType: SessionType = SessionType.TRAINING, val sessionType: SessionType = SessionType.TRAINING,
) )
data class SessionExerciseTiming(
val sessionId: String,
val entryId: String,
val exerciseId: String,
val startedAt: String,
val endedAt: String?,
) {
val active: Boolean get() = endedAt == null
}
data class ActiveSessionTimelineContext(
val sessionId: String,
val startedAt: String,
val sessionType: SessionType,
val activeEntryId: String?,
)
data class SessionDraftForm( data class SessionDraftForm(
val selectedExercise: ExerciseProfile? = null, val selectedExercise: ExerciseProfile? = null,
/** Null means creation; otherwise replace this durable draft entry. */ /** Null means creation; otherwise replace this durable draft entry. */
@ -101,10 +116,20 @@ data class SessionDraftForm(
* mistake unfinished capture for a completed session. * mistake unfinished capture for a completed session.
*/ */
data class ActiveSessionDraft( data class ActiveSessionDraft(
/** Stable lifecycle identity, allocated once when first persisted. */
val sessionId: String? = null,
/** Causal draft revision; local UI-only persistence may retain its tip. */
val revisionId: String = "dr_legacy_v1",
val parentRevisionId: String? = null,
/** Genuine lifecycle start, distinct from local form update time. */
val startedAt: String? = null,
val exercises: List<SessionExerciseDraft> = emptyList(), val exercises: List<SessionExerciseDraft> = emptyList(),
val sessionType: SessionType = SessionType.TRAINING, val sessionType: SessionType = SessionType.TRAINING,
/** Existing max_test session updated atomically on finalization, if any. */ /** Existing max_test session updated atomically on finalization, if any. */
val sourceSessionId: String? = null, val sourceSessionId: String? = null,
/** Stable desktop-owned Program provenance; both values are present or absent. */
val sourceProgramId: String? = null,
val sourceProgramSessionId: String? = null,
val form: SessionDraftForm = SessionDraftForm(), val form: SessionDraftForm = SessionDraftForm(),
val updatedAt: String = "", val updatedAt: String = "",
) )
@ -123,6 +148,18 @@ data class AiSessionDraft(
val entries: List<SessionExerciseDraft>, val entries: List<SessionExerciseDraft>,
) )
/** Immutable desktop preparation delivery waiting for an explicit start. */
data class PreparedSession(
val deliveryId: String,
val preparationId: String,
val revisionId: String,
val executionSessionId: String,
val plannedFor: String?,
val title: String,
val notes: String?,
val entries: List<SessionExerciseDraft>,
)
data class SessionSummary( data class SessionSummary(
val sessionId: String, val sessionId: String,
val startedAt: String, val startedAt: String,

View file

@ -0,0 +1,34 @@
package com.labfytools.trainlog.model
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
enum class SleepEventType(val wireValue: String) {
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
DAYTIME_SLEEPINESS("daytime_sleepiness"),
}
enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED }
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
data class SleepMedication(
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String,
val name: String, val defaultDoseValue: Double?, val defaultDoseUnit: String?,
val form: String, val note: String, val active: Boolean,
)
data class MedicationIntake(
val intakeId: String, val medicationId: String, val medicationName: String,
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
val createdAt: String, val quantity: Int = 1,
)
data class SleepDiaryEntry(
val entryId: String, val nightStartDate: String, val nightEndDate: String,
val createdAt: String, val updatedAt: String, val revisionId: String,
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
val publicationStatus: SleepPublicationStatus,
)
data class SleepDiaryDraft(
val entryId: String? = null, val expectedRevision: String? = null,
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
val intakes: List<MedicationIntake> = emptyList(),
)

View file

@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -32,6 +33,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import com.labfytools.trainlog.R import com.labfytools.trainlog.R
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -41,7 +43,7 @@ private val drawerIcons = mapOf(
AppSection.SESSIONS to R.drawable.ic_sessions, AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises, AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment, AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.STATISTICS to R.drawable.ic_statistics, AppSection.SLEEP to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync, AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings, AppSection.SETTINGS to R.drawable.ic_settings,
) )
@ -55,6 +57,29 @@ fun AndroidAppShell(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext().resources
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.SLEEP -> R.string.nav_sleep
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
AppRoute.CardioCalibration -> R.string.route_cardio_calibration
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.SleepDiary -> R.string.nav_sleep
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
})
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val root = route == route.section.rootRoute() val root = route == route.section.rootRoute()
@ -81,13 +106,22 @@ fun AndroidAppShell(
AppSection.entries.filterNot { it == AppSection.EQUIPMENT }.forEach { section -> AppSection.entries.filterNot { it == AppSection.EQUIPMENT }.forEach { section ->
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) }, icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
label = { Text(section.label) }, label = { Text(sectionLabel(section)) },
selected = route.section == section, selected = route.section == section,
onClick = { onClick = {
onOpenSection(section) onOpenSection(section)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
shape = RoundedCornerShape(10.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = colors.surfaceAlt,
selectedIconColor = colors.accent,
selectedTextColor = colors.text,
unselectedContainerColor = colors.mantle,
unselectedIconColor = colors.muted,
unselectedTextColor = colors.muted,
),
) )
} }
} }
@ -96,14 +130,14 @@ fun AndroidAppShell(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (root) "TRAINLOG" else routeTitle(route)) }, title = { Text(if (root) "TRAINLOG" else title()) },
navigationIcon = { navigationIcon = {
IconButton( IconButton(
onClick = { onClick = {
if (root) scope.launch { drawerState.open() } else onBack() if (root) scope.launch { drawerState.open() } else onBack()
}, },
modifier = Modifier.semantics { modifier = Modifier.semantics {
contentDescription = if (root) "Ouvrir la navigation" else "Retour" contentDescription = strings.getString(if (root) R.string.a11y_open_navigation else R.string.a11y_back)
}, },
) { ) {
Icon( Icon(
@ -113,10 +147,11 @@ fun AndroidAppShell(
} }
}, },
actions = { actions = {
HeartRateIndicator()
if (!root) { if (!root) {
IconButton( IconButton(
onClick = { scope.launch { drawerState.open() } }, onClick = { scope.launch { drawerState.open() } },
modifier = Modifier.semantics { contentDescription = "Navigation" }, modifier = Modifier.semantics { contentDescription = strings.getString(R.string.a11y_navigation) },
) { Icon(painterResource(R.drawable.ic_menu), contentDescription = null) } ) { Icon(painterResource(R.drawable.ic_menu), contentDescription = null) }
} }
}, },

View file

@ -22,7 +22,7 @@ class AndroidSpeechRecognitionAdapter(context: Context) : SpeechRecognitionAdapt
instance.setRecognitionListener(object : RecognitionListener { instance.setRecognitionListener(object : RecognitionListener {
override fun onPartialResults(results: Bundle) = listener.onPartial(best(results)) override fun onPartialResults(results: Bundle) = listener.onPartial(best(results))
override fun onResults(results: Bundle) = listener.onFinal(best(results)) override fun onResults(results: Bundle) = listener.onFinal(best(results))
override fun onError(error: Int) = listener.onError("Reconnaissance interrompue (code $error).") override fun onError(error: Int) = listener.onError("speech_error:$error")
override fun onReadyForSpeech(params: Bundle?) = Unit override fun onReadyForSpeech(params: Bundle?) = Unit
override fun onBeginningOfSpeech() = Unit override fun onBeginningOfSpeech() = Unit
override fun onRmsChanged(rmsdB: Float) = Unit override fun onRmsChanged(rmsdB: Float) = Unit

View file

@ -10,9 +10,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
enum class AppSection(val label: String) { enum class AppSection {
HOME("Accueil"), SESSIONS("Séances"), EXERCISES("Exercices"), HOME, SESSIONS, EXERCISES,
EQUIPMENT("Équipements"), STATISTICS("Statistiques"), SYNC("Synchronisation"), SETTINGS("Paramètres"), EQUIPMENT, SLEEP, SYNC, SETTINGS,
} }
sealed interface AppRoute { sealed interface AppRoute {
@ -23,6 +23,9 @@ sealed interface AppRoute {
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS } data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS } data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS } data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
data object CardioCalibration : AppRoute { override val section = AppSection.SESSIONS }
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data object Exercises : AppRoute { override val section = AppSection.EXERCISES } data object Exercises : AppRoute { override val section = AppSection.EXERCISES }
@ -32,11 +35,12 @@ sealed interface AppRoute {
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT } data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT } data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section } data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object Statistics : AppRoute { override val section = AppSection.STATISTICS } data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS } data object SleepDiary : AppRoute { override val section = AppSection.SLEEP }
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS } data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC } data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS } data object Settings : AppRoute { override val section = AppSection.SETTINGS }
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
} }
fun AppSection.rootRoute(): AppRoute = when (this) { fun AppSection.rootRoute(): AppRoute = when (this) {
@ -44,7 +48,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.STATISTICS -> AppRoute.Statistics AppSection.SLEEP -> AppRoute.SleepDiary
AppSection.SYNC -> AppRoute.Sync AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings AppSection.SETTINGS -> AppRoute.Settings
} }
@ -158,29 +162,6 @@ class AppNavigationController(
} }
} }
internal fun routeTitle(route: AppRoute): String = when (route) {
AppRoute.Home -> "Accueil"
AppRoute.Sessions -> "Séances"
AppRoute.SessionEditor -> "Séance en cours"
AppRoute.SessionGenerator -> "Programmer une séance"
AppRoute.AiSessionDrafts -> "Brouillons"
AppRoute.CompletedSessions -> "Séances effectuées"
is AppRoute.SessionDetail -> "Détail de la séance"
is AppRoute.SessionCorrection -> "Modifier la séance"
AppRoute.Exercises -> "Exercices"
is AppRoute.ExerciseDetail -> "Fiche exercice"
is AppRoute.ExerciseEdit -> "Modifier l'exercice"
is AppRoute.ExerciseCreate -> "Créer un exercice"
AppRoute.Equipment -> "Équipements"
is AppRoute.EquipmentDetail -> "Fiche équipement"
is AppRoute.EquipmentCreate -> "Créer un équipement"
AppRoute.Statistics -> "Statistiques"
AppRoute.BodyMeasurements -> "Mensurations"
AppRoute.LatestMaxima -> "Derniers MAX"
AppRoute.Sync -> "Synchronisation"
AppRoute.Settings -> "Paramètres"
}
/** /**
* WHY: configuration recreation must retain small navigation and form state, * WHY: configuration recreation must retain small navigation and form state,
* while process death deliberately restores only the repository-owned draft. * while process death deliberately restores only the repository-owned draft.
@ -192,5 +173,11 @@ class TrainlogAppState : ViewModel() {
val equipment = EquipmentScreenState() val equipment = EquipmentScreenState()
val exercise = ExerciseScreenState() val exercise = ExerciseScreenState()
val body = BodyScreenState() val body = BodyScreenState()
/* WHY: the durable form intentionally does not serialize transient edit
* position, but a catalogue detour must still return to the same row.
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
* all actual form fields remain repository-owned and durable.
* INVARIANT: null means a new occurrence, never an inferred row. */
var pendingSessionEquipmentEntryId: String? = null
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body) val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
} }

View file

@ -8,6 +8,13 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -25,6 +32,7 @@ import com.labfytools.trainlog.data.SaveBodyObservationResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
class BodyScreenState { class BodyScreenState {
val values = List(14) { mutableStateOf("") } val values = List(14) { mutableStateOf("") }
@ -39,6 +47,8 @@ fun BodyScreen(
onBodySaved: () -> Unit, onBodySaved: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val strings = localizedContext()
val locale = presentationLocale()
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
@ -81,164 +91,90 @@ fun BodyScreen(
} }
TrainlogScreen( TrainlogScreen(
subtitle = "Mensurations" subtitle = strings.getString(R.string.route_body_measurements)
) { ) {
TrainlogFrame( TrainlogFrame(
title = "Général" title = strings.getString(R.string.general)
) { ) {
BodyMetricField( BodyMetricGrid(
label = "Poids", listOf(
unit = "kg", BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
value = bodyWeight, bodyWeight = it
onValueChange = { message = null
bodyWeight = it },
message = null BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
}, neck = it
) message = null
},
BodyMetricField( BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
label = "Cou", shoulders = it
unit = "cm", message = null
value = neck, },
onValueChange = { BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
neck = it chest = it
message = null message = null
}, },
) BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
waist = it
BodyMetricField( message = null
label = "Épaules", },
unit = "cm", BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
value = shoulders, hips = it
onValueChange = { message = null
shoulders = it },
message = null ),
},
)
BodyMetricField(
label = "Poitrine",
unit = "cm",
value = chest,
onValueChange = {
chest = it
message = null
},
)
BodyMetricField(
label = "Tour de taille",
unit = "cm",
value = waist,
onValueChange = {
waist = it
message = null
},
)
BodyMetricField(
label = "Hanches",
unit = "cm",
value = hips,
onValueChange = {
hips = it
message = null
},
) )
} }
TrainlogFrame( TrainlogFrame(
title = "Membres" title = strings.getString(R.string.limbs)
) { ) {
BodyMetricField( BodyMetricGrid(
label = "Bras gauche", listOf(
unit = "cm", BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
value = leftArm, leftArm = it
onValueChange = { message = null
leftArm = it },
message = null BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
}, rightArm = it
) message = null
},
BodyMetricField( BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
label = "Bras droit", leftForearm = it
unit = "cm", message = null
value = rightArm, },
onValueChange = { BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
rightArm = it rightForearm = it
message = null message = null
}, },
) BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
leftThigh = it
BodyMetricField( message = null
label = "Avant-bras gauche", },
unit = "cm", BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
value = leftForearm, rightThigh = it
onValueChange = { message = null
leftForearm = it },
message = null BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
}, leftCalf = it
) message = null
},
BodyMetricField( BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
label = "Avant-bras droit", rightCalf = it
unit = "cm", message = null
value = rightForearm, },
onValueChange = { ),
rightForearm = it
message = null
},
)
BodyMetricField(
label = "Cuisse gauche",
unit = "cm",
value = leftThigh,
onValueChange = {
leftThigh = it
message = null
},
)
BodyMetricField(
label = "Cuisse droite",
unit = "cm",
value = rightThigh,
onValueChange = {
rightThigh = it
message = null
},
)
BodyMetricField(
label = "Mollet gauche",
unit = "cm",
value = leftCalf,
onValueChange = {
leftCalf = it
message = null
},
)
BodyMetricField(
label = "Mollet droit",
unit = "cm",
value = rightCalf,
onValueChange = {
rightCalf = it
message = null
},
) )
} }
TrainlogFrame( TrainlogFrame(
title = "Enregistrement" title = strings.getString(R.string.recording)
) { ) {
TrainlogAction( TrainlogAction(
label = label =
"Enregistrer les mensurations", strings.getString(R.string.body_save),
description = description =
"Les champs vides sont ignorés.", strings.getString(R.string.empty_fields_ignored),
accent = accent =
colors.success, colors.success,
onClick = { onClick = {
@ -279,7 +215,7 @@ fun BodyScreen(
} }
) { ) {
message = message =
"Une valeur est invalide." strings.getString(R.string.invalid_value)
} else { } else {
val doubles = val doubles =
parsed.map { parsed.map {
@ -337,19 +273,19 @@ fun BodyScreen(
clearForm() clearForm()
revision += 1 revision += 1
message = message =
"Mensurations enregistrées." strings.getString(R.string.body_saved)
onBodySaved() onBodySaved()
} }
SaveBodyObservationResult.Invalid -> { SaveBodyObservationResult.Invalid -> {
message = message =
"Ajoutez au moins une mesure positive." strings.getString(R.string.positive_measure_required)
} }
SaveBodyObservationResult.DatabaseError -> { SaveBodyObservationResult.DatabaseError -> {
message = message =
"Erreur base locale." strings.getString(R.string.local_database_error)
} }
} }
} }
@ -365,7 +301,7 @@ fun BodyScreen(
color = color =
if ( if (
message == message ==
"Mensurations enregistrées." strings.getString(R.string.body_saved)
) { ) {
colors.success colors.success
} else { } else {
@ -376,13 +312,13 @@ fun BodyScreen(
} }
TrainlogFrame( TrainlogFrame(
title = "Derniers relevés", title = strings.getString(R.string.latest_measurements),
active = active =
recent.isNotEmpty(), recent.isNotEmpty(),
) { ) {
if (recent.isEmpty()) { if (recent.isEmpty()) {
TrainlogInfo( TrainlogInfo(
"Aucun relevé enregistré." strings.getString(R.string.body_none)
) )
} else { } else {
recent.forEach { recent.forEach {
@ -398,14 +334,13 @@ fun BodyScreen(
) )
append( append(
" · ${item.metricCount} mesure(s)" " · " + strings.resources.getQuantityString(R.plurals.measurement_count, item.metricCount, item.metricCount)
) )
item.bodyWeightKg item.bodyWeightKg
?.let { ?.let {
append( append(
" · %.1f kg" " · %.1f kg".format(locale, it)
.format(it)
) )
} }
}, },
@ -424,12 +359,14 @@ private fun BodyMetricField(
unit: String, unit: String,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
TrainlogInputField( TrainlogInputField(
label = "$label ($unit)", label = label,
value = value, value = value,
onValueChange = onValueChange =
onValueChange, onValueChange,
modifier = modifier,
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
keyboardType = keyboardType =
@ -437,9 +374,51 @@ private fun BodyMetricField(
imeAction = imeAction =
ImeAction.Next, ImeAction.Next,
), ),
suffix = unit,
compact = true,
) )
} }
internal data class BodyMetricItem(
val label: String,
val unit: String,
val value: String,
val onValueChange: (String) -> Unit,
)
/** Two columns retain scanability; very narrow accessibility layouts stack. */
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
val twoCompactFieldsDp = 2 * 140f + 10f
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
}
@Composable
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
/* WHY: this 360 dp device has 312 dp after screen padding; the old
* 330 dp threshold therefore stacked fields that need only 140 dp.
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
* unless width or accessibility font scaling makes them unreadable. */
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowItems.forEach { item ->
BodyMetricField(
item.label,
item.unit,
item.value,
item.onValueChange,
Modifier.weight(1f),
)
}
repeat(columns - rowItems.size) { androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) }
}
}
}
}
}
private sealed interface MetricParse { private sealed interface MetricParse {
data object Empty : data object Empty :
MetricParse MetricParse

View file

@ -0,0 +1,172 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun CardioCalibrationScreen(
repository: TrainlogRepository,
onChanged: () -> Unit,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember { mutableIntStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
val active = remember(revision) { repository.activeCardioCalibration() }
val latest = remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
message =
when (result) {
is TrainlogRepository.CardioCalibrationResult.Applied -> {
revision++
onChanged()
null
}
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
TrainlogRepository.CardioCalibrationResult.Conflict ->
strings.getString(R.string.calibration_conflict)
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
result.message
}
}
fun phaseLabel(phase: CardioCalibrationPhase): String =
strings.getString(
when (phase) {
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
},
)
TrainlogScreen(
strings.getString(R.string.route_cardio_calibration),
scrollKey = "cardio-calibration",
) {
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
if (active == null) {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_start),
strings.getString(R.string.calibration_start_description),
) {
apply(repository.startCardioCalibration())
}
latest?.let { profile ->
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
TrainlogInfo(
strings.getString(
R.string.calibration_peak_value,
profile.observedPeakBpm ?: 0,
),
colors.accent,
)
profile.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
}
} else {
TrainlogFrame(strings.getString(R.string.calibration_current)) {
TrainlogInfo(
strings.getString(
R.string.calibration_phase_value,
phaseLabel(active.phase),
),
colors.accent,
)
active.observedPeakBpm?.let { bpm ->
TrainlogInfo(
strings.getString(R.string.calibration_peak_value, bpm),
colors.warning,
)
}
active.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
when (active.phase) {
CardioCalibrationPhase.WARMUP -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_progressive),
strings.getString(R.string.calibration_progressive_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.PROGRESSIVE -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_high_effort),
strings.getString(R.string.calibration_high_effort_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.HIGH_EFFORT -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_end_effort),
strings.getString(R.string.calibration_end_effort_description),
) {
apply(repository.markCardioCalibrationEffortEnd())
}
}
CardioCalibrationPhase.RECOVERY -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_finish),
strings.getString(R.string.calibration_finish_description),
) {
apply(repository.finishCardioCalibration())
}
}
CardioCalibrationPhase.COMPLETED,
CardioCalibrationPhase.ABORTED -> Unit
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogButton(
strings.getString(R.string.calibration_abort),
{ apply(repository.abortCardioCalibration()) },
style = TrainlogButtonStyle.DESTRUCTIVE,
)
}
}
message?.let { TrainlogInfo(it, colors.error) }
}
}

View file

@ -0,0 +1,385 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.CardioGuidanceLiveState
import com.labfytools.trainlog.data.CardioTargetResolver
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import java.util.UUID
@Composable
internal fun CardioGuidancePanel(
repository: TrainlogRepository,
entryId: String,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember(entryId) { mutableStateOf(0) }
val active =
remember(entryId, revision) {
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
}
val calibration =
remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
DisposableEffect(entryId) {
val subscription = CardioGuidanceLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
TrainlogFrame(
title = strings.getString(R.string.cardio_guidance_title),
active = active != null,
) {
if (active != null) {
val phase = active.phase
val current = live.takeIf { it.phaseId == phase.phaseId }
val instruction = current?.instruction ?: active.currentInstruction
val bpm = current?.bpm
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_phase_value,
phaseKindLabel(strings, phase.kind),
),
colors.muted,
)
phase.target?.let { target ->
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_target_value,
target.minimumBpm,
target.maximumBpm,
),
colors.accent,
)
if (target.calibrationId != null) {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_calibration_value,
target.calibrationObservedPeakBpm ?: 0,
target.minimumPercent ?: 0,
target.maximumPercent ?: 0,
),
colors.muted,
)
}
}
TrainlogInfo(
guidanceInstructionLabel(strings, instruction),
when (instruction) {
CardioGuidanceInstruction.MAINTAIN -> colors.success
CardioGuidanceInstruction.ACCELERATE -> colors.accent
CardioGuidanceInstruction.SLOW_DOWN,
CardioGuidanceInstruction.SUSPENDED -> colors.warning
},
)
TrainlogInfo(
if (bpm == null) {
strings.getString(R.string.cardio_guidance_bpm_missing)
} else {
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
},
if (bpm == null) colors.warning else colors.text,
)
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_finish_phase),
onClick = {
if (
repository.finishCardioGuidancePhase(phase.phaseId) is
TrainlogRepository.CardioGuidanceMutationResult.Applied
) {
CardioGuidanceLiveState.clear()
revision++
}
},
modifier = Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SECONDARY,
)
return@TrainlogFrame
}
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
var targetMode by
remember(entryId, calibration?.calibrationId) {
mutableStateOf(if (calibration == null) "bpm" else "calibration")
}
var minimumBpm by remember(entryId) { mutableStateOf("") }
var maximumBpm by remember(entryId) { mutableStateOf("") }
var minimumPercent by remember(entryId) { mutableStateOf("") }
var maximumPercent by remember(entryId) { mutableStateOf("") }
var exitMode by remember(entryId) { mutableStateOf("duration") }
var durationSeconds by remember(entryId) { mutableStateOf("") }
var recoveryBpm by remember(entryId) { mutableStateOf("") }
var message by remember(entryId) { mutableStateOf<String?>(null) }
TrainlogChoiceChips(
choices =
listOf(
"warmup" to strings.getString(R.string.cardio_phase_warmup),
"work" to strings.getString(R.string.cardio_phase_work),
"recovery" to strings.getString(R.string.cardio_phase_recovery),
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
),
selectedId = kind.wireValue,
onSelected = { selected ->
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
},
)
TrainlogChoiceChips(
choices =
buildList {
add("bpm" to strings.getString(R.string.cardio_target_absolute))
if (calibration?.observedPeakBpm != null) {
add(
"calibration" to
strings.getString(R.string.cardio_target_calibration),
)
}
},
selectedId = targetMode,
onSelected = { targetMode = it },
)
if (targetMode == "calibration") {
calibration?.observedPeakBpm?.let { peak ->
TrainlogInfo(
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
colors.muted,
)
}
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_percent),
left = minimumPercent,
onLeft = { minimumPercent = digits(it, 3) },
rightLabel = strings.getString(R.string.cardio_target_max_percent),
right = maximumPercent,
onRight = { maximumPercent = digits(it, 3) },
suffix = "%",
)
} else {
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
left = minimumBpm,
onLeft = { minimumBpm = digits(it, 5) },
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
right = maximumBpm,
onRight = { maximumBpm = digits(it, 5) },
suffix = "BPM",
)
}
TrainlogChoiceChips(
choices =
listOf(
"duration" to strings.getString(R.string.cardio_exit_duration),
"target" to strings.getString(R.string.cardio_exit_target),
"recover" to strings.getString(R.string.cardio_exit_recover),
"bounded" to strings.getString(R.string.cardio_exit_bounded),
),
selectedId = exitMode,
onSelected = { exitMode = it },
)
if (exitMode == "duration" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_seconds),
value = durationSeconds,
onValueChange = { durationSeconds = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "s",
compact = true,
)
}
if (exitMode == "recover" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_recovery_bpm),
value = recoveryBpm,
onValueChange = { recoveryBpm = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "BPM",
compact = true,
)
}
val target =
if (targetMode == "calibration") {
val peak = calibration?.observedPeakBpm
val min = minimumPercent.toIntOrNull()
val max = maximumPercent.toIntOrNull()
if (calibration == null || peak == null || min == null || max == null) {
null
} else {
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibration.calibrationId,
peak,
min,
max,
),
)
}
} else {
val min = minimumBpm.toIntOrNull()
val max = maximumBpm.toIntOrNull()
if (min == null || max == null) null
else CardioTargetResolver.resolve(
CardioTargetDefinition.AbsoluteBpm(min, max),
)
}
target?.let {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_resolved_target,
it.minimumBpm,
it.maximumBpm,
),
colors.accent,
)
}
val exit =
when (exitMode) {
"duration" ->
durationSeconds.toIntOrNull()
?.takeIf { it in 1..86400 }
?.let(CardioPhaseExitCondition::FixedDuration)
"target" -> CardioPhaseExitCondition.EnterTarget
"recover" ->
recoveryBpm.toIntOrNull()
?.takeIf { it in 1..65535 }
?.let(CardioPhaseExitCondition::RecoverBelow)
"bounded" -> {
val seconds = durationSeconds.toIntOrNull()
val bpm = recoveryBpm.toIntOrNull()
if (seconds != null && bpm != null &&
seconds in 1..86400 && bpm in 1..65535
) {
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
} else null
}
else -> null
}
message?.let { TrainlogInfo(it, colors.warning) }
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_start_phase),
onClick = {
val actualTarget = target
val actualExit = exit
if (actualTarget == null || actualExit == null) {
message =
strings.getString(R.string.cardio_guidance_invalid_configuration)
return@TrainlogButton
}
val result =
repository.startCardioGuidancePhase(
entryId,
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = kind,
target = actualTarget,
exitCondition = actualExit,
),
)
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
message = null
revision++
} else {
message = strings.getString(R.string.cardio_guidance_start_failed)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = target != null && exit != null,
style = TrainlogButtonStyle.SUCCESS,
)
}
}
@Composable
private fun NumberPair(
leftLabel: String,
left: String,
onLeft: (String) -> Unit,
rightLabel: String,
right: String,
onRight: (String) -> Unit,
suffix: String,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogInputField(
label = leftLabel,
value = left,
onValueChange = onLeft,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
TrainlogInputField(
label = rightLabel,
value = right,
onValueChange = onRight,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
}
}
private fun phaseKindLabel(
strings: android.content.Context,
kind: CardioPhaseKind,
): String =
strings.getString(
when (kind) {
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
CardioPhaseKind.WORK -> R.string.cardio_phase_work
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
},
)
private fun guidanceInstructionLabel(
strings: android.content.Context,
instruction: CardioGuidanceInstruction,
): String =
strings.getString(
when (instruction) {
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
},
)
private fun digits(value: String, maximum: Int): String =
value.filter(Char::isDigit).take(maximum)

View file

@ -7,11 +7,16 @@ package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import com.labfytools.trainlog.data.CorrectCompletedSessionResult import com.labfytools.trainlog.data.CorrectCompletedSessionResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseProfile
@ -22,6 +27,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
/** /**
* WHY: a completed-session correction must be reviewed as one bounded form, * WHY: a completed-session correction must be reviewed as one bounded form,
@ -37,6 +43,7 @@ fun CompletedSessionCorrectionScreen(
onFinished: (saved: Boolean) -> Unit, onFinished: (saved: Boolean) -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
val original = remember(sessionId) { repository.getSessionDetail(sessionId) } val original = remember(sessionId) { repository.getSessionDetail(sessionId) }
var drafts by remember(sessionId) { var drafts by remember(sessionId) {
mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty()) mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty())
@ -44,81 +51,116 @@ fun CompletedSessionCorrectionScreen(
var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) } var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(null) } var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(null) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
val decimalText = remember(sessionId) { mutableStateMapOf<String, String>() }
TrainlogScreen("Modifier la séance", scrollKey = "session-correction:$sessionId") { TrainlogScreen(strings.getString(R.string.route_session_correction), scrollKey = "session-correction:$sessionId") {
if (original == null) { if (original == null) {
TrainlogInfo("Séance introuvable.", colors.error) TrainlogInfo(strings.getString(R.string.session_not_found), colors.error)
return@TrainlogScreen return@TrainlogScreen
} }
TrainlogInfo("Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.", colors.muted) TrainlogInfo(strings.getString(R.string.correction_profile_note), colors.muted)
drafts.forEachIndexed { occurrenceIndex, draft -> drafts.forEachIndexed { occurrenceIndex, draft ->
TrainlogFrame("${occurrenceIndex + 1}. ${draft.exercise.name}") { /* INVARIANT: Compose editor/focus state follows the stable entry,
* never a position that can be occupied by another occurrence. */
key(draft.entryId) {
var reorderVisualOffset by remember(draft.entryId) { mutableStateOf(0f) }
TrainlogFrame(
"${occurrenceIndex + 1}. ${draft.exercise.name}",
modifier = Modifier.graphicsLayer { translationY = reorderVisualOffset },
titleTrailing = {
ExerciseReorderHandle(
entryId = draft.entryId,
index = occurrenceIndex,
itemCount = drafts.size,
onMove = { from, to ->
/* CONTRACT: completed history is untouched here;
* Save owns the sole transactional mutation. */
drafts = reorderExerciseOccurrences(drafts, from, to)
},
onCommit = {},
onVisualOffset = { reorderVisualOffset = it },
)
},
) {
draft.plan?.let { plan -> draft.plan?.let { plan ->
TrainlogInputField("Nombre de séries cible", plan.sets.toString(), { raw -> TrainlogInputField(strings.getString(R.string.field_target_sets), plan.sets.toString(), { raw ->
raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } } raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } }
}) })
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions cibles" else "Durée cible par série (s)", TrainlogInputField(strings.getString(if (draft.exercise.trackingMode == TrackingMode.REPS) R.string.field_target_reps else R.string.field_target_duration),
(plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value -> (plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it } replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it }
} }) } })
TrainlogInputField("Repos (s)", plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value -> TrainlogInputField(strings.getString(R.string.field_rest_seconds), plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it } replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it }
} }) } })
if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> if (plan.weightKg != null) CompletedDecimalField(
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it } "${draft.entryId}:target-weight", strings.getString(R.string.field_target_weight),
} }) plan.weightKg, decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it } }
} }
if (draft.maxWeightKg != null) { if (draft.maxWeightKg != null) {
TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> CompletedDecimalField(
replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it } "${draft.entryId}:max", strings.getString(R.string.field_explicit_max),
} }) draft.maxWeightKg, decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it } }
} else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) { } else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
TrainlogInputField("Durée continue (s)", draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value -> TrainlogInputField(strings.getString(R.string.field_continuous_duration), draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it } replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it }
} }) } })
draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> draft.speedKmh?.let { speed -> CompletedDecimalField(
replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it } "${draft.entryId}:speed", strings.getString(R.string.field_speed), speed,
} }) } decimalText, allowZero = false,
draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> ) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it } } }
replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it } draft.distanceKm?.let { distance -> CompletedDecimalField(
} }) } "${draft.entryId}:distance", strings.getString(R.string.field_distance), distance,
decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it } } }
} else { } else {
draft.sets.forEachIndexed { setIndex, set -> draft.sets.forEachIndexed { setIndex, set ->
TrainlogInfo("Série ${setIndex + 1}", colors.accent) TrainlogInfo(strings.getString(R.string.set_number, setIndex + 1), colors.accent)
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)", TrainlogInputField(strings.getString(if (draft.exercise.trackingMode == TrackingMode.REPS) R.string.field_reps else R.string.field_duration_seconds),
(if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value -> (if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value ->
val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value) val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value)
replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it } replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it }
} }) } })
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> if (set.weightKg != null) CompletedDecimalField(
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it } "${draft.entryId}:set-weight:$setIndex", strings.getString(R.string.field_weight),
} }) set.weightKg, decimalText, allowZero = true,
TrainlogDeleteButton("Supprimer cette série", ) { value -> replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it } }
TrainlogDeleteButton(strings.getString(R.string.a11y_delete_set),
{ pendingSetRemoval = occurrenceIndex to setIndex }, { pendingSetRemoval = occurrenceIndex to setIndex },
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex")) Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"))
} }
TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", { TrainlogAction(strings.getString(R.string.add_set), strings.getString(R.string.add_set_description), {
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it } replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
}, accent = colors.success) }, accent = colors.success)
} }
TrainlogDeleteButton("Supprimer cette occurrence", TrainlogDeleteButton(strings.getString(R.string.delete_occurrence),
{ pendingOccurrenceRemoval = occurrenceIndex }, { pendingOccurrenceRemoval = occurrenceIndex },
Modifier.testTag("remove-completed-occurrence-${draft.entryId}")) Modifier.testTag("remove-completed-occurrence-${draft.entryId}"))
} }
}
} }
message?.let { TrainlogInfo(it, colors.error) } message?.let { TrainlogInfo(it, colors.error) }
TrainlogPrimaryAction("Enregistrer", "Appliquer toute la correction dans une transaction.", { TrainlogPrimaryAction(strings.getString(R.string.save), strings.getString(R.string.correction_save_description), {
if (decimalText.any { (field, raw) ->
val value = parseFiniteDecimal(raw)
value == null || if (field.contains(":set-weight:")) value < 0.0 else value <= 0.0
}) {
message = strings.getString(R.string.invalid_decimal_value)
return@TrainlogPrimaryAction
}
when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) { when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) {
CorrectCompletedSessionResult.Saved -> onFinished(true) CorrectCompletedSessionResult.Saved -> onFinished(true)
is CorrectCompletedSessionResult.Invalid -> message = result.message is CorrectCompletedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
is CorrectCompletedSessionResult.DatabaseError -> message = result.message is CorrectCompletedSessionResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
} }
}) })
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted) TrainlogAction(strings.getString(R.string.dialog_cancel), strings.getString(R.string.cancel_no_database_change), { onFinished(false) }, accent = colors.muted)
} }
pendingSetRemoval?.let { (occurrence, set) -> pendingSetRemoval?.let { (occurrence, set) ->
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null }, DestructiveConfirmationDialog(strings.getString(R.string.delete_set_question), strings.getString(R.string.delete_set_detail), strings.getString(R.string.delete), { pendingSetRemoval = null },
deleteContentDescription = "Supprimer cette série") { deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set)) {
val draft = drafts[occurrence] val draft = drafts[occurrence]
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it } replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
pendingSetRemoval = null pendingSetRemoval = null
@ -126,16 +168,49 @@ fun CompletedSessionCorrectionScreen(
} }
pendingOccurrenceRemoval?.let { occurrence -> pendingOccurrenceRemoval?.let { occurrence ->
val draft = drafts[occurrence] val draft = drafts[occurrence]
DestructiveConfirmationDialog("Supprimer cette occurrence ?", DestructiveConfirmationDialog(strings.getString(R.string.delete_occurrence_question),
"Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.", strings.getString(R.string.occurrence_delete_impact,
"Supprimer", { pendingOccurrenceRemoval = null }, strings.resources.getQuantityString(R.plurals.performed_set_count, draft.sets.size, draft.sets.size),
deleteContentDescription = "Supprimer cette occurrence") { if (draft.continuousDurationSeconds > 0) strings.getString(R.string.continuous_activity_suffix) else "",
if (draft.maxWeightKg != null) strings.getString(R.string.max_suffix) else ""),
strings.getString(R.string.delete), { pendingOccurrenceRemoval = null },
deleteContentDescription = strings.getString(R.string.a11y_delete_occurrence)) {
drafts = drafts.filterIndexed { index, _ -> index != occurrence } drafts = drafts.filterIndexed { index, _ -> index != occurrence }
pendingOccurrenceRemoval = null pendingOccurrenceRemoval = null
} }
} }
} }
/**
* WHY: a controlled numeric model cannot represent intermediate input such as
* `0,` without destroying what the user is typing.
* CONTRACT: visible text remains raw and accepts both decimal separators;
* finite/sign validation is repeated by Save before repository mutation.
*/
@Composable
private fun CompletedDecimalField(
fieldKey: String,
label: String,
value: Double,
rawValues: MutableMap<String, String>,
allowZero: Boolean,
onParsed: (Double) -> Unit,
) {
val raw = rawValues[fieldKey] ?: presentationNumber(value, 3)
TrainlogInputField(
label = label,
value = raw,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
onValueChange = { updated ->
if (decimalEditorTextValid(updated)) {
rawValues[fieldKey] = updated
parseFiniteDecimal(updated)?.takeIf { if (allowZero) it >= 0.0 else it > 0.0 }
?.let(onParsed)
}
},
)
}
private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft( private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft(
entryId = entryId, entryId = entryId,
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields), exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields),

View file

@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -47,6 +50,7 @@ import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.R
class ExerciseScreenState { class ExerciseScreenState {
val name = mutableStateOf("") val name = mutableStateOf("")
@ -73,7 +77,7 @@ class ExerciseScreenState {
} }
fun prepareEdit(exercise: ExerciseProfile) { fun prepareEdit(exercise: ExerciseProfile) {
if (editedExerciseId.value == exercise.exerciseId) return if (editedExerciseId.value == exercise.exerciseId) return
require(!dirty) { "un éditeur sale ne peut pas être remplacé" } require(!dirty) { "dirty editor state cannot be replaced" }
editedExerciseId.value = exercise.exerciseId editedExerciseId.value = exercise.exerciseId
name.value = exercise.name name.value = exercise.name
recordingMode.value = exercise.recordingMode recordingMode.value = exercise.recordingMode
@ -106,6 +110,7 @@ fun ExerciseScreen(
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
val strings = localizedContext()
var name by state.name var name by state.name
var recordingMode by state.recordingMode var recordingMode by state.recordingMode
@ -150,18 +155,18 @@ fun ExerciseScreen(
} }
TrainlogScreen( TrainlogScreen(
subtitle = if (editedExerciseId == null) "Créer un exercice" else "Modifier l'exercice" subtitle = strings.getString(if (editedExerciseId == null) R.string.route_exercise_create else R.string.route_exercise_edit)
) { ) {
TrainlogFrame( TrainlogFrame(
title = title =
if (editedExerciseId == null) { if (editedExerciseId == null) {
"Nouvel exercice" strings.getString(R.string.new_exercise)
} else { } else {
"Modifier l'exercice" strings.getString(R.string.route_exercise_edit)
} }
) { ) {
TrainlogField( TrainlogField(
label = "Nom", label = strings.getString(R.string.name),
value = name, value = name,
onValueChange = { onValueChange = {
name = it name = it
@ -170,10 +175,10 @@ fun ExerciseScreen(
) )
TrainlogChoiceGroup( TrainlogChoiceGroup(
label = "Organisation", label = strings.getString(R.string.organization),
) { ) {
TrainlogChoice( TrainlogChoice(
label = "Séries", label = strings.getString(R.string.organization_sets),
selected = selected =
recordingMode == recordingMode ==
RecordingMode.SETS, RecordingMode.SETS,
@ -189,7 +194,7 @@ fun ExerciseScreen(
) )
TrainlogChoice( TrainlogChoice(
label = "Continu", label = strings.getString(R.string.continuous),
selected = selected =
recordingMode == recordingMode ==
RecordingMode.CONTINUOUS, RecordingMode.CONTINUOUS,
@ -207,7 +212,7 @@ fun ExerciseScreen(
} }
TrainlogChoiceGroup( TrainlogChoiceGroup(
label = "Mesure principale", label = strings.getString(R.string.primary_measurement),
) { ) {
if ( if (
recordingMode == recordingMode ==
@ -215,7 +220,7 @@ fun ExerciseScreen(
) { ) {
TrainlogChoice( TrainlogChoice(
label = label =
"Répétitions", strings.getString(R.string.field_reps),
selected = selected =
trackingMode == trackingMode ==
TrackingMode.REPS, TrackingMode.REPS,
@ -230,7 +235,7 @@ fun ExerciseScreen(
} }
TrainlogChoice( TrainlogChoice(
label = "Durée", label = strings.getString(R.string.field_duration),
selected = selected =
trackingMode == trackingMode ==
TrackingMode.DURATION, TrackingMode.DURATION,
@ -250,10 +255,10 @@ fun ExerciseScreen(
) { ) {
TrainlogChoiceGroup( TrainlogChoiceGroup(
label = label =
"Données complémentaires", strings.getString(R.string.supplemental_data),
) { ) {
TrainlogChoice( TrainlogChoice(
label = "Vitesse", label = strings.getString(R.string.field_speed_short),
selected = speed, selected = speed,
enabled = true, enabled = true,
onClick = { onClick = {
@ -263,7 +268,7 @@ fun ExerciseScreen(
) )
TrainlogChoice( TrainlogChoice(
label = "Distance", label = strings.getString(R.string.field_distance_short),
selected = distance, selected = distance,
enabled = true, enabled = true,
onClick = { onClick = {
@ -276,9 +281,9 @@ fun ExerciseScreen(
} }
} }
TrainlogChoiceGroup(label = "Zone principale") { TrainlogChoiceGroup(label = strings.getString(R.string.primary_zone)) {
TrainlogChoice( TrainlogChoice(
label = "Non renseignée", label = strings.getString(R.string.not_specified_feminine),
selected = primaryZoneId == null, selected = primaryZoneId == null,
onClick = { onClick = {
primaryZoneId = null primaryZoneId = null
@ -288,7 +293,7 @@ fun ExerciseScreen(
) )
BodyZoneChoices(zones) { zone, indented -> BodyZoneChoices(zones) { zone, indented ->
TrainlogChoice( TrainlogChoice(
label = (if (indented) " ↳ " else "") + zone.displayName, label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
selected = primaryZoneId == zone.zoneId, selected = primaryZoneId == zone.zoneId,
enabled = zone.kind != BodyZoneKind.GROUP, enabled = zone.kind != BodyZoneKind.GROUP,
onClick = { onClick = {
@ -300,10 +305,10 @@ fun ExerciseScreen(
} }
} }
TrainlogChoiceGroup(label = "Zones secondaires") { TrainlogChoiceGroup(label = strings.getString(R.string.secondary_zones)) {
BodyZoneChoices(zones) { zone, indented -> BodyZoneChoices(zones) { zone, indented ->
TrainlogChoice( TrainlogChoice(
label = (if (indented) " ↳ " else "") + zone.displayName, label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
selected = zone.zoneId in secondaryZoneIds, selected = zone.zoneId in secondaryZoneIds,
enabled = primaryZoneId != null && enabled = primaryZoneId != null &&
zone.kind != BodyZoneKind.GROUP && zone.zoneId != primaryZoneId, zone.kind != BodyZoneKind.GROUP && zone.zoneId != primaryZoneId,
@ -359,7 +364,7 @@ fun ExerciseScreen(
if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) { if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) {
TrainlogInfo( TrainlogInfo(
text = text =
"Les séances passées et l'occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s'appliquera aux prochaines utilisations.", strings.getString(R.string.profile_change_note),
color = colors.warning, color = colors.warning,
) )
} }
@ -367,17 +372,17 @@ fun ExerciseScreen(
TrainlogPrimaryAction( TrainlogPrimaryAction(
label = label =
if (editedExerciseId != null) { if (editedExerciseId != null) {
"Enregistrer les modifications" strings.getString(R.string.save_changes)
} else if (inline) { } else if (inline) {
"Créer et revenir à la séance" strings.getString(R.string.create_return_session)
} else { } else {
"Enregistrer l'exercice" strings.getString(R.string.exercise_save)
}, },
description = description =
if (editedExerciseId == null) { if (editedExerciseId == null) {
"Ajouter ce profil au catalogue local." strings.getString(R.string.add_profile_description)
} else { } else {
"Conserver l'identité et mettre à jour le catalogue." strings.getString(R.string.identity_update_description)
}, },
onClick = save@{ onClick = save@{
if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) { if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) {
@ -386,7 +391,7 @@ fun ExerciseScreen(
} }
if (editedExerciseId == null && if (editedExerciseId == null &&
recordingMode == RecordingMode.SETS && primaryZoneId == null) { recordingMode == RecordingMode.SETS && primaryZoneId == null) {
message = "Une zone principale est requise pour un nouvel exercice musculaire." message = strings.getString(R.string.primary_zone_required)
return@save return@save
} }
val current = editedExerciseId val current = editedExerciseId
@ -427,12 +432,12 @@ fun ExerciseScreen(
CreateExerciseResult.Conflict -> { CreateExerciseResult.Conflict -> {
message = message =
"Un exercice portant ce nom existe déjà." strings.getString(R.string.exercise_name_exists)
} }
CreateExerciseResult.Invalid -> { CreateExerciseResult.Invalid -> {
message = message =
"Profil ou nom invalide." strings.getString(R.string.invalid_profile_name)
} }
is CreateExerciseResult.DatabaseError -> { is CreateExerciseResult.DatabaseError -> {
@ -447,20 +452,20 @@ fun ExerciseScreen(
} }
EditExerciseResult.Conflict -> { EditExerciseResult.Conflict -> {
message = "Un autre exercice porte déjà ce nom." message = strings.getString(R.string.other_exercise_name_exists)
} }
EditExerciseResult.InvalidNameOrProfile -> { EditExerciseResult.InvalidNameOrProfile -> {
message = "Nom ou profil invalide." message = strings.getString(R.string.invalid_name_profile)
} }
EditExerciseResult.IncompatibleProfileChange -> { EditExerciseResult.IncompatibleProfileChange -> {
message = message =
"Le profil ne peut pas changer après utilisation." strings.getString(R.string.profile_used_error)
} }
EditExerciseResult.DatabaseError -> { EditExerciseResult.DatabaseError -> {
message = "Enregistrement en base impossible." message = strings.getString(R.string.database_save_failed)
} }
} }
}, },
@ -468,8 +473,8 @@ fun ExerciseScreen(
if (editedExerciseId != null) { if (editedExerciseId != null) {
TrainlogAction( TrainlogAction(
label = "Annuler", label = strings.getString(R.string.dialog_cancel),
description = "Revenir au catalogue sans modification.", description = strings.getString(R.string.cancel_catalog_description),
accent = colors.muted, accent = colors.muted,
onClick = { onClick = {
editedExerciseId = null editedExerciseId = null
@ -494,27 +499,27 @@ fun ExerciseScreen(
} }
} }
if (catalogueVisible) TrainlogFrame(title = "Exercices existants", active = false) { if (catalogueVisible) TrainlogFrame(title = strings.getString(R.string.existing_exercises), active = false) {
TrainlogInputField( TrainlogInputField(
label = "Recherche par préfixe", label = strings.getString(R.string.search_prefix),
value = searchQuery, value = searchQuery,
onValueChange = { searchQuery = it }, onValueChange = { searchQuery = it },
) )
TrainlogChoiceGroup(label = "Filtre par zone") { TrainlogChoiceGroup(label = strings.getString(R.string.zone_filter)) {
TrainlogChoice( TrainlogChoice(
label = "Toutes les zones", label = strings.getString(R.string.all_zones),
selected = filterZoneId == null && !unclassifiedFilter, selected = filterZoneId == null && !unclassifiedFilter,
onClick = { filterZoneId = null; unclassifiedFilter = false }, onClick = { filterZoneId = null; unclassifiedFilter = false },
) )
BodyZoneChoices(zones, includeGroups = true) { zone, indented -> BodyZoneChoices(zones, includeGroups = true) { zone, indented ->
TrainlogChoice( TrainlogChoice(
label = (if (indented) " ↳ " else "") + zone.displayName, label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
selected = filterZoneId == zone.zoneId && !unclassifiedFilter, selected = filterZoneId == zone.zoneId && !unclassifiedFilter,
onClick = { filterZoneId = zone.zoneId; unclassifiedFilter = false }, onClick = { filterZoneId = zone.zoneId; unclassifiedFilter = false },
) )
} }
TrainlogChoice( TrainlogChoice(
label = "Non renseignés", label = strings.getString(R.string.not_specified_plural),
selected = unclassifiedFilter, selected = unclassifiedFilter,
onClick = { filterZoneId = null; unclassifiedFilter = true }, onClick = { filterZoneId = null; unclassifiedFilter = true },
) )
@ -526,20 +531,20 @@ fun ExerciseScreen(
unclassifiedOnly = unclassifiedFilter, unclassifiedOnly = unclassifiedFilter,
) )
if (exercises.isEmpty()) { if (exercises.isEmpty()) {
TrainlogInfo("Aucun exercice enregistré.") TrainlogInfo(strings.getString(R.string.exercise_none_saved))
} else { } else {
exercises.forEach { exercise -> exercises.forEach { exercise ->
TrainlogAction( TrainlogAction(
label = "Modifier · ${exercise.name}", label = strings.getString(R.string.modify_named, exercise.name),
description = exerciseZoneSummary(repository, exercise), description = exerciseZoneSummary(repository, exercise, strings),
accent = colors.accent, accent = colors.accent,
onClick = { startEditing(exercise) }, onClick = { startEditing(exercise) },
) )
repository.getExerciseKnowledge(exercise.exerciseId)?.let { knowledge -> repository.getExerciseKnowledge(exercise.exerciseId)?.let { knowledge ->
val expanded = exercise.exerciseId in expandedKnowledgeIds val expanded = exercise.exerciseId in expandedKnowledgeIds
TrainlogAction( TrainlogAction(
label = if (expanded) "− Connaissances" else "+ Connaissances", label = strings.getString(if (expanded) R.string.knowledge_collapse else R.string.knowledge_expand),
description = knowledgeSummary(knowledge), description = knowledgeSummary(knowledge, strings),
accent = colors.muted, accent = colors.muted,
onClick = { onClick = {
expandedKnowledgeIds = if (expanded) { expandedKnowledgeIds = if (expanded) {
@ -552,8 +557,8 @@ fun ExerciseScreen(
if (expanded) KnowledgePanel(repository, knowledge) if (expanded) KnowledgePanel(repository, knowledge)
} }
TrainlogAction( TrainlogAction(
label = "Voir les derniers MAX", label = strings.getString(R.string.view_latest_max),
description = "Ouvrir la liste agrégée existante dans Statistiques.", description = strings.getString(R.string.view_latest_max_description),
accent = colors.warning, accent = colors.warning,
onClick = onOpenMaxima, onClick = onOpenMaxima,
) )
@ -562,7 +567,7 @@ fun ExerciseScreen(
} }
TrainlogFrame( TrainlogFrame(
title = "Contrat", title = strings.getString(R.string.contract),
active = false, active = false,
) { ) {
TrainlogInfo( TrainlogInfo(
@ -570,20 +575,20 @@ fun ExerciseScreen(
) )
TrainlogInfo( TrainlogInfo(
"Aucune règle ne dépend du nom." strings.getString(R.string.name_no_rules)
) )
} }
} }
if (confirmingIncompatible) AlertDialog( if (confirmingIncompatible) AlertDialog(
onDismissRequest = { confirmingIncompatible = false }, onDismissRequest = { confirmingIncompatible = false },
title = { Text("Modifier le mode de suivi ?") }, title = { Text(strings.getString(R.string.tracking_change_question)) },
text = { Text("Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.") }, text = { Text(strings.getString(R.string.profile_change_dialog_detail)) },
confirmButton = { TextButton(onClick = { confirmButton = { TextButton(onClick = {
confirmedIncompatible = true confirmedIncompatible = true
confirmingIncompatible = false confirmingIncompatible = false
message = "Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations." message = strings.getString(R.string.profile_transition_confirmed)
}) { Text("Modifier") } }, }) { Text(strings.getString(R.string.modify)) } },
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text("Annuler") } }, dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text(strings.getString(R.string.dialog_cancel)) } },
) )
} }
@ -595,6 +600,7 @@ fun ExerciseEditorRoute(
inline: Boolean, inline: Boolean,
onSaved: () -> Unit, onSaved: () -> Unit,
) { ) {
val strings = localizedContext()
val profile = remember(exerciseId) { val profile = remember(exerciseId) {
exerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } } exerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
} }
@ -602,9 +608,9 @@ fun ExerciseEditorRoute(
if (profile == null) state.prepareCreate() else state.prepareEdit(profile) if (profile == null) state.prepareCreate() else state.prepareEdit(profile)
} }
if (exerciseId != null && profile == null) { if (exerciseId != null && profile == null) {
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Exercice introuvable.") } TrainlogScreen(strings.getString(R.string.route_exercise_edit)) { TrainlogInfo(strings.getString(R.string.exercise_not_found)) }
} else if (state.editedExerciseId.value != exerciseId) { } else if (state.editedExerciseId.value != exerciseId) {
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Chargement du profil…") } TrainlogScreen(strings.getString(R.string.route_exercise_edit)) { TrainlogInfo(strings.getString(R.string.profile_loading)) }
} else { } else {
ExerciseScreen(repository, state, inline, {}, onSaved, {}, catalogueVisible = false) ExerciseScreen(repository, state, inline, {}, onSaved, {}, catalogueVisible = false)
} }
@ -613,6 +619,7 @@ fun ExerciseEditorRoute(
@Composable @Composable
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) { private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
/* WHY: conditional content is opened only under an explicit uncertainty /* WHY: conditional content is opened only under an explicit uncertainty
* label; it cannot be mistaken for an ordinary resolved classification. */ * label; it cannot be mistaken for an ordinary resolved classification. */
val interpretation = when (knowledge.resolutionStatus) { val interpretation = when (knowledge.resolutionStatus) {
@ -621,11 +628,11 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
ExerciseKnowledgeStatus.UNRESOLVED -> null ExerciseKnowledgeStatus.UNRESOLVED -> null
} }
if (interpretation == null) { if (interpretation == null) {
TrainlogInfo("Classification scientifique non résolue.", colors.warning) TrainlogInfo(strings.getString(R.string.science_unresolved), colors.warning)
TrainlogInfo("Confiance : ${confidenceLabel(knowledge.confidence)}", colors.muted) TrainlogInfo(strings.getString(R.string.confidence_inline, confidenceLabel(knowledge.confidence, strings)), colors.muted)
} else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) { } else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
TrainlogInfo( TrainlogInfo(
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}", strings.getString(R.string.conditional_interpretation, interpretation.requiredConfirmation.orEmpty()),
colors.warning, colors.warning,
) )
} }
@ -635,24 +642,26 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge) val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val stabilizers = resolved.stabilizerMuscleIds.mapNotNull(repository::getMuscleKnowledge) val stabilizers = resolved.stabilizerMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val actions = resolved.actionIds.mapNotNull(repository::getJointActionKnowledge) val actions = resolved.actionIds.mapNotNull(repository::getJointActionKnowledge)
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.let { localizedBodyZoneName(strings, it.zoneId, it.displayName) } ?: resolved.primaryZoneId
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it } val secondaryZones = resolved.secondaryZoneIds.map { id -> repository.bodyZone(id)?.let { localizedBodyZoneName(strings, it.zoneId, it.displayName) } ?: id }
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId } val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
val equipment = repository.listEquipmentForKnownExercise(knowledge.exerciseId) val equipment = repository.listEquipmentForKnownExercise(knowledge.exerciseId)
.map { runtimeEquipment[it.equipmentId]?.displayName ?: it.equipmentId } .map { runtimeEquipment[it.equipmentId]?.displayName ?: it.equipmentId }
TrainlogInfo("Mouvements : ${actions.joinToString { it.displayNameFr }.ifEmpty { patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classés" } }}") val french = LocalLanguagePresentation.current.language == AppLanguage.FRENCH
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}") TrainlogInfo(strings.getString(R.string.movements_value, actions.joinToString { scientificCatalogLabel(it.actionId, it.displayNameFr, null, french) }.ifEmpty { patterns.joinToString { scientificCatalogLabel(it.patternId, it.displayNameFr, null, french) }.ifEmpty { strings.getString(R.string.unclassified) } }))
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}") TrainlogInfo(strings.getString(R.string.primary_muscles, primary.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.unclassified) }))
TrainlogInfo("Stabilisateurs : ${stabilizers.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}") TrainlogInfo(strings.getString(R.string.secondary_muscles, secondary.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.none_established) }))
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}") TrainlogInfo(strings.getString(R.string.stabilizers, stabilizers.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.none_established) }))
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}") TrainlogInfo(strings.getString(R.string.scientific_zones, primaryZone,
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted) if (secondaryZones.isEmpty()) "" else strings.getString(R.string.secondary_suffix, secondaryZones.joinToString())))
TrainlogInfo(strings.getString(R.string.compatible_equipment_value, equipment.joinToString().ifEmpty { strings.getString(R.string.not_established) }))
TrainlogInfo(strings.getString(R.string.confidence_inline, confidenceLabel(resolved.confidence, strings)), colors.muted)
} }
knowledge.limitations.forEach { TrainlogInfo("Limite : $it", colors.muted) } knowledge.limitations.forEach { TrainlogInfo(strings.getString(R.string.limitation_value, it), colors.muted) }
knowledge.sourceRefs.mapNotNull(repository::getScienceReference).forEach { reference -> knowledge.sourceRefs.mapNotNull(repository::getScienceReference).forEach { reference ->
TrainlogInfo( TrainlogInfo(
"Source : ${reference.authorsOrOrganization} · ${reference.title}" + strings.getString(R.string.source_value, reference.authorsOrOrganization, reference.title,
(reference.year?.let { " ($it)" } ?: ""), reference.year?.let { " ($it)" } ?: ""),
colors.muted, colors.muted,
) )
} }
@ -666,27 +675,28 @@ fun ExerciseCatalogueScreen(
onCreate: () -> Unit, onCreate: () -> Unit,
onOpenDetail: (String) -> Unit, onOpenDetail: (String) -> Unit,
) { ) {
val strings = localizedContext()
var query by state.searchQuery var query by state.searchQuery
var zoneId by state.filterZoneId var zoneId by state.filterZoneId
var unclassified by state.unclassifiedFilter var unclassified by state.unclassifiedFilter
val zones = repository.listBodyZones() val zones = repository.listBodyZones()
val exercises = repository.listExercises(query, zoneId, true, false, unclassified) val exercises = repository.listExercises(query, zoneId, true, false, unclassified)
TrainlogScreen("Catalogue d'exercices", scrollKey = "exercise-catalogue") { TrainlogScreen(strings.getString(R.string.exercise_catalog), scrollKey = "exercise-catalogue") {
TrainlogPrimaryAction("Créer un exercice", "Ajouter un profil au catalogue local.", onCreate) TrainlogPrimaryAction(strings.getString(R.string.route_exercise_create), strings.getString(R.string.create_exercise_description), onCreate)
TrainlogInputField("Recherche par préfixe", query, { query = it }) TrainlogInputField(strings.getString(R.string.search_prefix), query, { query = it })
TrainlogChoiceGroup("Filtrer") { TrainlogChoiceGroup(strings.getString(R.string.filter)) {
TrainlogChoice("Toutes les zones", zoneId == null && !unclassified) { TrainlogChoice(strings.getString(R.string.all_zones), zoneId == null && !unclassified) {
zoneId = null; unclassified = false zoneId = null; unclassified = false
} }
zones.filter { it.parentZoneId == null }.forEach { zone -> zones.filter { it.parentZoneId == null }.forEach { zone ->
TrainlogChoice(zone.displayName, zoneId == zone.zoneId && !unclassified) { TrainlogChoice(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), zoneId == zone.zoneId && !unclassified) {
zoneId = zone.zoneId; unclassified = false zoneId = zone.zoneId; unclassified = false
} }
} }
TrainlogChoice("Non renseignés", unclassified) { zoneId = null; unclassified = true } TrainlogChoice(strings.getString(R.string.not_specified_plural), unclassified) { zoneId = null; unclassified = true }
} }
TrainlogFrame("Catalogue", active = exercises.isNotEmpty()) { TrainlogFrame(strings.getString(R.string.catalog), active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.") if (exercises.isEmpty()) TrainlogInfo(strings.getString(R.string.exercise_none_found))
if (exercises.isNotEmpty()) { if (exercises.isNotEmpty()) {
/* CONTRACT: filtering and ordering stay repository-owned. This /* CONTRACT: filtering and ordering stay repository-owned. This
* fixed viewport bounds presentation only; its child owns the * fixed viewport bounds presentation only; its child owns the
@ -699,7 +709,7 @@ fun ExerciseCatalogueScreen(
.testTag("exercise-catalogue-scroll"), .testTag("exercise-catalogue-scroll"),
) { ) {
exercises.forEach { exercise -> exercises.forEach { exercise ->
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), { TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise, strings), {
state.selectedExerciseId = exercise.exerciseId state.selectedExerciseId = exercise.exerciseId
onOpenDetail(exercise.exerciseId) onOpenDetail(exercise.exerciseId)
}) })
@ -718,24 +728,25 @@ fun ExerciseDetailScreen(
onOpenMaxima: () -> Unit, onOpenMaxima: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
val context = remember(exerciseId) { repository.getTrainingExerciseContext(exerciseId) } val context = remember(exerciseId) { repository.getTrainingExerciseContext(exerciseId) }
TrainlogScreen("Fiche exercice", scrollKey = "exercise-detail:$exerciseId") { TrainlogScreen(strings.getString(R.string.route_exercise_detail), scrollKey = "exercise-detail:$exerciseId") {
if (context == null) { if (context == null) {
TrainlogInfo("Exercice introuvable.", colors.error) TrainlogInfo(strings.getString(R.string.exercise_not_found), colors.error)
return@TrainlogScreen return@TrainlogScreen
} }
TrainlogFrame("Profil") { TrainlogFrame(strings.getString(R.string.profile)) {
TrainlogInfo(context.exercise.name, colors.accent) TrainlogInfo(context.exercise.name, colors.accent)
TrainlogInfo(exerciseZoneSummary(repository, context.exercise)) TrainlogInfo(exerciseZoneSummary(repository, context.exercise, strings))
TrainlogInfo(profilePreview(context.exercise.recordingMode, context.exercise.trackingMode, context.exercise.dataFields)) TrainlogInfo(profilePreview(context.exercise.recordingMode, context.exercise.trackingMode, context.exercise.dataFields))
TrainlogAction("Modifier", "Modifier le nom, le profil et les zones selon les règles existantes.", onModify) TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.exercise_modify_description), onModify)
} }
TrainlogFrame("Connaissances", active = context.knowledge != null) { TrainlogFrame(strings.getString(R.string.knowledge), active = context.knowledge != null) {
context.knowledge?.let { KnowledgePanel(repository, it) } context.knowledge?.let { KnowledgePanel(repository, it) }
?: TrainlogInfo("Aucune connaissance scientifique liée à cet identifiant.", colors.muted) ?: TrainlogInfo(strings.getString(R.string.knowledge_none), colors.muted)
} }
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) { TrainlogFrame(strings.getString(R.string.compatible_equipment), active = context.compatibleEquipment.isNotEmpty()) {
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.") if (context.compatibleEquipment.isEmpty()) TrainlogInfo(strings.getString(R.string.compatibility_none))
context.compatibleEquipment.forEach { equipment -> context.compatibleEquipment.forEach { equipment ->
val name = repository.listEquipment().firstOrNull { it.equipmentId == equipment.equipmentId }?.displayName val name = repository.listEquipment().firstOrNull { it.equipmentId == equipment.equipmentId }?.displayName
?: listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId } ?: listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
@ -744,25 +755,25 @@ fun ExerciseDetailScreen(
} }
context.latestExplicitMax?.let { max -> context.latestExplicitMax?.let { max ->
TrainlogFrame("MAX") { TrainlogFrame("MAX") {
TrainlogInfo("Dernier résultat : ${max.maxWeightKg} kg · ${max.startedAt.take(10)}", colors.warning) TrainlogInfo(strings.getString(R.string.latest_result, max.maxWeightKg, formatDate(max.startedAt)), colors.warning)
} }
} }
TrainlogAction("Voir les derniers MAX", "Ouvrir la liste agrégée existante dans Statistiques.", onOpenMaxima) TrainlogAction(strings.getString(R.string.view_latest_max), strings.getString(R.string.view_latest_max_description), onOpenMaxima)
} }
} }
private fun knowledgeSummary(knowledge: ExerciseKnowledge): String = when (knowledge.resolutionStatus) { private fun knowledgeSummary(knowledge: ExerciseKnowledge, context: android.content.Context): String = when (knowledge.resolutionStatus) {
ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED -> ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED ->
"Classification scientifique · confiance ${confidenceLabel(knowledge.confidence)}" context.getString(R.string.science_classification_confidence, confidenceLabel(knowledge.confidence, context))
ExerciseKnowledgeStatus.CONDITIONAL -> ExerciseKnowledgeStatus.CONDITIONAL ->
"Classification conditionnelle · incertitude explicite" context.getString(R.string.conditional_classification)
ExerciseKnowledgeStatus.UNRESOLVED -> "Classification scientifique non résolue" ExerciseKnowledgeStatus.UNRESOLVED -> context.getString(R.string.science_unresolved)
} }
private fun confidenceLabel(confidence: KnowledgeConfidence): String = when (confidence) { private fun confidenceLabel(confidence: KnowledgeConfidence, context: android.content.Context): String = when (confidence) {
KnowledgeConfidence.HIGH -> "élevée" KnowledgeConfidence.HIGH -> context.getString(R.string.confidence_high)
KnowledgeConfidence.MODERATE -> "modérée" KnowledgeConfidence.MODERATE -> context.getString(R.string.confidence_moderate)
KnowledgeConfidence.UNCERTAIN -> "incertaine" KnowledgeConfidence.UNCERTAIN -> context.getString(R.string.confidence_uncertain)
} }
@Composable @Composable
@ -771,11 +782,12 @@ private fun BodyZoneChoices(
includeGroups: Boolean = false, includeGroups: Boolean = false,
content: @Composable (BodyZone, Boolean) -> Unit, content: @Composable (BodyZone, Boolean) -> Unit,
) { ) {
val strings = localizedContext()
zones.forEach { zone -> zones.forEach { zone ->
if (includeGroups || zone.kind != BodyZoneKind.GROUP) { if (includeGroups || zone.kind != BodyZoneKind.GROUP) {
content(zone, zone.parentZoneId != null) content(zone, zone.parentZoneId != null)
} else { } else {
TrainlogInfo(zone.displayName) TrainlogInfo(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
} }
} }
} }
@ -783,19 +795,26 @@ private fun BodyZoneChoices(
private fun exerciseZoneSummary( private fun exerciseZoneSummary(
repository: TrainlogRepository, repository: TrainlogRepository,
exercise: ExerciseProfile, exercise: ExerciseProfile,
context: android.content.Context,
): String { ): String {
val primary = exercise.primaryZoneId?.let(repository::bodyZone) val primary = exercise.primaryZoneId?.let(repository::bodyZone)
?: return "Zone : Non renseignée" ?: return context.getString(R.string.zone_not_specified)
val secondary = exercise.secondaryZoneIds.mapNotNull(repository::bodyZone) val secondary = exercise.secondaryZoneIds.mapNotNull(repository::bodyZone)
val group = repository.bodyZoneAncestors(primary.zoneId).firstOrNull() val group = repository.bodyZoneAncestors(primary.zoneId).firstOrNull()
return buildString { return buildString {
append("Zone principale : ${primary.displayName}") append(context.getString(R.string.primary_zone_value, localizedBodyZoneName(context, primary.zoneId, primary.displayName)))
append(" · Zones secondaires : ") append(context.getString(R.string.secondary_zones_value,
append(if (secondary.isEmpty()) "Aucune" else secondary.joinToString { it.displayName }) if (secondary.isEmpty()) context.getString(R.string.value_none_feminine) else secondary.joinToString { localizedBodyZoneName(context, it.zoneId, it.displayName) }))
group?.let { append(" · Groupe : ${it.displayName}") } group?.let { append(context.getString(R.string.group_value, localizedBodyZoneName(context, it.zoneId, it.displayName))) }
} }
} }
/** INVARIANT: scientific catalog IDs/names are never rewritten; this derives presentation text only. */
private fun scientificCatalogLabel(id: String, frenchLabel: String, englishLabel: String?, french: Boolean): String =
if (french) frenchLabel else englishLabel ?: id.split('_').joinToString(" ") { token ->
token.replaceFirstChar { character -> character.uppercase() }
}
@Composable @Composable
private fun TrainlogField( private fun TrainlogField(
label: String, label: String,
@ -836,7 +855,11 @@ private fun TrainlogChoiceGroup(
), ),
) )
content() FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { content() }
} }
} }
@ -853,15 +876,15 @@ private fun TrainlogChoice(
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .widthIn(min = 132.dp)
.padding(vertical = 2.dp)
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.background( .background(
if (selected) { if (selected) {
colors.surfaceAlt colors.surfaceAlt
} else { } else {
colors.surface colors.surface
} },
androidx.compose.material3.MaterialTheme.shapes.medium,
) )
.semantics { this.selected = selected } .semantics { this.selected = selected }
.clickable( .clickable(

View file

@ -45,16 +45,16 @@ class FeedbackDictationController(
fun edit(text: String) { if (state.phase != DictationPhase.Listening) update(state.copy( fun edit(text: String) { if (state.phase != DictationPhase.Listening) update(state.copy(
phase = DictationPhase.Editing, committedText = text, partialText = "", message = null)) } phase = DictationPhase.Editing, committedText = text, partialText = "", message = null)) }
fun start(permissionGranted: Boolean) { fun start(permissionGranted: Boolean, languageTag: String = "fr-FR") {
if (!permissionGranted) { if (!permissionGranted) {
update(state.copy(phase = DictationPhase.Error, partialText = "", update(state.copy(phase = DictationPhase.Error, partialText = "",
message = "Microphone refusé : la saisie manuelle reste disponible.")) message = "feedback_microphone_denied"))
} else if (!recognizer.available) { } else if (!recognizer.available) {
update(state.copy(phase = DictationPhase.Error, partialText = "", update(state.copy(phase = DictationPhase.Error, partialText = "",
message = "Reconnaissance vocale indisponible : utilisez la saisie manuelle.")) message = "feedback_speech_unavailable"))
} else { } else {
update(state.copy(phase = DictationPhase.Listening, partialText = "", message = null)) update(state.copy(phase = DictationPhase.Listening, partialText = "", message = null))
recognizer.start("fr-FR", this) recognizer.start(languageTag, this)
} }
} }
@ -78,7 +78,7 @@ class FeedbackDictationController(
fun beginSaving(): String? { fun beginSaving(): String? {
val text = state.visibleText.trim() val text = state.visibleText.trim()
if (text.isBlank()) { update(state.copy(phase = DictationPhase.Error, if (text.isBlank()) { update(state.copy(phase = DictationPhase.Error,
message = "Un ressenti vide ne peut pas être enregistré.")); return null } message = "feedback_empty")); return null }
update(state.copy(phase = DictationPhase.Saving, committedText = text, partialText = "")) update(state.copy(phase = DictationPhase.Saving, committedText = text, partialText = ""))
return text return text
} }

View file

@ -33,6 +33,7 @@ import com.labfytools.trainlog.ui.theme.TrainlogTypography
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.labfytools.trainlog.data.MAX_FEEDBACK_UTF8_BYTES import com.labfytools.trainlog.data.MAX_FEEDBACK_UTF8_BYTES
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@Composable @Composable
fun FeedbackEditor( fun FeedbackEditor(
@ -44,6 +45,15 @@ fun FeedbackEditor(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
val speechTag = if (LocalLanguagePresentation.current.language == AppLanguage.FRENCH) "fr-FR" else "en-US"
fun message(value: String): String = when (value) {
"feedback_microphone_denied" -> strings.getString(R.string.feedback_microphone_denied)
"feedback_speech_unavailable" -> strings.getString(R.string.feedback_speech_unavailable)
"feedback_empty" -> strings.getString(R.string.feedback_empty)
else -> if (value.startsWith("speech_error:")) strings.getString(R.string.speech_interrupted,
value.substringAfter(':').toIntOrNull() ?: -1) else value
}
var state by remember(initialText) { mutableStateOf(DictationState(committedText = initialText)) } var state by remember(initialText) { mutableStateOf(DictationState(committedText = initialText)) }
val controller = remember(initialText) { FeedbackDictationController( val controller = remember(initialText) { FeedbackDictationController(
recognizerFactory?.invoke(context) ?: AndroidSpeechRecognitionAdapter(context), recognizerFactory?.invoke(context) ?: AndroidSpeechRecognitionAdapter(context),
@ -51,7 +61,7 @@ fun FeedbackEditor(
if (initialText.isNotEmpty()) it.edit(initialText) if (initialText.isNotEmpty()) it.edit(initialText)
} } } }
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
controller.start(granted) controller.start(granted, speechTag)
} }
DisposableEffect(controller) { onDispose { controller.destroy() } } DisposableEffect(controller) { onDispose { controller.destroy() } }
val transcriptScroll = rememberScrollState() val transcriptScroll = rememberScrollState()
@ -64,10 +74,10 @@ fun FeedbackEditor(
} }
} }
TrainlogFrame(title = "Dictée du ressenti") { TrainlogFrame(title = strings.getString(R.string.feedback_dictation)) {
if (state.phase == DictationPhase.Listening) { if (state.phase == DictationPhase.Listening) {
TrainlogInfo("● Enregistrement en cours", color = colors.error) TrainlogInfo(strings.getString(R.string.feedback_recording), color = colors.error)
TrainlogIconAction(TrainlogIcons.Stop, "Arrêter la dictée", TrainlogIconAction(TrainlogIcons.Stop, strings.getString(R.string.feedback_stop),
onClick = { controller.stop() }, accent = colors.warning, onClick = { controller.stop() }, accent = colors.warning,
modifier = Modifier.testTag("feedback-stop")) modifier = Modifier.testTag("feedback-stop"))
Box(Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 192.dp) Box(Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 192.dp)
@ -76,22 +86,22 @@ fun FeedbackEditor(
BasicText(state.visibleText, style = TrainlogTypography.normal.copy(color = colors.text)) BasicText(state.visibleText, style = TrainlogTypography.normal.copy(color = colors.text))
} }
} else { } else {
TrainlogInputField("Texte", state.visibleText, onValueChange = { TrainlogInputField(strings.getString(R.string.feedback_text), state.visibleText, onValueChange = {
if (it.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES) controller.edit(it) if (it.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES) controller.edit(it)
}, testTag = "feedback-edit-transcript", singleLine = false, minLines = 4, maxLines = 8) }, testTag = "feedback-edit-transcript", singleLine = false, minLines = 4, maxLines = 8)
val resume = state.committedText.isNotBlank() val resume = state.committedText.isNotBlank()
TrainlogIconAction(TrainlogIcons.Mic, TrainlogIconAction(TrainlogIcons.Mic,
if (resume) "Reprendre la dictée" else "Démarrer la dictée", onClick = { strings.getString(if (resume) R.string.feedback_resume else R.string.feedback_start), onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
controller.start(true) else permission.launch(Manifest.permission.RECORD_AUDIO) controller.start(true, speechTag) else permission.launch(Manifest.permission.RECORD_AUDIO)
}, accent = colors.accent, modifier = Modifier.testTag("feedback-mic")) }, accent = colors.accent, modifier = Modifier.testTag("feedback-mic"))
TrainlogAction(if (editing) "Enregistrer les modifications" else "Enregistrer", TrainlogAction(strings.getString(if (editing) R.string.save_changes else R.string.save),
if (editing) "Créer une nouvelle révision immutable." else "Créer un enregistrement historique immutable.", onClick = { strings.getString(if (editing) R.string.feedback_revision_description else R.string.feedback_create_description), onClick = {
controller.beginSaving()?.let { text -> onSave(text)?.let(controller::saveFailed) } controller.beginSaving()?.let { text -> onSave(text)?.let(controller::saveFailed) }
}, accent = colors.success) }, accent = colors.success)
TrainlogAction("Annuler", "Ne rien enregistrer.", TrainlogAction(strings.getString(R.string.dialog_cancel), strings.getString(R.string.save_nothing),
onClick = { controller.cancel(); onCancel() }, accent = colors.muted) onClick = { controller.cancel(); onCancel() }, accent = colors.muted)
} }
state.message?.let { TrainlogInfo(it, color = colors.error) } state.message?.let { TrainlogInfo(message(it), color = colors.error) }
} }
} }

View file

@ -0,0 +1,203 @@
package com.labfytools.trainlog.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
val context = LocalContext.current
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
var pendingExpandedScan by remember { mutableStateOf(false) }
var permissionDenied by remember { mutableStateOf(false) }
val diagnostics =
remember(context) {
HeartRateBleDiagnostics(context) { next -> snapshot = next }
}
DisposableEffect(diagnostics) {
onDispose { diagnostics.close() }
}
val permissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
permissionDenied = false
diagnostics.startScan(pendingExpandedScan)
} else {
permissionDenied = true
}
}
fun scan(expanded: Boolean) {
pendingExpandedScan = expanded
if (hasHeartRateBleRuntimePermissions(context)) {
permissionDenied = false
diagnostics.startScan(expanded)
} else {
permissionLauncher.launch(heartRateBleRuntimePermissions())
}
}
fun phaseLabel(): String =
strings.getString(
when (snapshot.phase) {
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
},
)
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
TrainlogInfo(
phaseLabel(),
when (snapshot.phase) {
HeartRateDiagnosticPhase.RECEIVING -> colors.success
HeartRateDiagnosticPhase.ERROR -> colors.error
HeartRateDiagnosticPhase.CONNECTING,
HeartRateDiagnosticPhase.DISCOVERING,
HeartRateDiagnosticPhase.SUBSCRIBING,
HeartRateDiagnosticPhase.SCANNING -> colors.warning
else -> colors.muted
},
)
if (permissionDenied) {
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
}
snapshot.diagnostic?.let {
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
}
TrainlogInfo(
strings.getString(
R.string.hr_diag_services,
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
),
)
snapshot.latestMeasurement?.let { measurement ->
TrainlogInfo(
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
colors.success,
)
TrainlogInfo(
strings.getString(
R.string.hr_diag_measurement_meta,
measurement.rrIntervals1024.size,
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
measurement.energyExpended?.toString() ?: "—",
),
colors.muted,
)
}
TrainlogInfo(
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
colors.muted,
)
if (
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
snapshot.selectedAddress != null
) {
TrainlogButton(
strings.getString(R.string.hr_diag_use_sensor),
{
val address = checkNotNull(snapshot.selectedAddress)
val name =
snapshot.devices
.firstOrNull { it.address == address }
?.name
diagnostics.close()
HeartRateSensorPreferences(context).select(address, name)
HeartRateSensorService.start(context)
onSelected()
},
Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SUCCESS,
)
}
}
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.hr_diag_scan),
{ scan(false) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
TrainlogButton(
strings.getString(R.string.hr_diag_scan_all),
{ scan(true) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
}
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
}
TrainlogFrame(
strings.getString(R.string.hr_diag_devices),
active = snapshot.devices.isNotEmpty(),
) {
if (snapshot.devices.isEmpty()) {
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
}
snapshot.devices.forEach { device ->
val title =
device.name?.takeIf { it.isNotBlank() }
?: strings.getString(R.string.hr_diag_unnamed_device)
val advertised =
strings.getString(
if (device.advertisesHeartRate) {
R.string.hr_diag_hrs_advertised
} else {
R.string.hr_diag_hrs_not_advertised
},
)
TrainlogAction(
title,
strings.getString(
R.string.hr_diag_device_description,
device.address,
device.rssi,
advertised,
),
{ diagnostics.connect(device.address) },
)
}
}
}
}

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateLivePhase
import com.labfytools.trainlog.data.HeartRateLiveState
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
internal fun HeartRateIndicator() {
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
DisposableEffect(Unit) {
val subscription = HeartRateLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val bpm =
live.bpm?.takeIf {
live.phase == HeartRateLivePhase.CONNECTED
}
val color =
when (live.phase) {
HeartRateLivePhase.CONNECTED -> colors.success
HeartRateLivePhase.CONNECTING,
HeartRateLivePhase.STALE -> colors.warning
HeartRateLivePhase.DISCONNECTED -> colors.muted
}
val beatPeriodMs =
bpm?.takeIf { it > 0 }
?.let { (60_000 / it).coerceIn(300, 2_000) }
?: 1_000
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
val scale by
transition.animateFloat(
initialValue = 1f,
targetValue = if (bpm != null) 1.14f else 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = beatPeriodMs / 2),
repeatMode = RepeatMode.Reverse,
),
label = "heart-rate-scale",
)
val description =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
HeartRateLivePhase.CONNECTING ->
strings.getString(R.string.heart_rate_a11y_connecting)
HeartRateLivePhase.STALE ->
strings.getString(R.string.heart_rate_a11y_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
strings.getString(R.string.heart_rate_a11y_disconnected)
}
Row(
modifier =
Modifier
.padding(horizontal = 4.dp)
.semantics { contentDescription = description },
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
androidx.compose.material3.Text(
"♥",
color = color,
fontSize = 20.sp,
modifier = Modifier.scale(scale),
)
androidx.compose.material3.Text(
bpm?.let { "$it BPM" } ?: "-- BPM",
color = color,
fontSize = 14.sp,
)
}
}

View file

@ -10,6 +10,10 @@ import androidx.compose.runtime.remember
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import java.time.OffsetDateTime
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
@ -17,6 +21,8 @@ fun HistoryScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenSession: (String) -> Unit, onOpenSession: (String) -> Unit,
) { ) {
val locale = presentationLocale()
val strings = localizedContext()
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
@ -30,16 +36,16 @@ fun HistoryScreen(
} }
TrainlogScreen( TrainlogScreen(
subtitle = "Séances effectuées" subtitle = strings.getString(R.string.route_completed_sessions)
) { ) {
TrainlogFrame( TrainlogFrame(
title = "Séances", title = strings.getString(R.string.nav_sessions),
active = active =
sessions.isNotEmpty(), sessions.isNotEmpty(),
) { ) {
if (sessions.isEmpty()) { if (sessions.isEmpty()) {
TrainlogInfo( TrainlogInfo(
"Aucune séance enregistrée." strings.getString(R.string.history_none)
) )
} else { } else {
sessions.forEach { sessions.forEach {
@ -54,8 +60,8 @@ fun HistoryScreen(
sessionTypeLabel( sessionTypeLabel(
session.sessionType session.sessionType
) + ) +
" · " + " · " + strings.resources.getQuantityString(
"${session.exerciseCount} exercice(s)", R.plurals.exercise_count, session.exerciseCount, session.exerciseCount),
onClick = { onClick = {
onOpenSession( onOpenSession(
session.sessionId session.sessionId
@ -76,21 +82,22 @@ fun HistoryScreen(
} }
TrainlogFrame( TrainlogFrame(
title = "Derniers MAX", title = strings.getString(R.string.route_latest_maxima),
active = latestMaxima.isNotEmpty(), active = latestMaxima.isNotEmpty(),
) { ) {
if (latestMaxima.isEmpty()) { if (latestMaxima.isEmpty()) {
TrainlogInfo("Aucun max explicite enregistré.") TrainlogInfo(strings.getString(R.string.max_none))
} else { } else {
latestMaxima.forEach { max -> latestMaxima.forEach { max ->
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg) val weight = "%.2f".format(locale, max.maxWeightKg)
.trimEnd('0').trimEnd(',') .trimEnd('0').trimEnd(',')
TrainlogInfo( TrainlogInfo(
text = "${max.exerciseName} · $weight kg · ${formatStartedAt(max.startedAt).take(10)}", text = "${max.exerciseName} · $weight kg · ${formatDate(max.startedAt)}",
color = colors.warning, color = colors.warning,
) )
TrainlogInfo( TrainlogInfo(
text = "Équipement : ${max.equipmentDisplayName ?: "aucun"}", text = strings.getString(R.string.equipment_value,
max.equipmentDisplayName ?: strings.getString(R.string.value_none)),
color = colors.muted, color = colors.muted,
) )
} }
@ -99,6 +106,7 @@ fun HistoryScreen(
} }
} }
@Composable
internal fun sessionTypeLabel( internal fun sessionTypeLabel(
value: SessionType, value: SessionType,
): String = ): String =
@ -106,18 +114,28 @@ internal fun sessionTypeLabel(
value == value ==
SessionType.MAX_TEST SessionType.MAX_TEST
) { ) {
"TEST MAX" uiString(R.string.session_max_test)
} else { } else {
"ENTRAÎNEMENT" uiString(R.string.session_training)
} }
@Composable
internal fun formatStartedAt( internal fun formatStartedAt(
value: String, value: String,
): String { ): String {
return value val locale = presentationLocale()
.replace( val pattern = uiString(R.string.date_time_pattern)
'T', return runCatching {
' ' OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
) }.getOrElse { value.replace('T', ' ').take(16) }
.take(16) }
@Composable
internal fun formatDate(value: String): String {
val locale = presentationLocale()
val pattern = uiString(R.string.date_pattern)
val formatter = DateTimeFormatter.ofPattern(pattern, locale)
return runCatching { LocalDate.parse(value.take(10)).format(formatter) }
.recoverCatching { OffsetDateTime.parse(value).format(formatter) }
.getOrElse { value.take(10) }
} }

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -52,54 +51,41 @@ import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.data.BodyZoneHomeState import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.R
@Composable @Composable
fun HomeScreen( fun HomeScreen(
activeDraft: ActiveSessionDraft?, activeDraft: ActiveSessionDraft?,
draftError: String?, draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
bodyZoneOverview: BodyZoneHomeOverview, bodyZoneOverview: BodyZoneHomeOverview,
onSession: () -> Unit, onSession: () -> Unit,
onOpenExercises: (String) -> Unit, onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
onBody: () -> Unit, onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
TrainlogScreen("Accueil", scrollKey = "home") { val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) } draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame("Séance") { TrainlogFrame(strings.getString(R.string.home_session)) {
if (activeDraft != null) { if (activeDraft != null) {
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement" val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) strings.getString(R.string.session_max_test) else strings.getString(R.string.training)
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession) TrainlogPrimaryAction(strings.getString(R.string.resume_current_session), "$kind · " + strings.resources.getQuantityString(R.plurals.exercise_count, activeDraft.exercises.size, activeDraft.exercises.size), onSession)
} else { } else {
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession) TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
} }
} }
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics) BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
latestSession?.let { session -> TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
TrainlogFrame("Dernière séance") {
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
}
}
latestMaximum?.let { maximum ->
val weight = "%.2f".format(java.util.Locale.FRANCE, maximum.maxWeightKg).trimEnd('0').trimEnd(',')
TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
}
TrainlogFrame("Accès rapides", active = false) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"), Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogActionTile("Synchronisation", "État et actions", TrainlogIcons.Refresh, TrainlogActionTile(strings.getString(R.string.nav_sync), strings.getString(R.string.home_sync_state), TrainlogIcons.Refresh,
onSync, Modifier.weight(1f).fillMaxHeight().testTag("home-sync-action")) onSync, Modifier.weight(1f).fillMaxHeight().testTag("home-sync-action"))
TrainlogActionTile("Mensurations", "Suivi corporel", TrainlogIcons.BodyMeasurements, TrainlogActionTile(strings.getString(R.string.route_body_measurements), strings.getString(R.string.home_body_tracking), TrainlogIcons.BodyMeasurements,
onBody, Modifier.weight(1f).fillMaxHeight().testTag("home-body-action")) onBody, Modifier.weight(1f).fillMaxHeight().testTag("home-body-action"))
} }
} }
@ -115,78 +101,79 @@ fun HomeScreen(
internal fun BodyZoneHomeSection( internal fun BodyZoneHomeSection(
overview: BodyZoneHomeOverview, overview: BodyZoneHomeOverview,
onOpenExercises: (String) -> Unit, onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
var showExposureInfo by remember { mutableStateOf(false) } var showExposureInfo by remember { mutableStateOf(false) }
var selectedId by remember(overview.zones) { var selectedId by remember(overview.zones) {
mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId) mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId)
} }
val selected = overview.zones.firstOrNull { it.zoneId == selectedId } val selected = overview.zones.firstOrNull { it.zoneId == selectedId }
TrainlogFrame("Zones à travailler", active = overview.recommendations.isNotEmpty()) { TrainlogFrame(strings.getString(R.string.zones_to_work), active = overview.recommendations.isNotEmpty()) {
TextButton(onClick = { showExposureInfo = true }) { TextButton(onClick = { showExposureInfo = true }) {
Text("Exposition récente ⓘ") Text(strings.getString(R.string.recent_exposure) + " ⓘ")
} }
if (!overview.hasTrainingHistory) { if (!overview.hasTrainingHistory) {
TrainlogInfo("Pas encore d’historique d’entraînement.") TrainlogInfo(strings.getString(R.string.no_training_history))
} }
if (overview.hasInvalidHistoryTimestamp) { if (overview.hasInvalidHistoryTimestamp) {
TrainlogInfo("Données insuffisantes : un horodatage historique ne peut pas être interprété.", colors.warning) TrainlogInfo(strings.getString(R.string.invalid_history_timestamp), colors.warning)
} }
BodyZoneMap(overview.zones, selectedId) { selectedId = it } BodyZoneMap(overview.zones, selectedId) { selectedId = it }
selected?.let { zone -> selected?.let { zone ->
BasicText( BasicText(
"Sélection : ${zone.displayName}", strings.getString(R.string.selection_value, localizedBodyZoneName(strings, zone.zoneId, zone.displayName)),
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
style = TrainlogTypography.normal.copy(color = colors.lavender), style = TrainlogTypography.normal.copy(color = colors.lavender),
) )
} }
TrainlogInfo("Priorités", colors.accent) TrainlogInfo(strings.getString(R.string.priorities), colors.accent)
overview.recommendations.take(3).forEachIndexed { index, zone -> overview.recommendations.take(3).forEachIndexed { index, zone ->
TrainlogAction( TrainlogAction(
"${index + 1}. ${zone.displayName} · ${homeStateLabel(zone)}", "${index + 1}. ${localizedBodyZoneName(strings, zone.zoneId, zone.displayName)} · ${homeStateLabel(zone, strings)}",
recommendationReason(zone), recommendationReason(zone, strings),
{ selectedId = zone.zoneId }, { selectedId = zone.zoneId },
) )
} }
if (overview.recommendations.isEmpty()) { if (overview.recommendations.isEmpty()) {
TrainlogInfo("Aucune zone disposant d’un exercice résolu n’est disponible.") TrainlogInfo(strings.getString(R.string.resolved_zone_none))
} }
selected?.let { zone -> selected?.let { zone ->
Column( Column(
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp), Modifier.fillMaxWidth().padding(top = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
BasicText(zone.displayName, style = TrainlogTypography.section.copy(color = colors.text)) BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
BasicText(homeStateLabel(zone), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors))) BasicText(homeStateLabel(zone, strings), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
BasicText( BasicText(
"Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}", strings.getString(R.string.latest_primary_work_label, ageLabel(zone.primaryExposureAgeSeconds, strings)),
style = TrainlogTypography.small.copy(color = colors.text), style = TrainlogTypography.small.copy(color = colors.text),
) )
BasicText( BasicText(
recentWorkLabel(zone.primaryWork7Days, "principal") + " · " + recentWorkLabel(zone.primaryWork7Days, true, strings) + " · " +
recentWorkLabel(zone.secondaryWork7Days, "secondaire"), recentWorkLabel(zone.secondaryWork7Days, false, strings),
style = TrainlogTypography.small.copy(color = colors.text), style = TrainlogTypography.small.copy(color = colors.text),
) )
BasicText( BasicText(
availableExerciseLabel(zone.availableExerciseCount), availableExerciseLabel(zone.availableExerciseCount, strings),
style = TrainlogTypography.small.copy(color = colors.text), style = TrainlogTypography.small.copy(color = colors.text),
) )
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted) if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
TrainlogAction("Voir les exercices", "", { onOpenExercises(zone.zoneId) }) TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
TrainlogAction("Voir les statistiques", "", onOpenStatistics)
} }
} }
} }
if (showExposureInfo) { if (showExposureInfo) {
AlertDialog( AlertDialog(
onDismissRequest = { showExposureInfo = false }, onDismissRequest = { showExposureInfo = false },
title = { Text("Exposition récente") }, title = { Text(strings.getString(R.string.recent_exposure)) },
text = { text = {
Text("Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.") Text(strings.getString(R.string.exposure_explanation))
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { showExposureInfo = false }) { Text("Compris") } TextButton(onClick = { showExposureInfo = false }) { Text(strings.getString(R.string.understood)) }
}, },
) )
} }
@ -213,9 +200,11 @@ private fun BodyMapPanel(
modifier: Modifier, modifier: Modifier,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
val visibleZones = view.zoneIds.mapNotNull(zones::get) val visibleZones = view.zoneIds.mapNotNull(zones::get)
Column(modifier.semantics { contentDescription = "Carte corporelle, ${view.label}" }) { val viewLabel = strings.getString(view.labelId)
BasicText(view.label, style = TrainlogTypography.small.copy(color = colors.muted)) Column(modifier.semantics { contentDescription = strings.getString(R.string.body_map, viewLabel) }) {
BasicText(viewLabel, style = TrainlogTypography.small.copy(color = colors.muted))
BoxWithConstraints(Modifier.fillMaxWidth().height(236.dp)) { BoxWithConstraints(Modifier.fillMaxWidth().height(236.dp)) {
val panelWidth = maxWidth val panelWidth = maxWidth
val panelHeight = maxHeight val panelHeight = maxHeight
@ -281,14 +270,14 @@ private fun BodyMapPanel(
role = Role.Button role = Role.Button
selected = chosen selected = chosen
contentDescription = buildString { contentDescription = buildString {
append(view.label) append(viewLabel)
append(", ") append(", ")
append(zone.displayName) append(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
append(", ") append(", ")
append(homeStateLabel(zone)) append(homeStateLabel(zone, strings))
append(if (chosen) ", sélectionnée" else ", non sélectionnée") append(strings.getString(if (chosen) R.string.selected_suffix else R.string.not_selected_suffix))
} }
onClick(label = "Sélectionner ${zone.displayName}") { onClick(label = strings.getString(R.string.select_zone, localizedBodyZoneName(strings, zone.zoneId, zone.displayName))) {
select(zone.zoneId) select(zone.zoneId)
true true
} }
@ -300,13 +289,13 @@ private fun BodyMapPanel(
} }
private enum class BodyView( private enum class BodyView(
val label: String, @param:androidx.annotation.StringRes val labelId: Int,
val testName: String, val testName: String,
val zoneIds: List<String>, val zoneIds: List<String>,
val accessibilityBounds: Map<String, Rect>, val accessibilityBounds: Map<String, Rect>,
) { ) {
FRONT( FRONT(
label = "Face", labelId = R.string.body_front,
testName = "front", testName = "front",
zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"), zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"),
accessibilityBounds = mapOf( accessibilityBounds = mapOf(
@ -319,7 +308,7 @@ private enum class BodyView(
), ),
), ),
BACK( BACK(
label = "Dos", labelId = R.string.body_back,
testName = "back", testName = "back",
zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"), zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"),
accessibilityBounds = mapOf( accessibilityBounds = mapOf(
@ -491,28 +480,31 @@ private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color
} }
} }
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when { internal fun recommendationReason(zone: BodyZoneHomeStatus, context: android.content.Context): String = when {
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée" zone.lastPrimaryExposure == null -> context.getString(R.string.no_primary_exposure)
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}" zone.primaryWork7Days == 0 -> context.getString(R.string.latest_primary_work, ageLabel(zone.primaryExposureAgeSeconds, context))
else -> recentWorkLabel(zone.primaryWork7Days, "principal") else -> recentWorkLabel(zone.primaryWork7Days, true, context)
} }
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String = internal fun homeStateLabel(zone: BodyZoneHomeStatus, context: android.content.Context): String =
if (zone.state == BodyZoneHomeState.PRIORITIZE && zone.primaryWork7Days > 0) { if (zone.state == BodyZoneHomeState.PRIORITIZE && zone.primaryWork7Days > 0) {
"Priorité relative" context.getString(R.string.relative_priority)
} else { } else {
zone.state.label context.getString(when (zone.state) { BodyZoneHomeState.PRIORITIZE -> R.string.state_prioritize
BodyZoneHomeState.LITTLE_RECENT_WORK -> R.string.state_little_work; BodyZoneHomeState.RECENT_WORK -> R.string.state_recent_work
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> R.string.state_high_exposure; BodyZoneHomeState.INSUFFICIENT_DATA -> R.string.state_insufficient
BodyZoneHomeState.UNSUPPORTED -> R.string.state_unsupported })
} }
internal fun recentWorkLabel(count: Int, kind: String): String = internal fun recentWorkLabel(count: Int, primary: Boolean, context: android.content.Context): String =
"Travail $kind sur 7 j : $count" context.getString(if (primary) R.string.recent_primary_work else R.string.recent_secondary_work, count)
private fun availableExerciseLabel(count: Int): String = private fun availableExerciseLabel(count: Int, context: android.content.Context): String =
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}" context.resources.getQuantityString(R.plurals.available_exercise_count, count, count)
internal fun ageLabel(seconds: Long?): String = when { internal fun ageLabel(seconds: Long?, context: android.content.Context): String = when {
seconds == null -> "jamais enregistrée" seconds == null -> context.getString(R.string.age_never)
seconds < 86400L -> "aujourd’hui" seconds < 86400L -> context.getString(R.string.age_today)
seconds < 2L * 86400L -> "il y a 1 jour" seconds < 2L * 86400L -> context.getString(R.string.age_yesterday)
else -> "il y a ${seconds / 86400L} jours" else -> context.resources.getQuantityString(R.plurals.age_days, (seconds / 86400L).toInt(), seconds / 86400L)
} }

View file

@ -0,0 +1,133 @@
package com.labfytools.trainlog.ui
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.labfytools.trainlog.R
import java.util.Locale
import java.text.NumberFormat
enum class AppLanguage(val tag: String) {
FRENCH("fr"),
ENGLISH("en");
companion object {
fun fromStoredTag(tag: String?): AppLanguage = entries.firstOrNull { it.tag == tag } ?: FRENCH
}
}
/**
* WHY: language is device-local presentation state and must never enter the
* canonical workout database or an exchange artifact.
* CONTRACT: this owner persists exactly one BCP-47 tag in its own preferences
* file; French is used when the preference is absent or invalid.
* INVARIANT: changing [language] updates Compose synchronously and does not
* call TrainlogRepository, SQLite, import, export, or synchronization code.
*/
@Stable
class LanguageSettingsOwner(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
var language: AppLanguage by mutableStateOf(AppLanguage.fromStoredTag(preferences.getString(KEY_LANGUAGE, null)))
private set
fun select(value: AppLanguage) {
if (language == value) return
preferences.edit().putString(KEY_LANGUAGE, value.tag).apply()
language = value
}
companion object {
internal const val PREFERENCES_NAME = "trainlog_presentation_settings"
internal const val KEY_LANGUAGE = "language_tag"
}
}
@Immutable
data class LanguagePresentation(
val language: AppLanguage,
val select: (AppLanguage) -> Unit,
)
val LocalLanguagePresentation = staticCompositionLocalOf<LanguagePresentation> {
/* Preview and focused component tests have no Activity owner. Their
* presentation contract is the same deterministic French default. */
LanguagePresentation(AppLanguage.FRENCH) {}
}
@Composable
fun localizedContext(): Context {
val base = LocalContext.current
val language = LocalLanguagePresentation.current.language
val configuration = Configuration(base.resources.configuration).apply {
setLocale(Locale.forLanguageTag(language.tag))
}
return base.createConfigurationContext(configuration)
}
@Composable
fun presentationLocale(): Locale = Locale.forLanguageTag(LocalLanguagePresentation.current.language.tag)
@Composable
fun uiString(@StringRes id: Int, vararg arguments: Any): String =
localizedContext().resources.getString(id, *arguments)
@Composable
fun uiQuantity(@PluralsRes id: Int, quantity: Int, vararg arguments: Any): String =
localizedContext().resources.getQuantityString(id, quantity, *arguments)
@Composable
fun presentationNumber(value: Double, maximumFractionDigits: Int = 2): String =
NumberFormat.getNumberInstance(presentationLocale()).apply {
isGroupingUsed = false
minimumFractionDigits = 0
this.maximumFractionDigits = maximumFractionDigits
}.format(value)
/**
* CONTRACT: body-zone IDs are canonical catalog data; only their visible labels are localized.
* Unknown future IDs deliberately retain the catalog label instead of changing identity data.
*/
fun localizedBodyZoneName(context: Context, zoneId: String, catalogLabel: String): String {
val id = when (zoneId) {
"full_body" -> R.string.zone_full_body
"upper_body" -> R.string.zone_upper_body
"chest" -> R.string.zone_chest
"back" -> R.string.zone_back
"shoulders" -> R.string.zone_shoulders
"arms" -> R.string.zone_arms
"core" -> R.string.zone_core
"lower_body" -> R.string.zone_lower_body
"glutes" -> R.string.zone_glutes
"thighs" -> R.string.zone_thighs
"calves" -> R.string.zone_calves
else -> return catalogLabel
}
return context.getString(id)
}
/**
* WHY: repository diagnostics are persistence/domain values, not translated payload fields.
* CONTRACT: classify them at the Android presentation boundary without rewriting repository state.
*/
fun localizedRepositoryMessage(context: Context, raw: String): String {
val normalized = raw.lowercase(Locale.ROOT)
val id = when {
"seule la sélection" in normalized -> R.string.draft_selection_missing
listOf("introuvable", "not found", "missing").any(normalized::contains) -> R.string.repository_item_missing
listOf("base locale", "base de données", "database", "sqlite").any(normalized::contains) -> R.string.repository_database_error
listOf("invalide", "invalid", "incompatible").any(normalized::contains) -> R.string.repository_invalid_data
listOf("accès fichiers", "autorisation", "permission", "file access").any(normalized::contains) -> R.string.repository_file_access
else -> R.string.repository_operation_error
}
return context.getString(id)
}

View file

@ -0,0 +1,210 @@
package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.StartProgramSessionResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ProgramSessionExecutionState
/** Presentation-only list for the desktop-owned Programs projection. */
@Composable
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext()
val programs = repository.listSyncedPrograms()
val colors = LocalTrainlogColors.current
TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program ->
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
val completed = detail?.sessions?.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
} ?: 0
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(colors.surface, MaterialTheme.shapes.medium).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
program.title,
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = colors.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(
if (program.state == "active") strings.getString(R.string.program_active)
else strings.getString(R.string.program_archived),
program.state == "active",
)
}
TrainlogInfo(
listOfNotNull(
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString(
R.plurals.program_session_count,
program.sessionCount,
program.sessionCount,
),
).joinToString(" · "),
)
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
TrainlogButton(
strings.getString(R.string.program_open),
{ onOpen(program.programId) },
Modifier.testTag("program-${program.programId}"),
)
}
}
}
}
/** Program definitions remain read-only; only the local execution draft mutates. */
@Composable
fun ProgramDetailScreen(
repository: TrainlogRepository,
programId: String,
onOpenActiveSession: () -> Unit,
) {
val strings = localizedContext()
var revision by remember { mutableIntStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
val program = remember(programId, revision) { repository.getSyncedProgram(programId) }
fun start(programSessionId: String) {
when (val result = repository.startSyncedProgramSession(programId, programSessionId)) {
StartProgramSessionResult.Started,
StartProgramSessionResult.AlreadyActive -> onOpenActiveSession()
StartProgramSessionResult.AlreadyCompleted -> {
message = strings.getString(R.string.program_session_completed_detail)
revision++
}
StartProgramSessionResult.ExistingUnrelatedDraft -> {
message = strings.getString(R.string.existing_draft_warning)
}
StartProgramSessionResult.NotAvailable -> {
message = strings.getString(R.string.program_session_unavailable)
}
is StartProgramSessionResult.Error -> message = result.message
}
}
TrainlogScreen(strings.getString(R.string.route_program_detail)) {
if (program == null) {
TrainlogInfo(strings.getString(R.string.program_not_found))
return@TrainlogScreen
}
TrainlogFrame(program.title, active = program.state == "active") {
TrainlogInfo(strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived))
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
}
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session ->
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(
if (completed) LocalTrainlogColors.current.success.copy(alpha = .12f)
else LocalTrainlogColors.current.surface,
MaterialTheme.shapes.medium,
).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val stateLabel = when (session.executionState) {
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
"${index + 1}. ${session.title}",
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(strings.getString(stateLabel), completed)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "),
)
session.note?.let { TrainlogInfo(it) }
TrainlogButton(
strings.getString(
if (expanded) R.string.program_session_hide_occurrences
else R.string.program_session_show_occurrences,
),
{ expanded = !expanded },
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
)
when (session.executionState) {
ProgramSessionExecutionState.TODO -> TrainlogButton(
strings.getString(R.string.program_session_start),
{ start(session.programSessionId) },
Modifier.testTag("program-session-start-${session.programSessionId}"),
)
ProgramSessionExecutionState.IN_PROGRESS -> TrainlogButton(
strings.getString(R.string.program_session_resume),
onOpenActiveSession,
Modifier.testTag("program-session-resume-${session.programSessionId}"),
)
ProgramSessionExecutionState.COMPLETED -> TrainlogInfo(
strings.getString(R.string.program_session_completed_detail),
)
}
if (expanded) session.occurrences.forEachIndexed { occurrenceIndex, occurrence ->
val target = occurrence.targetReps?.let { strings.getString(R.string.program_target_reps, it) }
?: occurrence.targetDurationSeconds?.let { strings.getString(R.string.program_target_duration, it) }.orEmpty()
val sets = occurrence.targetSets?.let { strings.getString(R.string.program_target_sets, it) }.orEmpty()
val weight = occurrence.targetWeightKg?.let { strings.getString(R.string.program_target_weight, it) }.orEmpty()
TrainlogInfo(
"${occurrenceIndex + 1}. ${occurrence.exerciseName}" +
listOf(sets, target, weight).filter { it.isNotEmpty() }.joinToString(" · ", prefix = " · "),
)
occurrence.notes?.let { TrainlogInfo(it) }
}
}
}
}
}
/** Presentation-only state badge; execution truth remains repository-owned. */
@Composable
private fun ProgramStateBadge(label: String, positive: Boolean) {
val colors = LocalTrainlogColors.current
Text(
label,
modifier = Modifier.background(
(if (positive) colors.success else colors.surfaceAlt).copy(alpha = .22f),
MaterialTheme.shapes.small,
).padding(horizontal = 8.dp, vertical = 4.dp),
color = if (positive) colors.success else colors.muted,
style = MaterialTheme.typography.labelMedium,
)
}

View file

@ -9,16 +9,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -31,9 +38,16 @@ import com.labfytools.trainlog.data.KnowledgeConfidence
import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.StartAiSessionDraftResult import com.labfytools.trainlog.data.StartAiSessionDraftResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.directStoragePermissionIntent import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@Composable @Composable
fun SessionsHub( fun SessionsHub(
@ -43,25 +57,39 @@ fun SessionsHub(
onManual: () -> Unit, onManual: () -> Unit,
onDrafts: () -> Unit, onDrafts: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onPrograms: () -> Unit = {},
onCardioCalibration: () -> Unit = {},
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
TrainlogScreen("Séances") { val strings = localizedContext()
TrainlogFrame("Séance en cours", active = draft != null) { TrainlogScreen(strings.getString(R.string.nav_sessions)) {
if (draft == null) TrainlogInfo("Aucune séance en cours.") TrainlogFrame(strings.getString(R.string.route_session_editor), active = draft != null) {
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success) if (draft == null) TrainlogInfo(strings.getString(R.string.current_session_none))
else TrainlogAction(strings.getString(R.string.resume), strings.getString(R.string.durable_draft_count,
strings.resources.getQuantityString(R.plurals.exercise_count, draft.exercises.size, draft.exercises.size)), onResume, accent = colors.success)
} }
TrainlogFrame("Préparer") { TrainlogFrame(strings.getString(R.string.prepare)) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("sessions-prepare-row"), Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("sessions-prepare-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogActionTile("Nouvelle séance manuelle", "Créer ou reprendre", TrainlogIcons.Edit, TrainlogActionTile(strings.getString(R.string.new_manual_session), strings.getString(R.string.create_or_resume), TrainlogIcons.Edit,
onManual, Modifier.weight(1f).fillMaxHeight().testTag("sessions-manual-action")) onManual, Modifier.weight(1f).fillMaxHeight().testTag("sessions-manual-action"))
TrainlogActionTile("Brouillons", "$pendingAiDraftCount séance(s) préparée(s)", TrainlogActionTile(strings.getString(R.string.route_ai_drafts), strings.resources.getQuantityString(R.plurals.prepared_session_count, pendingAiDraftCount, pendingAiDraftCount),
TrainlogIcons.Drafts, onDrafts, TrainlogIcons.Drafts, onDrafts,
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action")) Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
} }
} }
TrainlogButton("Séances effectuées", onHistory, TrainlogButton(
strings.getString(R.string.route_cardio_calibration),
onCardioCalibration,
Modifier.fillMaxWidth().testTag("sessions-cardio-calibration-action"),
style = TrainlogButtonStyle.SUCCESS,
)
Spacer(Modifier.height(10.dp))
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action")) Modifier.fillMaxWidth().testTag("sessions-history-action"))
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
TrainlogButton(strings.getString(R.string.route_programs), onPrograms,
Modifier.fillMaxWidth().testTag("sessions-programs-action"))
} }
} }
@ -73,61 +101,85 @@ fun AiSessionDraftsScreen(
onStarted: () -> Unit, onStarted: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
var revision by remember { mutableStateOf(0) } var revision by remember { mutableStateOf(0) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) } var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() } val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() }
TrainlogScreen("Brouillons") { val preparations = remember(externalRevision, revision) { repository.listPreparedSessions() }
TrainlogScreen(strings.getString(R.string.route_ai_drafts)) {
message?.let { TrainlogInfo(it) } message?.let { TrainlogInfo(it) }
if (drafts.isEmpty()) TrainlogInfo("Aucun brouillon importé.") if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
preparations.forEach { preparation ->
DraftSummaryCard(
title = preparation.title,
provenance = strings.getString(R.string.manual_preparation),
plannedFor = preparation.plannedFor,
notes = preparation.notes,
entries = preparation.entries,
startDescription = strings.getString(R.string.start_preparation_description),
onStart = {
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
StartAiSessionDraftResult.ExistingActiveDraft ->
message = strings.getString(R.string.draft_already_active)
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
},
)
}
drafts.forEach { draft -> drafts.forEach { draft ->
TrainlogFrame(draft.title ?: "Proposition de séance", active = true) { DraftSummaryCard(
TrainlogInfo(listOfNotNull( title = draft.title ?: strings.getString(R.string.session_proposal),
draft.plannedFor?.let { "Prévue le $it" }, provenance = strings.getString(R.string.session_proposal),
"${draft.entries.size} exercice(s)", plannedFor = draft.plannedFor,
).joinToString(" · ")) notes = draft.notes,
draft.notes?.let { TrainlogInfo(it) } entries = draft.entries,
draft.entries.forEach { entry -> startDescription = strings.getString(R.string.start_proposal_description),
val plan = checkNotNull(entry.plan) onStart = {
val metric = plan.reps?.let { "$it répétitions" }
?: "${plan.durationSeconds} secondes"
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo("${entry.exercise.name} · ${plan.sets} × $metric$weight · repos ${plan.restSeconds} s")
}
TrainlogPrimaryAction("Démarrer", "Copier cette proposition dans la séance en cours.") {
when (val result = repository.startAiSessionDraft(draft.draftId)) { when (val result = repository.startAiSessionDraft(draft.draftId)) {
StartAiSessionDraftResult.Started -> { StartAiSessionDraftResult.Started -> {
onPendingChanged() onPendingChanged()
onStarted() onStarted()
} }
StartAiSessionDraftResult.ExistingActiveDraft -> StartAiSessionDraftResult.ExistingActiveDraft ->
message = "Une séance est déjà en cours. Le brouillon importé a été conservé." message = strings.getString(R.string.draft_already_active)
StartAiSessionDraftResult.NotPending -> { StartAiSessionDraftResult.NotPending -> {
message = "Ce brouillon n'est plus disponible." message = strings.getString(R.string.draft_missing)
revision++ revision++
onPendingChanged() onPendingChanged()
} }
is StartAiSessionDraftResult.Error -> message = result.message is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
} }
} },
TrainlogDeleteButton( footer = {
contentDescription = "Supprimer ce brouillon", Row(
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"), Modifier.fillMaxWidth(),
onClick = { horizontalArrangement = Arrangement.End,
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance") ) {
}, TrainlogDeleteButton(
) contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
} modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
fullWidth = false,
onClick = {
pendingDeletion = draft.draftId to
(draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
)
} }
} }
pendingDeletion?.let { (draftId, title) -> pendingDeletion?.let { (draftId, title) ->
DestructiveConfirmationDialog( DestructiveConfirmationDialog(
title = "Supprimer ce brouillon ?", title = strings.getString(R.string.delete_draft_question),
detail = "Le brouillon « $title » sera supprimé.\nCette action est irréversible.", detail = strings.getString(R.string.delete_draft_detail, title),
confirmLabel = "Supprimer", confirmLabel = strings.getString(R.string.delete),
onCancel = { pendingDeletion = null }, onCancel = { pendingDeletion = null },
dismissOnClickOutside = true, dismissOnClickOutside = true,
deleteContentDescription = "Supprimer ce brouillon", deleteContentDescription = localizedContext().getString(R.string.a11y_delete_draft),
) { ) {
/* CONTRACT: dialog confirmation is the sole UI path to the existing /* CONTRACT: dialog confirmation is the sole UI path to the existing
* tombstone mutation. Dismissal never reaches the repository. */ * tombstone mutation. Dismissal never reaches the repository. */
@ -137,23 +189,174 @@ fun AiSessionDraftsScreen(
revision++ revision++
onPendingChanged() onPendingChanged()
} }
is ActiveDraftMutationResult.Error -> message = result.message is ActiveDraftMutationResult.Error -> message = localizedRepositoryMessage(strings, result.message)
} }
} }
} }
} }
/** Compact presentation of an inert preparation; starting remains caller-owned. */
@Composable
private fun DraftSummaryCard(
title: String,
provenance: String,
plannedFor: String?,
notes: String?,
entries: List<SessionExerciseDraft>,
startDescription: String,
onStart: () -> Unit,
footer: (@Composable () -> Unit)? = null,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
TrainlogCompactCard {
androidx.compose.material3.Text(
title,
color = colors.text,
style = TrainlogTypography.section,
fontWeight = FontWeight.Bold,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TrainlogMetaBadge(provenance, Modifier.weight(1f))
TrainlogMetaBadge(
strings.resources.getQuantityString(
R.plurals.exercise_count,
entries.size,
entries.size,
),
)
}
plannedFor?.let {
androidx.compose.material3.Text(
strings.getString(R.string.planned_for, formatDate(it)),
color = colors.muted,
style = TrainlogTypography.small,
)
}
notes?.takeIf { it.isNotBlank() }?.let {
androidx.compose.material3.Text(
it,
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
entries.forEach { DraftExerciseRow(it) }
}
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.start),
onStart,
Modifier.widthIn(min = 128.dp),
style = TrainlogButtonStyle.SUCCESS,
)
androidx.compose.material3.Text(
startDescription,
modifier = Modifier.weight(1f),
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
footer?.invoke()
}
}
}
@Composable
private fun DraftExerciseRow(entry: SessionExerciseDraft) {
val colors = LocalTrainlogColors.current
val plan = checkNotNull(entry.plan)
Column(
Modifier.fillMaxWidth()
.background(colors.background, androidx.compose.material3.MaterialTheme.shapes.small)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
androidx.compose.material3.Text(
entry.exercise.name,
color = colors.text,
style = TrainlogTypography.small,
fontWeight = FontWeight.SemiBold,
)
androidx.compose.material3.Text(
draftPlanSummary(plan.sets, plan.reps, plan.durationSeconds, plan.weightKg, plan.restSeconds),
color = colors.muted,
style = TrainlogTypography.small,
)
}
}
@Composable
private fun draftPlanSummary(
sets: Int,
reps: Int?,
durationSeconds: Int?,
weightKg: Double?,
restSeconds: Int,
): String {
val strings = localizedContext()
val metric = when {
reps != null -> "$sets × $reps"
durationSeconds != null && sets > 0 -> "$sets × ${humanDuration(durationSeconds)}"
durationSeconds != null -> humanDuration(durationSeconds)
else -> "—"
}
return buildList {
add(metric)
weightKg?.let { add("${it.toString().trimEnd('0').trimEnd('.')} kg") }
if (restSeconds > 0) add(strings.getString(R.string.draft_rest_short, restSeconds))
}.joinToString(" · ")
}
@Composable
private fun humanDuration(seconds: Int): String {
val strings = localizedContext()
return if (seconds % 60 == 0) {
val minutes = seconds / 60
strings.resources.getQuantityString(R.plurals.duration_minutes_value, minutes, minutes)
} else {
strings.resources.getQuantityString(R.plurals.duration_seconds_value, seconds, seconds)
}
}
@Composable @Composable
fun LatestMaximaScreen(repository: TrainlogRepository) { fun LatestMaximaScreen(repository: TrainlogRepository) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val maxima = remember { repository.listLatestExerciseMaxima() } val maxima = remember { repository.listLatestExerciseMaxima() }
TrainlogScreen("Derniers MAX") { val locale = presentationLocale()
TrainlogFrame("CAPACITÉS / MAX", active = maxima.isNotEmpty()) { val strings = localizedContext()
if (maxima.isEmpty()) TrainlogInfo("Aucun max explicite enregistré.") TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
maxima.forEach { max -> if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg).trimEnd('0').trimEnd(',') maxima.forEach { max ->
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning) val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo("${formatStartedAt(max.startedAt).take(10)} · Équipement : ${max.equipmentDisplayName ?: "aucun"}", colors.muted) androidx.compose.foundation.layout.Column(
Modifier.fillMaxWidth().padding(bottom = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
) {
androidx.compose.material3.Text(
max.exerciseName,
color = colors.text,
style = com.labfytools.trainlog.ui.theme.TrainlogTypography.section,
)
androidx.compose.foundation.layout.Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
androidx.compose.material3.Text("$weight kg", color = colors.warning)
androidx.compose.material3.Text(formatDate(max.startedAt), color = colors.muted)
}
max.equipmentDisplayName?.let {
TrainlogInfo(strings.getString(R.string.equipment_value, it), colors.muted)
}
} }
} }
} }
@ -178,14 +381,15 @@ fun EquipmentScreen(
onCreate: () -> Unit, onCreate: () -> Unit,
onOpenDetail: (String) -> Unit, onOpenDetail: (String) -> Unit,
) { ) {
val strings = localizedContext()
val equipment = remember(state.query, state.revision) { repository.searchEquipment(state.query) } val equipment = remember(state.query, state.revision) { repository.searchEquipment(state.query) }
TrainlogScreen("Catalogue d'équipements", scrollKey = "equipment-catalogue") { TrainlogScreen(strings.getString(R.string.equipment_catalog), scrollKey = "equipment-catalogue") {
TrainlogPrimaryAction("Créer un équipement", "Ajouter une machine personnelle.", onCreate) TrainlogPrimaryAction(strings.getString(R.string.create_equipment), strings.getString(R.string.create_personal_machine), onCreate)
TrainlogInputField("Rechercher par nom, étiquette ou alias", state.query, { state.query = it }) TrainlogInputField(strings.getString(R.string.search_equipment), state.query, { state.query = it })
TrainlogFrame("Catalogue", active = equipment.isNotEmpty()) { TrainlogFrame(strings.getString(R.string.catalog), active = equipment.isNotEmpty()) {
if (equipment.isEmpty()) TrainlogInfo("Aucun équipement trouvé.") if (equipment.isEmpty()) TrainlogInfo(strings.getString(R.string.equipment_none))
equipment.forEach { entry -> equipment.forEach { entry ->
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics)).filter { it.isNotBlank() }.joinToString(" · "), { TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics, strings)).filter { it.isNotBlank() }.joinToString(" · "), {
state.selectedEquipmentId = entry.equipmentId state.selectedEquipmentId = entry.equipmentId
onOpenDetail(entry.equipmentId) onOpenDetail(entry.equipmentId)
}) })
@ -197,31 +401,32 @@ fun EquipmentScreen(
@Composable @Composable
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) { fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext()
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } } val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
val options = remember(equipmentId) { repository.listEquipmentExerciseOptions(equipmentId) } val options = remember(equipmentId) { repository.listEquipmentExerciseOptions(equipmentId) }
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") { TrainlogScreen(strings.getString(R.string.route_equipment_detail), scrollKey = "equipment-detail:$equipmentId") {
if (entry == null) { if (entry == null) {
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning) TrainlogInfo(strings.getString(R.string.unknown_reference, equipmentId), colors.warning)
return@TrainlogScreen return@TrainlogScreen
} }
TrainlogFrame("Définition") { TrainlogFrame(strings.getString(R.string.definition)) {
TrainlogInfo(entry.displayName, colors.accent) TrainlogInfo(entry.displayName, colors.accent)
TrainlogInfo(if (entry.type == "custom_machine") "Personnel" else "Fourni") TrainlogInfo(strings.getString(if (entry.type == "custom_machine") R.string.personal else R.string.provided))
if (entry.labelName.isNotBlank()) TrainlogInfo("Étiquette : ${entry.labelName}") if (entry.labelName.isNotBlank()) TrainlogInfo(strings.getString(R.string.label_value, entry.labelName))
TrainlogInfo("Type : ${entry.type}") TrainlogInfo(strings.getString(R.string.type_value, entry.type))
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}") TrainlogInfo(strings.getString(R.string.load_value, equipmentSemanticsLabel(entry.loadSemantics, strings)))
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted) if (entry.aliases.isNotEmpty()) TrainlogInfo(strings.getString(R.string.aliases_value, entry.aliases.joinToString()), colors.muted)
} }
TrainlogFrame("Exercices possibles", active = options.isNotEmpty()) { TrainlogFrame(strings.getString(R.string.possible_exercises_plain), active = options.isNotEmpty()) {
if (options.isEmpty()) TrainlogInfo("Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.", colors.muted) if (options.isEmpty()) TrainlogInfo(strings.getString(R.string.association_none), colors.muted)
options.forEach { (relation, exercise) -> options.forEach { (relation, exercise) ->
val confidence = when (relation.confidence) { val confidence = when (relation.confidence) {
KnowledgeConfidence.HIGH -> "élevée" KnowledgeConfidence.HIGH -> strings.getString(R.string.confidence_high)
KnowledgeConfidence.MODERATE -> "modérée" KnowledgeConfidence.MODERATE -> strings.getString(R.string.confidence_moderate)
KnowledgeConfidence.UNCERTAIN -> "incertaine" KnowledgeConfidence.UNCERTAIN -> strings.getString(R.string.confidence_uncertain)
} }
TrainlogAction(exercise.exerciseName, TrainlogAction(exercise.exerciseName,
listOfNotNull(relation.configurationLabel, "Confiance $confidence", "Sources vérifiées : ${relation.sourceRefs.size}").joinToString(" · "), listOfNotNull(relation.configurationLabel, strings.getString(R.string.confidence_value, confidence), strings.getString(R.string.verified_sources, relation.sourceRefs.size)).joinToString(" · "),
{ onOpenExercise(exercise.exerciseId) }) { onOpenExercise(exercise.exerciseId) })
} }
} }
@ -229,20 +434,33 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
} }
@Composable @Composable
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) { fun EquipmentCreateScreen(
repository: TrainlogRepository,
state: EquipmentScreenState,
onCreated: (EquipmentCatalogEntry) -> Unit,
) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
TrainlogScreen("Créer un équipement", scrollKey = "equipment-create") { val strings = localizedContext()
TrainlogFrame("Équipement personnel") { TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
TrainlogInputField("Nom", state.customName, { state.customName = it; state.message = null }) TrainlogFrame(strings.getString(R.string.personal_equipment)) {
TrainlogPrimaryAction("Créer", "Créer une machine personnelle avec la sémantique actuelle.") { TrainlogInputField(
strings.getString(R.string.name),
state.customName,
{ state.customName = it; state.message = null },
testTag = "equipment-create-name",
)
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
when (val result = repository.createCustomEquipment(state.customName)) { when (val result = repository.createCustomEquipment(state.customName)) {
is CreateEquipmentResult.Created -> { is CreateEquipmentResult.Created -> {
state.selectedEquipmentId = result.equipment.equipmentId state.selectedEquipmentId = result.equipment.equipmentId
state.customName = ""; state.message = null; state.revision++; onCreated() state.customName = ""
state.message = null
state.revision++
onCreated(result.equipment)
} }
CreateEquipmentResult.Invalid -> state.message = "Saisissez un nom de 1 à 120 caractères." CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
CreateEquipmentResult.Conflict -> state.message = "Un équipement porte déjà ce nom." CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
is CreateEquipmentResult.DatabaseError -> state.message = "Création impossible : ${result.message}." is CreateEquipmentResult.DatabaseError -> state.message = strings.getString(R.string.creation_failed, result.message)
} }
} }
state.message?.let { TrainlogInfo(it, colors.error) } state.message?.let { TrainlogInfo(it, colors.error) }
@ -250,48 +468,134 @@ fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreen
} }
} }
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value) { private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics, context: android.content.Context) = context.getString(when (value) {
EquipmentLoadSemantics.NONE -> "sans charge" EquipmentLoadSemantics.NONE -> R.string.load_none
EquipmentLoadSemantics.EXTERNAL -> "charge externe" EquipmentLoadSemantics.EXTERNAL -> R.string.load_external
EquipmentLoadSemantics.ASSISTANCE -> "assistance" EquipmentLoadSemantics.ASSISTANCE -> R.string.load_assistance
EquipmentLoadSemantics.BODYWEIGHT -> "poids du corps" EquipmentLoadSemantics.BODYWEIGHT -> R.string.load_bodyweight
EquipmentLoadSemantics.CARDIO -> "cardio" EquipmentLoadSemantics.CARDIO -> R.string.load_cardio
} })
private fun catalogCountsText(context: android.content.Context, imported: Int, reconciled: Int): String =
listOf(
context.resources.getQuantityString(R.plurals.catalog_new, imported, imported),
context.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled),
).joinToString(", ")
@Composable @Composable
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { fun SettingsScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox,
onHeartRateDiagnostics: () -> Unit,
onCatalogChanged: () -> Unit,
) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val context = LocalContext.current val context = LocalContext.current
val language = LocalLanguagePresentation.current
val strings = localizedContext()
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) } var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
val heartRatePreferences = remember(context) { HeartRateSensorPreferences(context) }
var heartRateRevision by remember { mutableIntStateOf(0) }
val selectedHeartRateSensor =
remember(heartRateRevision) { heartRatePreferences.selected() }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
authorized = inbox.hasFolderAccess() authorized = inbox.hasFolderAccess()
message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis." message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
if (authorized) { if (authorized) {
when (val result = inbox.importPcCatalog()) { when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> { is CatalogInboxResult.Imported -> {
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)." message = strings.getString(R.string.settings_folder_imported,
catalogCountsText(strings, result.imported, result.reconciled))
onCatalogChanged() onCatalogChanged()
} }
CatalogInboxResult.FileNotFound -> message = "Dossier autorisé · aucun catalogue PC reçu." CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé." CatalogInboxResult.FolderNotAuthorized -> message = strings.getString(R.string.settings_folder_revoked)
is CatalogInboxResult.Error -> message = result.message is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
} }
} }
} }
TrainlogScreen("Paramètres") { val backupService = remember { AndroidBackupService(context) }
TrainlogFrame("DOSSIER D'ÉCHANGE") { val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning) if (uri != null) {
TrainlogAction(if (authorized) "Ouvrir les réglages d'accès" else "Autoriser", "Autoriser l'accès au dossier Trainlog dans Android.", { launcher.launch(directStoragePermissionIntent(context)) }) val result = runCatching { context.contentResolver.openOutputStream(uri, "w")!!.use { backupService.create(repository, it) } }.getOrElse { AndroidBackupResult.Error(it.message ?: "Backup export failed.") }
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", { message = strings.getString(if (result is AndroidBackupResult.Success) R.string.settings_backup_complete else R.string.settings_backup_failed)
}
}
val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
val verified = runCatching { context.contentResolver.openInputStream(uri)!!.use { backupService.verify(it).getOrThrow() } }
val result = verified.fold({ backupService.restore(repository, it) }, { AndroidBackupResult.Error(it.message ?: "Backup verification failed.") })
if (result is AndroidBackupResult.Error) {
android.widget.Toast.makeText(context, strings.getString(R.string.settings_restore_failed), android.widget.Toast.LENGTH_LONG).show()
}
// Restore closes the repository before replacement. Recreate even
// after a rolled-back failure so no UI owner retains that handle.
(context as? android.app.Activity)?.recreate()
}
}
TrainlogScreen(strings.getString(R.string.settings_screen)) {
TrainlogFrame(strings.getString(R.string.settings_language_section)) {
TrainlogInfo("${strings.getString(R.string.settings_language_label)} : " +
strings.getString(if (language.language == AppLanguage.FRENCH) R.string.language_french else R.string.language_english))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogButton(
strings.getString(R.string.language_french),
{ language.select(AppLanguage.FRENCH) },
Modifier.weight(1f),
enabled = language.language != AppLanguage.FRENCH,
)
TrainlogButton(
strings.getString(R.string.language_english),
{ language.select(AppLanguage.ENGLISH) },
Modifier.weight(1f),
enabled = language.language != AppLanguage.ENGLISH,
)
}
}
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
if (selectedHeartRateSensor != null) {
TrainlogInfo(
strings.getString(R.string.settings_cardio_configured),
colors.success,
)
}
TrainlogAction(
strings.getString(R.string.settings_cardio_diagnostics),
strings.getString(R.string.settings_cardio_diagnostics_description),
onHeartRateDiagnostics,
)
if (selectedHeartRateSensor != null) {
TrainlogAction(
strings.getString(R.string.settings_cardio_forget),
strings.getString(R.string.settings_cardio_forget_description),
{
HeartRateSensorService.forget(context)
heartRateRevision++
},
)
}
}
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
if (authorized) TrainlogAction(strings.getString(R.string.settings_reload_catalog), strings.getString(R.string.settings_reload_description), {
when (val result = inbox.importPcCatalog()) { when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() } is CatalogInboxResult.Imported -> { message = strings.getString(R.string.settings_catalog_reloaded,
CatalogInboxResult.FileNotFound -> message = "Aucun catalogue PC reçu." catalogCountsText(strings, result.imported, result.reconciled)); onCatalogChanged() }
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = "Le dossier n'est plus autorisé." } CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
is CatalogInboxResult.Error -> message = result.message CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = strings.getString(R.string.settings_folder_revoked) }
is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
} }
}) })
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) } message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }
} }
TrainlogFrame(strings.getString(R.string.settings_backup_section)) {
TrainlogInfo(strings.getString(R.string.settings_backup_plaintext))
TrainlogAction(strings.getString(R.string.settings_backup_create), strings.getString(R.string.settings_backup_create_description),
{ backupLauncher.launch("trainlog-backup.tlbackup") })
TrainlogAction(strings.getString(R.string.settings_backup_restore), strings.getString(R.string.settings_backup_restore_description),
{ restoreLauncher.launch(arrayOf("application/zip", "application/octet-stream")) })
}
} }
} }

View file

@ -25,6 +25,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@Composable @Composable
fun SessionDetailScreen( fun SessionDetailScreen(
@ -36,6 +37,8 @@ fun SessionDetailScreen(
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
val locale = presentationLocale()
val strings = localizedContext()
var revision by remember(sessionId) { mutableStateOf(0) } var revision by remember(sessionId) { mutableStateOf(0) }
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) } var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
@ -61,15 +64,15 @@ fun SessionDetailScreen(
} }
TrainlogScreen( TrainlogScreen(
subtitle = "Détail de la séance" subtitle = strings.getString(R.string.route_session_detail)
) { ) {
if (detail == null) { if (detail == null) {
TrainlogFrame( TrainlogFrame(
title = "Erreur" title = strings.getString(R.string.error_title)
) { ) {
TrainlogInfo( TrainlogInfo(
text = text =
"Séance introuvable.", strings.getString(R.string.session_not_found),
color = color =
colors.error, colors.error,
) )
@ -79,7 +82,7 @@ fun SessionDetailScreen(
} }
TrainlogFrame( TrainlogFrame(
title = "Séance" title = strings.getString(R.string.home_session)
) { ) {
TrainlogInfo( TrainlogInfo(
formatStartedAt( formatStartedAt(
@ -89,11 +92,8 @@ fun SessionDetailScreen(
TrainlogInfo( TrainlogInfo(
text = text =
"Type : " + strings.getString(R.string.session_type_value,
sessionTypeLabel( sessionTypeLabel(detail.summary.sessionType)),
detail.summary
.sessionType
),
color = color =
if ( if (
detail.summary detail.summary
@ -107,21 +107,22 @@ fun SessionDetailScreen(
) )
TrainlogInfo( TrainlogInfo(
"${detail.summary.exerciseCount} exercice(s)" strings.resources.getQuantityString(R.plurals.exercise_count,
detail.summary.exerciseCount, detail.summary.exerciseCount)
) )
TrainlogButton("Modifier la séance", onCorrect, TrainlogButton(strings.getString(R.string.route_session_correction), onCorrect,
Modifier.fillMaxWidth().testTag("edit-completed-session")) Modifier.fillMaxWidth().testTag("edit-completed-session"))
if (detail.summary.sessionType == SessionType.MAX_TEST) { if (detail.summary.sessionType == SessionType.MAX_TEST) {
TrainlogAction( TrainlogAction(
label = "Reprendre ce Test max", label = strings.getString(R.string.resume_max_test),
description = "Continuer la même séance en conservant son identifiant et sa date.", description = strings.getString(R.string.resume_max_description),
accent = colors.success, accent = colors.success,
onClick = { onClick = {
when (val result = repository.resumeMaxTestSession(detail.summary.sessionId)) { when (val result = repository.resumeMaxTestSession(detail.summary.sessionId)) {
ActiveDraftMutationResult.Saved -> onResumeMaxTest() ActiveDraftMutationResult.Saved -> onResumeMaxTest()
is ActiveDraftMutationResult.Error -> resumeMessage = result.message is ActiveDraftMutationResult.Error -> resumeMessage = localizedRepositoryMessage(strings, result.message)
} }
}, },
) )
@ -139,25 +140,25 @@ fun SessionDetailScreen(
"${index + 1}. ${exercise.exerciseName}" "${index + 1}. ${exercise.exerciseName}"
) { ) {
exercise.equipmentDisplayName?.let { equipment -> exercise.equipmentDisplayName?.let { equipment ->
TrainlogInfo("Équipement : $equipment", color = colors.muted) TrainlogInfo(strings.getString(R.string.equipment_inline, equipment), color = colors.muted)
} }
exercise.plan?.let { plan -> exercise.plan?.let { plan ->
TrainlogInfo( TrainlogInfo(
"Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " + strings.getString(R.string.plan_summary, plan.sets,
"repos ${plan.restSeconds} s · " + (plan.reps ?: plan.durationSeconds).toString(), plan.restSeconds,
(plan.weightKg?.let { "charge cible $it kg" } plan.weightKg?.let { strings.getString(R.string.target_load_inline, it) }
?: "sans charge numérique"), ?: strings.getString(R.string.no_numeric_load)),
color = colors.muted, color = colors.muted,
) )
} }
if (editingEntryId == exercise.entryId) { if (editingEntryId == exercise.entryId) {
TrainlogInputField( TrainlogInputField(
label = "Rechercher une machine", label = strings.getString(R.string.search_machine),
value = equipmentQuery, value = equipmentQuery,
onValueChange = { equipmentQuery = it }, onValueChange = { equipmentQuery = it },
) )
TrainlogDeleteButton( TrainlogDeleteButton(
contentDescription = "Retirer la machine de cet exercice", contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_remove_machine),
modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"), modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"),
onClick = { onClick = {
pendingEquipmentChange = exercise.entryId to null pendingEquipmentChange = exercise.entryId to null
@ -179,17 +180,17 @@ fun SessionDetailScreen(
} }
} else { } else {
TrainlogAction( TrainlogAction(
label = "Modifier l'équipement", label = strings.getString(R.string.modify_equipment),
description = "Choisir, remplacer ou retirer la machine de cette entrée.", description = strings.getString(R.string.modify_equipment_description),
accent = colors.muted, accent = colors.muted,
onClick = { editingEntryId = exercise.entryId }, onClick = { editingEntryId = exercise.entryId },
) )
} }
if (exercise.maxWeightKg != null) { if (exercise.maxWeightKg != null) {
val rendered = "%.2f".format(java.util.Locale.FRANCE, exercise.maxWeightKg) val rendered = "%.2f".format(locale, exercise.maxWeightKg)
.trimEnd('0').trimEnd(',') .trimEnd('0').trimEnd(',')
TrainlogInfo( TrainlogInfo(
text = "Max : $rendered kg", text = strings.getString(R.string.max_value, rendered),
color = colors.warning, color = colors.warning,
) )
} else if ( } else if (
@ -208,67 +209,70 @@ fun SessionDetailScreen(
) )
} }
exercise.feedback.forEach { item -> exercise.feedback.forEach { item ->
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}") val elapsed = exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.", // CONTRACT: repository diagnostics remain stable data; only this visible sentinel is localized.
val localizedElapsed = if (elapsed == "Pendant la séance") strings.getString(R.string.during_session) else elapsed
TrainlogInfo("$localizedElapsed · ${item.rawText}${if (item.modified) strings.getString(R.string.modified_suffix) else ""}")
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_move_description),
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted) onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
if (editingFeedbackId == item.feedbackId) FeedbackEditor( if (editingFeedbackId == item.feedbackId) FeedbackEditor(
initialText = item.rawText, editing = true, initialText = item.rawText, editing = true,
onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) { onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) {
is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null } is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null }
is SaveFeedbackResult.Invalid -> result.message is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> result.message is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
} }, onCancel = { editingFeedbackId = null }) } }, onCancel = { editingFeedbackId = null })
} }
if (feedbackEntryId == exercise.entryId) { if (feedbackEntryId == exercise.entryId) {
FeedbackEditor(onSave = { text -> FeedbackEditor(onSave = { text ->
when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) { when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) {
is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null } is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null }
is SaveFeedbackResult.Invalid -> result.message is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> result.message is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
} }
}, onCancel = { feedbackEntryId = null }) }, onCancel = { feedbackEntryId = null })
} else { } else {
TrainlogButton("Ajouter un ressenti", { feedbackEntryId = exercise.entryId }, TrainlogButton(strings.getString(R.string.add_feedback), { feedbackEntryId = exercise.entryId },
Modifier.fillMaxWidth().testTag("add-exercise-feedback-${exercise.entryId}"), Modifier.fillMaxWidth().testTag("add-exercise-feedback-${exercise.entryId}"),
icon = TrainlogIcons.Mic) icon = TrainlogIcons.Mic)
} }
} }
} }
TrainlogFrame(title = "Suivi après séance") { TrainlogFrame(title = strings.getString(R.string.session_follow_up)) {
detail.followUps.forEach { item -> detail.followUps.forEach { item ->
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent) TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent)
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "") TrainlogInfo(item.rawText + if (item.modified) strings.getString(R.string.modified_suffix) else "")
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.", TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_marker_description),
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted) onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
if (editingFollowUpId == item.followupId) FeedbackEditor( if (editingFollowUpId == item.followupId) FeedbackEditor(
initialText = item.rawText, editing = true, initialText = item.rawText, editing = true,
onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) { onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) {
is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null } is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null }
is SaveFeedbackResult.Invalid -> result.message is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> result.message is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
} }, onCancel = { editingFollowUpId = null }) } }, onCancel = { editingFollowUpId = null })
} }
if (addingFollowUp) { if (addingFollowUp) {
FeedbackEditor(onSave = { text -> FeedbackEditor(onSave = { text ->
when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) { when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) {
is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null } is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null }
is SaveFeedbackResult.Invalid -> result.message is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> result.message is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
} }
}, onCancel = { addingFollowUp = false }) }, onCancel = { addingFollowUp = false })
} else TrainlogButton("Ajouter un enregistrement", { addingFollowUp = true }, } else TrainlogButton(strings.getString(R.string.add_record), { addingFollowUp = true },
Modifier.fillMaxWidth().testTag("add-session-follow-up"), icon = TrainlogIcons.Mic, Modifier.fillMaxWidth().testTag("add-session-follow-up"), icon = TrainlogIcons.Mic,
containerColor = colors.success) containerColor = colors.success)
} }
pendingEquipmentChange?.let { (entryId, equipmentId) -> pendingEquipmentChange?.let { (entryId, equipmentId) ->
DestructiveConfirmationDialog( DestructiveConfirmationDialog(
title = if (equipmentId == null) "Retirer la machine de cette entrée ?" else "Remplacer la machine associée ?", title = strings.getString(if (equipmentId == null) R.string.remove_machine_question else R.string.replace_machine_question),
detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.", detail = strings.getString(R.string.machine_change_detail),
confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer", confirmLabel = strings.getString(if (equipmentId == null) R.string.remove else R.string.replace),
onCancel = { pendingEquipmentChange = null }, onCancel = { pendingEquipmentChange = null },
deleteContentDescription = if (equipmentId == null) { deleteContentDescription = if (equipmentId == null) {
"Retirer la machine de cet exercice" strings.getString(R.string.a11y_remove_machine)
} else null, } else null,
onConfirm = { onConfirm = {
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) { if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
@ -287,6 +291,8 @@ private fun SetsDetail(
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
val locale = presentationLocale()
val strings = localizedContext()
TrainlogInfo( TrainlogInfo(
text = text =
@ -294,9 +300,9 @@ private fun SetsDetail(
exercise.trackingMode == exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
) { ) {
"Mode : séries · répétitions" strings.getString(R.string.mode_sets_reps)
} else { } else {
"Mode : séries · durée" strings.getString(R.string.mode_sets_duration)
}, },
color = colors.accent, color = colors.accent,
) )
@ -304,13 +310,13 @@ private fun SetsDetail(
if (exercise.trackingMode == TrackingMode.REPS) { if (exercise.trackingMode == TrackingMode.REPS) {
val loadHeading = val loadHeading =
if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) { if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
"Assistance (kg)" strings.getString(R.string.assistance_kg)
} else { } else {
"Charge (kg)" strings.getString(R.string.field_weight)
} }
/* Readable row table: history must expose every persisted value and /* Readable row table: history must expose every persisted value and
* distinguish an absent load from an explicit zero. */ * distinguish an absent load from an explicit zero. */
TrainlogInfo("Série | Répétitions | $loadHeading", color = colors.muted) TrainlogInfo(strings.getString(R.string.sets_table, loadHeading), color = colors.muted)
} }
exercise.sets exercise.sets
@ -324,11 +330,11 @@ private fun SetsDetail(
TrackingMode.REPS TrackingMode.REPS
) { ) {
val renderedWeight = set.weightKg?.let { val renderedWeight = set.weightKg?.let {
"%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',') "%.2f".format(locale, it).trimEnd('0').trimEnd(',', '.')
} ?: "—" } ?: "—"
"${index + 1} | ${set.reps} | $renderedWeight" "${index + 1} | ${set.reps} | $renderedWeight"
} else { } else {
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}" strings.getString(R.string.set_duration, index + 1, formatDuration(set.durationSeconds))
} }
) )
} }
@ -340,15 +346,16 @@ private fun ContinuousDetail(
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
val strings = localizedContext()
TrainlogInfo( TrainlogInfo(
text = text =
"Mode : continu", strings.getString(R.string.mode_continuous),
color = colors.accent, color = colors.accent,
) )
TrainlogInfo( TrainlogInfo(
"Durée : ${formatDuration(exercise.continuousDurationSeconds)}" strings.getString(R.string.duration_value, formatDuration(exercise.continuousDurationSeconds))
) )
if ( if (
@ -356,9 +363,7 @@ private fun ContinuousDetail(
ExerciseDataFields.SPEED_KMH != 0 ExerciseDataFields.SPEED_KMH != 0
) { ) {
TrainlogInfo( TrainlogInfo(
"Vitesse : %.1f km/h".format( strings.getString(R.string.speed_value, exercise.speedKmh ?: 0.0)
exercise.speedKmh ?: 0.0
)
) )
} }
@ -367,13 +372,12 @@ private fun ContinuousDetail(
ExerciseDataFields.DISTANCE_KM != 0 ExerciseDataFields.DISTANCE_KM != 0
) { ) {
TrainlogInfo( TrainlogInfo(
"Distance : %.2f km".format( strings.getString(R.string.distance_value, exercise.distanceKm ?: 0.0)
exercise.distanceKm ?: 0.0
)
) )
} }
} }
@Composable
private fun formatDuration( private fun formatDuration(
seconds: Int, seconds: Int,
): String { ): String {
@ -381,8 +385,8 @@ private fun formatDuration(
seconds > 0 && seconds > 0 &&
seconds % 60 == 0 seconds % 60 == 0
) { ) {
return "${seconds / 60} min" return uiQuantity(R.plurals.duration_minutes_value, seconds / 60, seconds / 60)
} }
return "$seconds s" return uiQuantity(R.plurals.duration_seconds_value, seconds, seconds)
} }

View file

@ -23,6 +23,7 @@ import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.labfytools.trainlog.R
internal object SessionGeneratorPreviewController { internal object SessionGeneratorPreviewController {
fun remove(preview: SessionGenerationPreview, index: Int): SessionGenerationPreview { fun remove(preview: SessionGenerationPreview, index: Int): SessionGenerationPreview {
@ -45,10 +46,7 @@ internal object SessionGeneratorPreviewController {
internal object SessionGeneratorFormController { internal object SessionGeneratorFormController {
val goals = listOf( val goals = listOf(
"general" to "Général", "general", "strength", "hypertrophy", "endurance",
"strength" to "Force",
"hypertrophy" to "Hypertrophie",
"endurance" to "Endurance locale",
) )
fun duration(text: String, allowed: IntRange): Int? = text.toIntOrNull()?.takeIf { it in allowed } fun duration(text: String, allowed: IntRange): Int? = text.toIntOrNull()?.takeIf { it in allowed }
@ -57,7 +55,7 @@ internal object SessionGeneratorFormController {
if (normalized.isEmpty()) return Result.success(null) if (normalized.isEmpty()) return Result.success(null)
val value = normalized.toDoubleOrNull() val value = normalized.toDoubleOrNull()
return if (value != null && value.isFinite() && value > 0.0) Result.success(value) return if (value != null && value.isFinite() && value > 0.0) Result.success(value)
else Result.failure(IllegalArgumentException("La charge cible doit être un nombre positif fini.")) else Result.failure(IllegalArgumentException("generator_weight_invalid"))
} }
} }
@ -112,6 +110,7 @@ fun SessionGeneratorScreen(
onAccepted: () -> Unit, onAccepted: () -> Unit,
onExistingDraft: () -> Unit, onExistingDraft: () -> Unit,
) { ) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val options = remember { repository.sessionGenerationFormOptions() } val options = remember { repository.sessionGenerationFormOptions() }
@ -146,8 +145,8 @@ fun SessionGeneratorScreen(
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
message = null message = null
} }
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = result.message is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = localizedRepositoryMessage(strings, result.message)
is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = result.message is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = localizedRepositoryMessage(strings, result.message)
} }
} finally { } finally {
busy = false busy = false
@ -155,99 +154,100 @@ fun SessionGeneratorScreen(
} }
} }
TrainlogScreen(subtitle = "Programmer une séance") { TrainlogScreen(subtitle = strings.getString(R.string.route_session_generator)) {
val current = preview val current = preview
if (current == null) { if (current == null) {
TrainlogFrame("Zone corporelle") { TrainlogFrame(strings.getString(R.string.generator_zone)) {
TrainlogChoiceChips(zones.map { it.zoneId to it.displayName }, zoneId) { zoneId = it } TrainlogChoiceChips(zones.map { it.zoneId to localizedBodyZoneName(strings, it.zoneId, it.displayName) }, zoneId) { zoneId = it }
} }
TrainlogFrame("Objectif") { TrainlogFrame(strings.getString(R.string.generator_goal)) {
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it } TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it in options.goalIds }.map {
it to strings.getString(when (it) { "general" -> R.string.goal_general; "strength" -> R.string.goal_strength
"hypertrophy" -> R.string.goal_hypertrophy; else -> R.string.goal_endurance })
}, goalId) { goalId = it }
} }
TrainlogFrame("Durée") { TrainlogFrame(strings.getString(R.string.field_duration)) {
TrainlogChoiceChips(options.durationPresets.map { it.toString() to "$it min" }, durationText) { durationText = it } TrainlogChoiceChips(options.durationPresets.map { it.toString() to "$it min" }, durationText) { durationText = it }
TrainlogInputField( TrainlogInputField(
"Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)", strings.getString(R.string.generator_custom_duration, options.customMinutes.first, options.customMinutes.last),
durationText, durationText,
onValueChange = { durationText = it }, onValueChange = { durationText = it },
) )
} }
TrainlogAction("Générer", "Analyser l'historique en lecture seule et préparer la proposition.", accent = colors.success, onClick = { TrainlogAction(strings.getString(R.string.generate), strings.getString(R.string.generate_description), accent = colors.success, onClick = {
val minutes = SessionGeneratorFormController.duration(durationText, options.customMinutes) val minutes = SessionGeneratorFormController.duration(durationText, options.customMinutes)
if (minutes == null) message = "La durée doit être comprise entre ${options.customMinutes.first} et ${options.customMinutes.last} minutes." if (minutes == null) message = strings.getString(R.string.duration_range_error, options.customMinutes.first, options.customMinutes.last)
else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString())) else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString()))
}) })
} else { } else {
TrainlogFrame("Proposition") { TrainlogFrame(strings.getString(R.string.proposal)) {
TrainlogInfo("Durée cible : ${current.request.durationMinutes} min · estimation : ${current.estimatedDurationSeconds / 60} min") TrainlogInfo(strings.getString(R.string.target_duration_estimate, current.request.durationMinutes, current.estimatedDurationSeconds / 60))
if (current.request.durationMinutes * 60 - current.estimatedDurationSeconds >= 300) if (current.request.durationMinutes * 60 - current.estimatedDurationSeconds >= 300)
TrainlogInfo("La proposition est nettement plus courte que la cible ; aucun exercice n'est ajouté pour remplir artificiellement le temps.", colors.warning) TrainlogInfo(strings.getString(R.string.proposal_short), colors.warning)
TrainlogInfo("Échauffement et retour au calme ne sont pas générés en V1.", colors.muted) TrainlogInfo(strings.getString(R.string.warmup_not_generated), colors.muted)
if (current.insufficientResolvedCandidates) TrainlogInfo( if (current.insufficientResolvedCandidates) TrainlogInfo(
"La couverture est incomplète faute de contextes résolus disponibles" + strings.getString(R.string.coverage_incomplete,
current.shortageCodes.takeIf { it.isNotEmpty() } current.shortageCodes.takeIf { it.isNotEmpty() }
?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it) } ?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it, strings) }.orEmpty()),
.orEmpty(),
colors.warning, colors.warning,
) )
val exposure = current.exposure val exposure = current.exposure
TrainlogInfo("Exposition observée : ${exposure.within24h.primarySetCount} séries principales et ${exposure.within24h.secondarySetCount} secondaires sur 24 h ; ${exposure.within72h.primarySetCount} principales et ${exposure.within72h.secondarySetCount} secondaires sur 72 h.") TrainlogInfo(strings.getString(R.string.exposure_summary, exposure.within24h.primarySetCount, exposure.within24h.secondarySetCount, exposure.within72h.primarySetCount, exposure.within72h.secondarySetCount))
exposure.latestStartedAt?.let { TrainlogInfo("Dernière exposition observée : $it", colors.muted) } exposure.latestStartedAt?.let { TrainlogInfo(strings.getString(R.string.latest_exposure, formatStartedAt(it)), colors.muted) }
if (exposure.warningLevel != GenerationWarningLevel.NONE) { if (exposure.warningLevel != GenerationWarningLevel.NONE) {
TrainlogInfo("Attention informative : l'historique montre une exposition récente. Cela ne mesure pas la récupération physiologique.", colors.warning) TrainlogInfo(strings.getString(R.string.recent_exposure_warning), colors.warning)
if (!warningAcknowledged) TrainlogAction("Continuer malgré l'avertissement", "Conserver la proposition et autoriser son acceptation.", accent = colors.warning, onClick = { if (!warningAcknowledged) TrainlogAction(strings.getString(R.string.continue_warning), strings.getString(R.string.continue_warning_description), accent = colors.warning, onClick = {
if (!busy) warningAcknowledged = true if (!busy) warningAcknowledged = true
}) })
} }
} }
current.exercises.forEachIndexed { index, item -> current.exercises.forEachIndexed { index, item ->
TrainlogFrame("${index + 1}. ${item.exerciseName}") { TrainlogFrame("${index + 1}. ${item.exerciseName}") {
TrainlogInfo("Équipement : ${item.equipmentName}") TrainlogInfo(strings.getString(R.string.equipment_generator, item.equipmentName))
TrainlogInfo("Cible : ${item.plan.sets} × ${item.plan.reps} · repos ${item.plan.restSeconds} s") TrainlogInfo(strings.getString(R.string.target_summary, item.plan.sets, item.plan.reps.toString(), item.plan.restSeconds))
TrainlogInfo(if (item.plan.weightKg == null) "Charge cible : aucune charge numérique proposée." TrainlogInfo(if (item.plan.weightKg == null) strings.getString(R.string.target_load_none_proposed)
else "Charge cible : ${item.plan.weightKg} kg") else strings.getString(R.string.target_load_proposed, item.plan.weightKg))
TrainlogInfo("Zone principale : ${item.primaryZoneName}") TrainlogInfo(strings.getString(R.string.primary_zone_value_short, item.primaryZoneName))
TrainlogInfo("Mouvement : ${item.patternNames.joinToString()}") TrainlogInfo(strings.getString(R.string.movement_value, item.patternNames.joinToString()))
if (item.recency.recentSameExercise || item.recency.recentSamePattern) if (item.recency.recentSameExercise || item.recency.recentSamePattern)
TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning) TrainlogInfo(strings.getString(R.string.recent_similar), colors.warning)
item.loadSourceStartedAt?.let { item.loadSourceStartedAt?.let {
TrainlogInfo( TrainlogInfo(
"Charge issue d'une dose réellement observée le $it ; " + strings.getString(R.string.observed_load_note, formatDate(it)),
"son applicabilité aujourd'hui reste incertaine.",
colors.muted, colors.muted,
) )
} }
TrainlogInfo( TrainlogInfo(
"Raisons : ${item.rationaleCodes.joinToString { generationReasonLabel(it) }}", strings.getString(R.string.reasons_value, item.rationaleCodes.joinToString { generationReasonLabel(it, strings) }),
colors.muted, colors.muted,
) )
if (editingIndex == index) { if (editingIndex == index) {
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it }) TrainlogInputField(strings.getString(R.string.sets_label), setsText, onValueChange = { setsText = it })
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it }) TrainlogInputField(strings.getString(R.string.field_reps), repsText, onValueChange = { repsText = it })
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it }) TrainlogInputField(strings.getString(R.string.rest_seconds), restText, onValueChange = { restText = it })
TrainlogInfo("Charge : choix utilisateur, jamais une recommandation.", colors.muted) TrainlogInfo(strings.getString(R.string.user_load_note), colors.muted)
TrainlogChoiceChips( TrainlogChoiceChips(
listOf("AUTOMATIC" to "Automatique", "PERCENT_MAX" to "% MAX", "NONE" to "Aucune"), listOf("AUTOMATIC" to strings.getString(R.string.automatic), "PERCENT_MAX" to "% MAX", "NONE" to strings.getString(R.string.none)),
loadChoice.name, loadChoice.name,
) { selected -> ) { selected ->
loadChoice = GeneratorLoadChoice.valueOf(selected) loadChoice = GeneratorLoadChoice.valueOf(selected)
if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = "" if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = ""
} }
if (loadChoice == GeneratorLoadChoice.PERCENT_MAX) if (loadChoice == GeneratorLoadChoice.PERCENT_MAX)
TrainlogInputField("Pourcentage du MAX (1 à 100)", maxPercentText, TrainlogInputField(strings.getString(R.string.max_percentage_field), maxPercentText,
onValueChange = { maxPercentText = it }) onValueChange = { maxPercentText = it })
if (loadChoice == GeneratorLoadChoice.AUTOMATIC) if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
TrainlogInputField( TrainlogInputField(
"Charge cible manuelle (vide = automatique)", strings.getString(R.string.manual_target_load),
loadText, loadText,
onValueChange = { loadText = it }, onValueChange = { loadText = it },
) )
TrainlogAction("Appliquer", "Réestimer la durée et requalifier la charge observée.", accent = colors.success, onClick = { TrainlogAction(strings.getString(R.string.apply), strings.getString(R.string.reestimate_description), accent = colors.success, onClick = {
val parsedWeight = if (loadChoice == GeneratorLoadChoice.AUTOMATIC) val parsedWeight = if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
SessionGeneratorFormController.manualWeight(loadText) SessionGeneratorFormController.manualWeight(loadText)
else Result.success(null) else Result.success(null)
if (parsedWeight.isFailure) { if (parsedWeight.isFailure) {
message = parsedWeight.exceptionOrNull()?.message message = strings.getString(R.string.generator_weight_invalid)
} else { } else {
val weight = parsedWeight.getOrNull() val weight = parsedWeight.getOrNull()
if (!busy) scope.launch { if (!busy) scope.launch {
@ -260,8 +260,8 @@ fun SessionGeneratorScreen(
maxPercentText.toIntOrNull()) maxPercentText.toIntOrNull())
}) { }) {
is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null } is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null }
is SessionGenerationResult.Invalid -> message = result.message is SessionGenerationResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
is SessionGenerationResult.DatabaseError -> message = result.message is SessionGenerationResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
} }
} finally { } finally {
busy = false busy = false
@ -269,7 +269,7 @@ fun SessionGeneratorScreen(
} }
} }
}) })
} else TrainlogAction("Modifier la cible", "Modifier séries, répétitions, repos ou charge.", onClick = { } else TrainlogAction(strings.getString(R.string.modify_target), strings.getString(R.string.modify_target_description), onClick = {
if (!busy) { if (!busy) {
editingIndex = index; setsText = item.plan.sets.toString(); repsText = item.plan.reps.toString() editingIndex = index; setsText = item.plan.sets.toString(); repsText = item.plan.reps.toString()
restText = item.plan.restSeconds.toString() restText = item.plan.restSeconds.toString()
@ -286,68 +286,64 @@ fun SessionGeneratorScreen(
item.plan.weightKg?.toString().orEmpty() else "" item.plan.weightKg?.toString().orEmpty() else ""
} }
}) })
TrainlogAction("Monter", "Déplacer cet exercice avant le précédent.", onClick = { TrainlogAction(strings.getString(R.string.move_up), strings.getString(R.string.move_up_description), onClick = {
if (!busy) preview = SessionGeneratorPreviewController.move(current, index, -1) if (!busy) preview = SessionGeneratorPreviewController.move(current, index, -1)
}, accent = colors.muted) }, accent = colors.muted)
TrainlogAction("Descendre", "Déplacer cet exercice après le suivant.", onClick = { TrainlogAction(strings.getString(R.string.move_down), strings.getString(R.string.move_down_description), onClick = {
if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1) if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1)
}, accent = colors.muted) }, accent = colors.muted)
TrainlogDeleteButton("Retirer cet exercice de la proposition", onClick = { TrainlogDeleteButton(strings.getString(R.string.remove_proposal_exercise), onClick = {
if (!busy) preview = SessionGeneratorPreviewController.remove(current, index) if (!busy) preview = SessionGeneratorPreviewController.remove(current, index)
}) })
} }
} }
TrainlogAction("Régénérer", "Relancer les mêmes entrées et le même instant de référence.", onClick = { generate(current.request) }) TrainlogAction(strings.getString(R.string.regenerate), strings.getString(R.string.regenerate_description), onClick = { generate(current.request) })
TrainlogAction("Accepter et saisir les valeurs réelles", "Créer le brouillon normal sans préremplir les séries réalisées.", accent = colors.success, onClick = { TrainlogAction(strings.getString(R.string.accept_actual_values), strings.getString(R.string.accept_actual_description), accent = colors.success, onClick = {
if (!warningAcknowledged) message = "Confirmez d'abord l'avertissement d'exposition récente." if (!warningAcknowledged) message = strings.getString(R.string.acknowledge_exposure_first)
else if (!busy) scope.launch { else if (!busy) scope.launch {
busy = true busy = true
try { try {
when (val result = withContext(Dispatchers.IO) { repository.acceptGeneratedSession(current) }) { when (val result = withContext(Dispatchers.IO) { repository.acceptGeneratedSession(current) }) {
AcceptGeneratedSessionResult.Accepted -> onAccepted() AcceptGeneratedSessionResult.Accepted -> onAccepted()
AcceptGeneratedSessionResult.ExistingActiveDraft -> onExistingDraft() AcceptGeneratedSessionResult.ExistingActiveDraft -> onExistingDraft()
is AcceptGeneratedSessionResult.Invalid -> message = result.message is AcceptGeneratedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
is AcceptGeneratedSessionResult.DatabaseError -> message = result.message is AcceptGeneratedSessionResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
} }
} finally { } finally {
busy = false busy = false
} }
} }
}) })
TrainlogAction("Annuler la proposition", "Revenir sans écrire de brouillon.", onClick = { TrainlogAction(strings.getString(R.string.cancel_proposal), strings.getString(R.string.cancel_proposal_description), onClick = {
if (!busy) onBack() if (!busy) onBack()
}, accent = colors.muted) }, accent = colors.muted)
} }
if (busy) TrainlogFrame("Traitement") { TrainlogInfo("Analyse en cours…", colors.muted) } if (busy) TrainlogFrame(strings.getString(R.string.processing)) { TrainlogInfo(strings.getString(R.string.analysis_running), colors.muted) }
message?.let { TrainlogFrame("Message") { TrainlogInfo(it, colors.error) } } message?.let { TrainlogFrame(strings.getString(R.string.message_title)) { TrainlogInfo(it, colors.error) } }
} }
} }
private fun generationReasonLabel(code: String): String = when (code) { private fun generationReasonLabel(code: String, context: android.content.Context): String = context.getString(when (code) {
"observed_repeated_dose_anchor" -> "dose répétée observée" "observed_repeated_dose_anchor" -> R.string.reason_observed_dose
"explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique" "explicit_max_present_no_numeric_prescription" -> R.string.reason_max_no_prescription
"user_selected_max_percentage" -> "pourcentage de MAX choisi par l'utilisateur" "user_selected_max_percentage" -> R.string.reason_user_max
"compatible_max_unavailable" -> "MAX compatible indisponible" "compatible_max_unavailable" -> R.string.reason_max_unavailable
"assistance_numeric_load_omitted" -> "charge d'assistance omise" "assistance_numeric_load_omitted" -> R.string.reason_assistance_omitted
"numeric_load_absent" -> "charge numérique absente" "numeric_load_absent" -> R.string.reason_load_absent
"manual_target_load" -> "charge saisie manuellement" "manual_target_load" -> R.string.reason_manual_load
"preferred_exercise" -> "exercice préféré" "preferred_exercise" -> R.string.reason_preferred
"requested_primary_zone" -> "zone principale demandée" "requested_primary_zone" -> R.string.reason_primary_zone
"requested_secondary_zone" -> "zone secondaire demandée" "requested_secondary_zone" -> R.string.reason_secondary_zone
"new_exact_pattern" -> "mouvement distinct" "new_exact_pattern" -> R.string.reason_distinct_movement
"recent_same_exercise_penalty" -> "même exercice observé récemment" "recent_same_exercise_penalty" -> R.string.reason_recent_exercise
"recent_same_pattern_penalty" -> "mouvement similaire observé récemment" "recent_same_pattern_penalty" -> R.string.reason_recent_pattern
else -> code.replace('_', ' ') else -> return code.replace('_', ' ')
} })
private fun generationShortageLabel(code: String): String = when (code) { private fun generationShortageLabel(code: String, context: android.content.Context): String = context.getString(when (code) {
"missing_upper_region" -> "région supérieure manquante" "missing_upper_region" -> R.string.shortage_upper; "missing_lower_region" -> R.string.shortage_lower
"missing_lower_region" -> "région inférieure manquante" "missing_core_region" -> R.string.shortage_core; "missing_upper_push" -> R.string.shortage_push
"missing_core_region" -> "tronc manquant" "missing_upper_pull" -> R.string.shortage_pull; "missing_lower_extension" -> R.string.shortage_lower_extension
"missing_upper_push" -> "poussée du haut du corps manquante" "missing_lower_flexion" -> R.string.shortage_lower_flexion; "fewer_than_max_exercises" -> R.string.shortage_exercises
"missing_upper_pull" -> "tirage du haut du corps manquant" else -> return code.replace('_', ' ')
"missing_lower_extension" -> "extension du bas du corps manquante" })
"missing_lower_flexion" -> "flexion du bas du corps manquante"
"fewer_than_max_exercises" -> "moins d'exercices distincts disponibles"
else -> code.replace('_', ' ')
}

View file

@ -0,0 +1,537 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.delay
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.time.LocalDate
import java.time.OffsetDateTime
import java.util.UUID
@Composable
fun SleepDiaryScreen(repository: TrainlogRepository) {
var correctionMode by remember { mutableStateOf(false) }
if (correctionMode) {
Column(Modifier.fillMaxSize()) {
Button(
onClick = { correctionMode = false },
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Text(stringResource(R.string.sleep_return_night_mode))
}
SleepDiaryEditorScreen(repository)
}
} else {
SleepNightCaptureScreen(repository) { correctionMode = true }
}
}
@Composable
private fun SleepNightCaptureScreen(
repository: TrainlogRepository,
onCorrection: () -> Unit,
) {
var revision by remember { mutableStateOf(0) }
var undoReceipt by remember {
mutableStateOf<TrainlogRepository.SleepQuickActionReceipt?>(null)
}
var message by remember { mutableStateOf<String?>(null) }
val strings = localizedContext()
val entries = remember(revision) { repository.listSleepDiary() }
val active =
entries.firstOrNull { entry ->
entry.events.any { it.type == SleepEventType.BED_TIME } &&
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
}
val pending =
entries.firstOrNull { entry ->
entry.events.none { it.type == SleepEventType.BED_TIME } &&
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
}
val visibleNight = active ?: pending
val medications =
remember(revision) {
repository.listSleepMedications(includeInactive = false)
}
LaunchedEffect(undoReceipt?.appliedRevisionId) {
if (undoReceipt != null) {
delay(10_000)
undoReceipt = null
}
}
fun apply(
successMessage: String,
result: TrainlogRepository.SleepQuickActionResult,
) {
when (result) {
is TrainlogRepository.SleepQuickActionResult.Applied -> {
undoReceipt = result.receipt
message = successMessage
revision++
}
TrainlogRepository.SleepQuickActionResult.NoActiveNight ->
message = strings.getString(R.string.sleep_quick_no_active)
TrainlogRepository.SleepQuickActionResult.AlreadyActive ->
message = strings.getString(R.string.sleep_quick_already_active)
TrainlogRepository.SleepQuickActionResult.InvalidMedication ->
message = strings.getString(R.string.sleep_quick_medication_unavailable)
TrainlogRepository.SleepQuickActionResult.Conflict ->
message = strings.getString(R.string.sleep_quick_conflict)
TrainlogRepository.SleepQuickActionResult.Error ->
message = strings.getString(R.string.sleep_quick_error)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(stringResource(R.string.sleep_night_capture_title))
}
item {
visibleNight?.let { night ->
Text(
stringResource(
R.string.sleep_night_in_progress,
night.nightStartDate,
night.nightEndDate,
),
)
} ?: Text(stringResource(R.string.sleep_no_night_active))
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_bed_recorded),
repository.quickSleepBedTime(),
)
},
enabled = active == null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_bed))
}
}
if (medications.isNotEmpty()) {
item { Text(stringResource(R.string.sleep_medications)) }
items(medications, key = { it.medicationId }) { medication ->
var quantity by remember(medication.medicationId) { mutableStateOf(1) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = { if (quantity > 1) quantity-- }) { Text("−") }
Text("×$quantity", modifier = Modifier.padding(top = 12.dp))
Button(onClick = { if (quantity < 99) quantity++ }) { Text("+") }
Button(
onClick = {
val selectedQuantity = quantity
val result =
repository.quickSleepMedication(
medication.medicationId,
quantity = selectedQuantity,
)
apply(
strings.getString(
R.string.sleep_medication_recorded,
medication.name +
if (selectedQuantity > 1) " ×$selectedQuantity" else "",
),
result,
)
if (result is TrainlogRepository.SleepQuickActionResult.Applied) {
quantity = 1
}
},
modifier = Modifier.weight(1f),
) {
val dose =
medication.defaultDoseValue?.let { doseValue ->
" · " + doseValue.toString().trimEnd('0').trimEnd('.') +
" " + medication.defaultDoseUnit.orEmpty()
}.orEmpty()
Text("💊 " + medication.name + dose)
}
}
}
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_wake_recorded),
repository.quickSleepWake(),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_wake))
}
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_get_up_recorded),
repository.quickSleepFinalGetUp(),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_get_up))
}
}
message?.let { text ->
item {
Card {
Column(
Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text)
undoReceipt?.let { receipt ->
Button(
onClick = {
when (repository.undoSleepQuickAction(receipt)) {
is TrainlogRepository.SleepQuickActionResult.Applied -> {
message = strings.getString(R.string.sleep_action_undone)
undoReceipt = null
revision++
}
else -> {
message = strings.getString(R.string.sleep_undo_unavailable)
undoReceipt = null
revision++
}
}
},
) {
Text(stringResource(R.string.sleep_undo))
}
}
}
}
}
}
visibleNight?.let { night ->
item {
Card {
Column(
Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(stringResource(R.string.sleep_night_timeline))
night.events.sortedBy { it.startAt }.forEach { event ->
Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt))
}
night.intakes.sortedBy { it.takenAt }.forEach { intake ->
Text(
"💊 " + intake.medicationName +
(if (intake.quantity > 1) " ×${intake.quantity}" else "") +
" · " + shortTime(intake.takenAt),
)
}
}
}
}
}
item {
Button(
onClick = onCorrection,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_correct_night))
}
}
}
}
private fun shortTime(value: String): String =
runCatching { OffsetDateTime.parse(value).toLocalTime().withSecond(0).withNano(0).toString() }
.getOrDefault(value)
private fun eventLabelPlain(type: SleepEventType, context: android.content.Context): String =
context.getString(
when (type) {
SleepEventType.BED_TIME -> R.string.sleep_bed_time
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
SleepEventType.SLEEP -> R.string.sleep_sleep
SleepEventType.NAP -> R.string.sleep_nap
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
},
)
@Composable
private fun SleepDiaryEditorScreen(repository: TrainlogRepository) {
var revision by remember { mutableStateOf(0) }
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
val today = LocalDate.now()
var startDate by remember(selected) { mutableStateOf(selected?.nightStartDate ?: today.minusDays(1).toString()) }
var endDate by remember(selected) { mutableStateOf(selected?.nightEndDate ?: today.toString()) }
var sleepQuality by remember(selected) { mutableStateOf(selected?.sleepQuality) }
var wakeQuality by remember(selected) { mutableStateOf(selected?.wakeQuality) }
var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) }
var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) }
var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) }
var intakes by remember(selected) { mutableStateOf(selected?.intakes.orEmpty()) }
val catalog = remember(revision) { repository.listSleepMedications() }
val medications = catalog.filter { it.active }
var intakeMedication by remember(medications) { mutableStateOf(medications.firstOrNull()) }
var intakeDose by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseValue?.toString().orEmpty()) }
var intakeUnit by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseUnit.orEmpty()) }
var medicationName by remember { mutableStateOf("") }
var medicationDose by remember { mutableStateOf("") }
var medicationUnit by remember { mutableStateOf("mg") }
var editingMedication by remember { mutableStateOf<SleepMedication?>(null) }
var editSerial by remember { mutableStateOf(0) }
val entries = repository.listSleepDiary()
fun changed() { editSerial++ }
LaunchedEffect(editSerial) {
if (editSerial > 0) {
val now = OffsetDateTime.now().toString()
val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,
current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,
wakeQuality,dayForm,notes,events,intakes))
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) {
selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId }
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item { Text(stringResource(R.string.sleep_current_night)) }
items(entries, key = { it.entryId }) { entry ->
Card(onClick = { selected = entry }) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("${entry.nightStartDate} → ${entry.nightEndDate}")
Text(stringResource(when (entry.publicationStatus) {
SleepPublicationStatus.DRAFT -> R.string.sleep_status_draft
SleepPublicationStatus.READY -> R.string.sleep_status_ready
SleepPublicationStatus.SYNCHRONIZED -> R.string.sleep_status_synchronized
SleepPublicationStatus.MODIFIED -> R.string.sleep_status_modified
}))
Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}")
}
}
}
item {
Card {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.sleep_morning))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(startDate, { startDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
OutlinedTextField(endDate, { endDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
}
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it; changed() }
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() }
Text(stringResource(R.string.sleep_day))
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it; changed() }
SleepEventType.entries.forEach { type ->
Button(onClick = {
val start = OffsetDateTime.now()
events = events + SleepDiaryEvent("sle_${UUID.randomUUID()}", type, start.toString(),
if (type in pointTypes) null else start.plusHours(1).toString())
changed()
}) { Text(eventLabel(type)) }
}
events.forEachIndexed { index, event ->
Card {
Column(Modifier.padding(8.dp)) {
Text(eventLabel(event.type))
TimeButton(event.startAt) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(startAt = value) else current
}
changed()
}
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current }; changed() }, label = { Text("ISO-8601") })
event.endAt?.let { end ->
TimeButton(end) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(endAt = value) else current
}
changed()
}
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current }; changed() }, label = { Text("ISO-8601 →") })
}
Button(onClick = { events = events.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) }
}
}
}
Text(stringResource(R.string.sleep_medications))
OutlinedTextField(medicationName, { medicationName = it },
label = { Text(stringResource(R.string.sleep_medications)) }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(medicationDose, { medicationDose = it },
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(medicationUnit, { medicationUnit = it },
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = {
val now = OffsetDateTime.now().toString()
val dose = medicationDose.toDoubleOrNull()
if (medicationName.isNotBlank() && (medicationDose.isEmpty() || dose != null && dose > 0.0)) {
val current = editingMedication
repository.saveSleepMedication(SleepMedication(current?.medicationId.orEmpty(),
current?.revisionId.orEmpty(), current?.createdAt ?: now, now,
medicationName.trim(), dose, if (dose == null) null else medicationUnit,
current?.form.orEmpty(), current?.note.orEmpty(), current?.active ?: true))
medicationName = ""; editingMedication = null; revision++
}
}) { Text(stringResource(R.string.sleep_add_medication)) }
medications.forEach { medication ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = intakeMedication?.medicationId == medication.medicationId,
onClick = { intakeMedication = medication
intakeDose = medication.defaultDoseValue?.toString().orEmpty()
intakeUnit = medication.defaultDoseUnit.orEmpty() },
label = { Text(medication.name) })
Button(onClick = { editingMedication = medication; medicationName = medication.name
medicationDose = medication.defaultDoseValue?.toString().orEmpty()
medicationUnit = medication.defaultDoseUnit.orEmpty() }) {
Text(stringResource(R.string.sleep_edit))
}
Button(onClick = { repository.saveSleepMedication(medication.copy(
updatedAt = OffsetDateTime.now().toString(), active = false)); revision++ }) {
Text(stringResource(R.string.sleep_deactivate))
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intakeDose, { intakeDose = it },
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intakeUnit, { intakeUnit = it },
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = {
intakeMedication?.let { medication ->
val dose = intakeDose.toDoubleOrNull()
if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
val now = OffsetDateTime.now().toString()
intakes = intakes + MedicationIntake("mdi_${UUID.randomUUID()}", medication.medicationId,
medication.name, now, dose, if (dose == null) null else intakeUnit,
"", now)
changed()
}
}
}) { Text(stringResource(R.string.sleep_add_intake)) }
intakes.forEachIndexed { index, intake -> Card {
Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(intake.medicationName)
TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(takenAt = value) else current }; changed() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value ->
intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(
doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current }
changed()
}, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(doseUnit = value) else current }
changed()
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index }; changed() }) {
Text(stringResource(R.string.sleep_delete))
}
}
} }
OutlinedTextField(notes, { notes = it; changed() }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
Button(onClick = {
val now = OffsetDateTime.now().toString()
val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes))
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
}) { Text(stringResource(R.string.sleep_save)) }
selected?.let { entry -> Button(onClick = {
repository.validateSleepDiary(entry.entryId, entry.revisionId,
OffsetDateTime.now().toString())
selected = repository.listSleepDiary().firstOrNull { it.entryId == entry.entryId }
}) { Text(stringResource(R.string.sleep_validate)) } }
selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
}
}
}
}
}
@Composable
private fun TimeButton(value: String, change: (String) -> Unit) {
val context = LocalContext.current
val parsed = runCatching { OffsetDateTime.parse(value) }.getOrNull()
Button(onClick = {
val current = parsed ?: OffsetDateTime.now()
android.app.TimePickerDialog(context, { _, hour, minute ->
change(current.withHour(hour).withMinute(minute).withSecond(0).withNano(0).toString())
}, current.hour, current.minute, true).show()
}) {
Text(stringResource(R.string.sleep_choose_time) + " " + (parsed?.toLocalTime()?.toString() ?: "—"))
}
}
private val pointTypes = setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)
@Composable
private fun QualityRow(label: String, selected: SleepQuality?, change: (SleepQuality?) -> Unit) {
Column { Text(label); Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SleepQuality.entries.forEach { value -> FilterChip(selected == value, { change(if (selected == value) null else value) }, { Text(value.wireValue) }) }
} }
}
@Composable
private fun eventLabel(type: SleepEventType): String = stringResource(when (type) {
SleepEventType.BED_TIME -> R.string.sleep_bed_time
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
SleepEventType.SLEEP -> R.string.sleep_sleep
SleepEventType.NAP -> R.string.sleep_nap
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
})

View file

@ -40,6 +40,7 @@ import com.labfytools.trainlog.data.StatisticsSeries
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.R
import java.time.LocalDate import java.time.LocalDate
import java.time.temporal.WeekFields import java.time.temporal.WeekFields
import java.util.Locale import java.util.Locale
@ -47,6 +48,15 @@ import kotlin.math.max
private enum class StatisticsDetail { PROGRESS, BODY, FREQUENCY, DISTRIBUTION, SUMMARY } private enum class StatisticsDetail { PROGRESS, BODY, FREQUENCY, DISTRIBUTION, SUMMARY }
@Composable
private fun statisticsPeriodLabel(period: StatisticsPeriod): String = uiString(when (period) {
StatisticsPeriod.DAYS_7 -> R.string.period_7_days
StatisticsPeriod.DAYS_30 -> R.string.period_30_days
StatisticsPeriod.DAYS_90 -> R.string.period_90_days
StatisticsPeriod.YEAR -> R.string.period_year
StatisticsPeriod.ALL -> R.string.period_all
})
internal data class DashboardWeek(val label: String, val sessions: Int, val maxima: Int) internal data class DashboardWeek(val label: String, val sessions: Int, val maxima: Int)
internal fun sixWeekFrequency( internal fun sixWeekFrequency(
@ -70,38 +80,40 @@ internal fun sixWeekFrequency(
@Composable @Composable
fun StatisticsDashboard(repository: TrainlogRepository) { fun StatisticsDashboard(repository: TrainlogRepository) {
val strings = localizedContext()
var period by remember { mutableStateOf(StatisticsPeriod.DAYS_30) } var period by remember { mutableStateOf(StatisticsPeriod.DAYS_30) }
var detail by remember { mutableStateOf<StatisticsDetail?>(null) } var detail by remember { mutableStateOf<StatisticsDetail?>(null) }
val overview = remember(period) { repository.loadStatistics(period) } val overview = remember(period) { repository.loadStatistics(period) }
if (detail != null) return StatisticsDetailScreen(detail!!, overview) { detail = null } if (detail != null) return StatisticsDetailScreen(detail!!, overview) { detail = null }
TrainlogScreen("Statistiques") { TrainlogScreen(strings.getString(R.string.nav_statistics)) {
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to it.label }, period.name) { TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to statisticsPeriodLabel(it) }, period.name) {
period = StatisticsPeriod.valueOf(it) period = StatisticsPeriod.valueOf(it)
} }
if (overview.hasInvalidData) TrainlogInfo( if (overview.hasInvalidData) TrainlogInfo(
"Certaines données historiques invalides ont été ignorées; les statistiques valides restent disponibles.", strings.getString(R.string.statistics_invalid_ignored),
) )
SummaryGrid(overview) SummaryGrid(overview)
PerformanceCard(overview) { detail = StatisticsDetail.PROGRESS } PerformanceCard(overview) { detail = StatisticsDetail.PROGRESS }
MeasurementsCard(overview) { detail = StatisticsDetail.BODY } MeasurementsCard(overview) { detail = StatisticsDetail.BODY }
FrequencyCard(overview) { detail = StatisticsDetail.FREQUENCY } FrequencyCard(overview) { detail = StatisticsDetail.FREQUENCY }
TrainlogFrame("EXPLORER") { TrainlogFrame(strings.getString(R.string.explore)) {
TrainlogAction("Répartition", "Zones et utilisation des exercices, uniquement quand elles sont classifiées.", { detail = StatisticsDetail.DISTRIBUTION }) TrainlogAction(strings.getString(R.string.distribution), strings.getString(R.string.distribution_description), { detail = StatisticsDetail.DISTRIBUTION })
TrainlogAction("Résumé global", "Indicateurs prudents dérivés des données disponibles.", { detail = StatisticsDetail.SUMMARY }) TrainlogAction(strings.getString(R.string.global_summary), strings.getString(R.string.global_summary_description), { detail = StatisticsDetail.SUMMARY })
} }
} }
} }
@Composable @Composable
private fun SummaryGrid(overview: StatisticsOverview) { private fun SummaryGrid(overview: StatisticsOverview) {
TrainlogFrame("RÉSUMÉ · ${overview.period.label}") { val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.summary_period, statisticsPeriodLabel(overview.period))) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Metric("Séances", overview.summary.sessions.toString(), Modifier.weight(1f)) Metric(strings.getString(R.string.nav_sessions), overview.summary.sessions.toString(), Modifier.weight(1f))
Metric("Séries", overview.summary.performedSets.toString(), Modifier.weight(1f)) Metric(strings.getString(R.string.sets), overview.summary.performedSets.toString(), Modifier.weight(1f))
} }
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Metric("Exercices", overview.summary.distinctExercises.toString(), Modifier.weight(1f)) Metric(strings.getString(R.string.nav_exercises), overview.summary.distinctExercises.toString(), Modifier.weight(1f))
Metric("MAX explicites", overview.summary.explicitMaxima.toString(), Modifier.weight(1f)) Metric(strings.getString(R.string.explicit_max_plural), overview.summary.explicitMaxima.toString(), Modifier.weight(1f))
} }
} }
} }
@ -120,17 +132,18 @@ internal fun PerformanceCard(overview: StatisticsOverview, click: () -> Unit) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val work = overview.performanceEvents.sumOf { it.workingImprovements } val work = overview.performanceEvents.sumOf { it.workingImprovements }
val maxima = overview.performanceEvents.sumOf { it.maxImprovements } val maxima = overview.performanceEvents.sumOf { it.maxImprovements }
TrainlogFrame("PROGRESSION", active = true) { val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.progression), active = true) {
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 2.dp)) { Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 2.dp)) {
PerformanceTimeline(overview) PerformanceTimeline(overview)
Text( Text(
if (work + maxima == 0) "Données insuffisantes pour établir une progression comparable." if (work + maxima == 0) strings.getString(R.string.progress_insufficient)
else "$work progression(s) travail · $maxima record(s) MAX", else strings.getString(R.string.progress_counts, work, maxima),
style = TrainlogTypography.small.copy(color = if (work + maxima == 0) colors.muted else colors.text), style = TrainlogTypography.small.copy(color = if (work + maxima == 0) colors.muted else colors.text),
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
Text( Text(
"${overview.summary.explicitMaxima} MAX explicite(s) · ouvrir le détail", strings.getString(R.string.max_open_detail, overview.summary.explicitMaxima),
style = TrainlogTypography.small.copy(color = colors.notice), style = TrainlogTypography.small.copy(color = colors.notice),
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
@ -144,8 +157,8 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
val recent = overview.performanceEvents.takeLast(6).map { it.workingImprovements + it.maxImprovements } val recent = overview.performanceEvents.takeLast(6).map { it.workingImprovements + it.maxImprovements }
val values = List(6 - recent.size) { 0 } + recent val values = List(6 - recent.size) { 0 } + recent
val description = if (values.all { it == 0 }) val description = if (values.all { it == 0 })
"Progression sur six semaines : aucun événement comparable, six points vides" uiString(R.string.progress_a11y_empty)
else "Progression sur six semaines : ${values.joinToString()} événements" else uiString(R.string.progress_a11y_values, values.joinToString())
Canvas(Modifier.fillMaxWidth().height(54.dp).semantics { contentDescription = description }) { Canvas(Modifier.fillMaxWidth().height(54.dp).semantics { contentDescription = description }) {
val step = size.width / values.size val step = size.width / values.size
val peak = max(1, values.max()) val peak = max(1, values.max())
@ -160,10 +173,11 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
@Composable @Composable
internal fun MeasurementsCard(overview: StatisticsOverview, click: () -> Unit) { internal fun MeasurementsCard(overview: StatisticsOverview, click: () -> Unit) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
TrainlogFrame("MENSURATIONS", active = overview.body.isNotEmpty()) { val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.route_body_measurements).uppercase(presentationLocale()), active = overview.body.isNotEmpty()) {
Column(Modifier.fillMaxWidth().clickable(onClick = click)) { Column(Modifier.fillMaxWidth().clickable(onClick = click)) {
if (overview.body.isEmpty()) { if (overview.body.isEmpty()) {
Text("Aucune mensuration dans cette période.", style = TrainlogTypography.normal.copy(color = colors.muted)) Text(strings.getString(R.string.body_period_none), style = TrainlogTypography.normal.copy(color = colors.muted))
} else overview.body.chunked(2).forEachIndexed { rowIndex, row -> } else overview.body.chunked(2).forEachIndexed { rowIndex, row ->
Row( Row(
Modifier.fillMaxWidth().padding(top = if (rowIndex == 0) 0.dp else 8.dp), Modifier.fillMaxWidth().padding(top = if (rowIndex == 0) 0.dp else 8.dp),
@ -183,11 +197,11 @@ private fun MeasurementTile(series: StatisticsSeries, modifier: Modifier) {
val latest = series.points.last().value val latest = series.points.last().value
val delta = series.points.takeIf { it.size >= 2 }?.let { latest - it[it.lastIndex - 1].value } val delta = series.points.takeIf { it.size >= 2 }?.let { latest - it[it.lastIndex - 1].value }
Column(modifier.background(colors.surface, RoundedCornerShape(10.dp)).padding(10.dp)) { Column(modifier.background(colors.surface, RoundedCornerShape(10.dp)).padding(10.dp)) {
Text(series.label, style = TrainlogTypography.small.copy(color = colors.muted)) Text(localizedStatisticsSeriesLabel(series), style = TrainlogTypography.small.copy(color = colors.muted))
Text("$latest ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text)) Text("${presentationNumber(latest)} ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text))
if (delta == null) Text("1 relevé · tendance indisponible", style = TrainlogTypography.small.copy(color = colors.muted)) if (delta == null) Text(uiString(R.string.measurement_single), style = TrainlogTypography.small.copy(color = colors.muted))
else { else {
Text("Δ ${if (delta >= 0) "+" else ""}$delta", style = TrainlogTypography.small.copy(color = colors.info)) Text("Δ ${if (delta >= 0) "+" else ""}${presentationNumber(delta)}", style = TrainlogTypography.small.copy(color = colors.info))
LineChart(listOf(series), Modifier.height(42.dp)) LineChart(listOf(series), Modifier.height(42.dp))
} }
} }
@ -198,16 +212,18 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val weeks = sixWeekFrequency(overview.frequency, today) val weeks = sixWeekFrequency(overview.frequency, today)
val current = weeks.last() val current = weeks.last()
TrainlogFrame("FRÉQUENCE", active = overview.frequency.isNotEmpty()) { val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.frequency), active = overview.frequency.isNotEmpty()) {
Column(Modifier.fillMaxWidth().clickable(onClick = click)) { Column(Modifier.fillMaxWidth().clickable(onClick = click)) {
FrequencyBars(weeks) FrequencyBars(weeks)
Text( Text(
"Semaine actuelle · ${current.sessions} séance(s) · ${current.maxima} MAX inclus", strings.getString(R.string.current_week_summary,
strings.resources.getQuantityString(R.plurals.session_count, current.sessions, current.sessions), current.maxima),
style = TrainlogTypography.small.copy(color = colors.accent, fontWeight = FontWeight.Bold), style = TrainlogTypography.small.copy(color = colors.accent, fontWeight = FontWeight.Bold),
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
Text( Text(
"Les MAX signalent des séances; ils ne sont jamais additionnés une seconde fois.", strings.getString(R.string.max_not_double_counted),
style = TrainlogTypography.small.copy(color = colors.muted), style = TrainlogTypography.small.copy(color = colors.muted),
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
@ -218,9 +234,12 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
@Composable @Composable
private fun FrequencyBars(weeks: List<DashboardWeek>) { private fun FrequencyBars(weeks: List<DashboardWeek>) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val description = "Fréquence sur six semaines : " + weeks.mapIndexed { index, week -> val strings = localizedContext()
"${if (index == weeks.lastIndex) "actuelle" else week.label} ${week.sessions} séances dont ${week.maxima} MAX" val description = strings.getString(R.string.frequency_a11y, weeks.mapIndexed { index, week ->
}.joinToString("; ") strings.getString(R.string.frequency_week_a11y,
if (index == weeks.lastIndex) strings.getString(R.string.current_feminine) else week.label,
strings.resources.getQuantityString(R.plurals.session_count, week.sessions, week.sessions), week.maxima)
}.joinToString("; "))
Canvas(Modifier.fillMaxWidth().height(116.dp).semantics { contentDescription = description }) { Canvas(Modifier.fillMaxWidth().height(116.dp).semantics { contentDescription = description }) {
val peak = max(1, weeks.maxOf { it.sessions }) val peak = max(1, weeks.maxOf { it.sessions })
val slot = size.width / weeks.size val slot = size.width / weeks.size
@ -247,10 +266,10 @@ private fun DashboardCard(title: String, subtitle: String, series: List<Statisti
TrainlogFrame(title, active = series.isNotEmpty()) { TrainlogFrame(title, active = series.isNotEmpty()) {
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 4.dp)) { Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 4.dp)) {
Text(subtitle, style = TrainlogTypography.small.copy(color = colors.muted)) Text(subtitle, style = TrainlogTypography.small.copy(color = colors.muted))
if (series.isEmpty()) Text("Pas encore assez de données pour une courbe.", style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp)) if (series.isEmpty()) Text(uiString(R.string.chart_insufficient), style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp))
else { else {
LineChart(series.take(3)) LineChart(series.take(3))
Text("${series.size} série(s) comparable(s) · ouvrir le détail", style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp)) Text(uiString(R.string.chart_series_detail, series.size), style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp))
} }
} }
} }
@ -281,17 +300,59 @@ private fun LineChart(series: List<StatisticsSeries>, modifier: Modifier = Modif
@Composable @Composable
private fun StatisticsDetailScreen(detail: StatisticsDetail, overview: StatisticsOverview, back: () -> Unit) { private fun StatisticsDetailScreen(detail: StatisticsDetail, overview: StatisticsOverview, back: () -> Unit) {
val strings = localizedContext()
val (title, series, description) = when (detail) { val (title, series, description) = when (detail) {
StatisticsDetail.PROGRESS -> Triple("Progression", overview.performance, "Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables; le dashboard ne somme jamais des kg.") StatisticsDetail.PROGRESS -> Triple(strings.getString(R.string.progression), overview.performance, strings.getString(R.string.progress_detail_description))
StatisticsDetail.BODY -> Triple("Mensurations", overview.body, "Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.") StatisticsDetail.BODY -> Triple(strings.getString(R.string.route_body_measurements), overview.body, strings.getString(R.string.body_detail_description))
StatisticsDetail.FREQUENCY -> Triple("Fréquence", listOf(StatisticsSeries("f", "Séances", "séances", overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), "Nombre de séances avec travail réel par semaine; MAX est un marqueur, pas une seconde séance.") StatisticsDetail.FREQUENCY -> Triple(strings.getString(R.string.frequency_title), listOf(StatisticsSeries("f", strings.getString(R.string.nav_sessions), strings.getString(R.string.frequency_unit), overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), strings.getString(R.string.frequency_detail_description))
StatisticsDetail.DISTRIBUTION -> Triple("Répartition", emptyList(), "La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.") StatisticsDetail.DISTRIBUTION -> Triple(strings.getString(R.string.distribution), emptyList(), strings.getString(R.string.distribution_detail_description))
StatisticsDetail.SUMMARY -> Triple("Résumé global", emptyList(), "${overview.summary.sessions} séances · ${overview.summary.performedSets} séries · ${overview.summary.distinctExercises} exercices · ${overview.summary.explicitMaxima} MAX explicites") StatisticsDetail.SUMMARY -> Triple(strings.getString(R.string.global_summary), emptyList(), strings.getString(R.string.summary_detail, overview.summary.sessions, overview.summary.performedSets, overview.summary.distinctExercises, overview.summary.explicitMaxima))
} }
TrainlogScreen(title) { TrainlogScreen(title) {
TrainlogAction("← Retour au dashboard", "", back) TrainlogAction(strings.getString(R.string.back_dashboard), "", back)
TrainlogInfo(description) TrainlogInfo(description)
if (series.isEmpty()) TrainlogInfo("Aucune donnée comparable dans cette période.") if (series.isEmpty()) TrainlogInfo(strings.getString(R.string.comparable_none))
else series.forEach { DashboardCard(it.label, "${it.points.size} point(s) · ${it.unit}", listOf(it), {}) } else series.forEach {
DashboardCard(
localizedStatisticsSeriesLabel(it),
strings.resources.getQuantityString(R.plurals.point_count, it.points.size, it.points.size) + " · ${it.unit}",
listOf(it),
{},
)
}
}
}
@Composable
private fun localizedStatisticsSeriesLabel(series: StatisticsSeries): String {
val strings = localizedContext()
val bodyLabel = when (series.id) {
"body_weight_kg" -> R.string.weight
"neck_cm" -> R.string.neck
"shoulders_cm" -> R.string.shoulders
"chest_cm" -> R.string.chest
"waist_cm" -> R.string.waist
"hips_cm" -> R.string.hips
"left_arm_cm" -> R.string.left_arm
"right_arm_cm" -> R.string.right_arm
"left_forearm_cm" -> R.string.left_forearm
"right_forearm_cm" -> R.string.right_forearm
"left_thigh_cm" -> R.string.left_thigh
"right_thigh_cm" -> R.string.right_thigh
"left_calf_cm" -> R.string.left_calf
"right_calf_cm" -> R.string.right_calf
else -> null
}
if (bodyLabel != null) return strings.getString(bodyLabel)
// INVARIANT: exercise/equipment names in the prefix are user/catalog data and remain byte-for-byte unchanged.
return when {
series.id.startsWith("max:") -> series.label.substringBeforeLast(" — ") + " — " + strings.getString(R.string.explicit_max_label)
series.id.startsWith("work:") -> {
val prefix = series.label.substringBeforeLast(" — ")
.replace(Regex(" reps$"), " " + strings.getString(R.string.reps_short))
.replace(Regex(" duration$"), " s")
prefix + " — " + strings.getString(R.string.performed_loads)
}
else -> series.label
} }
} }

View file

@ -7,6 +7,9 @@ package com.labfytools.trainlog.ui
/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */ /* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
import android.content.Context
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -24,6 +27,21 @@ import com.labfytools.trainlog.data.SyncExportResult
import com.labfytools.trainlog.data.SyncReceiptResult import com.labfytools.trainlog.data.SyncReceiptResult
import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult
import com.labfytools.trainlog.data.BackgroundSyncRunLedger
import com.labfytools.trainlog.data.SyncConversationArbiter
import com.labfytools.trainlog.data.SyncConversationIntent
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.AiExportDriveSettings
import com.labfytools.trainlog.data.BluetoothSyncSettings
import com.labfytools.trainlog.data.bondedBluetoothDesktops
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
import com.labfytools.trainlog.data.MtpPublicationVisibility
import com.labfytools.trainlog.data.logDirectExchange import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -31,23 +49,119 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.labfytools.trainlog.R
/* Package-visible for orchestration tests: this is the production ordering /* Package-visible for orchestration tests: this is the production ordering
* boundary, not a duplicate test-only implementation. */ * boundary, not a duplicate test-only implementation. */
internal suspend fun publishBundleAndRequest( internal suspend fun publishBundleAndRequest(
repository: TrainlogRepository,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
explicitClaim: SyncConversationArbiter.ExplicitClaim =
SyncConversationArbiter.process.announceExplicit(),
terminalLedger: BackgroundSyncRunLedger? = null,
): SyncRequestResult = withContext(Dispatchers.IO) {
try {
val directory = canonicalExchangeDirectory()
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
return@withContext when (
val result = coordinator.run(
directory,
afterPeerPublication = {
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
throw IllegalStateException(opened.message)
}
val snapshot =
(opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready)
.snapshot
val requestIntent = requestOutbox.newIntent()
/* WHY: a strict V3 snapshot cannot represent causal
* tombstones, but the complete generation can. CONTRACT:
* publish and confirm the modern trigger before attempting
* optional legacy compatibility. INVARIANT: an old desktop
* sees this intent only after a fresh V3 bundle succeeds. */
when (
val request =
requestOutbox.publishFullGeneration(snapshot, requestIntent)
) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
}
when (exporter.exportMobileBundle(snapshot)) {
is SyncExportResult.Exported ->
when (requestOutbox.publishLegacy(snapshot, requestIntent)) {
is SyncRequestResult.Requested ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_published",
)
else ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_unavailable",
)
}
SyncExportResult.Unsupported,
is SyncExportResult.Error ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_unavailable",
)
}
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = explicitClaim,
)
) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
ForegroundGenerationResult.Busy ->
SyncRequestResult.Error("sync_interrupted")
is ForegroundGenerationResult.Superseded ->
SyncRequestResult.Error("sync_interrupted").also {
result.runId?.let { runId ->
terminalLedger?.recordTerminal(
runId,
coordinator.remoteEvidence(directory, runId),
)
}
}
is ForegroundGenerationResult.Cancelled ->
SyncRequestResult.Error("sync_interrupted")
is ForegroundGenerationResult.Failed ->
SyncRequestResult.Error(result.message).also {
result.runId?.let { runId ->
terminalLedger?.recordTerminal(
runId,
coordinator.remoteEvidence(directory, runId),
)
}
}
}
} finally {
explicitClaim.close()
}
}
/* Compatibility tooling keeps the standalone V3 publisher strict and
* available, but production Android triggers no longer select this path. */
internal suspend fun publishLegacyBundleAndRequest(
exporter: SyncExporter, exporter: SyncExporter,
requestOutbox: SyncRequestOutbox, requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) { ): SyncRequestResult = withContext(Dispatchers.IO) {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin") logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) { when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error -> is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}") SyncRequestResult.Error("prepare:${opened.message}")
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready -> is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) { when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error( is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}")
"Préparation Android → PC : ${publication.message}",
)
is SyncExportResult.Exported -> { is SyncExportResult.Exported -> {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success") logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin") logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
@ -57,8 +171,26 @@ internal suspend fun publishBundleAndRequest(
} }
} }
/**
* WHY: receipt summary is an opaque desktop diagnostic retained for V1
* compatibility and can be written in a language different from this UI.
* CONTRACT: visible completion/failure copy is selected from typed receipt
* status; only locally produced structured catalog counts may be interpolated.
* INVARIANT: [SyncReceiptResult.Received.summary] never reaches presentation.
*/
internal fun visibleReceiptStatus(
strings: Context,
receipt: SyncReceiptResult.Received,
catalogCounts: String? = null,
): String = if (receipt.success) {
strings.getString(R.string.sync_complete, requireNotNull(catalogCounts))
} else {
strings.getString(R.string.sync_receipt_failure)
}
@Composable @Composable
fun SyncScreen( fun SyncScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox, inbox: SyncCatalogInbox,
exporter: SyncExporter, exporter: SyncExporter,
requestOutbox: SyncRequestOutbox, requestOutbox: SyncRequestOutbox,
@ -69,6 +201,18 @@ fun SyncScreen(
LocalTrainlogColors.current LocalTrainlogColors.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val strings = localizedContext()
fun catalogCounts(imported: Int, reconciled: Int, skipped: Int? = null): String = buildList {
add(strings.resources.getQuantityString(R.plurals.catalog_new, imported, imported))
add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
}.joinToString(", ")
fun visibleError(raw: String): String = when {
raw == "sync_interrupted" -> strings.getString(R.string.sync_interrupted)
raw.startsWith("prepare:") ->
strings.getString(R.string.sync_prepare_error, raw.removePrefix("prepare:"))
else -> raw
}
var status by var status by
remember { remember {
@ -97,6 +241,63 @@ fun SyncScreen(
} }
var syncRunning by remember { mutableStateOf(false) } var syncRunning by remember { mutableStateOf(false) }
val backgroundSettings = remember { BackgroundSyncSettings(context) }
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
val driveSettings = remember { AiExportDriveSettings(context) }
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
val bluetoothSettings = remember { BluetoothSyncSettings(context) }
var bluetoothPermission by remember {
mutableStateOf(hasBluetoothConnectPermission(context))
}
var bluetoothDesktop by remember {
mutableStateOf(bluetoothSettings.desktopAddress)
}
var bondedDesktops by remember {
mutableStateOf(
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
)
}
val driveFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
if (uri != null) {
runCatching { driveSettings.connect(uri) }
.onSuccess {
driveConnected = true
status = strings.getString(R.string.drive_sync_connected)
}
.onFailure { status = strings.getString(R.string.drive_sync_connection_failed) }
}
}
val bluetoothPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
bluetoothPermission = granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
bondedDesktops =
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
if (bluetoothPermission && backgroundSettings.enabled) {
SyncBackgroundService.start(context)
}
status =
if (bluetoothPermission) {
strings.getString(R.string.bluetooth_sync_permission_granted)
} else {
strings.getString(R.string.bluetooth_sync_permission_required)
}
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
SyncBackgroundService.start(context)
backgroundEnabled = true
} else {
status = strings.getString(R.string.background_sync_notification_required)
}
}
LaunchedEffect( LaunchedEffect(
pendingRequestId pendingRequestId
@ -119,7 +320,7 @@ fun SyncScreen(
success = false success = false
status = status =
"Dossier Trainlog non autorisé." strings.getString(R.string.sync_folder_unauthorized)
pendingRequestId = pendingRequestId =
null null
@ -143,7 +344,7 @@ fun SyncScreen(
success = false success = false
status = status =
receipt.summary visibleReceiptStatus(strings, receipt)
pendingRequestId = pendingRequestId =
null null
@ -160,13 +361,10 @@ fun SyncScreen(
success = true success = true
status = status =
( visibleReceiptStatus(
"Synchronisation terminée · " + strings,
receipt.summary + receipt,
" · Android catalogue : " + catalogCounts(catalog.imported, catalog.reconciled, catalog.skipped),
"${catalog.imported} nouveau(x), " +
"${catalog.reconciled} réconcilié(s), " +
"${catalog.skipped} présent(s)."
) )
onCatalogChanged() onCatalogChanged()
@ -176,26 +374,21 @@ fun SyncScreen(
success = false success = false
status = status =
( strings.getString(R.string.sync_pc_catalog_missing)
"Sync PC terminée, mais catalogue reçu introuvable."
)
} }
CatalogInboxResult.FolderNotAuthorized -> { CatalogInboxResult.FolderNotAuthorized -> {
success = false success = false
status = status =
"Sync PC terminée, dossier Trainlog non autorisé." strings.getString(R.string.sync_pc_folder_unauthorized)
} }
is CatalogInboxResult.Error -> { is CatalogInboxResult.Error -> {
success = false success = false
status = status =
( strings.getString(R.string.sync_pc_import_error, catalog.message)
"Sync PC terminée, import Android : " +
catalog.message
)
} }
} }
@ -210,7 +403,7 @@ fun SyncScreen(
success = false success = false
status = status =
"Le PC n'a pas répondu dans les 60 secondes." strings.getString(R.string.sync_timeout)
pendingRequestId = pendingRequestId =
null null
@ -223,20 +416,20 @@ fun SyncScreen(
folderAuthorized = inbox.hasFolderAccess() folderAuthorized = inbox.hasFolderAccess()
success = folderAuthorized success = folderAuthorized
status = if (folderAuthorized) { status = if (folderAuthorized) {
"Accès fichiers autorisé · Documents/Trainlog prêt." strings.getString(R.string.sync_access_ready)
} else { } else {
"Accès fichiers requis. Activez l'autorisation dans Android." strings.getString(R.string.sync_access_enable)
} }
if (folderAuthorized) { if (folderAuthorized) {
coroutineScope.launch { coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
is CatalogInboxResult.Imported -> { is CatalogInboxResult.Imported -> {
status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " + status = strings.getString(R.string.sync_access_catalog,
"${result.reconciled} réconcilié(s)." catalogCounts(result.imported, result.reconciled))
onCatalogChanged() onCatalogChanged()
} }
CatalogInboxResult.FileNotFound -> CatalogInboxResult.FileNotFound ->
status = "Accès autorisé · aucun catalogue PC reçu." status = strings.getString(R.string.sync_access_no_catalog)
CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false
is CatalogInboxResult.Error -> status = result.message is CatalogInboxResult.Error -> status = result.message
} }
@ -246,14 +439,14 @@ fun SyncScreen(
TrainlogScreen( TrainlogScreen(
subtitle = subtitle =
"Synchronisation" strings.getString(R.string.nav_sync)
) { ) {
TrainlogFrame( TrainlogFrame(
title = "Synchroniser" title = strings.getString(R.string.sync_action)
) { ) {
TrainlogInfo( TrainlogInfo(
text = text =
"Le snapshot Android est maintenu automatiquement.", strings.getString(R.string.sync_snapshot_automatic),
color = color =
colors.accent, colors.accent,
) )
@ -263,12 +456,12 @@ fun SyncScreen(
if ( if (
pendingRequestId != null || syncRunning pendingRequestId != null || syncRunning
) { ) {
"Synchronisation en cours..." strings.getString(R.string.sync_running)
} else { } else {
"Synchroniser maintenant" strings.getString(R.string.sync_now)
}, },
description = description =
"Android → PC puis PC → Android, en une seule opération.", strings.getString(R.string.sync_flow_description),
accent = accent =
colors.success, colors.success,
onClick = { onClick = {
@ -282,7 +475,7 @@ fun SyncScreen(
success = false success = false
status = status =
"Accès fichiers requis. Autorisez d'abord Documents/Trainlog." strings.getString(R.string.sync_authorize_first)
return@TrainlogAction return@TrainlogAction
} }
@ -292,25 +485,51 @@ fun SyncScreen(
* published from one prepared repository snapshot before * published from one prepared repository snapshot before
* that signal becomes visible; otherwise the PC can mix a * that signal becomes visible; otherwise the PC can mix a
* fresh V3 file with stale or absent causal companions. */ * fresh V3 file with stale or absent causal companions. */
/* WHY: Samsung may freeze the activity after USB mode
* changes or a permission surface opens. CONTRACT:
* announce explicit priority before waking the service.
* The service may keep Android alive, but cannot become
* conversation owner while this claim is pending. */
val explicitClaim = SyncConversationArbiter.process.announceExplicit()
if (backgroundSettings.enabled) SyncBackgroundService.start(context)
syncRunning = true syncRunning = true
status = strings.getString(R.string.sync_running)
coroutineScope.launch { coroutineScope.launch {
try { try {
when (val result = publishBundleAndRequest(exporter, requestOutbox)) { when (
val result = publishBundleAndRequest(
repository,
exporter,
requestOutbox,
AndroidMtpPublicationVisibility(context),
explicitClaim,
BackgroundSyncRunLedger(context),
)
) {
is SyncRequestResult.Completed -> {
// The generation coordinator has already consumed the
// desktop return and published its durable ACK. Waiting
// for the legacy receipt here would leave a false spinner.
success = true
pendingRequestId = null
status = strings.getString(R.string.sync_generation_complete)
}
is SyncRequestResult.Requested -> { is SyncRequestResult.Requested -> {
success = true success = true
pendingRequestId = result.requestId pendingRequestId = result.requestId
status = "Demande envoyée · attente du PC..." status = strings.getString(R.string.sync_request_sent)
} }
SyncRequestResult.Unsupported -> { SyncRequestResult.Unsupported -> {
success = false success = false
status = "Android non supporté." status = strings.getString(R.string.sync_android_unsupported)
} }
is SyncRequestResult.Error -> { is SyncRequestResult.Error -> {
success = false success = false
status = result.message status = visibleError(result.message)
} }
} }
} finally { } finally {
explicitClaim.close()
syncRunning = false syncRunning = false
} }
} }
@ -320,11 +539,11 @@ fun SyncScreen(
TrainlogFrame( TrainlogFrame(
title = title =
"DOSSIER D'ECHANGE" strings.getString(R.string.sync_folder_section)
) { ) {
if (folderAuthorized) { if (folderAuthorized) {
TrainlogInfo( TrainlogInfo(
"Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé", strings.getString(R.string.settings_folder_granted),
color = color =
colors.success, colors.success,
) )
@ -332,9 +551,9 @@ fun SyncScreen(
TrainlogAction( TrainlogAction(
label = label =
if (folderAuthorized) "Ouvrir les réglages d'accès" else "Autoriser l'accès au dossier Trainlog", strings.getString(if (folderAuthorized) R.string.settings_open_access else R.string.sync_authorize_folder),
description = description =
"Autoriser l'accès pour gérer tous les fichiers dans Android.", strings.getString(R.string.sync_manage_files),
onClick = { onClick = {
permissionLauncher.launch(directStoragePermissionIntent(context)) permissionLauncher.launch(directStoragePermissionIntent(context))
}, },
@ -344,9 +563,9 @@ fun SyncScreen(
TrainlogAction( TrainlogAction(
label = label =
"Relire le catalogue PC", strings.getString(R.string.settings_reload_catalog),
description = description =
"Action de récupération manuelle si nécessaire.", strings.getString(R.string.sync_manual_recovery),
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
@ -354,12 +573,8 @@ fun SyncScreen(
success = true success = true
status = status =
( strings.getString(R.string.sync_catalog_result,
"Catalogue PC : " + catalogCounts(result.imported, result.reconciled, result.skipped))
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s), " +
"${result.skipped} présent(s)."
)
onCatalogChanged() onCatalogChanged()
} }
@ -368,14 +583,14 @@ fun SyncScreen(
success = false success = false
status = status =
"Accès fichiers requis pour Documents/Trainlog." strings.getString(R.string.sync_folder_access_required)
} }
CatalogInboxResult.FileNotFound -> { CatalogInboxResult.FileNotFound -> {
success = false success = false
status = status =
"Aucun catalogue PC reçu." strings.getString(R.string.settings_no_catalog)
} }
is CatalogInboxResult.Error -> { is CatalogInboxResult.Error -> {
@ -390,9 +605,124 @@ fun SyncScreen(
) )
} }
TrainlogFrame(title = strings.getString(R.string.bluetooth_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.bluetooth_sync_description),
color =
if (bluetoothDesktop != null && bluetoothPermission) colors.success
else colors.warning,
)
if (!bluetoothPermission) {
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_permission),
description = strings.getString(R.string.bluetooth_sync_permission_required),
accent = colors.success,
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
bluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
} else {
bluetoothPermission = true
bondedDesktops = bondedBluetoothDesktops(context)
}
},
)
} else {
if (bondedDesktops.isEmpty()) {
TrainlogInfo(
text = strings.getString(R.string.bluetooth_sync_no_desktop),
color = colors.warning,
)
}
bondedDesktops.forEach { desktop ->
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_select, desktop.name),
description = strings.getString(R.string.bluetooth_sync_address, desktop.address),
accent =
if (bluetoothDesktop == desktop.address) colors.success else colors.accent,
onClick = {
bluetoothSettings.selectDesktop(desktop.address)
bluetoothDesktop = desktop.address
status =
strings.getString(R.string.bluetooth_sync_selected, desktop.name)
if (backgroundSettings.enabled) {
SyncBackgroundService.start(context)
}
},
)
}
if (bluetoothDesktop != null) {
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_disconnect),
description =
strings.getString(
R.string.bluetooth_sync_address,
bluetoothDesktop ?: "",
),
accent = colors.warning,
onClick = {
bluetoothSettings.disconnect()
bluetoothDesktop = null
},
)
}
}
}
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.background_sync_description),
color = if (backgroundEnabled) colors.success else colors.warning,
)
TrainlogAction(
label = strings.getString(
if (backgroundEnabled) R.string.background_sync_disable
else R.string.background_sync_enable,
),
description = strings.getString(R.string.background_sync_description),
accent = if (backgroundEnabled) colors.warning else colors.success,
onClick = {
if (backgroundEnabled) {
SyncBackgroundService.stop(context)
backgroundEnabled = false
} else if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
SyncBackgroundService.start(context)
backgroundEnabled = true
}
},
)
}
TrainlogFrame(title = strings.getString(R.string.drive_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.drive_sync_description),
color = if (driveConnected) colors.success else colors.warning,
)
TrainlogAction(
label = strings.getString(
if (driveConnected) R.string.drive_sync_disconnect else R.string.drive_sync_connect,
),
description = strings.getString(R.string.drive_sync_latency),
accent = if (driveConnected) colors.warning else colors.success,
onClick = {
if (driveConnected) {
driveSettings.disconnect()
driveConnected = false
} else {
driveFolderLauncher.launch(null)
}
},
)
}
if (status != null) { if (status != null) {
TrainlogFrame( TrainlogFrame(
title = "État", title = strings.getString(R.string.sync_status),
active = false, active = false,
) { ) {
TrainlogInfo( TrainlogInfo(

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.labfytools.trainlog.R
import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import com.labfytools.trainlog.data.ActiveDraftLoadResult import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.ActiveDraftMutationResult
@ -32,6 +33,8 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) { fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
val localized = localizedContext()
val strings = localized.resources
val navigation = appState.navigation val navigation = appState.navigation
val generatorState = appState.generator val generatorState = appState.generator
val equipmentState = appState.equipment val equipmentState = appState.equipment
@ -59,7 +62,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() } val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
val pendingAiDraftCount = remember(draftRevision, catalogRevision) { val pendingAiDraftCount = remember(draftRevision, catalogRevision) {
repository.listAiSessionDrafts().size repository.listAiSessionDrafts().size + repository.listPreparedSessions().size
} }
fun open(route: AppRoute) { navigationController.open(route) } fun open(route: AppRoute) { navigationController.open(route) }
@ -70,9 +73,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
is ActiveDraftLoadResult.Loaded -> open(AppRoute.SessionEditor) is ActiveDraftLoadResult.Loaded -> open(AppRoute.SessionEditor)
ActiveDraftLoadResult.None -> when (val result = repository.startActiveSessionDraft()) { ActiveDraftLoadResult.None -> when (val result = repository.startActiveSessionDraft()) {
ActiveDraftMutationResult.Saved -> { draftRevision++; open(AppRoute.SessionEditor) } ActiveDraftMutationResult.Saved -> { draftRevision++; open(AppRoute.SessionEditor) }
is ActiveDraftMutationResult.Error -> draftMessage = result.message is ActiveDraftMutationResult.Error -> draftMessage = localizedRepositoryMessage(localized, result.message)
} }
is ActiveDraftLoadResult.Error -> draftMessage = draftLoad.message is ActiveDraftLoadResult.Error -> draftMessage = localizedRepositoryMessage(localized, draftLoad.message)
} }
} }
@ -89,8 +92,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
routeStateHolder.SaveableStateProvider(routeKey) { routeStateHolder.SaveableStateProvider(routeKey) {
when (val route = navigation.route) { when (val route = navigation.route) {
AppRoute.Home -> HomeScreen( AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning, activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
repository.getBodyZoneHomeOverview(), repository.getBodyZoneHomeOverview(),
::openManualSession, ::openManualSession,
{ zoneId -> { zoneId ->
@ -99,14 +101,35 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
exerciseState.searchQuery.value = "" exerciseState.searchQuery.value = ""
open(AppRoute.Exercises) open(AppRoute.Exercises)
}, },
{ open(AppRoute.Statistics) }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) }, )
AppRoute.Sessions -> SessionsHub(
activeDraft,
pendingAiDraftCount,
{ open(AppRoute.SessionEditor) },
::openManualSession,
{ open(AppRoute.AiSessionDrafts) },
{ open(AppRoute.CompletedSessions) },
{ open(AppRoute.Programs) },
{ open(AppRoute.CardioCalibration) },
)
AppRoute.SessionEditor -> SessionScreen(
repository,
catalogRevision,
{ draftRevision++; back() },
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
{ editingEntryId ->
appState.pendingSessionEquipmentEntryId = editingEntryId
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
},
{ exportSnapshot(); draftRevision++ },
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
onEditingContextRestored = {
appState.pendingSessionEquipmentEntryId = null
},
) )
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, { AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée." draftMessage = strings.getString(R.string.existing_draft_warning)
draftRevision++ draftRevision++
/* WHY: ExistingActiveDraft is discovered only after the accept /* WHY: ExistingActiveDraft is discovered only after the accept
* action, while the proposal is still dirty. CONTRACT: this * action, while the proposal is still dirty. CONTRACT: this
@ -121,6 +144,15 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
onStarted = { open(AppRoute.SessionEditor) }, onStarted = { open(AppRoute.SessionEditor) },
) )
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
draftRevision++
exportSnapshot()
}
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
draftRevision++
open(AppRoute.SessionEditor)
}
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) } is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved -> is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
if (saved) exportSnapshot() if (saved) exportSnapshot()
@ -132,12 +164,51 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() } is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) }) AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) } is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() } is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
AppRoute.Statistics -> StatisticsDashboard(repository) /* WHY: equipment creation temporarily leaves the active-session editor.
* CONTRACT: returning from that catalogue route preserves the durable
* form and selects the exact stable ID that was just created.
* INVARIANT: no equipment is synthesized; this runs only after the
* explicit EquipmentCreateScreen action succeeded. */
if (route.caller == AppRoute.SessionEditor) {
when (val loaded = repository.loadActiveSessionDraft()) {
is ActiveDraftLoadResult.Loaded -> when (
val saved = repository.saveActiveSessionDraft(
loaded.draft.copy(
form = loaded.draft.form.copy(
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
?.let { entryId ->
loaded.draft.exercises.indexOfFirst {
it.entryId == entryId
}.takeIf { it >= 0 }
},
editingEntryId = appState.pendingSessionEquipmentEntryId,
selectedEquipmentId = equipment.equipmentId,
),
),
)
) {
ActiveDraftMutationResult.Saved -> draftRevision++
is ActiveDraftMutationResult.Error -> {
draftMessage = localizedRepositoryMessage(localized, saved.message)
}
}
is ActiveDraftLoadResult.Error -> {
draftMessage = localizedRepositoryMessage(localized, loaded.message)
}
ActiveDraftLoadResult.None -> Unit
}
}
exportSnapshot()
catalogRevision++
back()
}
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() }) AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository) AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() }) AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ } AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
}} }}
} }
@ -146,13 +217,13 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
val generator = guardedRoute == AppRoute.SessionGenerator val generator = guardedRoute == AppRoute.SessionGenerator
AlertDialog( AlertDialog(
onDismissRequest = navigationController::cancelPending, onDismissRequest = navigationController::cancelPending,
title = { Text(if (generator) "Proposition non acceptée" else "Modifications non enregistrées") }, title = { Text(strings.getString(if (generator) R.string.dialog_unaccepted_proposal else R.string.dialog_unsaved_changes)) },
text = { Text(if (generator) "La proposition peut rester en mémoire pendant que vous changez de rubrique." else "Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.") }, text = { Text(strings.getString(if (generator) R.string.dialog_proposal_retained else R.string.dialog_fields_retained)) },
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text("Conserver et quitter") } }, confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text(strings.getString(R.string.dialog_keep_leave)) } },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = {
navigationController.discardAndNavigate() navigationController.discardAndNavigate()
}) { Text(if (generator) "Abandonner la proposition" else "Abandonner les modifications") } }) { Text(strings.getString(if (generator) R.string.dialog_discard_proposal else R.string.dialog_discard_changes)) }
}, },
) )
} }
@ -161,6 +232,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
private fun AppRoute.stateKey(): String = when (this) { private fun AppRoute.stateKey(): String = when (this) {
is AppRoute.SessionDetail -> "session:${sessionId}" is AppRoute.SessionDetail -> "session:${sessionId}"
is AppRoute.SessionCorrection -> "session-correction:${sessionId}" is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
is AppRoute.ProgramDetail -> "program:${programId}"
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}" is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}" is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}" is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -94,6 +96,7 @@ fun TrainlogFrame(
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
active: Boolean = true, active: Boolean = true,
titleTrailing: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val colors = val colors =
@ -112,15 +115,19 @@ fun TrainlogFrame(
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
BasicText( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
text = title, BasicText(
style = text = title,
TrainlogTypography.small.copy( modifier = Modifier.weight(1f),
color = accent, style =
fontWeight = TrainlogTypography.small.copy(
FontWeight.Bold, color = accent,
), fontWeight =
) FontWeight.Bold,
),
)
titleTrailing?.invoke()
}
Box( Box(
modifier = modifier =
@ -159,7 +166,7 @@ fun TrainlogAction(
.padding(vertical = 3.dp) .padding(vertical = 3.dp)
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.background(Color.Transparent) .background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
Column( Column(
@ -194,6 +201,46 @@ fun TrainlogAction(
} }
} }
/** Presentation-only surface for dense object summaries and repeated rows. */
@Composable
fun TrainlogCompactCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalTrainlogColors.current
val interaction = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)
Column(
modifier
.fillMaxWidth()
.background(colors.surface, MaterialTheme.shapes.medium)
.then(interaction)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
content = content,
)
}
/** Small non-interactive metadata token; business state remains caller-owned. */
@Composable
fun TrainlogMetaBadge(
text: String,
modifier: Modifier = Modifier,
accent: Color? = null,
) {
val colors = LocalTrainlogColors.current
Text(
text = text,
modifier = modifier.background(colors.surfaceAlt, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = accent ?: colors.text,
style = TrainlogTypography.small,
maxLines = 1,
)
}
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
/** Compact pictogram action with a mandatory accessible name. */ /** Compact pictogram action with a mandatory accessible name. */
@Composable @Composable
fun TrainlogIconAction( fun TrainlogIconAction(
@ -220,11 +267,13 @@ fun TrainlogDeleteButton(
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fullWidth: Boolean = true,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp), modifier = (if (fullWidth) modifier.fillMaxWidth() else modifier).heightIn(min = 48.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(containerColor = colors.error), colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) { ) {
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription) Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
@ -235,9 +284,7 @@ fun TrainlogDeleteButton(
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) { fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { TrainlogButton(label, onClick, Modifier.fillMaxWidth())
Text(label)
}
if (description.isNotBlank()) { if (description.isNotBlank()) {
BasicText( BasicText(
description, description,
@ -265,7 +312,7 @@ fun TrainlogActionTile(
Column( Column(
modifier modifier
.heightIn(min = 112.dp) .heightIn(min = 112.dp)
.background(colors.surface, RoundedCornerShape(12.dp)) .background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(12.dp), .padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
@ -285,12 +332,34 @@ fun TrainlogButton(
icon: ImageVector? = null, icon: ImageVector? = null,
containerColor: Color? = null, containerColor: Color? = null,
maxLines: Int = 1, maxLines: Int = 1,
enabled: Boolean = true,
style: TrainlogButtonStyle = TrainlogButtonStyle.PRIMARY,
) { ) {
val colors = LocalTrainlogColors.current
val resolvedContainer = containerColor ?: when (style) {
TrainlogButtonStyle.PRIMARY -> colors.accent
TrainlogButtonStyle.SECONDARY -> colors.surfaceAlt
TrainlogButtonStyle.SUCCESS -> colors.success
TrainlogButtonStyle.DESTRUCTIVE -> colors.error
TrainlogButtonStyle.GHOST -> Color.Transparent
}
val resolvedContent = when (style) {
TrainlogButtonStyle.PRIMARY, TrainlogButtonStyle.SUCCESS,
TrainlogButtonStyle.DESTRUCTIVE -> colors.crust
TrainlogButtonStyle.SECONDARY, TrainlogButtonStyle.GHOST -> colors.text
}
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 52.dp), modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) } shape = MaterialTheme.shapes.medium,
?: ButtonDefaults.buttonColors(), border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
colors = ButtonDefaults.buttonColors(
containerColor = resolvedContainer,
contentColor = resolvedContent,
disabledContainerColor = colors.surfaceAlt,
disabledContentColor = colors.muted,
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) { ) {
icon?.let { icon?.let {
@ -327,12 +396,15 @@ fun TrainlogInputField(
label: String, label: String,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = keyboardOptions: KeyboardOptions =
KeyboardOptions.Default, KeyboardOptions.Default,
testTag: String? = null, testTag: String? = null,
singleLine: Boolean = true, singleLine: Boolean = true,
minLines: Int = 1, minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
suffix: String? = null,
compact: Boolean = false,
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
@ -343,8 +415,7 @@ fun TrainlogInputField(
} }
Column( Column(
modifier = modifier = modifier.padding(bottom = 10.dp)
Modifier.padding(bottom = 10.dp)
) { ) {
BasicText( BasicText(
text = label.uppercase(), text = label.uppercase(),
@ -388,7 +459,7 @@ fun TrainlogInputField(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .heightIn(min = if (compact) 44.dp else 48.dp)
.onFocusChanged { .onFocusChanged {
focused = it.isFocused focused = it.isFocused
} }
@ -399,11 +470,22 @@ fun TrainlogInputField(
Modifier.testTag(testTag) Modifier.testTag(testTag)
} }
) )
.background(colors.surface) .background(colors.surface, MaterialTheme.shapes.medium)
.padding( .padding(
horizontal = 12.dp, horizontal = if (compact) 10.dp else 12.dp,
vertical = 10.dp, vertical = if (compact) 8.dp else 10.dp,
), ),
decorationBox = { innerTextField ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.weight(1f)) { innerTextField() }
suffix?.let {
BasicText(it, style = TrainlogTypography.small.copy(color = colors.muted))
}
}
},
) )
Box( Box(
@ -466,7 +548,7 @@ fun DestructiveConfirmationDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
title = { Text(title) }, title = { Text(title) },
text = { Text(detail) }, text = { Text(detail) },
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } }, dismissButton = { TextButton(onClick = onCancel) { Text(localizedContext().getString(com.labfytools.trainlog.R.string.dialog_cancel)) } },
confirmButton = { confirmButton = {
if (deleteContentDescription != null) { if (deleteContentDescription != null) {
TrainlogDeleteButton( TrainlogDeleteButton(

View file

@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
data class TrainlogColors( data class TrainlogColors(
val background: Color, val background: Color,
@ -110,6 +113,19 @@ fun TrainlogTheme(
error = TrainlogDarkColors.error, error = TrainlogDarkColors.error,
) )
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) { CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
MaterialTheme(colorScheme = scheme, content = content) /* INVARIANT: all Material controls share the same restrained corner
* language. Screen code may change size, but must not reintroduce the
* former pill-shaped action hierarchy. */
MaterialTheme(
colorScheme = scheme,
shapes = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(10.dp),
large = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(14.dp),
),
content = content,
)
} }
} }

View file

@ -0,0 +1,620 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Trainlog</string>
<string name="nav_home">Home</string>
<string name="nav_sessions">Sessions</string>
<string name="nav_exercises">Exercises</string>
<string name="nav_equipment">Equipment</string>
<string name="nav_statistics">Statistics</string>
<string name="nav_sync">Synchronization</string>
<string name="nav_settings">Settings</string>
<string name="nav_sleep">Sleep</string>
<string name="sleep_current_night">Current night</string>
<string name="sleep_morning">Morning</string>
<string name="sleep_day">Day</string>
<string name="sleep_bed_time">Bedtime</string>
<string name="sleep_final_get_up">Final get-up</string>
<string name="sleep_night_get_up">Night get-up</string>
<string name="sleep_sleep">Sleep</string>
<string name="sleep_nap">Nap</string>
<string name="sleep_long_awake">Long awakening</string>
<string name="sleep_half_sleep">Half-sleep</string>
<string name="sleep_sleepiness">Sleepiness</string>
<string name="sleep_quality">Sleep quality</string>
<string name="sleep_wake_quality">Wake quality</string>
<string name="sleep_day_form">Day form</string>
<string name="sleep_notes">Treatment and notes</string>
<string name="sleep_add_event">Add event</string>
<string name="sleep_save">Save</string>
<string name="sleep_validate">Validate day</string>
<string name="sleep_status_draft">Draft</string>
<string name="sleep_status_ready">Ready to sync</string>
<string name="sleep_status_synchronized">Synchronized</string>
<string name="sleep_status_modified">Modified since synchronization</string>
<string name="sleep_delete">Delete</string>
<string name="sleep_choose_time">Choose time</string>
<string name="route_session_editor">Current session</string>
<string name="route_session_generator">Plan a session</string>
<string name="route_ai_drafts">Drafts</string>
<string name="route_completed_sessions">Completed sessions</string>
<string name="route_programs">Programs</string>
<string name="route_program_detail">Program details</string>
<string name="programs_none">No synchronized programs.</string>
<string name="program_active">ACTIVE</string>
<string name="program_archived">ARCHIVED</string>
<string name="program_start_date">Start: %1$s</string>
<string name="program_end_date">End: %1$s</string>
<string name="program_open">View program</string>
<string name="program_not_found">Program not found.</string>
<string name="program_target_sets">%1$d sets</string>
<string name="program_target_reps">%1$d repetitions</string>
<string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string>
<string name="program_session_todo">To do</string>
<string name="program_session_in_progress">In progress</string>
<string name="program_session_completed">Completed</string>
<string name="program_session_start">Start</string>
<string name="program_session_resume">Resume session</string>
<string name="program_session_completed_detail">This session has already been completed.</string>
<string name="program_session_unavailable">This session can no longer be started.</string>
<string name="program_session_show_occurrences">Show exercises</string>
<string name="program_session_hide_occurrences">Hide exercises</string>
<plurals name="program_session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
<string name="route_session_detail">Session details</string>
<string name="route_session_correction">Edit session</string>
<string name="route_exercise_detail">Exercise details</string>
<string name="route_exercise_edit">Edit exercise</string>
<string name="route_exercise_create">Create exercise</string>
<string name="route_equipment_detail">Equipment details</string>
<string name="route_equipment_create">Create equipment</string>
<string name="route_body_measurements">Body measurements</string>
<string name="route_latest_maxima">Latest MAX</string>
<string name="a11y_open_navigation">Open navigation</string>
<string name="a11y_back">Back</string>
<string name="a11y_navigation">Navigation</string>
<string name="settings_language_section">LANGUAGE</string>
<string name="settings_language_label">Language</string>
<string name="language_french">Français</string>
<string name="language_english">English</string>
<string name="settings_exchange_folder">EXCHANGE FOLDER</string>
<string name="settings_screen">Settings</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_keep_leave">Keep and leave</string>
<string name="dialog_discard_proposal">Discard proposal</string>
<string name="dialog_discard_changes">Discard changes</string>
<string name="dialog_unaccepted_proposal">Unaccepted proposal</string>
<string name="dialog_unsaved_changes">Unsaved changes</string>
<string name="dialog_proposal_retained">The proposal can remain in memory while you visit another section.</string>
<string name="dialog_fields_retained">The fields remain in memory until you explicitly discard them.</string>
<string name="a11y_delete_exercise">Delete this exercise</string>
<string name="a11y_delete_current_session">Delete the current session</string>
<string name="a11y_delete_set">Delete this set</string>
<string name="a11y_remove_machine">Remove the machine from this exercise</string>
<string name="a11y_delete_draft">Delete this draft</string>
<string name="home_session">Session</string>
<string name="home_sync_state">Status and actions</string>
<string name="home_body_tracking">Body tracking</string>
<string name="settings_access_granted">File access granted.</string>
<string name="settings_access_required">File access is still required.</string>
<string name="settings_folder_granted">Exchange folder: Documents/Trainlog\nFile access: granted</string>
<string name="settings_folder_required">File access required</string>
<string name="settings_open_access">Open access settings</string>
<string name="settings_authorize">Authorize</string>
<string name="settings_authorize_description">Allow access to the Trainlog folder in Android.</string>
<string name="settings_reload_catalog">Reload PC catalog</string>
<string name="settings_reload_description">Explicitly apply the catalog in the authorized folder.</string>
<string name="settings_no_catalog">No PC catalog received.</string>
<string name="settings_folder_revoked">The folder is no longer authorized.</string>
<string name="sync_folder_unauthorized">Trainlog folder not authorized.</string>
<string name="sync_complete">Synchronization complete · Android catalog: %1$s.</string>
<string name="sync_receipt_failure">PC synchronization failed. Check the PC log for diagnostics.</string>
<string name="sync_pc_catalog_missing">PC synchronization completed, but the received catalog could not be found.</string>
<string name="sync_pc_folder_unauthorized">PC synchronization completed, but the Trainlog folder is not authorized.</string>
<string name="sync_pc_import_error">PC synchronization completed; Android import: %1$s</string>
<string name="sync_timeout">The PC did not respond within 60 seconds.</string>
<string name="sync_access_ready">File access granted · Documents/Trainlog is ready.</string>
<string name="sync_access_enable">File access is required. Enable the permission in Android.</string>
<string name="sync_access_catalog">Access granted · PC catalog: %1$s.</string>
<string name="sync_access_no_catalog">Access granted · no PC catalog received.</string>
<string name="sync_action">Synchronize</string>
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</string>
<string name="sync_interrupted">Synchronization interrupted. You can start a new attempt.</string>
<string name="sync_now">Synchronize now</string>
<string name="background_sync_title">Background synchronization</string>
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
<string name="background_sync_enable">Enable background synchronization</string>
<string name="background_sync_disable">Disable background synchronization</string>
<string name="background_sync_channel">Trainlog synchronization</string>
<string name="background_sync_notification_title">Trainlog is ready to synchronize</string>
<string name="background_sync_notification_text">The service is waiting for a PC request.</string>
<string name="background_sync_notification_required">Allow notifications to enable background synchronization.</string>
<string name="drive_sync_title">Private Google Drive</string>
<string name="drive_sync_description">Select the private Trainlog/Sync/v1 folder. Trainlog only accesses that folder and never copies SQLite into it.</string>
<string name="drive_sync_connect">Connect Google Drive</string>
<string name="drive_sync_disconnect">Disconnect Google Drive</string>
<string name="drive_sync_latency">Background checks are bounded by Android and can take at least 15 minutes.</string>
<string name="drive_sync_connected">Drive folder connected. A check has been scheduled.</string>
<string name="drive_sync_connection_failed">Unable to retain access to the Drive folder.</string>
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
<string name="sync_generation_complete">Synchronization completed on Android and PC.</string>
<string name="sync_android_unsupported">Android is not supported.</string>
<string name="sync_folder_section">EXCHANGE FOLDER</string>
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
<string name="sync_manage_files">Allow access to manage all files in Android.</string>
<string name="sync_manual_recovery">Manual recovery action if needed.</string>
<string name="sync_catalog_result">PC catalog: %1$s.</string>
<string name="sync_folder_access_required">File access is required for Documents/Trainlog.</string>
<string name="sync_status">Status</string>
<string name="sync_prepare_error">Android → PC preparation: %1$s</string>
<plurals name="catalog_new"><item quantity="one">%d new</item><item quantity="other">%d new</item></plurals>
<plurals name="catalog_reconciled"><item quantity="one">%d reconciled</item><item quantity="other">%d reconciled</item></plurals>
<plurals name="catalog_present"><item quantity="one">%d present</item><item quantity="other">%d present</item></plurals>
<string name="history_none">No sessions recorded.</string>
<string name="max_none">No explicit MAX recorded.</string>
<string name="equipment_value">Equipment: %1$s</string>
<string name="value_none">none</string>
<string name="session_training">TRAINING</string>
<string name="session_max_test">MAX TEST</string>
<plurals name="exercise_count"><item quantity="one">%d exercise</item><item quantity="other">%d exercises</item></plurals>
<string name="date_time_pattern">MM/dd/yyyy h:mm a</string>
<string name="date_pattern">MM/dd/yyyy</string>
<string name="correction_profile_note">Previous profile interpretations are preserved. The identifiers of the session and retained occurrences do not change.</string>
<string name="field_target_sets">Target set count</string>
<string name="field_target_reps">Target repetitions</string>
<string name="field_target_duration">Target duration per set (s)</string>
<string name="field_rest_seconds">Rest (s)</string>
<string name="field_target_weight">Target load (kg)</string>
<string name="field_explicit_max">Explicit MAX (kg)</string>
<string name="field_continuous_duration">Continuous duration (s)</string>
<string name="field_speed">Speed (km/h)</string>
<string name="field_distance">Distance (km)</string>
<string name="field_reps">Repetitions</string>
<string name="field_duration_seconds">Duration (s)</string>
<string name="field_weight">Load (kg)</string>
<string name="set_number">Set %1$d</string>
<string name="add_set">+ Add a set</string>
<string name="add_set_description">Explicitly add a performed fact.</string>
<string name="delete_occurrence">Delete this occurrence</string>
<string name="save">Save</string>
<string name="correction_save_description">Apply the entire correction in one transaction.</string>
<string name="cancel_no_database_change">Leave without changing the database.</string>
<string name="delete_set_question">Delete this set?</string>
<string name="delete_set_detail">This performed fact will disappear from the corrected session.</string>
<string name="delete">Delete</string>
<string name="delete_occurrence_question">Delete this occurrence?</string>
<string name="a11y_delete_occurrence">Delete this occurrence</string>
<plurals name="performed_set_count"><item quantity="one">%d performed set</item><item quantity="other">%d performed sets</item></plurals>
<string name="occurrence_delete_impact">Impact: %1$s%2$s%3$s. Its feedback and revisions will be deleted with their parent.</string>
<string name="continuous_activity_suffix">, continuous activity</string>
<string name="max_suffix">, MAX</string>
<string name="session_not_found">Session not found.</string>
<string name="feedback_dictation">Feedback dictation</string>
<string name="feedback_recording">● Recording</string>
<string name="feedback_stop">Stop dictation</string>
<string name="feedback_text">Text</string>
<string name="feedback_resume">Resume dictation</string>
<string name="feedback_start">Start dictation</string>
<string name="save_changes">Save changes</string>
<string name="feedback_revision_description">Create a new immutable revision.</string>
<string name="feedback_create_description">Create an immutable historical record.</string>
<string name="save_nothing">Save nothing.</string>
<string name="feedback_microphone_denied">Microphone permission denied: manual input remains available.</string>
<string name="feedback_speech_unavailable">Speech recognition is unavailable: use manual input.</string>
<string name="feedback_empty">Empty feedback cannot be saved.</string>
<string name="current_session_none">No session in progress.</string>
<string name="prepare">Prepare</string>
<string name="new_manual_session">New manual session</string>
<string name="create_or_resume">Create or resume</string>
<plurals name="prepared_session_count"><item quantity="one">%d prepared session</item><item quantity="other">%d prepared sessions</item></plurals>
<string name="draft_none">No imported drafts.</string>
<string name="delete_draft_question">Delete this draft?</string>
<string name="max_capacities">CAPABILITIES / MAX</string>
<string name="equipment_catalog">Equipment catalog</string>
<string name="catalog">Catalog</string>
<string name="equipment_none">No equipment found.</string>
<string name="load_value">Load: %1$s</string>
<string name="possible_exercises">Possible exercises</string>
<string name="association_none">No verified association. Anatomy is not inferred from the machine name.</string>
<string name="settings_folder_imported">Folder authorized · %1$s.</string>
<string name="settings_catalog_reloaded">Catalog reloaded · %1$s.</string>
<string name="body_save">Save body measurements</string>
<string name="body_saved">Body measurements saved.</string>
<string name="local_database_error">Local database error.</string>
<string name="body_none">No measurements recorded.</string>
<string name="exercise_save">Save exercise</string>
<string name="existing_exercises">Existing exercises</string>
<string name="exercise_none_saved">No exercises saved.</string>
<string name="modify_named">Edit · %1$s</string>
<string name="view_latest_max">View latest MAX</string>
<string name="name_no_rules">No rule depends on the name.</string>
<string name="tracking_change_question">Change tracking mode?</string>
<string name="modify">Edit</string>
<string name="exercise_not_found">Exercise not found.</string>
<string name="profile_loading">Loading profile…</string>
<string name="primary_muscles">Primary muscles: %1$s</string>
<string name="secondary_muscles">Secondary muscles: %1$s</string>
<string name="stabilizers">Stabilizers: %1$s</string>
<string name="unclassified">Unclassified</string>
<string name="none_established">None established</string>
<string name="exercise_none_found">No exercises found.</string>
<string name="exercise_modify_description">Edit the name, profile, and zones according to the existing rules.</string>
<string name="knowledge_none">No scientific knowledge is linked to this identifier.</string>
<string name="view_latest_max_description">Open the existing aggregated list in Statistics.</string>
<string name="value_none_feminine">None</string>
<string name="field_duration">Duration</string>
<string name="field_speed_short">Speed</string>
<string name="field_distance_short">Distance</string>
<string name="error_title">Error</string>
<string name="draft_unsaved">Draft not saved: %1$s</string>
<string name="draft_unsaved_title">Draft not saved</string>
<string name="last_change_unsaved">Latest change not saved.</string>
<string name="session_saved_locally">Session saved locally.</string>
<string name="normal_session_description">Normal training session.</string>
<string name="max_session_description">Session explicitly dedicated to a MAX measurement.</string>
<string name="exercise_none_added">No exercises added.</string>
<string name="performed_sets_none">No performed sets entered: add the values actually performed.</string>
<string name="modify_name">Edit %1$s</string>
<string name="feedback_edit_description">Correct the text without changing the observation.</string>
<string name="exercise_added">Exercise added to the session.</string>
<string name="exercise_modified">Exercise edited.</string>
<string name="session_save">Save session</string>
<string name="exercise_title">Exercise</string>
<string name="exercise_none">No exercises.</string>
<string name="choose_exercise">Choose an exercise</string>
<string name="exercise_picker_description">Exercise selected. Tap to show suggestions.</string>
<string name="search_exercise">Search for an exercise…</string>
<string name="exercise_none_found_short">No exercises found</string>
<string name="cancel_search">Cancel search</string>
<string name="keep_named_values">Keep %1$s and the entered values.</string>
<string name="entry_title">ENTRY — %1$s</string>
<string name="target_load_note">Planned target load — separate from loads actually performed.</string>
<string name="load_kg_value">Value in kg</string>
<string name="percent_my_max">%% of my MAX</string>
<string name="percent_my_max_field">Percentage of my MAX (1 to 100)</string>
<string name="compatible_max">Compatible MAX: %1$s kg · %2$s</string>
<string name="target_none">No target load will be saved.</string>
<string name="set_reps_field">Set %1$d — Repetitions</string>
<string name="optional_load_note">Load is optional; both decimal separators are accepted. An empty field is not zero.</string>
<string name="sets_count">Number of sets</string>
<string name="set_duration_seconds">Duration per set (seconds)</string>
<string name="duration_minutes">Duration (minutes)</string>
<string name="speed_kmh">Speed km/h</string>
<string name="distance_km">Distance km</string>
<string name="invalid_target_load">Invalid target load.</string>
<string name="cancel_entry">Cancel entry</string>
<string name="delete_numbered_set_question">Delete set %1$d?</string>
<string name="session_type_value">Type: %1$s</string>
<string name="resume_max_test">Resume this MAX test</string>
<string name="resume_max_description">Continue the same session while preserving its identifier and date.</string>
<string name="equipment_inline">Equipment: %1$s</string>
<string name="plan_summary">Plan: %1$d × %2$s · rest %3$d s · %4$s</string>
<string name="target_load_inline">target load %1$s kg</string>
<string name="no_numeric_load">no numeric load</string>
<string name="search_machine">Search for a machine</string>
<string name="modify_equipment">Edit equipment</string>
<string name="modify_equipment_description">Choose, replace, or remove the machine for this entry.</string>
<string name="max_value">MAX: %1$s kg</string>
<string name="modified_suffix"> · Edited</string>
<string name="feedback_move_description">Correct the text without moving the observation.</string>
<string name="add_feedback">Add feedback</string>
<string name="session_follow_up">Post-session follow-up</string>
<string name="feedback_marker_description">Correct the text without changing the H+ marker.</string>
<string name="add_record">Add a record</string>
<string name="remove_machine_question">Remove the machine from this entry?</string>
<string name="replace_machine_question">Replace the associated machine?</string>
<string name="machine_change_detail">The currently recorded machine association will be lost. Sets, MAX, and feedback remain unchanged.</string>
<string name="remove">Remove</string>
<string name="replace">Replace</string>
<string name="mode_sets_reps">Mode: sets · repetitions</string>
<string name="mode_sets_duration">Mode: sets · duration</string>
<string name="assistance_kg">Assistance (kg)</string>
<string name="sets_table">Set | Repetitions | %1$s</string>
<string name="set_duration">Set %1$d: %2$s</string>
<string name="mode_continuous">Mode: continuous</string>
<string name="duration_value">Duration: %1$s</string>
<string name="speed_value">Speed: %1$.1f km/h</string>
<string name="distance_value">Distance: %1$.2f km</string>
<plurals name="duration_minutes_value"><item quantity="one">%d min</item><item quantity="other">%d min</item></plurals>
<plurals name="duration_seconds_value"><item quantity="one">%d sec</item><item quantity="other">%d sec</item></plurals>
<string name="period_7_days">7 days</string><string name="period_30_days">30 days</string><string name="period_90_days">90 days</string><string name="period_year">1 year</string><string name="period_all">All</string>
<string name="statistics_invalid_ignored">Some invalid historical data was ignored; valid statistics remain available.</string>
<string name="explore">EXPLORE</string><string name="distribution">Distribution</string>
<string name="distribution_description">Zones and exercise usage, only when they are classified.</string>
<string name="global_summary">Global summary</string><string name="global_summary_description">Cautious indicators derived from available data.</string>
<string name="summary_period">SUMMARY · %1$s</string><string name="sets">Sets</string><string name="explicit_max_plural">Explicit MAX</string>
<string name="progression">PROGRESSION</string><string name="progress_insufficient">Insufficient data to establish comparable progress.</string>
<string name="progress_counts">%1$d training improvements · %2$d MAX records</string><string name="max_open_detail">%1$d explicit MAX · open details</string>
<string name="body_period_none">No body measurements in this period.</string><string name="measurement_single">1 measurement · trend unavailable</string>
<string name="frequency">FREQUENCY</string><string name="current_week_summary">Current week · %1$s · %2$d MAX included</string>
<string name="max_not_double_counted">MAX values mark sessions; they are never added a second time.</string>
<string name="back_dashboard">← Back to dashboard</string><string name="comparable_none">No comparable data in this period.</string>
<plurals name="session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
<string name="progress_detail_description">Each detail row preserves a canonical exercise, comparable equipment, and mode; the dashboard never adds kilograms together.</string>
<string name="body_detail_description">No interpolation: only measurements actually recorded are shown.</string>
<string name="frequency_title">Frequency</string><string name="frequency_unit">sessions</string>
<string name="frequency_detail_description">Number of sessions with actual work per week; MAX is a marker, not a second session.</string>
<string name="distribution_detail_description">Detailed distribution appears when classified occurrences are available in this view.</string>
<string name="summary_detail">%1$d sessions · %2$d sets · %3$d exercises · %4$d explicit MAX</string>
<string name="generator_zone">Body zone</string><string name="generator_goal">Goal</string>
<string name="generator_custom_duration">Custom duration (%1$d to %2$d min)</string>
<string name="generate">Generate</string><string name="generate_description">Analyze history read-only and prepare the proposal.</string>
<string name="duration_range_error">Duration must be between %1$d and %2$d minutes.</string>
<string name="target_duration_estimate">Target duration: %1$d min · estimate: %2$d min</string>
<string name="proposal_short">The proposal is significantly shorter than the target; no exercise is added merely to fill time.</string>
<string name="warmup_not_generated">Warm-up and cool-down are not generated in V1.</string>
<string name="recent_exposure_warning">Informational warning: history shows recent exposure. This does not measure physiological recovery.</string>
<string name="continue_warning">Continue despite warning</string><string name="continue_warning_description">Keep the proposal and allow it to be accepted.</string>
<string name="equipment_generator">Equipment: %1$s</string><string name="target_load_none_proposed">Target load: no numeric load proposed.</string><string name="target_load_proposed">Target load: %1$s kg</string>
<string name="recent_similar">Similar recent history detected; informational only.</string>
<string name="sets_label">Sets</string><string name="user_load_note">Load: user choice, never a recommendation.</string>
<string name="automatic">Automatic</string><string name="none">None</string><string name="apply">Apply</string>
<string name="modify_target">Edit target</string><string name="modify_target_description">Edit sets, repetitions, rest, or load.</string>
<string name="regenerate">Regenerate</string><string name="accept_actual_values">Accept and enter actual values</string>
<string name="cancel_proposal">Cancel proposal</string><string name="cancel_proposal_description">Return without writing a draft.</string>
<string name="processing">Processing</string><string name="analysis_running">Analysis in progress…</string><string name="message_title">Message</string>
<string name="resume">Resume</string><string name="durable_draft_count">%1$s · the durable draft is preserved.</string>
<string name="session_proposal">Session proposal</string><string name="planned_for">Planned for %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="manual_preparation">Manual preparation</string>
<string name="start_preparation_description">Creates a protected active session.</string>
<string name="draft_rest_short">rest %1$d s</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>
<string name="new_manual_description">Explicitly create a durable draft.</string><string name="latest_session">Latest session</string><string name="quick_access">Quick access</string>
<string name="zones_to_work">Zones to work</string><string name="recent_exposure">Recent exposure</string><string name="no_training_history">No training history yet.</string>
<string name="invalid_history_timestamp">Insufficient data: a historical timestamp could not be interpreted.</string>
<string name="selection_value">Selection: %1$s</string><string name="priorities">Priorities</string><string name="resolved_zone_none">No zone with a resolved exercise is available.</string>
<string name="exposure_explanation">These colors describe recently recorded work. They do not measure physiological recovery.</string><string name="understood">Got it</string>
<string name="view_exercises">View exercises</string><string name="view_statistics">View statistics</string>
<string name="session_type">Session type</string><string name="training_selected">[✓] Training</string><string name="training_unselected">[ ] Training</string>
<string name="edit_exercise_description">Edit this exercise without removing it from the session.</string><string name="editor_opened">Editor opened for %1$s — prefilled fields appear below.</string>
<string name="editing_scroll_hint">Editing — scroll to ENTRY — %1$s.</string><string name="feedback_value">Feedback · %1$s%2$s</string>
<string name="feedback_now_description">Dictate or enter feedback now, before the session ends.</string><string name="feedback_saved_current">Feedback saved in the current session.</string>
<string name="create_new_exercise">Create a new exercise</string><string name="finalize_failed">Finalization failed, draft preserved: %1$s</string>
<string name="session_exercise_total">%1$s in the session.</string><string name="discard_session_question">Discard the current session?</string>
<string name="discard_session_detail">This will delete the draft, its %1$s, and all their recorded data.</string>
<string name="remove_exercise_question">Remove “%1$s” from the session?</string><string name="remove_occurrence_detail">This occurrence will be removed.</string>
<string name="continuous_fact">the continuous activity</string><string name="max_fact">the MAX value</string>
<string name="create_context">Create context</string><string name="context_compatibility">Temporary compatibility for a custom or unresolved exercise.</string>
<string name="context_exists">This context already exists.</string><string name="context_create_error">Context not created: %1$s</string><string name="equipment_selected">Equipment selected.</string>
<string name="positive_decimal_note">Strictly positive value; both decimal separators are accepted.</string><string name="calculated_target">Calculated target: %1$s kg</string>
<string name="set_independent_values">Each set retains its own repetitions and load.</string><string name="add_set_plain">Add a set</string>
<string name="add_set_row_description">Add an empty row without changing the other sets.</string><string name="add_to_session">Add to session</string>
<string name="delete_set_entered_detail">Its entered repetitions, duration, and load will be deleted.</string>
<string name="validation_set_required">Add at least one set.</string><string name="validation_set_reps">Set %1$d: enter repetitions between 0 and %2$d.</string>
<string name="validation_set_load">Set %1$d: enter a non-negative load or leave the field empty.</string><string name="validation_sets_invalid">Invalid set values.</string>
<string name="exercise_removed">Exercise removed from the session.</string>
<string name="target_positive">Enter a strictly positive target load.</string><string name="max_unavailable">Compatible MAX unavailable.</string>
<string name="max_assistance_unavailable">%%MAX is unavailable for assistance; choose external resistance.</string>
<string name="compatible_load_equipment_required">Choose equipment compatible with a target load.</string><string name="positive_dose_required">Define a positive target dose before the target load.</string>
<string name="percent_range">Percentage must be between 1 and 100.</string><string name="max_read_failed">Unable to read MAX.</string>
<string name="max_choose_external">Compatible MAX unavailable: choose external-resistance equipment.</string><string name="max_known_external_required">%%MAX is unavailable without known external-resistance equipment.</string><string name="max_exact_unavailable">Compatible MAX unavailable for this exact exercise and equipment.</string>
<string name="organization_sets">Sets</string><string name="supplemental_data">Supplemental data</string><string name="not_specified_feminine">Not specified</string><string name="not_specified_plural">Not specified</string>
<string name="profile_change_note">Past sessions and the occurrence already in the draft remain unchanged. The new profile applies to future uses.</string>
<string name="create_return_session">Create and return to session</string><string name="identity_update_description">Preserve identity and update the catalog.</string>
<string name="exercise_name_exists">An exercise with this name already exists.</string><string name="other_exercise_name_exists">Another exercise already has this name.</string><string name="profile_used_error">The profile cannot change after use.</string>
<string name="search_prefix">Prefix search</string><string name="profile_change_dialog_detail">Previous sessions and the active draft remain unchanged. New uses follow the new profile.</string>
<string name="profile_transition_confirmed">Transition confirmed. Save to apply the profile to future uses.</string>
<string name="science_unresolved">Scientific classification unresolved.</string><string name="conditional_interpretation">Conditional interpretation · confirm: %1$s</string>
<string name="movements_value">Movements: %1$s</string><string name="compatible_equipment_value">Compatible equipment: %1$s</string><string name="not_established">Not established</string>
<string name="create_exercise_description">Add a profile to the local catalog.</string><string name="compatible_equipment">Compatible equipment</string><string name="compatibility_none">Compatibility not established.</string>
<string name="latest_result">Latest result: %1$s kg · %2$s</string><string name="science_classification_confidence">Scientific classification · %1$s confidence</string>
<string name="conditional_classification">Conditional classification · explicit uncertainty</string><string name="confidence_high">high</string><string name="confidence_moderate">moderate</string><string name="confidence_low">low</string>
<string name="zone_not_specified">Zone: Not specified</string>
<string name="confidence_uncertain">uncertain</string><string name="primary_zone_value">Primary zone: %1$s</string><string name="secondary_zones_value"> · Secondary zones: %1$s</string><string name="group_value"> · Group: %1$s</string>
<string name="goal_general">General</string><string name="goal_strength">Strength</string><string name="goal_hypertrophy">Hypertrophy</string><string name="goal_endurance">Local endurance</string>
<string name="generator_weight_invalid">Target load must be a finite positive number.</string><string name="proposal">Proposal</string>
<string name="coverage_incomplete">Coverage is incomplete because resolved contexts are unavailable%1$s</string>
<string name="exposure_summary">Observed exposure: %1$d primary and %2$d secondary sets over 24 h; %3$d primary and %4$d secondary over 72 h.</string><string name="latest_exposure">Latest observed exposure: %1$s</string>
<string name="observed_load_note">Load derived from a dose actually observed on %1$s; its applicability today remains uncertain.</string>
<string name="max_percentage_field">MAX percentage (1 to 100)</string><string name="manual_target_load">Manual target load (empty = automatic)</string>
<string name="reestimate_description">Re-estimate duration and reclassify the observed load.</string><string name="move_up">Move up</string><string name="move_up_description">Move this exercise before the previous one.</string><string name="move_down">Move down</string><string name="move_down_description">Move this exercise after the next one.</string>
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
<string name="load_none">no load</string><string name="load_external">external load</string><string name="load_assistance">assistance</string><string name="load_bodyweight">bodyweight</string><string name="load_cardio">cardio</string>
<string name="existing_draft_warning">A session is already in progress. Resume it or return to the preserved proposal.</string><string name="no_numeric_load_proposed">no numeric load proposed</string>
<string name="incompatible_change_detail">This incompatible change will replace:\n- %1$s\nFeedback remains attached to the same entry_id.</string><string name="set_load_field">Set %1$d — %2$s</string>
<string name="selected_suffix">, selected</string><string name="not_selected_suffix">, not selected</string><string name="select_zone">Select %1$s</string>
<string name="progress_a11y_empty">Progress over six weeks: no comparable event, six empty points</string><string name="progress_a11y_values">Progress over six weeks: %1$s events</string>
<string name="chart_insufficient">Not enough data for a chart yet.</string><string name="chart_series_detail">%1$d comparable series · open details</string>
<string name="general">General</string><string name="shoulders">Shoulders</string><string name="empty_fields_ignored">Empty fields are ignored.</string><string name="latest_measurements">Latest measurements</string>
<string name="state_prioritize">Prioritize</string><string name="state_little_work">Little recent work</string><string name="state_recent_work">Recent work</string><string name="state_high_exposure">High recent exposure</string><string name="state_insufficient">Insufficient data</string><string name="state_unsupported">No resolved exercise available</string><string name="relative_priority">Relative priority</string>
<string name="no_primary_exposure">No primary exposure recorded</string><string name="latest_primary_work">Latest primary work: %1$s</string><string name="recent_primary_work">Primary work over 7 days: %1$d</string><string name="recent_secondary_work">Secondary work over 7 days: %1$d</string>
<string name="age_never">never recorded</string><string name="age_today">today</string><string name="age_yesterday">1 day ago</string><plurals name="age_days"><item quantity="one">%d day ago</item><item quantity="other">%d days ago</item></plurals>
<string name="latest_primary_work_label">Latest primary work: %1$s</string>
<string name="frequency_a11y">Frequency over six weeks: %1$s</string><string name="frequency_week_a11y">%1$s %2$s including %3$d MAX</string><string name="current_feminine">current</string>
<string name="weight">Weight</string><string name="target_summary">Target: %1$d × %2$s · rest %3$d s</string><string name="primary_zone_value_short">Primary zone: %1$s</string><string name="movement_value">Movement: %1$s</string><string name="reasons_value">Reasons: %1$s</string><string name="rest_seconds">Rest (seconds)</string>
<string name="name">Name</string><string name="primary_zone">Primary zone</string><string name="secondary_zones">Secondary zones</string><string name="add_profile_description">Add this profile to the local catalog.</string><string name="invalid_profile_name">Invalid profile or name.</string><string name="invalid_name_profile">Invalid name or profile.</string><string name="zone_filter">Filter by zone</string><string name="all_zones">All zones</string>
<string name="confidence_inline">Confidence: %1$s</string><string name="scientific_zones">Scientific zones: %1$s%2$s</string><string name="secondary_suffix"> · secondary: %1$s</string><string name="limitation_value">Limitation: %1$s</string><string name="source_value">Source: %1$s · %2$s%3$s</string><string name="exercise_catalog">Exercise catalog</string><string name="filter">Filter</string><string name="profile">Profile</string>
<string name="latest_max_value">Latest MAX · %1$s · %2$s kg · %3$s</string><string name="type_value">Type: %1$s</string><string name="aliases_value">Aliases: %1$s</string><string name="max_weight">MAX weight (kg)</string><string name="add_entry_description">Add this entry to the draft.</string>
<string name="neck">Neck</string><string name="chest">Chest</string><string name="waist">Waist</string><string name="hips">Hips</string><string name="limbs">Limbs</string>
<string name="left_arm">Left arm</string><string name="right_arm">Right arm</string><string name="left_forearm">Left forearm</string><string name="right_forearm">Right forearm</string><string name="left_thigh">Left thigh</string><string name="right_thigh">Right thigh</string><string name="left_calf">Left calf</string><string name="right_calf">Right calf</string><string name="recording">Recording</string>
<string name="body_map">Body map, %1$s</string><string name="body_front">Front</string><string name="body_back">Back</string>
<string name="plan_draft_summary">%1$s · %2$d × %3$s%4$s · rest %5$d s</string><string name="possible_exercises_plain">Possible exercises</string>
<string name="actions">Actions</string><string name="change_profile_question">Change %1$s’s profile?</string><string name="historical_context_optional">Historical context (optional)</string><string name="new_historical_context">New historical context</string>
<string name="organization">Organization</string><string name="continuous">Continuous</string><string name="primary_measurement">Primary measurement</string><string name="cancel_catalog_description">Return to the catalog without changes.</string><string name="contract">Contract</string><string name="knowledge">Knowledge</string>
<string name="remove_proposal_exercise">Remove this exercise from the proposal</string><string name="speech_interrupted">Recognition interrupted (code %1$d).</string>
<string name="draft_missing">This draft is no longer available.</string><string name="invalid_value">A value is invalid.</string><string name="positive_measure_required">Add at least one positive measurement.</string><plurals name="measurement_count"><item quantity="one">%d measurement</item><item quantity="other">%d measurements</item></plurals><plurals name="available_exercise_count"><item quantity="one">%d exercise available</item><item quantity="other">%d exercises available</item></plurals>
<string name="max_test_selected">[✓] MAX test</string><string name="max_test_unselected">[ ] MAX test</string><string name="feedback_add_count">Feedback (%1$d) · + Add feedback</string><string name="delete_facts_detail">This will delete:\n- %1$s</string><string name="plan_rest_fact">targets and rest</string>
<string name="context_name_invalid">Enter a valid context name.</string><string name="max_weight_invalid">Enter a strictly positive MAX weight (e.g. 100 or 86.5).</string><string name="invalid_values">Invalid values.</string><string name="return_catalog">Return to catalog.</string>
<string name="new_exercise">New exercise</string><string name="primary_zone_required">A primary zone is required for a new muscular exercise.</string>
<string name="feedback_voice">🎙 Feedback</string><string name="draft_max_summary">%1$s · MAX: %2$s kg</string><string name="reps_short">reps</string><string name="profile_continuous">CONTINUOUS</string><string name="profile_sets">SETS</string><string name="profile_reps">REPS</string><string name="profile_duration">DURATION</string>
<plurals name="point_count"><item quantity="one">%d point</item><item quantity="other">%d points</item></plurals>
<string name="database_save_failed">Unable to save to the database.</string><string name="knowledge_expand">+ Knowledge</string><string name="knowledge_collapse">− Knowledge</string>
<plurals name="seconds_count"><item quantity="one">%d second</item><item quantity="other">%d seconds</item></plurals><string name="discard">Discard</string>
<plurals name="feedback_count"><item quantity="one">%d feedback entry</item><item quantity="other">%d feedback entries</item></plurals><string name="during_session">During the session</string>
<string name="profile_speed_suffix"> · speed</string><string name="profile_distance_suffix"> · distance</string>
<string name="zone_full_body">Full body</string><string name="zone_upper_body">Upper body</string><string name="zone_chest">Chest</string><string name="zone_back">Back</string><string name="zone_shoulders">Shoulders</string><string name="zone_arms">Arms</string><string name="zone_core">Abdominals / core</string><string name="zone_lower_body">Lower body</string><string name="zone_glutes">Glutes</string><string name="zone_thighs">Thighs</string><string name="zone_calves">Calves</string>
<string name="performed_loads">performed loads</string><string name="explicit_max_label">explicit MAX</string>
<string name="repository_item_missing">The requested item is no longer available.</string><string name="repository_invalid_data">The requested data is invalid.</string><string name="repository_database_error">The database operation failed.</string><string name="repository_file_access">File access is required.</string><string name="repository_operation_error">The operation failed.</string>
<string name="draft_selection_missing">The exercise being entered no longer exists; only the selection was cleared. Partial input and added exercises were preserved.</string>
<string name="a11y_reorder_exercise">Reorder exercise</string>
<string name="a11y_move_exercise_up">Move exercise up</string>
<string name="a11y_move_exercise_down">Move exercise down</string>
<string name="exercise_order_saved">Session order saved.</string>
<string name="exercise_reorder_failed">Reorder was not saved: %1$s</string>
<string name="invalid_decimal_value">Enter a valid decimal value using “,” or “.”.</string>
<string name="settings_backup_section">Complete local backup</string>
<string name="settings_backup_plaintext">The file contains plaintext health data. Keep it in a private location.</string>
<string name="settings_backup_create">Create backup</string>
<string name="settings_backup_create_description">Export the complete database, active draft, and preferences.</string>
<string name="settings_backup_restore">Restore backup</string>
<string name="settings_backup_restore_description">Verify and restore this installation. Synchronization must remain stopped.</string>
<string name="settings_backup_complete">Complete backup exported.</string>
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
<string name="sleep_medications">Medications</string>
<string name="sleep_add_medication">Add medication</string>
<string name="sleep_add_intake">Add intake</string>
<string name="sleep_dose">Dose</string>
<string name="sleep_unit">Unit</string>
<string name="sleep_deactivate">Deactivate</string>
<string name="sleep_edit">Edit</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
<string name="hr_diag_status">Sensor status</string>
<string name="hr_diag_idle">Ready</string>
<string name="hr_diag_scanning">Scanning…</string>
<string name="hr_diag_connecting">Connecting…</string>
<string name="hr_diag_discovering">Discovering services…</string>
<string name="hr_diag_subscribing">Enabling measurements…</string>
<string name="hr_diag_receiving">Measurements received</string>
<string name="hr_diag_disconnected">Disconnected</string>
<string name="hr_diag_error">BLE error</string>
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
<string name="hr_diag_technical">Diagnostic: %1$s</string>
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
<string name="hr_diag_notifications">Notifications received: %1$d</string>
<string name="hr_diag_scan_section">BLE scan</string>
<string name="hr_diag_scan">Find heart-rate</string>
<string name="hr_diag_scan_all">Expanded scan</string>
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
<string name="hr_diag_devices">BLE devices</string>
<string name="hr_diag_no_devices">No device found yet.</string>
<string name="hr_diag_unnamed_device">Unnamed device</string>
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
<string name="hr_diag_use_sensor">Use this sensor</string>
<string name="heart_rate_service_channel">Heart-rate sensor</string>
<string name="heart_rate_service_title">Trainlog · cardio</string>
<string name="heart_rate_service_connecting">Connecting to sensor…</string>
<string name="heart_rate_service_reconnecting">Waiting for heart-rate signal…</string>
<string name="heart_rate_service_disconnected">Heart-rate sensor disconnected</string>
<string name="heart_rate_service_bpm">%1$d BPM</string>
<string name="heart_rate_a11y_connected">Heart rate %1$d BPM, sensor connected</string>
<string name="heart_rate_a11y_connecting">Heart-rate sensor connecting</string>
<string name="heart_rate_a11y_reconnecting">Heart-rate signal temporarily lost, reconnecting</string>
<string name="heart_rate_a11y_disconnected">Heart-rate sensor disconnected</string>
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
<string name="settings_cardio_forget">Forget sensor</string>
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
<string name="exercise_timeline_start">Start exercise</string>
<string name="exercise_timeline_finish">Finish exercise</string>
<string name="exercise_timeline_completed">Exercise completed</string>
<string name="exercise_timeline_started">%1$s · started</string>
<string name="exercise_timeline_finished">%1$s · finished</string>
<string name="exercise_timeline_switch_title">Switch exercise?</string>
<string name="exercise_timeline_switch_detail">%1$s is still active. Finish it now and then start %2$s?</string>
<string name="exercise_timeline_switch_confirm">Finish and start</string>
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
<string name="sleep_night_capture_title">Night capture</string>
<string name="sleep_no_night_active">No night in progress.</string>
<string name="sleep_night_in_progress">Night in progress · %1$s → %2$s</string>
<string name="sleep_quick_bed">🛏 BED</string>
<string name="sleep_quick_wake">👁 WAKE</string>
<string name="sleep_quick_get_up">☀ GET UP</string>
<string name="sleep_bed_recorded">Bed time recorded.</string>
<string name="sleep_wake_recorded">30-minute awakening recorded.</string>
<string name="sleep_get_up_recorded">Get-up recorded.</string>
<string name="sleep_medication_recorded">%1$s recorded.</string>
<string name="sleep_undo">UNDO</string>
<string name="sleep_action_undone">Last action undone.</string>
<string name="sleep_undo_unavailable">Undo unavailable: the night has already changed.</string>
<string name="sleep_correct_night">Correct the night</string>
<string name="sleep_return_night_mode">Back to night capture</string>
<string name="sleep_night_timeline">Night timeline</string>
<string name="sleep_quick_no_active">No night in progress.</string>
<string name="sleep_quick_already_active">A night is already in progress.</string>
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
<string name="sleep_quick_error">Unable to record the action.</string>
<string name="session_cardio">Cardio</string>
<string name="route_cardio_calibration">Cardio calibration</string>
<string name="calibration_explanation">This session measures your current heart-rate response. Effort increases progressively and you can stop immediately at any time. The result is stored as an observed heart-rate peak, not as a medical maximum heart rate.</string>
<string name="calibration_start">Start calibration</string>
<string name="calibration_start_description">Create a dedicated cardio session, start heart-rate capture and begin with warm-up.</string>
<string name="calibration_latest">Latest calibration</string>
<string name="calibration_current">Calibration in progress</string>
<string name="calibration_phase_value">Phase: %1$s</string>
<string name="calibration_phase_warmup">Warm-up</string>
<string name="calibration_phase_progressive">Progressive effort</string>
<string name="calibration_phase_high_effort">High effort</string>
<string name="calibration_phase_recovery">Recovery</string>
<string name="calibration_phase_completed">Completed</string>
<string name="calibration_phase_aborted">Stopped</string>
<string name="calibration_peak_value">Observed peak: %1$d BPM</string>
<string name="calibration_recovery_value">Recovery +%1$d min: %2$d BPM</string>
<string name="calibration_to_progressive">Start progressive effort</string>
<string name="calibration_progressive_description">Increase effort progressively without trying to beat a previous value.</string>
<string name="calibration_to_high_effort">Move to high effort</string>
<string name="calibration_high_effort_description">Continue only while you choose to tolerate the effort. The next button ends the effort phase immediately.</string>
<string name="calibration_end_effort">Stop effort</string>
<string name="calibration_end_effort_description">Mark the end of effort and begin measured recovery.</string>
<string name="calibration_finish">Finish calibration</string>
<string name="calibration_finish_description">Close recovery and save the calibration profile.</string>
<string name="calibration_abort">Stop calibration</string>
<string name="calibration_conflict">Another session or calibration is already active.</string>
<string name="cardio_guidance_title">Cardio guidance</string>
<string name="cardio_guidance_phase_value">Phase: %1$s</string>
<string name="cardio_guidance_target_value">Target: %1$d–%2$d BPM</string>
<string name="cardio_guidance_calibration_value">Calibration: observed peak %1$d BPM · %2$d–%3$d %%</string>
<string name="cardio_guidance_latest_calibration">Latest calibration: observed peak %1$d BPM</string>
<string name="cardio_guidance_resolved_target">Resolved target: %1$d–%2$d BPM</string>
<string name="cardio_guidance_accelerate">SPEED UP</string>
<string name="cardio_guidance_maintain">MAINTAIN</string>
<string name="cardio_guidance_slow_down">SLOW DOWN</string>
<string name="cardio_guidance_suspended">HEART-RATE SIGNAL UNAVAILABLE</string>
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
<string name="cardio_guidance_bpm_missing">-- BPM</string>
<string name="cardio_guidance_start_phase">Start phase</string>
<string name="cardio_guidance_finish_phase">End phase</string>
<string name="cardio_guidance_invalid_configuration">Complete the target and exit condition.</string>
<string name="cardio_guidance_start_failed">Unable to start this phase.</string>
<string name="cardio_phase_warmup">Warm-up</string>
<string name="cardio_phase_work">Work</string>
<string name="cardio_phase_recovery">Recovery</string>
<string name="cardio_phase_cooldown">Cool-down</string>
<string name="cardio_target_absolute">BPM</string>
<string name="cardio_target_calibration">Calibration %%</string>
<string name="cardio_target_min_bpm">Min BPM</string>
<string name="cardio_target_max_bpm">Max BPM</string>
<string name="cardio_target_min_percent">Min %%</string>
<string name="cardio_target_max_percent">Max %%</string>
<string name="cardio_exit_duration">Duration</string>
<string name="cardio_exit_target">Enter target</string>
<string name="cardio_exit_recover">Below threshold</string>
<string name="cardio_exit_bounded">Duration or threshold</string>
<string name="cardio_exit_seconds">Max duration</string>
<string name="cardio_exit_recovery_bpm">Recovery threshold</string>
</resources>

View file

@ -1,4 +1,630 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Trainlog</string> <string name="app_name">Trainlog</string>
<string name="nav_home">Accueil</string>
<string name="nav_sessions">Séances</string>
<string name="nav_exercises">Exercices</string>
<string name="nav_equipment">Équipements</string>
<string name="nav_statistics">Statistiques</string>
<string name="nav_sync">Synchronisation</string>
<string name="nav_settings">Paramètres</string>
<string name="nav_sleep">Sommeil</string>
<string name="sleep_current_night">Nuit actuelle</string>
<string name="sleep_morning">Matin</string>
<string name="sleep_day">Journée</string>
<string name="sleep_bed_time">Mise au lit</string>
<string name="sleep_final_get_up">Lever</string>
<string name="sleep_night_get_up">Lever nocturne</string>
<string name="sleep_sleep">Sommeil</string>
<string name="sleep_nap">Sieste</string>
<string name="sleep_long_awake">Long réveil</string>
<string name="sleep_half_sleep">Demi-sommeil</string>
<string name="sleep_sleepiness">Somnolence</string>
<string name="sleep_quality">Qualité du sommeil</string>
<string name="sleep_wake_quality">Qualité du réveil</string>
<string name="sleep_day_form">Forme de la journée</string>
<string name="sleep_notes">Traitement et remarques</string>
<string name="sleep_add_event">Ajouter un événement</string>
<string name="sleep_save">Enregistrer</string>
<string name="sleep_validate">Valider la journée</string>
<string name="sleep_status_draft">Brouillon</string>
<string name="sleep_status_ready">Prête à synchroniser</string>
<string name="sleep_status_synchronized">Synchronisée</string>
<string name="sleep_status_modified">Modifiée depuis la synchronisation</string>
<string name="sleep_delete">Supprimer</string>
<string name="sleep_choose_time">Choisir l’heure</string>
<string name="route_session_editor">Séance en cours</string>
<string name="route_session_generator">Programmer une séance</string>
<string name="route_ai_drafts">Brouillons</string>
<string name="route_completed_sessions">Séances effectuées</string>
<string name="route_programs">Programmes</string>
<string name="route_program_detail">Détail du programme</string>
<string name="programs_none">Aucun programme synchronisé.</string>
<string name="program_active">ACTIF</string>
<string name="program_archived">ARCHIVÉ</string>
<string name="program_start_date">Début : %1$s</string>
<string name="program_end_date">Fin : %1$s</string>
<string name="program_open">Afficher le programme</string>
<string name="program_not_found">Programme introuvable.</string>
<string name="program_target_sets">%1$d séries</string>
<string name="program_target_reps">%1$d répétitions</string>
<string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string>
<string name="program_session_todo">À faire</string>
<string name="program_session_in_progress">En cours</string>
<string name="program_session_completed">Effectuée</string>
<string name="program_session_start">Démarrer</string>
<string name="program_session_resume">Reprendre la séance</string>
<string name="program_session_completed_detail">Cette séance a déjà été effectuée.</string>
<string name="program_session_unavailable">Cette séance ne peut plus être démarrée.</string>
<string name="program_session_show_occurrences">Voir les exercices</string>
<string name="program_session_hide_occurrences">Masquer les exercices</string>
<plurals name="program_session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
<string name="route_session_detail">Détail de la séance</string>
<string name="route_session_correction">Modifier la séance</string>
<string name="route_exercise_detail">Fiche exercice</string>
<string name="route_exercise_edit">Modifier l’exercice</string>
<string name="route_exercise_create">Créer un exercice</string>
<string name="route_equipment_detail">Fiche équipement</string>
<string name="route_equipment_create">Créer un équipement</string>
<string name="route_body_measurements">Mensurations</string>
<string name="route_latest_maxima">Derniers MAX</string>
<string name="a11y_open_navigation">Ouvrir la navigation</string>
<string name="a11y_back">Retour</string>
<string name="a11y_navigation">Navigation</string>
<string name="settings_language_section">LANGUE</string>
<string name="settings_language_label">Langue</string>
<string name="language_french">Français</string>
<string name="language_english">English</string>
<string name="settings_exchange_folder">DOSSIER D’ÉCHANGE</string>
<string name="settings_screen">Paramètres</string>
<string name="dialog_cancel">Annuler</string>
<string name="dialog_keep_leave">Conserver et quitter</string>
<string name="dialog_discard_proposal">Abandonner la proposition</string>
<string name="dialog_discard_changes">Abandonner les modifications</string>
<string name="dialog_unaccepted_proposal">Proposition non acceptée</string>
<string name="dialog_unsaved_changes">Modifications non enregistrées</string>
<string name="dialog_proposal_retained">La proposition peut rester en mémoire pendant que vous changez de rubrique.</string>
<string name="dialog_fields_retained">Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.</string>
<string name="a11y_delete_exercise">Supprimer cet exercice</string>
<string name="a11y_delete_current_session">Supprimer la séance en cours</string>
<string name="a11y_delete_set">Supprimer cette série</string>
<string name="a11y_remove_machine">Retirer la machine de cet exercice</string>
<string name="a11y_delete_draft">Supprimer ce brouillon</string>
<string name="home_session">Séance</string>
<string name="home_sync_state">État et actions</string>
<string name="home_body_tracking">Suivi corporel</string>
<string name="settings_access_granted">Accès fichiers autorisé.</string>
<string name="settings_access_required">Accès fichiers toujours requis.</string>
<string name="settings_folder_granted">Dossier d’échange : Documents/Trainlog\nAccès fichiers : autorisé</string>
<string name="settings_folder_required">Accès fichiers requis</string>
<string name="settings_open_access">Ouvrir les réglages d’accès</string>
<string name="settings_authorize">Autoriser</string>
<string name="settings_authorize_description">Autoriser l’accès au dossier Trainlog dans Android.</string>
<string name="settings_reload_catalog">Relire le catalogue PC</string>
<string name="settings_reload_description">Appliquer explicitement le catalogue présent dans le dossier autorisé.</string>
<string name="settings_no_catalog">Aucun catalogue PC reçu.</string>
<string name="settings_folder_revoked">Le dossier n’est plus autorisé.</string>
<string name="sync_folder_unauthorized">Dossier Trainlog non autorisé.</string>
<string name="sync_complete">Synchronisation terminée · Catalogue Android : %1$s.</string>
<string name="sync_receipt_failure">La synchronisation PC a échoué. Consultez le journal du PC pour le diagnostic.</string>
<string name="sync_pc_catalog_missing">Synchronisation PC terminée, mais catalogue reçu introuvable.</string>
<string name="sync_pc_folder_unauthorized">Synchronisation PC terminée, dossier Trainlog non autorisé.</string>
<string name="sync_pc_import_error">Synchronisation PC terminée, import Android : %1$s</string>
<string name="sync_timeout">Le PC n’a pas répondu dans les 60 secondes.</string>
<string name="sync_access_ready">Accès fichiers autorisé · Documents/Trainlog prêt.</string>
<string name="sync_access_enable">Accès fichiers requis. Activez l’autorisation dans Android.</string>
<string name="sync_access_catalog">Accès autorisé · catalogue PC : %1$s.</string>
<string name="sync_access_no_catalog">Accès autorisé · aucun catalogue PC reçu.</string>
<string name="sync_action">Synchroniser</string>
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</string>
<string name="sync_interrupted">Synchronisation interrompue. Une nouvelle tentative peut être lancée.</string>
<string name="sync_now">Synchroniser maintenant</string>
<string name="background_sync_title">Synchronisation en arrière-plan</string>
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
<string name="background_sync_enable">Activer la synchronisation en arrière-plan</string>
<string name="background_sync_disable">Désactiver la synchronisation en arrière-plan</string>
<string name="background_sync_channel">Synchronisation Trainlog</string>
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
<string name="bluetooth_sync_title">Bluetooth automatique</string>
<string name="bluetooth_sync_description">Trainlog se connecte automatiquement au PC appairé dès qu’il est à portée. MTP reste disponible en secours.</string>
<string name="bluetooth_sync_permission">Autoriser l’accès Bluetooth</string>
<string name="bluetooth_sync_permission_required">Autorisation Bluetooth requise pour la connexion automatique.</string>
<string name="bluetooth_sync_permission_granted">Bluetooth autorisé. Sélectionnez le PC appairé.</string>
<string name="bluetooth_sync_no_desktop">Aucun ordinateur appairé détecté.</string>
<string name="bluetooth_sync_select">Utiliser %1$s</string>
<string name="bluetooth_sync_selected">PC Bluetooth sélectionné : %1$s.</string>
<string name="bluetooth_sync_disconnect">Oublier le PC Bluetooth</string>
<string name="bluetooth_sync_address">%1$s</string>
<string name="drive_sync_title">Export IA Google Drive</string>
<string name="drive_sync_description">Sélectionnez une fois le dossier Trainlog/AI. Après chaque synchronisation, Android y publie automatiquement trainlog_ai_export_v1.json.</string>
<string name="drive_sync_connect">Choisir Trainlog/AI</string>
<string name="drive_sync_disconnect">Déconnecter l’export IA Drive</string>
<string name="drive_sync_latency">L’échec de Drive ne bloque jamais la synchronisation. Android conserve l’export local et réessaie automatiquement.</string>
<string name="drive_sync_connected">Dossier Trainlog/AI connecté. L’export sera publié automatiquement.</string>
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Trainlog/AI.</string>
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
<string name="sync_generation_complete">Synchronisation terminée sur Android et PC.</string>
<string name="sync_android_unsupported">Android non supporté.</string>
<string name="sync_folder_section">DOSSIER D’ÉCHANGE</string>
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
<string name="sync_manage_files">Autoriser l’accès pour gérer tous les fichiers dans Android.</string>
<string name="sync_manual_recovery">Action de récupération manuelle si nécessaire.</string>
<string name="sync_catalog_result">Catalogue PC : %1$s.</string>
<string name="sync_folder_access_required">Accès fichiers requis pour Documents/Trainlog.</string>
<string name="sync_status">État</string>
<string name="sync_prepare_error">Préparation Android → PC : %1$s</string>
<plurals name="catalog_new"><item quantity="one">%d nouveau</item><item quantity="other">%d nouveaux</item></plurals>
<plurals name="catalog_reconciled"><item quantity="one">%d réconcilié</item><item quantity="other">%d réconciliés</item></plurals>
<plurals name="catalog_present"><item quantity="one">%d présent</item><item quantity="other">%d présents</item></plurals>
<string name="history_none">Aucune séance enregistrée.</string>
<string name="max_none">Aucun MAX explicite enregistré.</string>
<string name="equipment_value">Équipement : %1$s</string>
<string name="value_none">aucun</string>
<string name="session_training">ENTRAÎNEMENT</string>
<string name="session_max_test">TEST MAX</string>
<plurals name="exercise_count"><item quantity="one">%d exercice</item><item quantity="other">%d exercices</item></plurals>
<string name="date_time_pattern">dd/MM/yyyy HH:mm</string>
<string name="date_pattern">dd/MM/yyyy</string>
<string name="correction_profile_note">Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.</string>
<string name="field_target_sets">Nombre de séries cible</string>
<string name="field_target_reps">Répétitions cibles</string>
<string name="field_target_duration">Durée cible par série (s)</string>
<string name="field_rest_seconds">Repos (s)</string>
<string name="field_target_weight">Charge cible (kg)</string>
<string name="field_explicit_max">MAX explicite (kg)</string>
<string name="field_continuous_duration">Durée continue (s)</string>
<string name="field_speed">Vitesse (km/h)</string>
<string name="field_distance">Distance (km)</string>
<string name="field_reps">Répétitions</string>
<string name="field_duration_seconds">Durée (s)</string>
<string name="field_weight">Charge (kg)</string>
<string name="set_number">Série %1$d</string>
<string name="add_set">+ Ajouter une série</string>
<string name="add_set_description">Ajouter explicitement un fait réalisé.</string>
<string name="delete_occurrence">Supprimer cette occurrence</string>
<string name="save">Enregistrer</string>
<string name="correction_save_description">Appliquer toute la correction dans une transaction.</string>
<string name="cancel_no_database_change">Quitter sans modifier la base.</string>
<string name="delete_set_question">Supprimer cette série ?</string>
<string name="delete_set_detail">Ce fait réalisé disparaîtra de la séance corrigée.</string>
<string name="delete">Supprimer</string>
<string name="delete_occurrence_question">Supprimer cette occurrence ?</string>
<string name="a11y_delete_occurrence">Supprimer cette occurrence</string>
<plurals name="performed_set_count"><item quantity="one">%d série réalisée</item><item quantity="other">%d séries réalisées</item></plurals>
<string name="occurrence_delete_impact">Impact : %1$s%2$s%3$s. Ses ressentis et révisions seront supprimés avec leur parent.</string>
<string name="continuous_activity_suffix">, activité continue</string>
<string name="max_suffix">, MAX</string>
<string name="session_not_found">Séance introuvable.</string>
<string name="feedback_dictation">Dictée du ressenti</string>
<string name="feedback_recording">● Enregistrement en cours</string>
<string name="feedback_stop">Arrêter la dictée</string>
<string name="feedback_text">Texte</string>
<string name="feedback_resume">Reprendre la dictée</string>
<string name="feedback_start">Démarrer la dictée</string>
<string name="save_changes">Enregistrer les modifications</string>
<string name="feedback_revision_description">Créer une nouvelle révision immuable.</string>
<string name="feedback_create_description">Créer un enregistrement historique immuable.</string>
<string name="save_nothing">Ne rien enregistrer.</string>
<string name="feedback_microphone_denied">Microphone refusé : la saisie manuelle reste disponible.</string>
<string name="feedback_speech_unavailable">Reconnaissance vocale indisponible : utilisez la saisie manuelle.</string>
<string name="feedback_empty">Un ressenti vide ne peut pas être enregistré.</string>
<string name="current_session_none">Aucune séance en cours.</string>
<string name="prepare">Préparer</string>
<string name="new_manual_session">Nouvelle séance manuelle</string>
<string name="create_or_resume">Créer ou reprendre</string>
<plurals name="prepared_session_count"><item quantity="one">%d séance préparée</item><item quantity="other">%d séances préparées</item></plurals>
<string name="draft_none">Aucun brouillon importé.</string>
<string name="delete_draft_question">Supprimer ce brouillon ?</string>
<string name="max_capacities">CAPACITÉS / MAX</string>
<string name="equipment_catalog">Catalogue d’équipements</string>
<string name="catalog">Catalogue</string>
<string name="equipment_none">Aucun équipement trouvé.</string>
<string name="load_value">Charge : %1$s</string>
<string name="possible_exercises">Exercices possibles</string>
<string name="association_none">Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.</string>
<string name="settings_folder_imported">Dossier autorisé · %1$s.</string>
<string name="settings_catalog_reloaded">Catalogue relu · %1$s.</string>
<string name="body_save">Enregistrer les mensurations</string>
<string name="body_saved">Mensurations enregistrées.</string>
<string name="local_database_error">Erreur de base locale.</string>
<string name="body_none">Aucun relevé enregistré.</string>
<string name="exercise_save">Enregistrer l’exercice</string>
<string name="existing_exercises">Exercices existants</string>
<string name="exercise_none_saved">Aucun exercice enregistré.</string>
<string name="modify_named">Modifier · %1$s</string>
<string name="view_latest_max">Voir les derniers MAX</string>
<string name="name_no_rules">Aucune règle ne dépend du nom.</string>
<string name="tracking_change_question">Modifier le mode de suivi ?</string>
<string name="modify">Modifier</string>
<string name="exercise_not_found">Exercice introuvable.</string>
<string name="profile_loading">Chargement du profil…</string>
<string name="primary_muscles">Muscles principaux : %1$s</string>
<string name="secondary_muscles">Muscles secondaires : %1$s</string>
<string name="stabilizers">Stabilisateurs : %1$s</string>
<string name="unclassified">Non classés</string>
<string name="none_established">Aucun établi</string>
<string name="exercise_none_found">Aucun exercice trouvé.</string>
<string name="exercise_modify_description">Modifier le nom, le profil et les zones selon les règles existantes.</string>
<string name="knowledge_none">Aucune connaissance scientifique liée à cet identifiant.</string>
<string name="view_latest_max_description">Ouvrir la liste agrégée existante dans Statistiques.</string>
<string name="value_none_feminine">Aucune</string>
<string name="field_duration">Durée</string>
<string name="field_speed_short">Vitesse</string>
<string name="field_distance_short">Distance</string>
<string name="error_title">Erreur</string>
<string name="draft_unsaved">Brouillon non sauvegardé : %1$s</string>
<string name="draft_unsaved_title">Brouillon non sauvegardé</string>
<string name="last_change_unsaved">Dernière modification non sauvegardée.</string>
<string name="session_saved_locally">Séance sauvegardée localement.</string>
<string name="normal_session_description">Séance normale de travail.</string>
<string name="max_session_description">Séance explicitement dédiée à une mesure de MAX.</string>
<string name="exercise_none_added">Aucun exercice ajouté.</string>
<string name="performed_sets_none">Aucune série réalisée saisie : ajoutez les valeurs réellement effectuées.</string>
<string name="modify_name">Modifier %1$s</string>
<string name="feedback_edit_description">Corriger le texte sans changer l’observation.</string>
<string name="exercise_added">Exercice ajouté à la séance.</string>
<string name="exercise_modified">Exercice modifié.</string>
<string name="session_save">Enregistrer la séance</string>
<string name="exercise_title">Exercice</string>
<string name="exercise_none">Aucun exercice.</string>
<string name="choose_exercise">Choisir un exercice</string>
<string name="exercise_picker_description">Exercice sélectionné. Appuyer pour afficher les suggestions.</string>
<string name="search_exercise">Rechercher un exercice…</string>
<string name="exercise_none_found_short">Aucun exercice trouvé</string>
<string name="cancel_search">Annuler la recherche</string>
<string name="keep_named_values">Conserver %1$s et les valeurs saisies.</string>
<string name="entry_title">SAISIE — %1$s</string>
<string name="target_load_note">Charge cible prévue — séparée des charges réellement effectuées.</string>
<string name="load_kg_value">Valeur en kg</string>
<string name="percent_my_max">%% de mon MAX</string>
<string name="percent_my_max_field">Pourcentage de mon MAX (1 à 100)</string>
<string name="compatible_max">MAX compatible : %1$s kg · %2$s</string>
<string name="target_none">Aucune charge cible ne sera enregistrée.</string>
<string name="set_reps_field">Série %1$d — Répétitions</string>
<string name="optional_load_note">Charge facultative ; les deux séparateurs décimaux sont acceptés. Une case vide n’est pas zéro.</string>
<string name="sets_count">Nombre de séries</string>
<string name="set_duration_seconds">Durée par série (secondes)</string>
<string name="duration_minutes">Durée (minutes)</string>
<string name="speed_kmh">Vitesse km/h</string>
<string name="distance_km">Distance km</string>
<string name="invalid_target_load">Charge cible invalide.</string>
<string name="cancel_entry">Annuler la saisie</string>
<string name="delete_numbered_set_question">Supprimer la série %1$d ?</string>
<string name="session_type_value">Type : %1$s</string>
<string name="resume_max_test">Reprendre ce test MAX</string>
<string name="resume_max_description">Continuer la même séance en conservant son identifiant et sa date.</string>
<string name="equipment_inline">Équipement : %1$s</string>
<string name="plan_summary">Plan : %1$d × %2$s · repos %3$d s · %4$s</string>
<string name="target_load_inline">charge cible %1$s kg</string>
<string name="no_numeric_load">sans charge numérique</string>
<string name="search_machine">Rechercher une machine</string>
<string name="modify_equipment">Modifier l’équipement</string>
<string name="modify_equipment_description">Choisir, remplacer ou retirer la machine de cette entrée.</string>
<string name="max_value">MAX : %1$s kg</string>
<string name="modified_suffix"> · Modifié</string>
<string name="feedback_move_description">Corriger le texte sans déplacer l’observation.</string>
<string name="add_feedback">Ajouter un ressenti</string>
<string name="session_follow_up">Suivi après séance</string>
<string name="feedback_marker_description">Corriger le texte sans changer le repère H+.</string>
<string name="add_record">Ajouter un enregistrement</string>
<string name="remove_machine_question">Retirer la machine de cette entrée ?</string>
<string name="replace_machine_question">Remplacer la machine associée ?</string>
<string name="machine_change_detail">L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.</string>
<string name="remove">Retirer</string>
<string name="replace">Remplacer</string>
<string name="mode_sets_reps">Mode : séries · répétitions</string>
<string name="mode_sets_duration">Mode : séries · durée</string>
<string name="assistance_kg">Assistance (kg)</string>
<string name="sets_table">Série | Répétitions | %1$s</string>
<string name="set_duration">Série %1$d : %2$s</string>
<string name="mode_continuous">Mode : continu</string>
<string name="duration_value">Durée : %1$s</string>
<string name="speed_value">Vitesse : %1$.1f km/h</string>
<string name="distance_value">Distance : %1$.2f km</string>
<plurals name="duration_minutes_value"><item quantity="one">%d min</item><item quantity="other">%d min</item></plurals>
<plurals name="duration_seconds_value"><item quantity="one">%d s</item><item quantity="other">%d s</item></plurals>
<string name="period_7_days">7 j</string><string name="period_30_days">30 j</string><string name="period_90_days">90 j</string><string name="period_year">1 an</string><string name="period_all">Tout</string>
<string name="statistics_invalid_ignored">Certaines données historiques invalides ont été ignorées ; les statistiques valides restent disponibles.</string>
<string name="explore">EXPLORER</string><string name="distribution">Répartition</string>
<string name="distribution_description">Zones et utilisation des exercices, uniquement lorsqu’elles sont classifiées.</string>
<string name="global_summary">Résumé global</string><string name="global_summary_description">Indicateurs prudents dérivés des données disponibles.</string>
<string name="summary_period">RÉSUMÉ · %1$s</string><string name="sets">Séries</string><string name="explicit_max_plural">MAX explicites</string>
<string name="progression">PROGRESSION</string><string name="progress_insufficient">Données insuffisantes pour établir une progression comparable.</string>
<string name="progress_counts">%1$d progressions de travail · %2$d records MAX</string><string name="max_open_detail">%1$d MAX explicites · ouvrir le détail</string>
<string name="body_period_none">Aucune mensuration dans cette période.</string><string name="measurement_single">1 relevé · tendance indisponible</string>
<string name="frequency">FRÉQUENCE</string><string name="current_week_summary">Semaine actuelle · %1$s · %2$d MAX inclus</string>
<string name="max_not_double_counted">Les MAX signalent des séances ; ils ne sont jamais additionnés une seconde fois.</string>
<string name="back_dashboard">← Retour au tableau de bord</string><string name="comparable_none">Aucune donnée comparable dans cette période.</string>
<plurals name="session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
<string name="progress_detail_description">Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables ; le tableau de bord ne somme jamais des kg.</string>
<string name="body_detail_description">Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.</string>
<string name="frequency_title">Fréquence</string><string name="frequency_unit">séances</string>
<string name="frequency_detail_description">Nombre de séances avec travail réel par semaine ; MAX est un marqueur, pas une seconde séance.</string>
<string name="distribution_detail_description">La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.</string>
<string name="summary_detail">%1$d séances · %2$d séries · %3$d exercices · %4$d MAX explicites</string>
<string name="generator_zone">Zone corporelle</string><string name="generator_goal">Objectif</string>
<string name="generator_custom_duration">Durée personnalisée (%1$d à %2$d min)</string>
<string name="generate">Générer</string><string name="generate_description">Analyser l’historique en lecture seule et préparer la proposition.</string>
<string name="duration_range_error">La durée doit être comprise entre %1$d et %2$d minutes.</string>
<string name="target_duration_estimate">Durée cible : %1$d min · estimation : %2$d min</string>
<string name="proposal_short">La proposition est nettement plus courte que la cible ; aucun exercice n’est ajouté pour remplir artificiellement le temps.</string>
<string name="warmup_not_generated">Échauffement et retour au calme ne sont pas générés en V1.</string>
<string name="recent_exposure_warning">Attention informative : l’historique montre une exposition récente. Cela ne mesure pas la récupération physiologique.</string>
<string name="continue_warning">Continuer malgré l’avertissement</string><string name="continue_warning_description">Conserver la proposition et autoriser son acceptation.</string>
<string name="equipment_generator">Équipement : %1$s</string><string name="target_load_none_proposed">Charge cible : aucune charge numérique proposée.</string><string name="target_load_proposed">Charge cible : %1$s kg</string>
<string name="recent_similar">Historique récent similaire détecté ; information uniquement.</string>
<string name="sets_label">Séries</string><string name="user_load_note">Charge : choix utilisateur, jamais une recommandation.</string>
<string name="automatic">Automatique</string><string name="none">Aucune</string><string name="apply">Appliquer</string>
<string name="modify_target">Modifier la cible</string><string name="modify_target_description">Modifier séries, répétitions, repos ou charge.</string>
<string name="regenerate">Régénérer</string><string name="accept_actual_values">Accepter et saisir les valeurs réelles</string>
<string name="cancel_proposal">Annuler la proposition</string><string name="cancel_proposal_description">Revenir sans écrire de brouillon.</string>
<string name="processing">Traitement</string><string name="analysis_running">Analyse en cours…</string><string name="message_title">Message</string>
<string name="resume">Reprendre</string><string name="durable_draft_count">%1$s · le brouillon durable est conservé.</string>
<string name="session_proposal">Proposition de séance</string><string name="planned_for">Prévue le %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="manual_preparation">Préparation manuelle</string>
<string name="start_preparation_description">Crée une séance en cours protégée.</string>
<string name="draft_rest_short">repos %1$d s</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>
<string name="new_manual_description">Créer explicitement un brouillon durable.</string><string name="latest_session">Dernière séance</string><string name="quick_access">Accès rapides</string>
<string name="zones_to_work">Zones à travailler</string><string name="recent_exposure">Exposition récente</string><string name="no_training_history">Pas encore d’historique d’entraînement.</string>
<string name="invalid_history_timestamp">Données insuffisantes : un horodatage historique ne peut pas être interprété.</string>
<string name="selection_value">Sélection : %1$s</string><string name="priorities">Priorités</string><string name="resolved_zone_none">Aucune zone disposant d’un exercice résolu n’est disponible.</string>
<string name="exposure_explanation">Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.</string><string name="understood">Compris</string>
<string name="view_exercises">Voir les exercices</string><string name="view_statistics">Voir les statistiques</string>
<string name="session_type">Type de séance</string><string name="training_selected">[✓] Entraînement</string><string name="training_unselected">[ ] Entraînement</string>
<string name="edit_exercise_description">Corriger cet exercice sans le supprimer de la séance.</string><string name="editor_opened">Éditeur ouvert pour %1$s — les champs préremplis apparaissent ci-dessous.</string>
<string name="editing_scroll_hint">Modification en cours — faites défiler jusqu’à SAISIE — %1$s.</string><string name="feedback_value">Ressenti · %1$s%2$s</string>
<string name="feedback_now_description">Dicter ou saisir un ressenti maintenant, avant la fin de la séance.</string><string name="feedback_saved_current">Ressenti sauvegardé dans la séance en cours.</string>
<string name="create_new_exercise">Créer un nouvel exercice</string><string name="finalize_failed">Échec de finalisation, brouillon conservé : %1$s</string>
<string name="session_exercise_total">%1$s dans la séance.</string><string name="discard_session_question">Abandonner la séance en cours ?</string>
<string name="discard_session_detail">Cette action supprimera le brouillon, ses %1$s et toutes leurs données enregistrées.</string>
<string name="remove_exercise_question">Supprimer « %1$s » de la séance ?</string><string name="remove_occurrence_detail">Cette occurrence sera retirée.</string>
<string name="continuous_fact">l’activité continue</string><string name="max_fact">la donnée MAX</string>
<string name="create_context">Créer le contexte</string><string name="context_compatibility">Compatibilité temporaire pour un exercice personnalisé ou non résolu.</string>
<string name="context_exists">Ce contexte existe déjà.</string><string name="context_create_error">Contexte non créé : %1$s</string><string name="equipment_selected">Équipement sélectionné.</string>
<string name="positive_decimal_note">Valeur strictement positive ; les deux séparateurs décimaux sont acceptés.</string><string name="calculated_target">Cible calculée : %1$s kg</string>
<string name="set_independent_values">Chaque série conserve ses propres répétitions et sa propre charge.</string><string name="add_set_plain">Ajouter une série</string>
<string name="add_set_row_description">Ajouter une ligne vide sans modifier les autres séries.</string><string name="add_to_session">Ajouter à la séance</string>
<string name="delete_set_entered_detail">Ses répétitions, sa durée et sa charge saisies seront supprimées.</string>
<string name="validation_set_required">Ajoutez au moins une série.</string><string name="validation_set_reps">Série %1$d : saisissez des répétitions entre 0 et %2$d.</string>
<string name="validation_set_load">Série %1$d : saisissez une charge non négative ou laissez la case vide.</string><string name="validation_sets_invalid">Valeurs de séries invalides.</string>
<string name="exercise_removed">Exercice retiré de la séance.</string>
<string name="target_positive">Saisissez une charge cible strictement positive.</string><string name="max_unavailable">MAX compatible indisponible.</string>
<string name="max_assistance_unavailable">Le %%MAX est indisponible pour une assistance ; choisissez une résistance externe.</string>
<string name="compatible_load_equipment_required">Choisissez un équipement compatible avec une charge cible.</string><string name="positive_dose_required">Définissez une dose cible positive avant la charge cible.</string>
<string name="percent_range">Le pourcentage doit être compris entre 1 et 100.</string><string name="max_read_failed">Lecture du MAX impossible.</string>
<string name="max_choose_external">MAX compatible indisponible : choisissez un équipement à résistance externe.</string><string name="max_known_external_required">Le %%MAX est indisponible sans équipement connu à résistance externe.</string><string name="max_exact_unavailable">MAX compatible indisponible pour cet exercice et cet équipement exacts.</string>
<string name="organization_sets">Séries</string><string name="supplemental_data">Données complémentaires</string><string name="not_specified_feminine">Non renseignée</string><string name="not_specified_plural">Non renseignés</string>
<string name="profile_change_note">Les séances passées et l’occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s’appliquera aux prochaines utilisations.</string>
<string name="create_return_session">Créer et revenir à la séance</string><string name="identity_update_description">Conserver l’identité et mettre à jour le catalogue.</string>
<string name="exercise_name_exists">Un exercice portant ce nom existe déjà.</string><string name="other_exercise_name_exists">Un autre exercice porte déjà ce nom.</string><string name="profile_used_error">Le profil ne peut pas changer après utilisation.</string>
<string name="search_prefix">Recherche par préfixe</string><string name="profile_change_dialog_detail">Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.</string>
<string name="profile_transition_confirmed">Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations.</string>
<string name="science_unresolved">Classification scientifique non résolue.</string><string name="conditional_interpretation">Interprétation conditionnelle · à confirmer : %1$s</string>
<string name="movements_value">Mouvements : %1$s</string><string name="compatible_equipment_value">Équipement compatible : %1$s</string><string name="not_established">Non établi</string>
<string name="create_exercise_description">Ajouter un profil au catalogue local.</string><string name="compatible_equipment">Équipements compatibles</string><string name="compatibility_none">Compatibilité non établie.</string>
<string name="latest_result">Dernier résultat : %1$s kg · %2$s</string><string name="science_classification_confidence">Classification scientifique · confiance %1$s</string>
<string name="conditional_classification">Classification conditionnelle · incertitude explicite</string><string name="confidence_high">élevée</string><string name="confidence_moderate">modérée</string><string name="confidence_low">faible</string>
<string name="zone_not_specified">Zone : Non renseignée</string>
<string name="confidence_uncertain">incertaine</string><string name="primary_zone_value">Zone principale : %1$s</string><string name="secondary_zones_value"> · Zones secondaires : %1$s</string><string name="group_value"> · Groupe : %1$s</string>
<string name="goal_general">Général</string><string name="goal_strength">Force</string><string name="goal_hypertrophy">Hypertrophie</string><string name="goal_endurance">Endurance locale</string>
<string name="generator_weight_invalid">La charge cible doit être un nombre positif fini.</string><string name="proposal">Proposition</string>
<string name="coverage_incomplete">La couverture est incomplète faute de contextes résolus disponibles%1$s</string>
<string name="exposure_summary">Exposition observée : %1$d séries principales et %2$d secondaires sur 24 h ; %3$d principales et %4$d secondaires sur 72 h.</string><string name="latest_exposure">Dernière exposition observée : %1$s</string>
<string name="observed_load_note">Charge issue d’une dose réellement observée le %1$s ; son applicabilité aujourd’hui reste incertaine.</string>
<string name="max_percentage_field">Pourcentage du MAX (1 à 100)</string><string name="manual_target_load">Charge cible manuelle (vide = automatique)</string>
<string name="reestimate_description">Réestimer la durée et requalifier la charge observée.</string><string name="move_up">Monter</string><string name="move_up_description">Déplacer cet exercice avant le précédent.</string><string name="move_down">Descendre</string><string name="move_down_description">Déplacer cet exercice après le suivant.</string>
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
<string name="load_none">sans charge</string><string name="load_external">charge externe</string><string name="load_assistance">assistance</string><string name="load_bodyweight">poids du corps</string><string name="load_cardio">cardio</string>
<string name="existing_draft_warning">Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée.</string><string name="no_numeric_load_proposed">aucune charge numérique proposée</string>
<string name="incompatible_change_detail">Ce changement incompatible remplacera :\n- %1$s\nLes ressentis restent attachés au même entry_id.</string><string name="set_load_field">Série %1$d — %2$s</string>
<string name="selected_suffix">, sélectionnée</string><string name="not_selected_suffix">, non sélectionnée</string><string name="select_zone">Sélectionner %1$s</string>
<string name="progress_a11y_empty">Progression sur six semaines : aucun événement comparable, six points vides</string><string name="progress_a11y_values">Progression sur six semaines : %1$s événements</string>
<string name="chart_insufficient">Pas encore assez de données pour une courbe.</string><string name="chart_series_detail">%1$d séries comparables · ouvrir le détail</string>
<string name="general">Général</string><string name="shoulders">Épaules</string><string name="empty_fields_ignored">Les champs vides sont ignorés.</string><string name="latest_measurements">Derniers relevés</string>
<string name="state_prioritize">À privilégier</string><string name="state_little_work">Peu travaillé récemment</string><string name="state_recent_work">Travaillé récemment</string><string name="state_high_exposure">Forte exposition récente</string><string name="state_insufficient">Données insuffisantes</string><string name="state_unsupported">Aucun exercice résolu disponible</string><string name="relative_priority">Priorité relative</string>
<string name="no_primary_exposure">Aucune exposition principale enregistrée</string><string name="latest_primary_work">Dernier travail principal : %1$s</string><string name="recent_primary_work">Travail principal sur 7 j : %1$d</string><string name="recent_secondary_work">Travail secondaire sur 7 j : %1$d</string>
<string name="age_never">jamais enregistrée</string><string name="age_today">aujourd’hui</string><string name="age_yesterday">il y a 1 jour</string><plurals name="age_days"><item quantity="one">il y a %d jour</item><item quantity="other">il y a %d jours</item></plurals>
<string name="latest_primary_work_label">Dernier travail principal : %1$s</string>
<string name="frequency_a11y">Fréquence sur six semaines : %1$s</string><string name="frequency_week_a11y">%1$s %2$s dont %3$d MAX</string><string name="current_feminine">actuelle</string>
<string name="weight">Poids</string><string name="target_summary">Cible : %1$d × %2$s · repos %3$d s</string><string name="primary_zone_value_short">Zone principale : %1$s</string><string name="movement_value">Mouvement : %1$s</string><string name="reasons_value">Raisons : %1$s</string><string name="rest_seconds">Repos (secondes)</string>
<string name="name">Nom</string><string name="primary_zone">Zone principale</string><string name="secondary_zones">Zones secondaires</string><string name="add_profile_description">Ajouter ce profil au catalogue local.</string><string name="invalid_profile_name">Profil ou nom invalide.</string><string name="invalid_name_profile">Nom ou profil invalide.</string><string name="zone_filter">Filtre par zone</string><string name="all_zones">Toutes les zones</string>
<string name="confidence_inline">Confiance : %1$s</string><string name="scientific_zones">Zones scientifiques : %1$s%2$s</string><string name="secondary_suffix"> · secondaires : %1$s</string><string name="limitation_value">Limite : %1$s</string><string name="source_value">Source : %1$s · %2$s%3$s</string><string name="exercise_catalog">Catalogue d’exercices</string><string name="filter">Filtrer</string><string name="profile">Profil</string>
<string name="latest_max_value">Dernier MAX · %1$s · %2$s kg · %3$s</string><string name="type_value">Type : %1$s</string><string name="aliases_value">Alias : %1$s</string><string name="max_weight">Poids MAX (kg)</string><string name="add_entry_description">Ajouter cette saisie au brouillon.</string>
<string name="neck">Cou</string><string name="chest">Poitrine</string><string name="waist">Tour de taille</string><string name="hips">Hanches</string><string name="limbs">Membres</string>
<string name="left_arm">Bras gauche</string><string name="right_arm">Bras droit</string><string name="left_forearm">Avant-bras gauche</string><string name="right_forearm">Avant-bras droit</string><string name="left_thigh">Cuisse gauche</string><string name="right_thigh">Cuisse droite</string><string name="left_calf">Mollet gauche</string><string name="right_calf">Mollet droit</string><string name="recording">Enregistrement</string>
<string name="body_map">Carte corporelle, %1$s</string><string name="body_front">Face</string><string name="body_back">Dos</string>
<string name="plan_draft_summary">%1$s · %2$d × %3$s%4$s · repos %5$d s</string><string name="possible_exercises_plain">Exercices possibles</string>
<string name="actions">Actions</string><string name="change_profile_question">Changer le profil de %1$s ?</string><string name="historical_context_optional">Contexte historique (optionnel)</string><string name="new_historical_context">Nouveau contexte historique</string>
<string name="organization">Organisation</string><string name="continuous">Continu</string><string name="primary_measurement">Mesure principale</string><string name="cancel_catalog_description">Revenir au catalogue sans modification.</string><string name="contract">Contrat</string><string name="knowledge">Connaissances</string>
<string name="remove_proposal_exercise">Retirer cet exercice de la proposition</string><string name="speech_interrupted">Reconnaissance interrompue (code %1$d).</string>
<string name="draft_missing">Ce brouillon n’est plus disponible.</string><string name="invalid_value">Une valeur est invalide.</string><string name="positive_measure_required">Ajoutez au moins une mesure positive.</string><plurals name="measurement_count"><item quantity="one">%d mesure</item><item quantity="other">%d mesures</item></plurals><plurals name="available_exercise_count"><item quantity="one">%d exercice disponible</item><item quantity="other">%d exercices disponibles</item></plurals>
<string name="max_test_selected">[✓] Test MAX</string><string name="max_test_unselected">[ ] Test MAX</string><string name="feedback_add_count">Ressenti (%1$d) · + Ajouter un ressenti</string><string name="delete_facts_detail">Cette action supprimera :\n- %1$s</string><string name="plan_rest_fact">les objectifs et le repos</string>
<string name="context_name_invalid">Donnez un nom de contexte valide.</string><string name="max_weight_invalid">Saisissez un poids MAX strictement positif (ex. 100 ou 86,5).</string><string name="invalid_values">Valeurs invalides.</string><string name="return_catalog">Revenir au catalogue.</string>
<string name="new_exercise">Nouvel exercice</string><string name="primary_zone_required">Une zone principale est requise pour un nouvel exercice musculaire.</string>
<string name="feedback_voice">🎙 Ressenti</string><string name="draft_max_summary">%1$s · MAX : %2$s kg</string><string name="reps_short">rép.</string><string name="profile_continuous">CONTINU</string><string name="profile_sets">SÉRIES</string><string name="profile_reps">RÉP.</string><string name="profile_duration">DURÉE</string>
<plurals name="point_count"><item quantity="one">%d point</item><item quantity="other">%d points</item></plurals>
<string name="database_save_failed">Enregistrement en base impossible.</string><string name="knowledge_expand">+ Connaissances</string><string name="knowledge_collapse">− Connaissances</string>
<plurals name="seconds_count"><item quantity="one">%d seconde</item><item quantity="other">%d secondes</item></plurals><string name="discard">Abandonner</string>
<plurals name="feedback_count"><item quantity="one">%d ressenti</item><item quantity="other">%d ressentis</item></plurals><string name="during_session">Pendant la séance</string>
<string name="profile_speed_suffix"> · vitesse</string><string name="profile_distance_suffix"> · distance</string>
<string name="zone_full_body">Corps entier</string><string name="zone_upper_body">Membres supérieurs</string><string name="zone_chest">Pectoraux</string><string name="zone_back">Dos</string><string name="zone_shoulders">Épaules</string><string name="zone_arms">Bras</string><string name="zone_core">Abdominaux / tronc</string><string name="zone_lower_body">Membres inférieurs</string><string name="zone_glutes">Fessiers</string><string name="zone_thighs">Cuisses</string><string name="zone_calves">Mollets</string>
<string name="performed_loads">charges réalisées</string><string name="explicit_max_label">MAX explicite</string>
<string name="repository_item_missing">L’élément demandé n’est plus disponible.</string><string name="repository_invalid_data">Les données demandées sont invalides.</string><string name="repository_database_error">L’opération en base de données a échoué.</string><string name="repository_file_access">L’accès aux fichiers est requis.</string><string name="repository_operation_error">L’opération a échoué.</string>
<string name="draft_selection_missing">L’exercice en cours de saisie n’existe plus ; seule la sélection a été annulée. La saisie partielle et les exercices ajoutés sont conservés.</string>
<string name="a11y_reorder_exercise">Réordonner l’exercice</string>
<string name="a11y_move_exercise_up">Déplacer l’exercice vers le haut</string>
<string name="a11y_move_exercise_down">Déplacer l’exercice vers le bas</string>
<string name="exercise_order_saved">Ordre de la séance enregistré.</string>
<string name="exercise_reorder_failed">Réorganisation non enregistrée : %1$s</string>
<string name="invalid_decimal_value">Saisissez une valeur décimale valide avec « , » ou « . ».</string>
<string name="settings_backup_section">Sauvegarde locale complète</string>
<string name="settings_backup_plaintext">Le fichier contient vos données de santé en clair. Conservez-le dans un emplacement privé.</string>
<string name="settings_backup_create">Créer une sauvegarde</string>
<string name="settings_backup_create_description">Exporter la base complète, le brouillon actif et les préférences.</string>
<string name="settings_backup_restore">Restaurer une sauvegarde</string>
<string name="settings_backup_restore_description">Vérifier puis restaurer cette installation. La synchronisation doit rester arrêtée.</string>
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
<string name="sleep_medications">Médicaments</string>
<string name="sleep_add_medication">Ajouter un médicament</string>
<string name="sleep_add_intake">Ajouter une prise</string>
<string name="sleep_dose">Dosage</string>
<string name="sleep_unit">Unité</string>
<string name="sleep_deactivate">Désactiver</string>
<string name="sleep_edit">Modifier</string>
<string name="yes">Oui</string>
<string name="no">Non</string>
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
<string name="hr_diag_status">État du capteur</string>
<string name="hr_diag_idle">Prêt</string>
<string name="hr_diag_scanning">Recherche en cours…</string>
<string name="hr_diag_connecting">Connexion…</string>
<string name="hr_diag_discovering">Découverte des services…</string>
<string name="hr_diag_subscribing">Activation des mesures…</string>
<string name="hr_diag_receiving">Mesures reçues</string>
<string name="hr_diag_disconnected">Déconnecté</string>
<string name="hr_diag_error">Erreur BLE</string>
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
<string name="hr_diag_technical">Diagnostic : %1$s</string>
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
<string name="hr_diag_scan_section">Recherche BLE</string>
<string name="hr_diag_scan">Rechercher cardio</string>
<string name="hr_diag_scan_all">Recherche élargie</string>
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
<string name="hr_diag_devices">Périphériques BLE</string>
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
<string name="hr_diag_use_sensor">Utiliser ce capteur</string>
<string name="heart_rate_service_channel">Capteur cardio</string>
<string name="heart_rate_service_title">Trainlog · cardio</string>
<string name="heart_rate_service_connecting">Connexion au capteur…</string>
<string name="heart_rate_service_reconnecting">Signal cardio en attente…</string>
<string name="heart_rate_service_disconnected">Capteur cardio déconnecté</string>
<string name="heart_rate_service_bpm">%1$d BPM</string>
<string name="heart_rate_a11y_connected">Fréquence cardiaque %1$d BPM, capteur connecté</string>
<string name="heart_rate_a11y_connecting">Capteur cardio en connexion</string>
<string name="heart_rate_a11y_reconnecting">Signal cardio temporairement perdu, reconnexion en cours</string>
<string name="heart_rate_a11y_disconnected">Capteur cardio déconnecté</string>
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
<string name="settings_cardio_forget">Oublier le capteur</string>
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
<string name="exercise_timeline_start">Démarrer l’exercice</string>
<string name="exercise_timeline_finish">Terminer l’exercice</string>
<string name="exercise_timeline_completed">Exercice terminé</string>
<string name="exercise_timeline_started">%1$s · démarré</string>
<string name="exercise_timeline_finished">%1$s · terminé</string>
<string name="exercise_timeline_switch_title">Changer d’exercice ?</string>
<string name="exercise_timeline_switch_detail">%1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ?</string>
<string name="exercise_timeline_switch_confirm">Terminer et démarrer</string>
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
<string name="sleep_night_capture_title">Saisie nocturne</string>
<string name="sleep_no_night_active">Aucune nuit en cours.</string>
<string name="sleep_night_in_progress">Nuit en cours · %1$s → %2$s</string>
<string name="sleep_quick_bed">🛏 COUCHÉ</string>
<string name="sleep_quick_wake">👁 RÉVEIL</string>
<string name="sleep_quick_get_up">☀ LEVÉ</string>
<string name="sleep_bed_recorded">Couché enregistré.</string>
<string name="sleep_wake_recorded">Réveil de 30 minutes enregistré.</string>
<string name="sleep_get_up_recorded">Levé enregistré.</string>
<string name="sleep_medication_recorded">%1$s enregistré.</string>
<string name="sleep_undo">ANNULER</string>
<string name="sleep_action_undone">Dernière action annulée.</string>
<string name="sleep_undo_unavailable">Annulation impossible : la nuit a déjà changé.</string>
<string name="sleep_correct_night">Corriger la nuit</string>
<string name="sleep_return_night_mode">Retour à la saisie nocturne</string>
<string name="sleep_night_timeline">Chronologie de la nuit</string>
<string name="sleep_quick_no_active">Aucune nuit en cours.</string>
<string name="sleep_quick_already_active">Une nuit est déjà en cours.</string>
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
<string name="sleep_quick_error">Enregistrement impossible.</string>
<string name="session_cardio">Cardio</string>
<string name="route_cardio_calibration">Calibration cardio</string>
<string name="calibration_explanation">Cette séance mesure votre réponse cardiaque actuelle. L’effort augmente progressivement et vous pouvez arrêter immédiatement à tout moment. Le résultat est enregistré comme un pic de fréquence cardiaque observé, pas comme une FC max médicale.</string>
<string name="calibration_start">Démarrer la calibration</string>
<string name="calibration_start_description">Créer une séance cardio dédiée, démarrer la capture cardiaque et commencer par l’échauffement.</string>
<string name="calibration_latest">Dernière calibration</string>
<string name="calibration_current">Calibration en cours</string>
<string name="calibration_phase_value">Phase : %1$s</string>
<string name="calibration_phase_warmup">Échauffement</string>
<string name="calibration_phase_progressive">Montée progressive</string>
<string name="calibration_phase_high_effort">Effort élevé</string>
<string name="calibration_phase_recovery">Récupération</string>
<string name="calibration_phase_completed">Terminée</string>
<string name="calibration_phase_aborted">Arrêtée</string>
<string name="calibration_peak_value">Pic observé : %1$d BPM</string>
<string name="calibration_recovery_value">Récupération +%1$d min : %2$d BPM</string>
<string name="calibration_to_progressive">Commencer la montée progressive</string>
<string name="calibration_progressive_description">Augmenter progressivement l’effort sans chercher à battre une ancienne valeur.</string>
<string name="calibration_to_high_effort">Passer à l’effort élevé</string>
<string name="calibration_high_effort_description">Continuer seulement tant que vous choisissez de tolérer l’effort. Le bouton suivant arrête immédiatement la phase d’effort.</string>
<string name="calibration_end_effort">Arrêter l’effort</string>
<string name="calibration_end_effort_description">Marquer la fin de l’effort et commencer la récupération mesurée.</string>
<string name="calibration_finish">Terminer la calibration</string>
<string name="calibration_finish_description">Fermer la récupération et enregistrer le profil de calibration.</string>
<string name="calibration_abort">Arrêter la calibration</string>
<string name="calibration_conflict">Une autre séance ou calibration est déjà en cours.</string>
<string name="cardio_guidance_title">Guidage cardio</string>
<string name="cardio_guidance_phase_value">Phase : %1$s</string>
<string name="cardio_guidance_target_value">Cible : %1$d–%2$d BPM</string>
<string name="cardio_guidance_calibration_value">Calibration : pic observé %1$d BPM · %2$d–%3$d %%</string>
<string name="cardio_guidance_latest_calibration">Dernière calibration : pic observé %1$d BPM</string>
<string name="cardio_guidance_resolved_target">Cible utilisée : %1$d–%2$d BPM</string>
<string name="cardio_guidance_accelerate">ACCÉLÈRE</string>
<string name="cardio_guidance_maintain">MAINTIENS</string>
<string name="cardio_guidance_slow_down">RALENTIS</string>
<string name="cardio_guidance_suspended">SIGNAL CARDIO INDISPONIBLE</string>
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
<string name="cardio_guidance_bpm_missing">-- BPM</string>
<string name="cardio_guidance_start_phase">Démarrer la phase</string>
<string name="cardio_guidance_finish_phase">Terminer la phase</string>
<string name="cardio_guidance_invalid_configuration">Complète la cible et la condition de fin.</string>
<string name="cardio_guidance_start_failed">Impossible de démarrer cette phase.</string>
<string name="cardio_phase_warmup">Échauffement</string>
<string name="cardio_phase_work">Travail</string>
<string name="cardio_phase_recovery">Récupération</string>
<string name="cardio_phase_cooldown">Retour au calme</string>
<string name="cardio_target_absolute">BPM</string>
<string name="cardio_target_calibration">%% calibration</string>
<string name="cardio_target_min_bpm">BPM min</string>
<string name="cardio_target_max_bpm">BPM max</string>
<string name="cardio_target_min_percent">%% min</string>
<string name="cardio_target_max_percent">%% max</string>
<string name="cardio_exit_duration">Durée</string>
<string name="cardio_exit_target">Entrée dans la cible</string>
<string name="cardio_exit_recover">Sous un seuil</string>
<string name="cardio_exit_bounded">Durée ou seuil</string>
<string name="cardio_exit_seconds">Durée max</string>
<string name="cardio_exit_recovery_bpm">Seuil de récupération</string>
</resources> </resources>

View file

@ -62,6 +62,41 @@ class AiSessionDraftRepositoryTest {
assertTrue(repository.listSessions().isEmpty()) assertTrue(repository.listSessions().isEmpty())
} }
@Test
fun desktopWithdrawalTombstonePreventsLegacyReplay() {
val exercise = exercise()
val legacyArtifact = artifact(exercise.exerciseId)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applyAiSessionDraftsJson(legacyArtifact),
)
val withdrawalArtifact = JSONObject()
.put("format", "trainlog-ai-session-drafts")
.put("version", 2)
.put("generated_at", "2026-09-18T12:01:00Z")
.put("drafts", JSONArray())
.put(
"withdrawals",
JSONArray().put(
JSONObject()
.put("draft_id", DRAFT_ID)
.put("withdrawn_at", "2026-09-18T12:00:00Z"),
),
)
.toString()
assertTrue(
repository.applyAiSessionDraftsJson(withdrawalArtifact) is
AiSessionDraftImportResult.Applied,
)
assertTrue(repository.listAiSessionDrafts().isEmpty())
assertEquals(
AiSessionDraftImportResult.Applied(0, 1),
repository.applyAiSessionDraftsJson(legacyArtifact),
)
assertTrue(repository.listAiSessionDrafts().isEmpty())
}
@Test @Test
fun pendingDraftIsListed() { fun pendingDraftIsListed() {
val exercise = exercise() val exercise = exercise()

View file

@ -0,0 +1,122 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.zip.ZipInputStream
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun manifestUsesInjectedCanonicalProductVersion() {
val name = "backup-version-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val output = ByteArrayOutputStream()
val service = AndroidBackupService(context, "0.1.3-test")
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
var manifest: JSONObject? = null
ZipInputStream(ByteArrayInputStream(output.toByteArray())).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.name == "manifest.json") {
manifest = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
}
}
}
assertEquals("0.1.3-test", checkNotNull(manifest).getString("product_version"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
val service = AndroidBackupService(context)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Backup exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 9, weightKg = 31.5)),
)
),
form = SessionDraftForm(selectedExercise = exercise, weightText = "31,"),
)
),
)
val peer = SyncGenerationService(repository).peerId()
context
.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.edit()
.putString("language", "en")
.commit()
val output = ByteArrayOutputStream()
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
val verified = service.verify(ByteArrayInputStream(output.toByteArray())).getOrThrow()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
val draft = repository.loadActiveSessionDraft()
assertTrue(draft is ActiveDraftLoadResult.Loaded)
assertEquals("31,", (draft as ActiveDraftLoadResult.Loaded).draft.form.weightText)
assertEquals(peer, SyncGenerationService(repository).peerId())
assertEquals(
"en",
context
.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.getString("language", null),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun corruptAndTraversalContainersAreRejectedBeforeRestore() {
val service = AndroidBackupService(context)
val output = ByteArrayOutputStream()
java.util.zip.ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite"))
zip.write(byteArrayOf(1))
zip.closeEntry()
}
assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure)
}
}

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() } TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(17, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) { for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(17, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and /* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */ * exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(17, scalar(db, "PRAGMA user_version")) assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf( assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"), scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -0,0 +1,93 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Additive v24 -> v25 provenance migration preserves the singleton draft. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV25ProgramExecutionMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v24FixturePreservesDraftAndInventsNoProgramProvenance() {
val name = "program-execution-v24-${UUID.randomUUID()}.db"
val first = TrainlogRepository(context, name)
val exercise = (
first.createExercise(
NewExerciseProfile(
"Migration fixture",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created
).exercise
assertEquals(
ActiveDraftMutationResult.Saved,
first.saveActiveSessionDraft(
ActiveSessionDraft(
exercises = listOf(
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 3, reps = 8),
),
),
),
),
)
first.close()
val path = context.getDatabasePath(name).path
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
/* The published-unreleased fixture may already contain additive
* columns; lowering only user_version proves migration replay is safe. */
db.execSQL("PRAGMA user_version=24")
}
val reopened = TrainlogRepository(context, name)
try {
val draft = (reopened.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(null, draft.sourceProgramId)
assertEquals(null, draft.sourceProgramSessionId)
} finally {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
assertFalse(cursor.moveToFirst())
}
}
context.deleteDatabase(name)
}
private fun scalar(db: SQLiteDatabase, sql: String): Int =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
private fun text(db: SQLiteDatabase, sql: String): String =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
}

View file

@ -0,0 +1,50 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV26SleepMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v25AddsEmptySleepDomainAndReopens() {
val name = "sleep-v25-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).useForTest { assertTrueEmpty(it) }
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("DROP TABLE sleep_diary_events")
db.execSQL("DROP TABLE sleep_diary_revisions")
db.execSQL("DROP TABLE sleep_diary_entries")
db.version = 25
}
var repository = TrainlogRepository(context, name)
try {
assertTrueEmpty(repository)
repository.close()
repository = TrainlogRepository(context, name)
assertTrueEmpty(repository)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun assertTrueEmpty(repository: TrainlogRepository) = assertEquals(emptyList<Any>(), repository.listSleepDiary())
private fun TrainlogRepository.useForTest(block: (TrainlogRepository) -> Unit) {
try { block(this) } finally { close() }
}
}

View file

@ -0,0 +1,54 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV27HeartRateMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v26AddsEmptyHeartRateDomainAndReopens() {
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
JSONObject(it.buildHeartRateV1Json())
it.close()
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE heart_rate_generation_captures")
db.execSQL("DROP TABLE heart_rate_rr_intervals")
db.execSQL("DROP TABLE heart_rate_samples")
db.execSQL("DROP TABLE heart_rate_captures")
db.version = 26
}
var repository = TrainlogRepository(context, name)
try {
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
.getJSONArray("captures").length())
repository.close()
repository = TrainlogRepository(context, name)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,105 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV28SessionTimelineMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() {
val name = "timeline-v27-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(
ActiveDraftMutationResult.Saved,
repository.startActiveSessionDraft(),
)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Timeline migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(loaded.draft.sessionId)
val startedAt = checkNotNull(loaded.draft.startedAt)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
loaded.draft.copy(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE session_timeline_generation_sessions")
db.execSQL("DROP TABLE session_timeline_exercises")
db.execSQL("DROP TABLE session_timeline_sessions")
db.version = 27
}
repository = TrainlogRepository(context, name)
assertNotNull(repository.activeSessionTimelineContext())
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(sessionId, cursor.getString(0))
assertEquals(startedAt, cursor.getString(1))
assertTrue(cursor.isNull(2))
}
db.rawQuery(
"SELECT COUNT(*) FROM session_timeline_exercises",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getInt(0))
}
}
assertNotNull(repository.activeSessionTimelineContext())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,108 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV29CardioSessionMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() {
val name = "cardio-v28-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertTrue(
repository.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
),
),
),
) is SaveSessionResult.Saved,
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 6)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("UPDATE sessions SET session_kind='cardio'")
db.execSQL("UPDATE active_session_draft SET session_kind='cardio'")
db.version = 28
}
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listSessions().size)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("training", cursor.getString(1))
}
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("training", cursor.getString(1))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,69 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV30CardioCalibrationMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v29AddsEmptyCalibrationDomainAndReservedExercise() {
val name = "calibration-v29-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listExercises()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_calibration_generation")
db.execSQL("DROP TABLE cardio_calibration_recovery")
db.execSQL("DROP TABLE cardio_calibrations")
db.version = 29
}
repository = TrainlogRepository(context, name)
assertTrue(repository.listExercises().none { it.name == "Calibration cardio" })
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
"AND name='Calibration cardio'",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
}
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_calibrations','cardio_calibration_recovery'," +
"'cardio_calibration_generation')",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(3, cursor.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,65 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV31CardioGuidanceMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v30AddsEmptyGuidanceDomainAndReopens() {
val name = "guidance-v30-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listSessions()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_guidance_generation")
db.execSQL("DROP TABLE cardio_guidance_events")
db.execSQL("DROP TABLE cardio_guidance_phases")
db.execSQL("DROP TABLE cardio_guidance_runs")
db.version = 30
}
repository = TrainlogRepository(context, name)
assertTrue(repository.activeCardioGuidancePhase() == null)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," +
"'cardio_guidance_events','cardio_guidance_generation')",
null,
).use { c ->
assertTrue(c.moveToFirst())
assertEquals(4, c.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM cardio_guidance_runs", null).use { c ->
assertTrue(c.moveToFirst())
assertEquals(0, c.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV32SleepQuantityMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v31IntakesHydrateToOneAndQuickPublicationStartsEmpty() {
val name = "sleep-quantity-v31-${UUID.randomUUID()}.db"
val initial = TrainlogRepository(context, name)
try {
val saved = initial.saveSleepMedication(
SleepMedication(
"", "", "2026-09-23T20:00:00+02:00", "2026-09-23T20:00:00+02:00",
"Migration medication", 75.0, "mg", "", "", true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
initial.quickSleepMedication(saved.entryId, "2026-09-23T21:00:00+02:00")
} finally {
initial.close()
}
val path = context.getDatabasePath(name).path
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("PRAGMA foreign_keys=OFF")
db.execSQL("DROP TABLE sleep_diary_quick_publication_state")
db.execSQL("ALTER TABLE sleep_medication_intakes RENAME TO sleep_medication_intakes_v32")
db.execSQL(
"""CREATE TABLE sleep_medication_intakes(
revision_id TEXT NOT NULL, intake_id TEXT NOT NULL,
medication_id TEXT NOT NULL, medication_name TEXT NOT NULL,
taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT,
note TEXT, created_at TEXT NOT NULL,
PRIMARY KEY(revision_id,intake_id)
)""".trimIndent(),
)
db.execSQL(
"INSERT INTO sleep_medication_intakes SELECT revision_id,intake_id,medication_id," +
"medication_name,taken_at,dose_value,dose_unit,note,created_at " +
"FROM sleep_medication_intakes_v32",
)
db.execSQL("DROP TABLE sleep_medication_intakes_v32")
db.execSQL("PRAGMA user_version=31")
}
val migrated = TrainlogRepository(context, name)
migrated.listSleepDiary()
migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(
1,
scalar(
db,
"SELECT COUNT(*) FROM pragma_table_info('sleep_medication_intakes') " +
"WHERE name='quantity' AND \"notnull\"=1 AND dflt_value='1'",
),
)
assertEquals(1, scalar(db, "SELECT quantity FROM sleep_medication_intakes"))
assertEquals(
1,
scalar(
db,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' " +
"AND name='sleep_diary_quick_publication_state'",
),
)
assertEquals("ok", text(db, "PRAGMA integrity_check"))
}
context.deleteDatabase(name)
}
private fun scalar(db: SQLiteDatabase, sql: String): Int =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
private fun text(db: SQLiteDatabase, sql: String): String =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
}

View file

@ -0,0 +1,38 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BluetoothSyncTransportContractTest {
@Test
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
assertTrue(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = false,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = false,
)
)
}
@Test
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
assertTrue(
BLUETOOTH_ANDROID_PULL_COORDINATION.contains(
"android-archive-acknowledgements-v1.json"
)
)
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(17, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
} }
db.rawQuery("SELECT id FROM exercises", null).use { cursor -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -0,0 +1,129 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.io.File
import java.io.FileInputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BridgeBackupMigrationTest {
@Test
fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() {
val source = System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File)
assumeTrue(source?.isFile == true)
val context: Context = ApplicationProvider.getApplicationContext()
val name = "bridge-migrate-${UUID.randomUUID()}.db"
val service = AndroidBackupService(context)
var repository = TrainlogRepository(context, name)
try {
val verified = FileInputStream(source!!).use { service.verify(it).getOrThrow() }
val historical = historicalDump(verified.database)
val activeDraft = historical.entries.single { it.key.startsWith("active_session_draft|") }
val hadActiveDraft = activeDraft.value.isNotEmpty()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
assertEquals(
hadActiveDraft,
repository.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded,
)
val migrated = context.getDatabasePath(name)
assertEquals(historical, historicalDump(migrated, historical.keys))
SQLiteDatabase.openDatabase(
migrated.path,
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { assertEquals(31, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
/**
* CONTRACT: every pre-migration column and value is compared exactly;
* additive v18-v20 tables/columns are outside the historical shape.
* INVARIANT: the supplied archive is opened read-only and only the
* test-owned restored database is migrated.
*/
private fun historicalDump(
databasePath: File,
shape: Set<String>? = null,
): Map<String, List<List<String>>> =
SQLiteDatabase.openDatabase(
databasePath.path,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { database ->
val tables =
database
.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' " +
"AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' " +
"ORDER BY name;",
null,
)
.use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getString(0))
}
}
val specifications =
shape
?: tables
.map { table ->
val columns =
database.rawQuery("PRAGMA table_info(`$table`);", null).use {
cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getString(1))
}
}
"$table|${columns.joinToString(",")}"
}
.toSet()
specifications.associateWith { specification ->
val table = specification.substringBefore('|')
val columns = specification.substringAfter('|').split(',')
val selection = columns.joinToString(",") { "`$it`" }
database
.rawQuery(
"SELECT $selection FROM `$table` ORDER BY rowid;",
null,
)
.use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
(0 until cursor.columnCount).map { column ->
when (cursor.getType(column)) {
android.database.Cursor.FIELD_TYPE_NULL -> "null"
android.database.Cursor.FIELD_TYPE_INTEGER ->
"integer:${cursor.getLong(column)}"
android.database.Cursor.FIELD_TYPE_FLOAT ->
"float:${java.lang.Double.toHexString(cursor.getDouble(column))}"
android.database.Cursor.FIELD_TYPE_STRING ->
"string:${cursor.getString(column)}"
android.database.Cursor.FIELD_TYPE_BLOB ->
"blob:${cursor.getBlob(column).contentToString()}"
else -> error("Unknown SQLite field type")
}
}
)
}
}
}
}
}
}

View file

@ -0,0 +1,187 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.model.SessionType
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioCalibrationRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
val name = "calibration-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val started =
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:00:10+02:00",
measurement(80),
)
assertEquals(
CardioCalibrationPhase.PROGRESSIVE,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:01:20+02:00",
measurement(112),
)
assertEquals(
CardioCalibrationPhase.HIGH_EFFORT,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:02:30+02:00",
measurement(150),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:03:00+02:00",
measurement(160),
)
val recovery =
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
assertEquals(160, recovery.profile.observedPeakBpm)
assertTrue(recovery.profile.heartRateCaptureId != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:04:01+02:00",
measurement(140),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:05:02+02:00",
measurement(120),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:06:01+02:00",
measurement(100),
)
val completed =
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
assertEquals(
listOf(60 to 140, 120 to 120, 180 to 100),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
assertNull(repository.activeCardioCalibration())
assertNull(repository.activeHeartRateCapture())
val session = repository.listSessions().single()
assertEquals(SessionType.CARDIO, session.sessionType)
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
assertEquals(1, repository.listCompletedCardioCalibrations().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:00:10+02:00",
measurement(80),
)
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:02:30+02:00",
measurement(155),
)
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:05:05+02:00",
measurement(118),
)
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
assertEquals(
listOf(120 to 118),
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
)
val tooEarly =
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
assertTrue(repository.activeCardioCalibration() != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:06:03+02:00",
measurement(105),
)
val completed =
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(
listOf(120 to 118, 180 to 105),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
val aborted =
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
assertNull(aborted.profile.effortEndAt)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,147 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioGuidanceEngineTest {
private val target = CardioTargetSnapshot(120, 140)
@Test
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
assertEquals(
CardioTargetSnapshot(120, 140),
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
)
assertEquals(
CardioTargetSnapshot(
minimumBpm = 100,
maximumBpm = 125,
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
calibrationObservedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
observedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
),
)
}
@Test
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
),
)
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
),
)
}
@Test
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 110, true, 1_000).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 111, true, 3_999).instruction,
)
val changed = engine.evaluate(target, 111, true, 4_000)
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
assertNull(changed.pendingInstruction)
}
@Test
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
engine.evaluate(target, 150, true, 0)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 150, true, 2_000).instruction,
)
}
@Test
fun hysteresisPreventsBoundaryYoyo() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 117, true, 0).instruction,
)
// 119 is still below target, but not enough to leave ACCELERATE.
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 119, true, 1).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 122, true, 2).instruction,
)
// 141 is just above the zone and remains MAINTAIN within hysteresis.
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 141, true, 3).instruction,
)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 143, true, 4).instruction,
)
}
@Test
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
engine.evaluate(target, 110, true, 0)
engine.evaluate(target, 110, true, 3_000)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 150, true, 3_100).instruction,
)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 112, true, 3_200).instruction,
)
}
@Test
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 110, true, 0).instruction,
)
val stale = engine.evaluate(target, 110, false, 1_000)
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
assertNull(stale.bpm)
// A fresh sample must re-establish a candidate from real current data.
val resumed = engine.evaluate(target, 130, true, 2_000)
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
assertEquals(130, resumed.bpm)
}
@Test
fun noTargetSuspendsGuidance() {
val engine = CardioGuidanceEngine()
val output = engine.evaluate(null, 130, true, 0)
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
assertNull(output.bpm)
}
}

View file

@ -0,0 +1,260 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé sync",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
val exerciseStart = base.plusSeconds(1).toString()
assertTrue(
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(2).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val lowAt = base.plusSeconds(3).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lowAt,
measurement(110),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
lowAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
val targetAt = base.plusSeconds(4).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
targetAt,
measurement(130),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
targetAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
repository.finishCardioGuidancePhase(
phase.phaseId,
base.plusSeconds(5).toString(),
)
repository.finishActiveSessionExercise(
entry.entryId,
base.plusSeconds(6).toString(),
)
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == entry.entryId) {
it.copy(continuousDurationSeconds = 5)
} else {
it
}
},
),
),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
1,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(repository)
val captured = service.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-guidance-v1.json",
).readText(),
)
.getJSONArray("runs")
.length(),
)
val published =
service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
assertEquals(
"consumed",
JSONObject(ackFile.readText()).getString("result"),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
0,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
assertEquals(
"1|1|2",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_events);",
)
.trim(),
)
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,428 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private data class ActiveCardio(
val repository: TrainlogRepository,
val databaseName: String,
val entryId: String,
val exerciseStartedAt: String,
) {
fun at(seconds: Long): String =
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
}
private fun activeCardio(): ActiveCardio {
val name = "guidance-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Tapis guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val exerciseStartedAt =
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
.plusSeconds(10)
.toString()
val started =
repository.startActiveSessionExercise(
entry.entryId,
exerciseStartedAt,
)
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
}
@Test
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé après transition",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val training =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 101,
sensorContactDetected = true,
energyExpended = 12,
rrIntervals1024 = listOf(611),
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
base.plusSeconds(1).toString(),
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
)
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
)
val cardio =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
)
assertTrue(
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(100, 110)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WARMUP,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(3).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val secondAt = base.plusSeconds(4).toString()
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
secondAt,
firstMeasurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.recordCardioGuidanceOutput(
phase.phaseId,
secondAt,
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
assertEquals(
listOf(CardioGuidanceInstruction.ACCELERATE),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
val lateReverse =
repository.saveActiveSessionDraft(
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.copy(sessionType = SessionType.TRAINING),
)
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
assertEquals(
SessionType.CARDIO,
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.sessionType,
)
assertEquals(
HeartRateContextKind.CARDIO,
checkNotNull(repository.activeHeartRateCapture()).contextKind,
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
"WHERE capture_id=? ORDER BY sequence",
arrayOf(originalCapture.captureId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0L, cursor.getLong(0))
assertEquals(101, cursor.getInt(1))
assertTrue(cursor.isNull(2))
assertTrue(cursor.moveToNext())
assertEquals(1L, cursor.getLong(0))
assertEquals(99, cursor.getInt(1))
assertEquals(entry.entryId, cursor.getString(2))
assertTrue(!cursor.moveToNext())
}
}
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
assertNull(repository.activeHeartRateCapture())
assertNull(repository.activeCardioGuidancePhase())
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val training =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val observedAt =
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
observedAt,
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
)
val cardio =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
)
val reversed = checkNotNull(repository.activeHeartRateCapture())
assertEquals(captureId, reversed.captureId)
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
arrayOf(captureId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
assertEquals(103, cursor.getInt(1))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
val active = activeCardio()
val repository = active.repository
try {
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
)
val started =
repository.startCardioGuidancePhase(
active.entryId,
phase,
active.at(10),
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(13),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(14),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
111,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(30),
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
assertEquals(
listOf(
CardioGuidanceInstruction.ACCELERATE,
CardioGuidanceInstruction.MAINTAIN,
),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
repository.finishActiveSessionExercise(
active.entryId,
active.at(600),
)
assertNull(repository.activeCardioGuidancePhase())
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == active.entryId) {
it.copy(continuousDurationSeconds = 600)
} else {
it
}
},
),
),
)
val finalized = repository.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
@Test
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
repository.startActiveSessionDraft()
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
repository.saveActiveSessionDraft(
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
)
val result =
repository.startCardioGuidancePhase(
entry.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WARMUP,
CardioTargetSnapshot(100, 115),
CardioPhaseExitCondition.FixedDuration(60),
),
"2026-09-23T13:00:00+02:00",
)
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
val active = activeCardio()
val repository = active.repository
try {
repository.startCardioGuidancePhase(
active.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WORK,
CardioTargetSnapshot(120, 140),
CardioPhaseExitCondition.FixedDuration(120),
),
active.at(10),
)
assertTrue(repository.activeCardioGuidancePhase() != null)
repository.discardActiveSessionDraft()
assertNull(repository.activeCardioGuidancePhase())
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
}

View file

@ -0,0 +1,67 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioPhaseCompletionTest {
private val target = CardioTargetSnapshot(120, 140)
private fun phase(
exit: CardioPhaseExitCondition,
targetOverride: CardioTargetSnapshot? = target,
) = CardioGuidedPhase(
phaseId = "phase_test",
kind = CardioPhaseKind.WORK,
target = targetOverride,
exitCondition = exit,
)
@Test
fun fixedDurationUsesElapsedTimeOnly() {
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
}
@Test
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
val p = phase(CardioPhaseExitCondition.EnterTarget)
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
}
@Test
fun enterTargetWithoutTargetNeverCompletes() {
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
}
@Test
fun recoveryThresholdRequiresFreshMeasurement() {
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
}
@Test
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
}
@Test
fun negativeElapsedTimeCannotCompleteAnything() {
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
}
}

View file

@ -0,0 +1,194 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioSessionSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
val exercise =
(source.createExercise(
NewExerciseProfile(
"Cardio sync exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 15)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
assertTrue(
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
val sessionId = source.listSessions().single().sessionId
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: " + command.joinToString(" ") + "\n" + output,
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(
repositoryRoot,
"build/tui/sync-generation-fixture",
).absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val history =
JSONObject(
java.io.File(
captured.stagingDirectory,
"history-v4.json",
).readText(),
)
assertEquals(0, history.getJSONArray("sessions").length())
val cardio =
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-sessions-v1.json",
).readText(),
)
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
assertEquals(1, cardio.getJSONArray("sessions").length())
assertEquals(
"cardio",
cardio
.getJSONArray("sessions")
.getJSONObject(0)
.getString("session_type"),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals(
"training|cardio|15",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
"WHERE s.session_id='$sessionId';",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
ack.toString(),
JSONObject(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "replay-ack.json").absolutePath,
).let { java.io.File(root, "replay-ack.json").readText() },
).toString(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioSessionTypeTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio kind exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(occurrence),
),
),
)
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
db.close()
assertTrue(
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
assertEquals(
SessionType.CARDIO,
repository.listSessions().single().sessionType,
)
val frozen = JSONObject(repository.buildMobileExportV4Json())
assertEquals(0, frozen.getJSONArray("sessions").length())
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
verify.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
verify.close()
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,42 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HeartRateLiveStateTest {
@Test
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
HeartRateLiveState.disconnected()
val observed = mutableListOf<HeartRateLiveSnapshot>()
val subscription = HeartRateLiveState.subscribe { observed += it }
try {
HeartRateLiveState.connecting()
HeartRateLiveState.measurement(
ParsedHeartRateMeasurement(
bpm = 82,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(768),
),
)
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
assertEquals(82, HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
assertEquals(1L, HeartRateLiveState.current().notificationCount)
HeartRateLiveState.stale()
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
HeartRateLiveState.disconnected()
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(5, observed.size)
} finally {
subscription.close()
HeartRateLiveState.disconnected()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HeartRateMeasurementParserTest {
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
val result =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
)
return (result as HeartRateMeasurementParseResult.Parsed).measurement
}
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
) as HeartRateMeasurementParseResult.Invalid
@Test
fun parsesEightBitBpm() {
val value = parsed(0x00, 72)
assertEquals(72, value.bpm)
assertNull(value.sensorContactDetected)
assertNull(value.energyExpended)
assertTrue(value.rrIntervals1024.isEmpty())
}
@Test
fun parsesSixteenBitBpmLittleEndian() {
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
}
@Test
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
assertNull(parsed(0x02, 80).sensorContactDetected)
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
}
@Test
fun parsesEnergyExpended() {
val value = parsed(0x08, 90, 0x34, 0x12)
assertEquals(0x1234, value.energyExpended)
}
@Test
fun parsesOneAndMultipleRawRrIntervals() {
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
assertEquals(
listOf(1024, 1000, 65535),
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
)
}
@Test
fun parsesCombinedKnownFlagsInWireOrder() {
val value =
parsed(
0x1f,
0x96,
0x00,
0x07,
0x00,
0xf0,
0x03,
0x00,
0x04,
)
assertEquals(150, value.bpm)
assertEquals(true, value.sensorContactDetected)
assertEquals(7, value.energyExpended)
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
}
@Test
fun rejectsTruncatedAndIncoherentPayloads() {
assertEquals("missing flags", invalid().reason)
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
}
@Test
fun preservesBoundaryBpmValues() {
assertEquals(0, parsed(0x00, 0).bpm)
assertEquals(255, parsed(0x00, 255).bpm)
}
}

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.SleepDiaryDraft
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
val name = "heart-rate-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val created =
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val started =
repository.startHeartRateCapture(
HeartRateContextKind.SLEEP,
created.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR sensor",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
val sample =
repository.appendHeartRateSample(
captureId = started.captureId,
observedAt = "2026-09-22T22:30:01+02:00",
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals = listOf(
HeartRateRrInterval(0, 1024),
HeartRateRrInterval(1, 1000),
),
) as TrainlogRepository.HeartRateMutationResult.Applied
assertEquals(0L, sample.sequence)
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertTrue(
repository.stopHeartRateCapture(
started.captureId,
"2026-09-23T06:30:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
val root = JSONObject(repository.buildHeartRateV1Json())
assertEquals("trainlog-heart-rate", root.getString("format"))
assertEquals(1, root.getInt("version"))
val capture = root.getJSONArray("captures").getJSONObject(0)
assertEquals(created.entryId, capture.getString("context_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
val stored = capture.getJSONArray("samples").getJSONObject(0)
assertEquals(61, stored.getInt("bpm"))
assertTrue(stored.getBoolean("sensor_contact_detected"))
assertEquals(7, stored.getInt("energy_expended"))
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,32 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateSensorPreferencesTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun selectionIsDurableAndClearable() {
val preferences = HeartRateSensorPreferences(context)
preferences.clear()
assertNull(preferences.selected())
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
assertEquals(
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
HeartRateSensorPreferences(context).selected(),
)
preferences.clear()
assertNull(HeartRateSensorPreferences(context).selected())
}
}

View file

@ -0,0 +1,35 @@
package com.labfytools.trainlog.data
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.isDirectory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class IsolatedTestEnvironmentTest {
@Test
fun `private paths reach the Robolectric JVM`() {
val rootText = System.getenv("TRAINLOG_TEST_RUN_ROOT") ?: return
val root = Path(rootText).toRealPath()
fun assertedPath(environmentName: String): Path {
val value = System.getenv(environmentName)
assertTrue("$environmentName missing", !value.isNullOrBlank())
val path = Path(value!!).toRealPath()
assertTrue("$environmentName escapes run root", path.startsWith(root))
assertTrue("$environmentName is not a directory", path.isDirectory())
return path
}
/* CONTRACT: JAVA_TOOL_OPTIONS must reach the forked test JVM, not only
* the Gradle launcher; native Robolectric extraction uses this value. */
assertEquals(assertedPath("TRAINLOG_TEST_TMPDIR"), Path(System.getProperty("java.io.tmpdir")).toRealPath())
assertedPath("HOME")
assertedPath("XDG_DATA_HOME")
assertedPath("XDG_CONFIG_HOME")
assertedPath("XDG_CACHE_HOME")
assertedPath("XDG_RUNTIME_DIR")
assertedPath("TRAINLOG_TEST_EXCHANGE_DIR")
assertedPath("TRAINLOG_TEST_DATABASE_DIR")
}
}

View file

@ -0,0 +1,490 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.SessionSetDraft
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProgramSessionCardioReadinessTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
val android = TrainlogRepository(context, androidName)
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val desktopDb = java.io.File(root, "desktop.sqlite")
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
fun measurement(bpm: Int, rr: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(rr),
)
try {
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val programExerciseAId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
val programExerciseBId = "ex_11111111-1111-4111-8111-111111111111"
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(
JSONObject()
.put("exercise_id", programExerciseAId)
.put("name", "Program exercise fixture A")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
)
.put(
JSONObject()
.put("exercise_id", programExerciseBId)
.put("name", "Program exercise fixture B")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("title", "Tomorrow readiness")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-23T16:00:00Z")
.put("updated_at", "2026-09-23T16:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Session A")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put(
"entry_id",
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
.put("exercise_id", programExerciseAId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 1)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
)
.put(
JSONObject()
.put(
"entry_id",
"pge_22222222-2222-4222-8222-222222222222",
)
.put("exercise_id", programExerciseBId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 60)
.put("target_sets", 1)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 25.0)
.put("notes", JSONObject.NULL),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-23T16:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
android.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
android.startSyncedProgramSession(programId, programSessionId),
)
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
assertEquals(programId, opened.draft.sourceProgramId)
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
assertEquals(2, opened.draft.exercises.size)
val exerciseA = opened.draft.exercises[0]
val exerciseBEntry = opened.draft.exercises[1]
assertEquals(programExerciseAId, exerciseA.exercise.exerciseId)
assertEquals(programExerciseBId, exerciseBEntry.exercise.exerciseId)
assertTrue(exerciseA.entryId != exerciseBEntry.entryId)
assertEquals(
ActiveDraftMutationResult.Saved,
android.saveActiveSessionDraft(
opened.draft.copy(
exercises =
listOf(
exerciseA.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
),
exerciseBEntry.copy(
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
),
),
),
),
)
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(sessionId, edited.draft.sessionId)
assertEquals(sessionStartedAt, edited.draft.startedAt)
assertEquals(programId, edited.draft.sourceProgramId)
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
val preExerciseAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
preExerciseAt,
measurement(91, 675),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val capture = checkNotNull(android.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
assertEquals(sessionId, capture.contextId)
assertEquals(sessionStartedAt, capture.startedAt)
val exerciseAStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseASampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseASampleAt,
measurement(112, 548),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseAEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val betweenExercisesAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
betweenExercisesAt,
measurement(98, 620),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseBStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBStartedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseBSampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseBSampleAt,
measurement(119, 525),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val activeFinalize = android.finalizeActiveSessionDraft()
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
assertTrue(
(activeFinalize as FinalizeActiveDraftResult.Invalid)
.message
.startsWith("ACTIVE_EXERCISE:"),
)
val exerciseBEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBEndedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val finalized = android.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
assertNull(android.activeHeartRateCapture())
val timeline = JSONObject(android.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
val sessionEndedAt = timelineSession.getString("ended_at")
assertEquals(sessionId, timelineSession.getString("session_id"))
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
val timelineExercises = timelineSession.getJSONArray("exercises")
assertEquals(2, timelineExercises.length())
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
assertEquals(
exerciseBEntry.entryId,
timelineExercises.getJSONObject(1).getString("entry_id"),
)
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
val heart = JSONObject(android.buildHeartRateV1Json())
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
assertEquals("session", heartCapture.getString("context_kind"))
assertEquals(sessionId, heartCapture.getString("context_id"))
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
val samples = heartCapture.getJSONArray("samples")
assertEquals(4, samples.length())
val expectedOwnership =
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
val expectedTimes =
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
val expectedRr = listOf(675, 548, 620, 525)
for (index in 0 until samples.length()) {
val sample = samples.getJSONObject(index)
assertEquals(index.toLong(), sample.getLong("sequence"))
assertEquals(expectedTimes[index], sample.getString("observed_at"))
if (expectedOwnership[index] == null) {
assertTrue(sample.isNull("exercise_entry_id"))
} else {
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
}
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(androidName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT source_program_id,source_program_session_id FROM sessions " +
"WHERE session_id=?",
arrayOf(sessionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(programId, cursor.getString(0))
assertEquals(programSessionId, cursor.getString(1))
}
}
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(android)
val captured = service.capture(root, desktopPeer)
val published = service.publish(captured, java.io.File(root, "objects"))
val publishedTimeline =
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
val publishedHeart =
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
val firstAck = java.io.File(root, "desktop-ack-first.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
firstAck.absolutePath,
)
val countsBeforeReplay =
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim()
assertEquals("2|4|4", countsBeforeReplay)
val replayAck = java.io.File(root, "desktop-ack-replay.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
replayAck.absolutePath,
)
assertEquals(
countsBeforeReplay,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(firstAck.readBytes()),
)
assertEquals(
0,
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
)
assertEquals(
0,
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
val desktopFacts =
JSONObject(
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT json_object(" +
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
"JOIN program_session_executions x ON x.session_id=s.session_id " +
"WHERE s.session_id='$sessionId' AND x.state='completed';",
).trim(),
)
assertEquals(programId, desktopFacts.getString("program_id"))
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
assertEquals(
listOf(
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT context_kind,context_id,started_at,ended_at " +
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
).trim(),
)
assertEquals(
listOf(
"0|$preExerciseAt|",
"1|$exerciseASampleAt|${exerciseA.entryId}",
"2|$betweenExercisesAt|",
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
"FROM heart_rate_samples " +
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
expectedRr,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT value_1024 FROM heart_rate_rr_intervals " +
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
)
} finally {
android.close()
context.deleteDatabase(androidName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,347 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import com.labfytools.trainlog.model.ProgramSessionExecutionState
import com.labfytools.trainlog.model.SessionSetDraft
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProgramsProjectionRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun program(title: String = "Cycle lisible"): JSONObject =
JSONObject()
.put("program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("revision_id", "pgr_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("title", title)
.put("note", "Lecture seule")
.put("state", "active")
.put("start_date", "2026-09-21")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-18T10:00:00Z")
.put("updated_at", "2026-09-18T11:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", "pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("title", "Séance A")
.put("session_type", "training")
.put("planned_for", JSONObject.NULL)
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray().put(
JSONObject()
.put("entry_id", "pge_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
),
),
),
)
private fun artifact(programs: JSONArray, deletions: JSONArray = JSONArray()): String =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-18T12:00:00Z")
.put("programs", programs)
.put("deletions", deletions)
.toString()
private fun preparationArtifact(
includeProgramProvenance: Boolean = true,
deliveryState: String = "pending",
): String {
val occurrence =
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL)
val delivery =
JSONObject()
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
.put("state", deliveryState)
.put("title", "Séance A")
.put("session_type", "training")
.put("planned_for", JSONObject.NULL)
.put("notes", JSONObject.NULL)
.put("source_proposal_id", JSONObject.NULL)
.put("source_payload_sha256", JSONObject.NULL)
.put("occurrences", JSONArray().put(occurrence))
if (includeProgramProvenance) {
delivery
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put(
"source_program_session_id",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
}
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 2)
.put("generated_at", "2026-09-18T12:05:00Z")
.put("deliveries", JSONArray().put(delivery))
.put("withdrawals", JSONArray())
.toString()
}
private fun deletion(): JSONObject =
JSONObject()
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
.put("program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("predecessor_revision_id", "pgr_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "pgr_11111111-1111-4111-8111-111111111111")
.put("requested_at", "2026-09-18T12:30:00Z")
@Test
fun projectionReplayConflictDeletionAndRestartAreDurable() {
val name = "programs-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
val summary = repository.listSyncedPrograms().single()
assertEquals(1, summary.sessionCount)
val detail = repository.getSyncedProgram(summary.programId)!!
assertEquals("ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", detail.sessions.single().occurrences.single().exerciseName)
val conflict = repository.applyProgramsV1Json(artifact(JSONArray().put(program("Divergent"))))
assertTrue(conflict is ProgramsImportResult.Invalid)
assertEquals("Cycle lisible", repository.listSyncedPrograms().single().title)
assertEquals(ProgramsImportResult.Applied(0, 1, 0), repository.applyProgramsV1Json(artifact(JSONArray(), JSONArray().put(deletion()))))
assertTrue(repository.listSyncedPrograms().isEmpty())
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
assertTrue(repository.listSyncedPrograms().isEmpty())
assertTrue(repository.listEquipment().isNotEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun malformedNestedDocumentDoesNotPartiallyDelete() {
val name = "programs-invalid-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
val malformed = program().apply { getJSONArray("sessions").getJSONObject(0).remove("title") }
val result = repository.applyProgramsV1Json(artifact(JSONArray().put(malformed), JSONArray().put(deletion())))
assertTrue(result is ProgramsImportResult.Invalid)
assertEquals(1, repository.listSyncedPrograms().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun programSessionStartsResumesCompletesAndKeepsProvenanceAcrossRestart() {
val name = "program-execution-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalog = JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put("exercises", JSONArray().put(
JSONObject()
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("name", "Exercise fixture")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
))
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val programSessionId = "pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc"
assertEquals(
StartProgramSessionResult.Started,
repository.startSyncedProgramSession(programId, programSessionId),
)
assertEquals(
StartProgramSessionResult.AlreadyActive,
repository.startSyncedProgramSession(programId, programSessionId),
)
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(programId, active.sourceProgramId)
assertEquals(programSessionId, active.sourceProgramSessionId)
val performed = active.copy(
exercises = active.exercises.map { occurrence ->
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
},
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramSessionExecutionState.COMPLETED,
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
)
assertEquals(
StartProgramSessionResult.AlreadyCompleted,
repository.startSyncedProgramSession(programId, programSessionId),
)
val exported = JSONObject(repository.buildProgramExecutionsV1Json())
val execution = exported.getJSONArray("executions").getJSONObject(0)
assertEquals("completed", execution.getString("state"))
assertEquals(programSessionId, execution.getString("program_session_id"))
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
val detail = repository.listSyncedPrograms().single()
assertEquals(1, detail.sessionCount)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertEquals(1, repository.listSessions().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
val name = "program-delivery-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray().put(
JSONObject()
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("name", "Exercise fixture")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(
preparationArtifact(includeProgramProvenance = false),
),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(null, active.sourceProgramId)
assertEquals(null, active.sourceProgramSessionId)
val performed =
active.copy(
exercises =
active.exercises.map { occurrence ->
occurrence.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
)
},
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(performed),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramSessionExecutionState.TODO,
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
.sessions.single().executionState,
)
assertEquals(
AiSessionDraftImportResult.Applied(0, 1),
repository.applySessionPreparationsJson(
preparationArtifact(deliveryState = "remote_unknown"),
),
)
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
val execution =
JSONObject(repository.buildProgramExecutionsV1Json())
.getJSONArray("executions")
.getJSONObject(0)
assertEquals("completed", execution.getString("state"))
assertEquals(
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
execution.getString("program_session_id"),
)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(1, detail.sessions.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
})
assertEquals(
StartProgramSessionResult.AlreadyCompleted,
repository.startSyncedProgramSession(
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,434 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionPreparationExchangeTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private fun artifact(repository: TrainlogRepository): String {
val exercise = repository.listExercises().first { it.recordingMode.wireValue == "sets" }
val occurrence =
JSONObject()
.put("entry_id", "spe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("position", 0)
.put("exercise_id", exercise.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", exercise.recordingMode.wireValue)
.put("tracking_mode", exercise.trackingMode.wireValue)
.put("data_fields", exercise.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 60)
.put("target_sets", 2)
.put("target_reps", if (exercise.trackingMode.wireValue == "reps") 8 else JSONObject.NULL)
.put(
"target_duration_seconds",
if (exercise.trackingMode.wireValue == "duration") 45 else JSONObject.NULL,
)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", JSONObject.NULL)
val delivery =
JSONObject()
.put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
.put("state", "pending")
.put("title", "Préparation contrôlée")
.put("session_type", "training")
.put("planned_for", "2026-09-20")
.put("notes", "Sans faits réalisés")
.put("source_proposal_id", JSONObject.NULL)
.put("source_payload_sha256", JSONObject.NULL)
.put("occurrences", JSONArray().put(occurrence))
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 1)
.put("generated_at", "2026-09-17T20:00:00Z")
.put("deliveries", JSONArray().put(delivery))
.toString()
}
private fun withdrawalArtifact(): String {
val delivery =
JSONObject()
.put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
val withdrawal =
JSONObject()
.put("withdrawal_id", "spw_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("requested_at", "2026-09-18T12:00:00Z")
.put("deliveries", JSONArray().put(delivery))
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 2)
.put("generated_at", "2026-09-18T12:01:00Z")
.put("deliveries", JSONArray())
.put("withdrawals", JSONArray().put(withdrawal))
.toString()
}
private fun scalarText(name: String, sql: String): String {
val path = context.getDatabasePath(name)
return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
}
}
private fun scalarInt(name: String, sql: String): Int {
val path = context.getDatabasePath(name)
return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
}
}
@Test
fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() {
val name = "prepared-mixed-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared walk",
recordingMode = RecordingMode.CONTINUOUS,
trackingMode = TrackingMode.DURATION,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared press",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
val continuous = repository.listExercises().single { it.name == "Test prepared walk" }
val sets = repository.listExercises().single { it.name == "Test prepared press" }
val payload = JSONObject(artifact(repository))
val delivery = payload.getJSONArray("deliveries").getJSONObject(0)
delivery.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", continuous.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "continuous")
.put("tracking_mode", "duration")
.put("data_fields", continuous.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 0)
.put("target_sets", JSONObject.NULL)
.put("target_reps", JSONObject.NULL)
.put("target_duration_seconds", 600)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", "Target only"),
)
.put(
JSONObject()
.put("entry_id", "spe_22222222-2222-4222-8222-222222222222")
.put("position", 1)
.put("exercise_id", sets.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", sets.dataFields)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 52.0)
.put("notes", JSONObject.NULL),
),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(payload.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val loadResult = repository.loadActiveSessionDraft()
assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded)
val active = loadResult as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(0, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(3, active.draft.exercises[1].plan?.sets)
assertEquals(10, active.draft.exercises[1].plan?.reps)
assertEquals(52.0, active.draft.exercises[1].plan?.weightKg)
assertTrue(active.draft.exercises[1].sets.isEmpty())
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions"))
assertEquals("ok", scalarText(name, "PRAGMA integrity_check"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db"
var repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test preparation exercise",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val payload = artifact(repository)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(payload))
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(payload))
assertEquals(1, repository.listPreparedSessions().size)
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
assertEquals(
StartAiSessionDraftResult.ExistingActiveDraft,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
repository.close()
context.deleteDatabase(name)
repository = TrainlogRepository(context, name)
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test preparation exercise",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val reloadedPayload = artifact(repository)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(reloadedPayload))
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId)
assertTrue(active.draft.exercises.all { it.sets.isEmpty() })
assertEquals(
StartAiSessionDraftResult.ExistingActiveDraft,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun withdrawalCancelsOnlyPendingDeliveryAndPreventsOlderReplayResurrection() {
val name = "prepared-withdraw-pending-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test pending withdrawal",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val delivery = artifact(repository)
val withdrawal = withdrawalArtifact()
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery))
val invalidWithdrawal = JSONObject(withdrawal)
invalidWithdrawal.getJSONArray("withdrawals").getJSONObject(0)
.put("withdrawal_id", "spw_invalid")
assertTrue(repository.applySessionPreparationsJson(invalidWithdrawal.toString()) is AiSessionDraftImportResult.Invalid)
assertEquals(1, repository.listPreparedSessions().size)
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal))
assertTrue(repository.listPreparedSessions().isEmpty())
assertEquals(
StartAiSessionDraftResult.NotPending,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal))
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(delivery))
assertTrue(repository.listPreparedSessions().isEmpty())
repository.close()
assertEquals(
"cancelled",
scalarText(name, "SELECT state FROM session_preparation_deliveries"),
)
assertEquals(
"pending_cancelled",
scalarText(name, "SELECT result FROM session_preparation_withdrawals"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun withdrawalPreservesStartedDeliveryAndActiveExecutionIdentity() {
val name = "prepared-withdraw-started-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test started withdrawal",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(artifact(repository)),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val before = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawalArtifact()))
val after = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(before.draft.sessionId, after.draft.sessionId)
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", after.draft.sessionId)
repository.close()
assertEquals(
"started",
scalarText(name, "SELECT state FROM session_preparation_deliveries"),
)
assertEquals(
"execution_preserved",
scalarText(name, "SELECT result FROM session_preparation_withdrawals"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun version22MigrationAddsWithdrawalLedgerWithoutChangingDeliveries() {
val name = "prepared-withdraw-migration-${System.nanoTime()}.db"
var repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test withdrawal migration",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val delivery = artifact(repository)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery))
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).absolutePath,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE session_preparation_withdrawals")
db.execSQL("PRAGMA user_version=22")
}
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("33", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun withdrawalCancelsAllPendingTargetDeliveriesButPreservesStartedAndWitnessRows() {
val name = "prepared-withdraw-multiple-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test multiple withdrawal",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val deliveryRoot = JSONObject(artifact(repository))
val targetTemplate = deliveryRoot.getJSONArray("deliveries").getJSONObject(0)
val secondTarget = JSONObject(targetTemplate.toString())
.put("delivery_id", "spd_11111111-1111-4111-8111-111111111111")
.put("execution_session_id", "se_11111111-1111-4111-8111-111111111111")
val witness = JSONObject(targetTemplate.toString())
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
.put("preparation_id", "sp_22222222-2222-4222-8222-222222222222")
.put("revision_id", "spr_22222222-2222-4222-8222-222222222222")
.put("execution_session_id", "se_22222222-2222-4222-8222-222222222222")
.put("title", "Préparation témoin")
deliveryRoot.getJSONArray("deliveries").put(secondTarget).put(witness)
assertEquals(
AiSessionDraftImportResult.Applied(3, 0),
repository.applySessionPreparationsJson(deliveryRoot.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val withdrawalRoot = JSONObject(withdrawalArtifact())
withdrawalRoot.getJSONArray("withdrawals").getJSONObject(0)
.getJSONArray("deliveries")
.put(
JSONObject()
.put("delivery_id", "spd_11111111-1111-4111-8111-111111111111")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("execution_session_id", "se_11111111-1111-4111-8111-111111111111"),
)
assertEquals(
AiSessionDraftImportResult.Applied(0, 0),
repository.applySessionPreparationsJson(withdrawalRoot.toString()),
)
assertEquals(
listOf("spd_22222222-2222-4222-8222-222222222222"),
repository.listPreparedSessions().map { it.deliveryId },
)
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId)
repository.close()
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='started'"))
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='cancelled'"))
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'"))
assertEquals("execution_preserved", scalarText(name, "SELECT result FROM session_preparation_withdrawals"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,458 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionTimelineRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun startedOccurrenceCannotBeRemovedOrSilentlyClosedBySessionFinalization() {
val name = "timeline-guard-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Timeline guarded exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertEquals(
ActiveDraftMutationResult.Saved,
repository.startActiveSessionDraft(),
)
val opened =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
)
val activeDraft = opened.draft.copy(exercises = listOf(occurrence))
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(activeDraft),
)
assertTrue(
repository.startActiveSessionExercise(occurrence.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val removed = repository.saveActiveSessionDraft(activeDraft.copy(exercises = emptyList()))
assertTrue(removed is ActiveDraftMutationResult.Error)
val finalize = repository.finalizeActiveSessionDraft()
assertTrue(finalize is FinalizeActiveDraftResult.Invalid)
assertTrue(
(finalize as FinalizeActiveDraftResult.Invalid)
.message
.startsWith("ACTIVE_EXERCISE:"),
)
assertTrue(
repository.finishActiveSessionExercise(occurrence.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
repository.finalizeActiveSessionDraft() is
FinalizeActiveDraftResult.Saved,
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun trainingTimelineCorrelatesMeasuredSamplesWithoutInventingRestOwner() {
val name = "timeline-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
fun exercise(label: String) =
(repository.createExercise(
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise
val first = exercise("Timeline A")
val second = exercise("Timeline B")
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val firstEntry = SessionExerciseDraft(
exercise = first,
sets = listOf(SessionSetDraft(reps = 10)),
)
val secondEntry = SessionExerciseDraft(
exercise = second,
sets = listOf(SessionSetDraft(reps = 10)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
opened.draft.copy(exercises = listOf(firstEntry, secondEntry)),
),
)
val startA = OffsetDateTime.now().toString()
val startedA = repository.startActiveSessionExercise(firstEntry.entryId, startA)
assertTrue(startedA is TrainlogRepository.SessionExerciseTimingResult.Started)
val conflict = repository.startActiveSessionExercise(secondEntry.entryId)
assertEquals(
firstEntry.entryId,
(conflict as TrainlogRepository.SessionExerciseTimingResult.ActiveConflict).activeEntryId,
)
val measurement = ParsedHeartRateMeasurement(
bpm = 101,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(620),
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR", OffsetDateTime.now().toString(), measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.finishActiveSessionExercise(firstEntry.entryId)
is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 88),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.startActiveSessionExercise(secondEntry.entryId)
is TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 109),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.finishActiveSessionExercise(secondEntry.entryId)
is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val finalized = repository.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
val session = timeline.getJSONArray("sessions").getJSONObject(0)
assertEquals(2, session.getJSONArray("exercises").length())
assertEquals(firstEntry.entryId, session.getJSONArray("exercises").getJSONObject(0).getString("entry_id"))
assertEquals(secondEntry.entryId, session.getJSONArray("exercises").getJSONObject(1).getString("entry_id"))
val heart = JSONObject(repository.buildHeartRateV1Json())
val samples = heart.getJSONArray("captures").getJSONObject(0).getJSONArray("samples")
assertEquals(3, samples.length())
assertEquals(firstEntry.entryId, samples.getJSONObject(0).getString("exercise_entry_id"))
assertTrue(samples.getJSONObject(1).isNull("exercise_entry_id"))
assertEquals(secondEntry.entryId, samples.getJSONObject(2).getString("exercise_entry_id"))
assertEquals(101, samples.getJSONObject(0).getInt("bpm"))
assertEquals(88, samples.getJSONObject(1).getInt("bpm"))
assertEquals(109, samples.getJSONObject(2).getInt("bpm"))
assertNull(repository.activeHeartRateCapture())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
val name = "program-session-finish-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val programId = "pg_11111111-1111-4111-8111-111111111111"
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
val rowId = "ex_44444444-4444-4444-8444-444444444444"
val pressId = "ex_55555555-5555-4555-8555-555555555555"
val curlId = "ex_66666666-6666-4666-8666-666666666666"
fun catalogExercise(
exerciseId: String,
exerciseName: String,
recordingMode: String,
trackingMode: String,
) =
JSONObject()
.put("exercise_id", exerciseId)
.put("name", exerciseName)
.put("recording_mode", recordingMode)
.put("tracking_mode", trackingMode)
.put("data_fields", 0)
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
fun occurrence(
entryId: String,
exerciseId: String,
targetSets: Int?,
targetReps: Int?,
targetDuration: Int?,
targetWeight: Double?,
restSeconds: Int,
) =
JSONObject()
.put("entry_id", entryId)
.put("exercise_id", exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", if (targetWeight == null) "none" else "external")
.put("rest_seconds", restSeconds)
.put("target_sets", targetSets ?: JSONObject.NULL)
.put("target_reps", targetReps ?: JSONObject.NULL)
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
.put("notes", JSONObject.NULL)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
.put("title", "Session finalization regression")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-24T08:00:00Z")
.put("updated_at", "2026-09-24T08:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Workout")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
occurrence(
"pge_88888888-8888-4888-8888-888888888888",
warmupId,
null,
null,
480,
null,
0,
),
)
.put(
occurrence(
"pge_99999999-9999-4999-8999-999999999999",
rowId,
3,
10,
null,
39.0,
90,
),
)
.put(
occurrence(
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
pressId,
3,
10,
null,
32.0,
90,
),
)
.put(
occurrence(
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
curlId,
2,
10,
null,
23.0,
60,
),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-24T08:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
repository.startSyncedProgramSession(programId, programSessionId),
)
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
val warmup = opened.draft.exercises[0]
val row = opened.draft.exercises[1]
val press = opened.draft.exercises[2]
val curl = opened.draft.exercises[3]
val performed =
opened.draft.copy(
exercises =
listOf(
warmup,
row.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
),
press.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
),
curl.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
),
),
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
val warmupStart = sessionStart.plusSeconds(10)
val warmupEnd = warmupStart.plusSeconds(433)
assertTrue(
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
/* Recreate the exact v49 persisted state: its timeline was closed,
* but the target-only continuous occurrence had no performed row. */
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
db.execSQL(
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
arrayOf(warmup.entryId),
)
}
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
listOf(row, press, curl).forEachIndexed { index, exercise ->
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
val endedAt = startedAt.plusSeconds(60)
assertTrue(
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
}
assertTrue(
repository.saveDraftExerciseFeedback(
curl.entryId,
"Valid effort feedback",
warmupEnd.plusSeconds(400).toString(),
) is SaveFeedbackResult.Saved,
)
val measurement =
ParsedHeartRateMeasurement(
bpm = 107,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(620),
)
val finalizationAt = warmupEnd.plusSeconds(500)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
finalizationAt.minusSeconds(1).toString(),
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val lateSampleAt = finalizationAt.plusSeconds(10)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lateSampleAt.toString(),
measurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
assertEquals(
sessionId,
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
)
val detail = checkNotNull(repository.getSessionDetail(sessionId))
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
assertEquals(4, detail.exercises.size)
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
assertEquals(2, detail.exercises[3].plan?.sets)
assertEquals(3, detail.exercises[3].sets.size)
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
assertEquals(1, detail.exercises[3].feedback.size)
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
assertEquals(4, timelineSession.getJSONArray("exercises").length())
val heart = JSONObject(repository.buildHeartRateV1Json())
val capture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(finalizationAt.toString(), capture.getString("ended_at"))
assertEquals(1, capture.getJSONArray("samples").length())
assertEquals(1, capture.getJSONArray("samples").getJSONObject(0)
.getJSONArray("rr_intervals_1024").length())
assertNull(repository.activeHeartRateCapture())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,209 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionTimelineSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
fun exercise(label: String) =
(source.createExercise(
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise
val a = SessionExerciseDraft(
exercise = exercise("Sync timeline A"),
sets = listOf(SessionSetDraft(reps = 8)),
)
val b = SessionExerciseDraft(
exercise = exercise("Sync timeline B"),
sets = listOf(SessionSetDraft(reps = 9)),
)
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
)
assertTrue(
source.startActiveSessionExercise(a.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.finishActiveSessionExercise(a.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base.copy(bpm = 84),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.startActiveSessionExercise(b.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base.copy(bpm = 105),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.finishActiveSessionExercise(b.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: " + command.joinToString(" ") + "\n" + output,
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val timelineJson =
JSONObject(
java.io.File(
captured.stagingDirectory,
"session-timeline-v1.json",
).readText(),
)
assertEquals(1, timelineJson.getJSONArray("sessions").length())
assertEquals(
2,
timelineJson
.getJSONArray("sessions")
.getJSONObject(0)
.getJSONArray("exercises")
.length(),
)
val heartJson =
JSONObject(
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
)
assertEquals(
3,
heartJson
.getJSONArray("captures")
.getJSONObject(0)
.getJSONArray("samples")
.length(),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
assertEquals(
"2|3|3",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals);",
).trim(),
)
assertEquals(
0,
JSONObject(source.buildSessionTimelineV1Json())
.getJSONArray("sessions")
.length(),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.length(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,434 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SleepDiaryRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun createEditConflictDeleteAndReopen() {
val name = "sleep-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalogTime = "2026-10-24T18:00:00+02:00"
val medicationResult = repository.saveSleepMedication(SleepMedication("", "", catalogTime,
catalogTime, "Synthetic medication", 5.0, "mg", "tablet", "", true)) as
TrainlogRepository.SaveSleepDiaryResult.Saved
val initial = SleepDiaryDraft(nightStartDate = "2026-10-24", nightEndDate = "2026-10-25",
createdAt = "2026-10-24T23:30:00+02:00", updatedAt = "2026-10-24T23:30:00+02:00",
sleepQuality = SleepQuality.B, wakeQuality = null, dayForm = null,
treatmentAndNotes = "Synthetic fixture", events = listOf(
SleepDiaryEvent("", SleepEventType.SLEEP, "2026-10-24T23:30:00+02:00", "2026-10-25T06:30:00+01:00"),
SleepDiaryEvent("", SleepEventType.DAYTIME_SLEEPINESS, "2026-10-25T14:00:00+01:00", null)),
intakes = listOf(MedicationIntake("", medicationResult.entryId,
"Synthetic medication", "2026-10-25T05:15:00+01:00", 10.0, "mg", "", catalogTime)))
val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved
val loaded = repository.listSleepDiary().single()
assertEquals(SleepPublicationStatus.DRAFT, loaded.publicationStatus)
assertEquals(0, org.json.JSONObject(repository.buildSleepDiaryV1Json())
.getJSONArray("entries").length())
assertTrue(repository.validateSleepDiary(loaded.entryId, loaded.revisionId,
"2026-10-25T17:30:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals(SleepPublicationStatus.READY,
repository.listSleepDiary().single().publicationStatus)
assertEquals(1, org.json.JSONObject(repository.buildSleepDiaryV1Json())
.getJSONArray("entries").length())
assertEquals(2, loaded.events.size)
assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0)
val medication = repository.listSleepMedications().single()
assertEquals(5.0, medication.defaultDoseValue!!, 0.0)
assertTrue(repository.saveSleepMedication(medication.copy(name = "Renamed medication",
updatedAt = "2026-10-25T12:00:00+01:00")) is
TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals("Synthetic medication", loaded.intakes.single().medicationName)
val edited = repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId, updatedAt = "2026-10-25T18:00:00+01:00",
wakeQuality = SleepQuality.MOY, dayForm = SleepQuality.TB))
assertTrue(edited is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId)) is TrainlogRepository.SaveSleepDiaryResult.Conflict)
repository.close(); repository = TrainlogRepository(context, name)
val reopened = repository.listSleepDiary().single()
assertEquals(SleepQuality.MOY, reopened.wakeQuality)
assertEquals(SleepPublicationStatus.DRAFT, reopened.publicationStatus)
assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId,
"2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.listSleepDiary().isEmpty())
} finally { repository.close(); context.deleteDatabase(name) }
}
@Test
fun persistedSleepRevisionPayloadIsAppendOnly() {
val name = "sleep-immutable-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val saved = try {
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
} finally {
repository.close()
}
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"DELETE FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertEquals(
"B",
raw.rawQuery(
"SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
},
)
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() {
val name = "sleep-revision-chain-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val identities = mutableListOf<String>()
val expectedParents = mutableMapOf<String, String?>()
val expectedNotes = mutableMapOf<String, String>()
try {
val created = repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "revision-root",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
identities += created.revisionId
expectedParents[created.revisionId] = null
expectedNotes[created.revisionId] = "revision-root"
repeat(64) { index ->
val current = repository.listSleepDiary().single()
val note = "revision-$index"
val hour = 8 + index / 60
val minute = index % 60
val saved = repository.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute),
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = current.dayForm,
treatmentAndNotes = note,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
expectedParents[saved.revisionId] = current.revisionId
expectedNotes[saved.revisionId] = note
identities += saved.revisionId
}
} finally {
repository.close()
}
assertEquals(65, identities.toSet().size)
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
)
try {
identities.forEach { revisionId ->
raw.rawQuery(
"SELECT parent_revision_id,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
val parent = if (cursor.isNull(0)) null else cursor.getString(0)
assertEquals(expectedParents[revisionId], parent)
assertEquals(expectedNotes[revisionId], cursor.getString(1))
}
}
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun ancestryAllowsMultiHopRemoteAdvance() {
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
val destinationName = "sleep-stale-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val createdAt = "2026-09-20T20:00:00+02:00"
source.saveSleepMedication(
SleepMedication("", "", createdAt, createdAt, "Medication", 5.0, "mg", "", "", true),
)
val firstSaved = source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-20",
nightEndDate = "2026-09-21",
createdAt = createdAt,
updatedAt = createdAt,
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(
SleepDiaryEvent(
"",
SleepEventType.SLEEP,
"2026-09-20T23:00:00+02:00",
"2026-09-21T07:00:00+02:00",
),
),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
firstSaved.entryId,
firstSaved.revisionId,
"2026-09-21T08:00:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val firstSnapshot = source.buildSleepDiaryV1Json()
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(firstSnapshot),
)
val medication = source.listSleepMedications().single()
source.saveSleepMedication(
medication.copy(
name = "Medication revised",
updatedAt = "2026-09-21T08:05:00+02:00",
),
)
val current = source.listSleepDiary().single()
val secondSaved = source.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-09-21T08:10:00+02:00",
sleepQuality = current.sleepQuality,
wakeQuality = SleepQuality.MOY,
dayForm = current.dayForm,
treatmentAndNotes = current.treatmentAndNotes,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
secondSaved.entryId,
secondSaved.revisionId,
"2026-09-21T08:15:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val afterSecond = source.listSleepDiary().single()
val thirdSaved = source.saveSleepDiary(
SleepDiaryDraft(
entryId = afterSecond.entryId,
expectedRevision = secondSaved.revisionId,
nightStartDate = afterSecond.nightStartDate,
nightEndDate = afterSecond.nightEndDate,
createdAt = afterSecond.createdAt,
updatedAt = "2026-09-21T08:20:00+02:00",
sleepQuality = afterSecond.sleepQuality,
wakeQuality = afterSecond.wakeQuality,
dayForm = SleepQuality.TB,
treatmentAndNotes = afterSecond.treatmentAndNotes,
events = afterSecond.events,
intakes = afterSecond.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
thirdSaved.entryId,
thirdSaved.revisionId,
"2026-09-21T08:25:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val latestSnapshot = source.buildSleepDiaryV1Json()
val ancestry =
org.json.JSONObject(latestSnapshot)
.getJSONArray("entries")
.getJSONObject(0)
.getJSONArray("ancestry")
assertTrue((0 until ancestry.length()).any { ancestry.getString(it) == firstSaved.revisionId })
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(latestSnapshot),
)
assertEquals(thirdSaved.revisionId, destination.listSleepDiary().single().revisionId)
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
val reusedAncestor = org.json.JSONObject(firstSnapshot)
reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M")
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
),
destination.applySleepDiaryV1Json(reusedAncestor.toString()),
)
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
}
}
@Test
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
val destinationName = "sleep-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val time = "2026-09-21T20:00:00+02:00"
val createdMedication =
source.saveSleepMedication(
SleepMedication("", "", time, time, "Medication", 5.0, "mg", "", "", true),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val medication = source.listSleepMedications().single()
source.saveSleepMedication(
medication.copy(name = "Medication revised", updatedAt = "2026-09-21T20:05:00+02:00"),
)
val createdEntry =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-20",
nightEndDate = "2026-09-21",
createdAt = time,
updatedAt = time,
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(
SleepDiaryEvent(
"",
SleepEventType.SLEEP,
"2026-09-20T23:00:00+02:00",
"2026-09-21T07:00:00+02:00",
),
),
intakes = listOf(
MedicationIntake(
"",
createdMedication.entryId,
"Medication",
"2026-09-20T22:00:00+02:00",
5.0,
"mg",
"",
time,
),
),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val current = source.listSleepDiary().single()
val edited =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = createdEntry.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-09-21T20:10:00+02:00",
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = SleepQuality.B,
treatmentAndNotes = current.treatmentAndNotes,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
current.entryId,
edited.revisionId,
"2026-09-21T20:15:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
assertEquals(SleepQuality.B, destination.listSleepDiary().single().dayForm)
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
}
}
}

View file

@ -0,0 +1,287 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SleepQuickCaptureTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun pendingMedicationSurvivesReopenAndBedTimeKeepsEntryIdentity() {
val name = "sleep-reopen-" + UUID.randomUUID()
var repository = TrainlogRepository(context, name)
try {
val medication =
repository.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Test medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "",
note = "",
active = true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val pending =
repository.quickSleepMedication(
medication.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
repository.close()
repository = TrainlogRepository(context, name)
val bed =
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(pending.receipt.entryId, bed.receipt.entryId)
assertEquals(2, repository.listSleepDiary().single().intakes.single().quantity)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() {
val name = "sleep-quick-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
val medicationCreated =
repository.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Test medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "",
note = "",
active = true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val medication =
repository.listSleepMedications(false).single {
it.medicationId == medicationCreated.entryId
}
val preBed = repository.quickSleepMedication(
medication.medicationId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
assertNull(repository.activeHeartRateCapture())
val pending = repository.listSleepDiary().single()
assertTrue(pending.events.none { it.type == SleepEventType.BED_TIME })
assertEquals(2, pending.intakes.single().quantity)
val preBedExport = JSONObject(repository.buildSleepDiaryV1Json())
val exportedPending = preBedExport.getJSONArray("entries").getJSONObject(0)
assertEquals(preBed.receipt.entryId, exportedPending.getString("entry_id"))
assertEquals(preBed.receipt.appliedRevisionId, exportedPending.getString("revision_id"))
assertEquals(0, exportedPending.getJSONArray("events").length())
assertEquals(1, exportedPending.getJSONArray("intakes").length())
val bed =
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate)
val measurement =
ParsedHeartRateMeasurement(
bpm = 62,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1000),
)
assertTrue(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-23T22:30:01+02:00",
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
assertTrue(
repository.quickSleepMedication(
medication.medicationId,
"2026-09-23T22:35:00+02:00",
) is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertTrue(
repository.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertTrue(
repository.quickSleepWake("2026-09-24T04:42:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
val beforeGetUp = repository.listSleepDiary().single()
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
assertEquals(
2,
beforeGetUp.events.count { it.type == SleepEventType.LONG_AWAKE },
)
assertEquals(
listOf(
"2026-09-24T02:17:00+02:00" to "2026-09-24T02:47:00+02:00",
"2026-09-24T04:42:00+02:00" to "2026-09-24T05:12:00+02:00",
),
beforeGetUp.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
assertTrue(beforeGetUp.events.none { it.type == SleepEventType.NIGHT_GET_UP })
assertEquals(2, beforeGetUp.intakes.size)
assertEquals(2, beforeGetUp.intakes.first().quantity)
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
assertTrue(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-24T04:43:00+02:00",
measurement.copy(bpm = 65),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
assertNull(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-24T06:32:00+02:00",
measurement.copy(bpm = 70),
),
)
val finalEntry = repository.listSleepDiary().single()
assertEquals(
1,
finalEntry.events.count { it.type == SleepEventType.FINAL_GET_UP },
)
val exported = JSONObject(repository.buildHeartRateV1Json())
val capture = exported.getJSONArray("captures").getJSONObject(0)
assertEquals(captureId, capture.getString("capture_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals(finalEntry.entryId, capture.getString("context_id"))
assertEquals(62, capture.getJSONArray("samples").getJSONObject(0).getInt("bpm"))
assertEquals("2026-09-23T22:30:00+02:00", capture.getString("started_at"))
assertEquals("2026-09-24T06:31:00+02:00", capture.getString("ended_at"))
assertEquals(2, capture.getJSONArray("samples").length())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun undoIsGuardedByTheExactAppliedRevision() {
val name = "sleep-undo-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
val firstWake =
repository.quickSleepWake("2026-09-24T02:00:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
val secondWake =
repository.quickSleepWake("2026-09-24T03:00:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertTrue(
repository.undoSleepQuickAction(firstWake.receipt)
is TrainlogRepository.SleepQuickActionResult.Conflict,
)
assertTrue(
repository.undoSleepQuickAction(
secondWake.receipt,
"2026-09-24T03:00:05+02:00",
) is TrainlogRepository.SleepQuickActionResult.Applied,
)
val night = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T02:00:00+02:00"),
night.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun repeatedWakeExtendsOneWindowAndFinalGetUpClampsIt() {
val name = "sleep-wake-window-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:05:00+02:00")
repository.quickSleepWake("2026-09-24T04:11:00+02:00")
repository.quickSleepWake("2026-09-24T04:14:00+02:00")
val extended = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:44:00+02:00"),
extended.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
repository.quickSleepFinalGetUp("2026-09-24T04:14:30+02:00")
val completed = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:14:30+02:00"),
completed.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
val name = "sleep-no-sensor-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
assertNull(repository.activeHeartRateCapture())
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -296,7 +296,7 @@ class StatisticsRepositoryTest {
} }
private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") { private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") {
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type)) db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
} }
private fun occurrence( private fun occurrence(

Some files were not shown because too many files have changed in this diff Show more