Compare commits
208 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8708c63b | ||
|
|
bb20aa44ef | ||
|
|
45bb44e45d | ||
|
|
bd795495df | ||
|
|
a652644e24 | ||
|
|
8646422bef | ||
|
|
77b5bbca85 | ||
|
|
ffcdc3efcf | ||
|
|
23717ba6da | ||
|
|
de4b77d59b | ||
|
|
4cb911d5c4 | ||
|
|
a60e1aa056 | ||
|
|
696dac82a3 | ||
|
|
fe79c44e51 | ||
|
|
2ce458d286 | ||
|
|
3a51211f01 | ||
|
|
2755d29f2d | ||
|
|
713238fd0e | ||
|
|
d7ca6a1924 | ||
|
|
e7fbd6ff86 | ||
|
|
7adeb59b06 | ||
|
|
f20e5c4d39 | ||
|
|
f2747b357b | ||
|
|
f2fd610b4f | ||
|
|
c8bfb7a759 | ||
|
|
21f56e3986 | ||
|
|
5a7f80049a | ||
|
|
41d0f1ff96 | ||
|
|
0750a47cef | ||
|
|
8f8b361915 | ||
|
|
246e47ee8f | ||
|
|
ad493a72aa | ||
|
|
8371d5e16c | ||
|
|
3ded6ee425 | ||
|
|
070a29298f | ||
|
|
dd39c85f94 | ||
|
|
1ca354af53 | ||
|
|
5bf90899bd | ||
|
|
984b1e058e | ||
|
|
14c3ac294e | ||
|
|
c13549338c | ||
|
|
e1d6372338 | ||
|
|
5d58071601 | ||
|
|
b4fd753ac5 | ||
|
|
8a334613f8 | ||
|
|
26d24d2ed7 | ||
|
|
1d8450ad1c | ||
|
|
d5b4584ae2 | ||
|
|
b580b63886 | ||
|
|
066a3a44a7 | ||
|
|
87931ca1ec | ||
|
|
c11ea07a19 | ||
|
|
3347de3dbb | ||
|
|
0a35ddacbf | ||
|
|
a88e85c235 | ||
|
|
0f7d793960 | ||
|
|
dd6d2a180d | ||
|
|
891c09179e | ||
|
|
e6e5e76f68 | ||
|
|
9652b35189 | ||
|
|
a04e41d252 | ||
|
|
20fe562cd8 | ||
|
|
a98a4440ed | ||
|
|
915728cec4 | ||
|
|
47ee128807 | ||
|
|
e320767516 | ||
|
|
c5e62c2e5e | ||
|
|
e2284be1e3 | ||
|
|
f48be35f3b | ||
|
|
0cf53228da | ||
|
|
afd1fcc92f | ||
|
|
53f15e1354 | ||
|
|
1096b13939 | ||
|
|
bf27e05e7a | ||
|
|
36a7e651ac | ||
|
|
30df3030e6 | ||
|
|
2565d0eafa | ||
|
|
6948e0bbd5 | ||
|
|
c31c394e4f | ||
|
|
109eb6fcd4 | ||
|
|
2bd68100e9 | ||
|
|
451d0a36a0 | ||
|
|
bedf392542 | ||
|
|
0262433615 | ||
|
|
ab3986200e | ||
|
|
eb6d70fb62 | ||
|
|
38d304d014 | ||
|
|
ca2ea9379b | ||
|
|
0e13c3947a | ||
|
|
60b08f225a | ||
|
|
a2eaf224e8 | ||
|
|
ba5021b436 | ||
|
|
99e428c558 | ||
|
|
2a68e5a505 | ||
|
|
1e9c8aa741 | ||
|
|
f27ef5af5f | ||
|
|
53baa7ad24 | ||
|
|
e41949557b | ||
|
|
d56bd3b54f | ||
|
|
cb4010dbd5 | ||
|
|
88e0eb0fb7 | ||
|
|
0a9639bb94 | ||
|
|
5701e2a541 | ||
|
|
fdf4301502 | ||
|
|
799054ee2c | ||
|
|
3ad50be247 | ||
|
|
ca45683813 | ||
|
|
4ad7ccc9c0 | ||
|
|
a8c50a9512 | ||
|
|
736d209675 | ||
|
|
457abc0a51 | ||
|
|
59673a0739 | ||
|
|
2c0fa714c3 | ||
|
|
68af06b470 | ||
|
|
894c944636 | ||
|
|
949ace6301 | ||
|
|
db722588f8 | ||
|
|
f431b6ec9c | ||
|
|
20fc427c2f | ||
|
|
4d6ee07ac5 | ||
|
|
a0fc48f082 | ||
|
|
4dffb05210 | ||
|
|
c8a292326d | ||
|
|
a4d7f5d350 | ||
|
|
0cff443893 | ||
|
|
ed9395366e | ||
|
|
679ed6184a | ||
|
|
4573fe9a42 | ||
|
|
4f0a5fee31 | ||
|
|
0e7fb720c8 | ||
|
|
4b76848c54 | ||
|
|
823184d99e | ||
|
|
1fc15d6214 | ||
|
|
1a373cb3bb | ||
|
|
c4074f4732 | ||
|
|
1be4ab0397 | ||
|
|
189307b548 | ||
|
|
ed3301217f | ||
|
|
9cdd9c2313 | ||
|
|
f57682790d | ||
|
|
a856451875 | ||
|
|
c99493fa54 | ||
|
|
bbcb487491 | ||
|
|
e0a0397d2a | ||
|
|
47385c7a86 | ||
|
|
ec17fca45d | ||
|
|
a5cdbdf2b9 | ||
|
|
d87d7cd302 | ||
|
|
908c38546c | ||
|
|
4c330be8c3 | ||
|
|
6f3ad45cb4 | ||
|
|
f7b556cc37 | ||
|
|
56ac1cdfd2 | ||
|
|
e64ea836f7 | ||
|
|
bad79ed926 | ||
|
|
d94ff495e3 | ||
|
|
09ada28c6b | ||
|
|
72c30ba7e0 | ||
|
|
99e6c0b073 | ||
|
|
05c93777d6 | ||
|
|
1552dcac1f | ||
|
|
0018edff26 | ||
|
|
8aa3c20954 | ||
|
|
dd8c91a946 | ||
|
|
cc87f26b98 | ||
|
|
2c5a8be282 | ||
|
|
d29532a735 | ||
|
|
3a740eb95d | ||
|
|
7d29dc83a2 | ||
|
|
7b769076c1 | ||
|
|
6cd6219ac5 | ||
|
|
34c6c0b369 | ||
|
|
86b317c5bd | ||
|
|
dd991830ac | ||
|
|
7b34ca15d8 | ||
|
|
aa8ab7ae69 | ||
|
|
ae4e493253 | ||
|
|
ef435148f8 | ||
|
|
a6fe9be296 | ||
|
|
444508b8b9 | ||
|
|
70cb7a116b | ||
|
|
98cb40749f | ||
|
|
4b8f5b02b8 | ||
|
|
3db84bbfb0 | ||
|
|
99f3f46d13 | ||
|
|
77c15172e1 | ||
|
|
9b2da4e9e8 | ||
|
|
14b863eb0f | ||
|
|
32877f11e9 | ||
|
|
c04fa76f0b | ||
|
|
86af874de5 | ||
|
|
f290286122 | ||
|
|
ecec81f077 | ||
|
|
799c2e0a52 | ||
|
|
3973a60233 | ||
|
|
6f34233751 | ||
|
|
f894f744f3 | ||
|
|
6443221327 | ||
|
|
188aba2df7 | ||
|
|
7597a167f8 | ||
|
|
456713586b | ||
|
|
3e06039890 | ||
|
|
af86895c2e | ||
|
|
7ea6022eac | ||
|
|
7a12dd3728 | ||
|
|
d4b56a3aa5 | ||
|
|
1674059203 | ||
|
|
3200358641 |
476 changed files with 102355 additions and 7691 deletions
19
.clang-format
Normal file
19
.clang-format
Normal 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
8
.gitignore
vendored
|
|
@ -3,11 +3,19 @@ build/
|
|||
builddir/
|
||||
meson-logs/
|
||||
meson-private/
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
/web/coverage/
|
||||
|
||||
# Android / Gradle
|
||||
.gradle/
|
||||
local.properties
|
||||
**/build/
|
||||
*.jks
|
||||
*.keystore
|
||||
/android/release-signing.properties
|
||||
/android/release-signing.env
|
||||
/android/keystore.properties
|
||||
|
||||
# Editors
|
||||
*.swp
|
||||
|
|
|
|||
60
AGENTS.md
60
AGENTS.md
|
|
@ -6,15 +6,21 @@ Trainlog consists of:
|
|||
|
||||
- a native Android application for fast workout and body-data capture;
|
||||
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
|
||||
visualization, and manual synchronization. The current authorized
|
||||
`TUI_NOTCURSES_V1` infrastructure tranche migrates the active rendering/input
|
||||
backend from legacy ncursesw to Notcurses without changing product semantics;
|
||||
visualization, and manual synchronization, using Notcurses as its completed
|
||||
active rendering/input backend;
|
||||
- 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
|
||||
synchronization;
|
||||
- versioned JSON synchronization artifacts exchanged over direct MTP.
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||
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
|
||||
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
||||
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
|
||||
completed-session exchange is the separate strict
|
||||
`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:
|
||||
|
||||
```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:
|
||||
|
||||
```text
|
||||
|
|
@ -232,11 +241,14 @@ Desktop SQLite schema is versioned with:
|
|||
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
|
||||
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
||||
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
|
||||
regression coverage.
|
||||
|
|
@ -264,6 +276,30 @@ User-facing code must not intentionally return placeholders such as
|
|||
|
||||
## 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:
|
||||
|
||||
- `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
|
||||
`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
|
||||
|
||||
Before every meaningful push:
|
||||
|
|
|
|||
814
CHANGELOG.md
814
CHANGELOG.md
|
|
@ -1,10 +1,818 @@
|
|||
# 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,
|
||||
SHA-256 verification, runtime/build/optional dependencies, and the release
|
||||
signing and exact-tag requirements. No v0.1.0 release is claimed until a
|
||||
signed APK and authenticated GitHub publication path are available.
|
||||
signing and exact-tag requirements. The Android artifact is built with the
|
||||
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
|
||||
canonical documentation index, made README a concise entry point, separated
|
||||
|
|
|
|||
389
README.md
389
README.md
|
|
@ -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
|
||||
is the lightweight field companion for fast capture and glanceable summaries;
|
||||
its C17/Notcurses desktop TUI is the detailed surface for correction,
|
||||
catalogue management, analytics, graphs, and long-term follow-up.
|
||||
<p align="center">
|
||||
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
|
||||
</p>
|
||||
|
||||
Android and desktop each own a local SQLite database. The desktop database is
|
||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||
over direct MTP; it never copies SQLite database files between devices.
|
||||
Trainlog is a **local-first system for recording, synchronizing, and examining
|
||||
training and body data**. Its Android companion handles capture in the field;
|
||||
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 |
|
||||
|---|---|
|
||||
| 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. |
|
||||
> **Release line:** The latest stable release is
|
||||
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
|
||||
|
||||
In short: **Android captures and summarizes. The TUI analyzes and tracks over
|
||||
time.** Android intentionally remains lightweight for analytics.
|
||||
## ✦ Showcase
|
||||
|
||||
## 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)
|
||||
- [Documentation](docs/README.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
|
||||
|
||||
## 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
|
||||
`SHA256SUMS` file on [GitHub Releases](https://github.com/labfytools/trainlog/releases).
|
||||
Verify the downloaded binary against `SHA256SUMS` before installing it.
|
||||
| Area | Available in the current development tree |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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
|
||||
not a universally portable Linux binary: the host must provide compatible
|
||||
runtime libraries listed under [Dependencies](#dependencies).
|
||||
### Boundaries and work in progress
|
||||
|
||||
```bash
|
||||
chmod +x trainlog-tui-linux-x86_64-v0.1.0
|
||||
./trainlog-tui-linux-x86_64-v0.1.0
|
||||
```
|
||||
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
|
||||
and V2 remain readable, while V4 codecs are staged but not selected by the
|
||||
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
|
||||
on `PATH`, such as a user-managed `~/.local/bin`, is optional.
|
||||
The canonical status and limitations live in
|
||||
[`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
|
||||
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
|
||||
## ✦ Architecture
|
||||
|
||||
```text
|
||||
Android local SQLite
|
||||
|
|
||||
| versioned JSON snapshots and requests
|
||||
v
|
||||
Documents/Trainlog on Android storage
|
||||
|
|
||||
| direct MTP / libmtp
|
||||
v
|
||||
trainlog_sync_run() <---- trainlog-syncd or desktop TUI
|
||||
|
|
||||
+---- import Android snapshot into desktop SQLite
|
||||
+---- publish desktop catalog companions to Android
|
||||
+---- write a synchronization receipt
|
||||
|
||||
desktop SQLite = canonical long-term history
|
||||
┌──────────────────────────────┐
|
||||
│ Android field companion │
|
||||
│ private SQLite capture store │
|
||||
└──────────────┬───────────────┘
|
||||
│ versioned JSON generations + acknowledgements
|
||||
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
|
||||
▼
|
||||
┌──────────────────────────────┐ optional, separately configured
|
||||
│ Synchronization orchestration│◄───── private Drive workflow
|
||||
└──────────────┬───────────────┘
|
||||
│ typed imports, commands and read models
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ Trainlog Core · C17 │
|
||||
│ 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
|
||||
proposals through Google Drive via the external `rclone` process. Android does
|
||||
not contain cloud credentials and does not run `rclone`.
|
||||
The TUI, Web application, and Android client are sibling adapters with
|
||||
different responsibilities. Business rules, calculations, persistence, and
|
||||
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`;
|
||||
- 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.
|
||||
## ✦ Build and run
|
||||
|
||||
The latest executable result belongs in
|
||||
[current state](docs/current_state.md), not in multiple README narratives.
|
||||
### Desktop and TUI
|
||||
|
||||
## Desktop development quick start
|
||||
|
||||
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
|
||||
libmtp, and Notcurses.
|
||||
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
|
||||
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
|
||||
Notcurses Core, GNU libmicrohttpd, and yyjson.
|
||||
|
||||
```bash
|
||||
meson setup build
|
||||
|
|
@ -198,13 +127,36 @@ meson test -C build --print-errorlogs
|
|||
./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.
|
||||
|
||||
## Android development quick start
|
||||
### Embedded local Web application
|
||||
|
||||
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
|
||||
`android/local.properties` file.
|
||||
Node and npm are build-time dependencies only. Meson does not download npm
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Instrumented tests belong on an emulator. Do not run
|
||||
`connectedDebugAndroidTest` on the primary personal phone.
|
||||
Instrumented tests belong on an emulator; the primary personal phone is
|
||||
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
|
||||
meson compile -C build
|
||||
bash tools/install_syncd_user.sh
|
||||
systemctl --user is-active trainlog-syncd.service
|
||||
```
|
||||
|
||||
On Android, grant all-files access for the fixed `Documents/Trainlog`
|
||||
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
|
||||
validation requires an unlocked connected device and remains an explicit manual
|
||||
check.
|
||||
This modifies the current user's `~/.local/bin` and systemd user configuration.
|
||||
Bluetooth generation transport starts only when its separate Trainlog
|
||||
configuration explicitly enables it. The loopback Web development unit in
|
||||
`systemd/trainlog-web.service` is another optional integration; neither service
|
||||
is required for manual TUI use.
|
||||
|
||||
## Development principles
|
||||
</details>
|
||||
|
||||
- Preserve frozen, versioned compatibility boundaries.
|
||||
- Use stable IDs; display names are not identities.
|
||||
- Keep performed work separate from plans and continuous activity separate
|
||||
from fake sets.
|
||||
- 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.
|
||||
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
|
||||
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
|
||||
downloaded artifacts against the accompanying `SHA256SUMS`.
|
||||
|
||||
## License and project notes
|
||||
## ✦ Repository layout
|
||||
|
||||
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
|
||||
Repository layout, detailed contracts, and historical review records are linked
|
||||
from [docs/README.md](docs/README.md).
|
||||
```text
|
||||
trainlog/
|
||||
├── 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).
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
|
|||
LOCAL_PERSISTENCE=IMPLEMENTED
|
||||
DIRECT_STORAGE=IMPLEMENTED
|
||||
MTP_SYNC=IMPLEMENTED
|
||||
ANDROID_SCHEMA=17
|
||||
ANDROID_SCHEMA=33
|
||||
```
|
||||
|
||||
## Visual contract
|
||||
|
|
|
|||
|
|
@ -1,8 +1,82 @@
|
|||
import java.time.OffsetDateTime
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
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 {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
compileSdk = 37
|
||||
|
|
@ -12,12 +86,33 @@ android {
|
|||
minSdk = 26
|
||||
targetSdk = 36
|
||||
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.6"
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||
testInstrumentationRunner =
|
||||
"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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
|
|
@ -25,6 +120,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
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 {
|
||||
val composeBom =
|
||||
platform("androidx.compose:compose-bom:2026.08.00")
|
||||
|
|
@ -64,6 +181,9 @@ dependencies {
|
|||
implementation(
|
||||
"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(
|
||||
"androidx.compose.foundation:foundation"
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
sessionVisible = false
|
||||
},
|
||||
onCreateExercise = {},
|
||||
onCreateEquipment = { _ -> },
|
||||
/* CONTRACT: this host never writes shared export data. */
|
||||
onSessionSaved = {},
|
||||
)
|
||||
|
|
@ -82,8 +83,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
?: (loaded as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.warning,
|
||||
latestSession = null,
|
||||
latestMaximum = null,
|
||||
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
|
||||
onSession = {
|
||||
when (
|
||||
|
|
@ -102,9 +101,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
}
|
||||
},
|
||||
onOpenExercises = {},
|
||||
onOpenStatistics = {},
|
||||
onBody = {},
|
||||
onOpenLatestSession = {},
|
||||
onSync = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,25 @@
|
|||
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
|
||||
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
||||
<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
|
||||
android:allowBackup="false"
|
||||
|
|
@ -26,5 +45,14 @@
|
|||
android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -9,20 +9,45 @@ import android.os.Bundle
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
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.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
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val appState by viewModels<TrainlogAppState>()
|
||||
private lateinit var languageSettings: LanguageSettingsOwner
|
||||
override fun onCreate(
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
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 =
|
||||
TrainlogRepository(
|
||||
|
|
@ -47,15 +72,22 @@ class MainActivity : ComponentActivity() {
|
|||
)
|
||||
|
||||
setContent {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
/* CONTRACT: reading this observable property makes a language
|
||||
* selection recompose the existing activity immediately. */
|
||||
val language = languageSettings.language
|
||||
CompositionLocalProvider(
|
||||
LocalLanguagePresentation provides LanguagePresentation(language, languageSettings::select),
|
||||
) {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
repository = repository,
|
||||
exporter = exporter,
|
||||
inbox = inbox,
|
||||
requestOutbox =
|
||||
requestOutbox,
|
||||
appState = appState,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ internal fun canonicalExchangeDirectory(): File =
|
|||
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
|
||||
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
|
||||
"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-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
|
||||
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -13,7 +13,29 @@ import java.util.UUID
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
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 {
|
||||
/** The full-generation coordinator completed both durable peer ACKs. */
|
||||
data class Completed(
|
||||
val runId: String,
|
||||
) : SyncRequestResult
|
||||
|
||||
data class Requested(
|
||||
val requestId: String,
|
||||
) : SyncRequestResult
|
||||
|
|
@ -28,13 +50,16 @@ sealed interface SyncRequestResult {
|
|||
|
||||
class SyncRequestOutbox private constructor(
|
||||
private val publisher: DirectExchangePublisher,
|
||||
private val visibility: MtpPublicationVisibility,
|
||||
) {
|
||||
constructor(context: Context) : this(
|
||||
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||
AndroidMtpPublicationVisibility(context),
|
||||
)
|
||||
|
||||
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
|
||||
DirectExchangePublisher(directoryProvider),
|
||||
ImmediateMtpPublicationVisibility,
|
||||
)
|
||||
|
||||
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 {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
|
|
@ -52,37 +87,42 @@ class SyncRequestOutbox private constructor(
|
|||
return SyncRequestResult.Unsupported
|
||||
}
|
||||
|
||||
val requestId =
|
||||
"sr_" +
|
||||
UUID.randomUUID()
|
||||
.toString()
|
||||
val intent = newIntent()
|
||||
return publishLegacy(snapshot, intent)
|
||||
}
|
||||
|
||||
val payload =
|
||||
JSONObject()
|
||||
.put(
|
||||
"format",
|
||||
"trainlog-sync-request",
|
||||
)
|
||||
.put(
|
||||
"version",
|
||||
1,
|
||||
)
|
||||
.put(
|
||||
"request_id",
|
||||
requestId,
|
||||
)
|
||||
.put(
|
||||
"requested_at",
|
||||
OffsetDateTime.now()
|
||||
.toString(),
|
||||
)
|
||||
.toString()
|
||||
internal fun newIntent(): SyncRequestIntent =
|
||||
SyncRequestIntent(
|
||||
requestId = "sr_${UUID.randomUUID()}",
|
||||
requestedAt = OffsetDateTime.now().toString(),
|
||||
)
|
||||
|
||||
val displayName =
|
||||
"trainlog-sync-request-v1.json"
|
||||
val error = snapshot.writeJson(displayName, payload)
|
||||
internal fun publishFullGeneration(
|
||||
snapshot: DirectExchangeSnapshot,
|
||||
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) {
|
||||
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 {
|
||||
SyncRequestResult.Error(error)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -9,17 +9,14 @@ enum class SessionType(
|
|||
val wireValue: String,
|
||||
) {
|
||||
TRAINING("training"),
|
||||
MAX_TEST("max_test");
|
||||
MAX_TEST("max_test"),
|
||||
CARDIO("cardio");
|
||||
|
||||
companion object {
|
||||
fun fromWire(
|
||||
value: String,
|
||||
): SessionType =
|
||||
if (value == "max_test") {
|
||||
MAX_TEST
|
||||
} else {
|
||||
TRAINING
|
||||
}
|
||||
entries.firstOrNull { it.wireValue == value } ?: TRAINING
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +76,24 @@ data class SessionDraft(
|
|||
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(
|
||||
val selectedExercise: ExerciseProfile? = null,
|
||||
/** Null means creation; otherwise replace this durable draft entry. */
|
||||
|
|
@ -101,10 +116,20 @@ data class SessionDraftForm(
|
|||
* mistake unfinished capture for a completed session.
|
||||
*/
|
||||
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 sessionType: SessionType = SessionType.TRAINING,
|
||||
/** Existing max_test session updated atomically on finalization, if any. */
|
||||
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 updatedAt: String = "",
|
||||
)
|
||||
|
|
@ -123,6 +148,18 @@ data class AiSessionDraft(
|
|||
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(
|
||||
val sessionId: String,
|
||||
val startedAt: String,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
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.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -41,7 +43,7 @@ private val drawerIcons = mapOf(
|
|||
AppSection.SESSIONS to R.drawable.ic_sessions,
|
||||
AppSection.EXERCISES to R.drawable.ic_exercises,
|
||||
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.SETTINGS to R.drawable.ic_settings,
|
||||
)
|
||||
|
|
@ -55,6 +57,29 @@ fun AndroidAppShell(
|
|||
content: @Composable () -> Unit,
|
||||
) {
|
||||
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 scope = rememberCoroutineScope()
|
||||
val root = route == route.section.rootRoute()
|
||||
|
|
@ -81,13 +106,22 @@ fun AndroidAppShell(
|
|||
AppSection.entries.filterNot { it == AppSection.EQUIPMENT }.forEach { section ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
|
||||
label = { Text(section.label) },
|
||||
label = { Text(sectionLabel(section)) },
|
||||
selected = route.section == section,
|
||||
onClick = {
|
||||
onOpenSection(section)
|
||||
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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (root) "TRAINLOG" else routeTitle(route)) },
|
||||
title = { Text(if (root) "TRAINLOG" else title()) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (root) scope.launch { drawerState.open() } else onBack()
|
||||
},
|
||||
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(
|
||||
|
|
@ -113,10 +147,11 @@ fun AndroidAppShell(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
HeartRateIndicator()
|
||||
if (!root) {
|
||||
IconButton(
|
||||
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) }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class AndroidSpeechRecognitionAdapter(context: Context) : SpeechRecognitionAdapt
|
|||
instance.setRecognitionListener(object : RecognitionListener {
|
||||
override fun onPartialResults(results: Bundle) = listener.onPartial(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 onBeginningOfSpeech() = Unit
|
||||
override fun onRmsChanged(rmsdB: Float) = Unit
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
enum class AppSection(val label: String) {
|
||||
HOME("Accueil"), SESSIONS("Séances"), EXERCISES("Exercices"),
|
||||
EQUIPMENT("Équipements"), STATISTICS("Statistiques"), SYNC("Synchronisation"), SETTINGS("Paramètres"),
|
||||
enum class AppSection {
|
||||
HOME, SESSIONS, EXERCISES,
|
||||
EQUIPMENT, SLEEP, SYNC, SETTINGS,
|
||||
}
|
||||
|
||||
sealed interface AppRoute {
|
||||
|
|
@ -23,6 +23,9 @@ sealed interface AppRoute {
|
|||
data object SessionGenerator : 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 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 SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
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 class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||
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.STATISTICS }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
||||
data object SleepDiary : AppRoute { override val section = AppSection.SLEEP }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||
|
|
@ -44,7 +48,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
|
|||
AppSection.SESSIONS -> AppRoute.Sessions
|
||||
AppSection.EXERCISES -> AppRoute.Exercises
|
||||
AppSection.EQUIPMENT -> AppRoute.Equipment
|
||||
AppSection.STATISTICS -> AppRoute.Statistics
|
||||
AppSection.SLEEP -> AppRoute.SleepDiary
|
||||
AppSection.SYNC -> AppRoute.Sync
|
||||
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,
|
||||
* while process death deliberately restores only the repository-owned draft.
|
||||
|
|
@ -192,5 +173,11 @@ class TrainlogAppState : ViewModel() {
|
|||
val equipment = EquipmentScreenState()
|
||||
val exercise = ExerciseScreenState()
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ package com.labfytools.trainlog.ui
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.getValue
|
||||
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.model.BodyObservationDraft
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
class BodyScreenState {
|
||||
val values = List(14) { mutableStateOf("") }
|
||||
|
|
@ -39,6 +47,8 @@ fun BodyScreen(
|
|||
onBodySaved: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val locale = presentationLocale()
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
||||
|
|
@ -81,164 +91,90 @@ fun BodyScreen(
|
|||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "Mensurations"
|
||||
subtitle = strings.getString(R.string.route_body_measurements)
|
||||
) {
|
||||
TrainlogFrame(
|
||||
title = "Général"
|
||||
title = strings.getString(R.string.general)
|
||||
) {
|
||||
BodyMetricField(
|
||||
label = "Poids",
|
||||
unit = "kg",
|
||||
value = bodyWeight,
|
||||
onValueChange = {
|
||||
bodyWeight = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = "Cou",
|
||||
unit = "cm",
|
||||
value = neck,
|
||||
onValueChange = {
|
||||
neck = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = "Épaules",
|
||||
unit = "cm",
|
||||
value = shoulders,
|
||||
onValueChange = {
|
||||
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
|
||||
},
|
||||
BodyMetricGrid(
|
||||
listOf(
|
||||
BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
|
||||
bodyWeight = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
|
||||
neck = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
|
||||
shoulders = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
|
||||
chest = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
|
||||
waist = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
|
||||
hips = it
|
||||
message = null
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Membres"
|
||||
title = strings.getString(R.string.limbs)
|
||||
) {
|
||||
BodyMetricField(
|
||||
label = "Bras gauche",
|
||||
unit = "cm",
|
||||
value = leftArm,
|
||||
onValueChange = {
|
||||
leftArm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = "Bras droit",
|
||||
unit = "cm",
|
||||
value = rightArm,
|
||||
onValueChange = {
|
||||
rightArm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = "Avant-bras gauche",
|
||||
unit = "cm",
|
||||
value = leftForearm,
|
||||
onValueChange = {
|
||||
leftForearm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = "Avant-bras droit",
|
||||
unit = "cm",
|
||||
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
|
||||
},
|
||||
BodyMetricGrid(
|
||||
listOf(
|
||||
BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
|
||||
leftArm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
|
||||
rightArm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
|
||||
leftForearm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
|
||||
rightForearm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
|
||||
leftThigh = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
|
||||
rightThigh = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
|
||||
leftCalf = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
|
||||
rightCalf = it
|
||||
message = null
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Enregistrement"
|
||||
title = strings.getString(R.string.recording)
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Enregistrer les mensurations",
|
||||
strings.getString(R.string.body_save),
|
||||
description =
|
||||
"Les champs vides sont ignorés.",
|
||||
strings.getString(R.string.empty_fields_ignored),
|
||||
accent =
|
||||
colors.success,
|
||||
onClick = {
|
||||
|
|
@ -279,7 +215,7 @@ fun BodyScreen(
|
|||
}
|
||||
) {
|
||||
message =
|
||||
"Une valeur est invalide."
|
||||
strings.getString(R.string.invalid_value)
|
||||
} else {
|
||||
val doubles =
|
||||
parsed.map {
|
||||
|
|
@ -337,19 +273,19 @@ fun BodyScreen(
|
|||
clearForm()
|
||||
revision += 1
|
||||
message =
|
||||
"Mensurations enregistrées."
|
||||
strings.getString(R.string.body_saved)
|
||||
|
||||
onBodySaved()
|
||||
}
|
||||
|
||||
SaveBodyObservationResult.Invalid -> {
|
||||
message =
|
||||
"Ajoutez au moins une mesure positive."
|
||||
strings.getString(R.string.positive_measure_required)
|
||||
}
|
||||
|
||||
SaveBodyObservationResult.DatabaseError -> {
|
||||
message =
|
||||
"Erreur base locale."
|
||||
strings.getString(R.string.local_database_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -365,7 +301,7 @@ fun BodyScreen(
|
|||
color =
|
||||
if (
|
||||
message ==
|
||||
"Mensurations enregistrées."
|
||||
strings.getString(R.string.body_saved)
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
|
|
@ -376,13 +312,13 @@ fun BodyScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Derniers relevés",
|
||||
title = strings.getString(R.string.latest_measurements),
|
||||
active =
|
||||
recent.isNotEmpty(),
|
||||
) {
|
||||
if (recent.isEmpty()) {
|
||||
TrainlogInfo(
|
||||
"Aucun relevé enregistré."
|
||||
strings.getString(R.string.body_none)
|
||||
)
|
||||
} else {
|
||||
recent.forEach {
|
||||
|
|
@ -398,14 +334,13 @@ fun BodyScreen(
|
|||
)
|
||||
|
||||
append(
|
||||
" · ${item.metricCount} mesure(s)"
|
||||
" · " + strings.resources.getQuantityString(R.plurals.measurement_count, item.metricCount, item.metricCount)
|
||||
)
|
||||
|
||||
item.bodyWeightKg
|
||||
?.let {
|
||||
append(
|
||||
" · %.1f kg"
|
||||
.format(it)
|
||||
" · %.1f kg".format(locale, it)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -424,12 +359,14 @@ private fun BodyMetricField(
|
|||
unit: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TrainlogInputField(
|
||||
label = "$label ($unit)",
|
||||
label = label,
|
||||
value = value,
|
||||
onValueChange =
|
||||
onValueChange,
|
||||
modifier = modifier,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType =
|
||||
|
|
@ -437,9 +374,51 @@ private fun BodyMetricField(
|
|||
imeAction =
|
||||
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 {
|
||||
data object Empty :
|
||||
MetricParse
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -7,11 +7,16 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.input.KeyboardType
|
||||
import com.labfytools.trainlog.data.CorrectCompletedSessionResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
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.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
/**
|
||||
* WHY: a completed-session correction must be reviewed as one bounded form,
|
||||
|
|
@ -37,6 +43,7 @@ fun CompletedSessionCorrectionScreen(
|
|||
onFinished: (saved: Boolean) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val original = remember(sessionId) { repository.getSessionDetail(sessionId) }
|
||||
var drafts by remember(sessionId) {
|
||||
mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty())
|
||||
|
|
@ -44,81 +51,116 @@ fun CompletedSessionCorrectionScreen(
|
|||
var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(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) {
|
||||
TrainlogInfo("Séance introuvable.", colors.error)
|
||||
TrainlogInfo(strings.getString(R.string.session_not_found), colors.error)
|
||||
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 ->
|
||||
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 ->
|
||||
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 } }
|
||||
})
|
||||
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 ->
|
||||
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 }
|
||||
} })
|
||||
if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it }
|
||||
} })
|
||||
if (plan.weightKg != null) CompletedDecimalField(
|
||||
"${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) {
|
||||
TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it }
|
||||
} })
|
||||
CompletedDecimalField(
|
||||
"${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) {
|
||||
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 }
|
||||
} })
|
||||
draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it }
|
||||
} }) }
|
||||
draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it }
|
||||
} }) }
|
||||
draft.speedKmh?.let { speed -> CompletedDecimalField(
|
||||
"${draft.entryId}:speed", strings.getString(R.string.field_speed), speed,
|
||||
decimalText, allowZero = false,
|
||||
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = 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 {
|
||||
draft.sets.forEachIndexed { setIndex, set ->
|
||||
TrainlogInfo("Série ${setIndex + 1}", colors.accent)
|
||||
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)",
|
||||
TrainlogInfo(strings.getString(R.string.set_number, setIndex + 1), colors.accent)
|
||||
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 ->
|
||||
val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value)
|
||||
replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it }
|
||||
} })
|
||||
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it }
|
||||
} })
|
||||
TrainlogDeleteButton("Supprimer cette série",
|
||||
if (set.weightKg != null) CompletedDecimalField(
|
||||
"${draft.entryId}:set-weight:$setIndex", strings.getString(R.string.field_weight),
|
||||
set.weightKg, decimalText, allowZero = true,
|
||||
) { value -> replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it } }
|
||||
TrainlogDeleteButton(strings.getString(R.string.a11y_delete_set),
|
||||
{ pendingSetRemoval = occurrenceIndex to 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 }
|
||||
}, accent = colors.success)
|
||||
}
|
||||
TrainlogDeleteButton("Supprimer cette occurrence",
|
||||
TrainlogDeleteButton(strings.getString(R.string.delete_occurrence),
|
||||
{ pendingOccurrenceRemoval = occurrenceIndex },
|
||||
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
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))) {
|
||||
CorrectCompletedSessionResult.Saved -> onFinished(true)
|
||||
is CorrectCompletedSessionResult.Invalid -> message = result.message
|
||||
is CorrectCompletedSessionResult.DatabaseError -> message = result.message
|
||||
is CorrectCompletedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, 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) ->
|
||||
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null },
|
||||
deleteContentDescription = "Supprimer cette série") {
|
||||
DestructiveConfirmationDialog(strings.getString(R.string.delete_set_question), strings.getString(R.string.delete_set_detail), strings.getString(R.string.delete), { pendingSetRemoval = null },
|
||||
deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set)) {
|
||||
val draft = drafts[occurrence]
|
||||
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
|
||||
pendingSetRemoval = null
|
||||
|
|
@ -126,16 +168,49 @@ fun CompletedSessionCorrectionScreen(
|
|||
}
|
||||
pendingOccurrenceRemoval?.let { occurrence ->
|
||||
val draft = drafts[occurrence]
|
||||
DestructiveConfirmationDialog("Supprimer cette occurrence ?",
|
||||
"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.",
|
||||
"Supprimer", { pendingOccurrenceRemoval = null },
|
||||
deleteContentDescription = "Supprimer cette occurrence") {
|
||||
DestructiveConfirmationDialog(strings.getString(R.string.delete_occurrence_question),
|
||||
strings.getString(R.string.occurrence_delete_impact,
|
||||
strings.resources.getQuantityString(R.plurals.performed_set_count, draft.sets.size, draft.sets.size),
|
||||
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 }
|
||||
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(
|
||||
entryId = entryId,
|
||||
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields),
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
class ExerciseScreenState {
|
||||
val name = mutableStateOf("")
|
||||
|
|
@ -73,7 +77,7 @@ class ExerciseScreenState {
|
|||
}
|
||||
fun prepareEdit(exercise: ExerciseProfile) {
|
||||
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
|
||||
name.value = exercise.name
|
||||
recordingMode.value = exercise.recordingMode
|
||||
|
|
@ -106,6 +110,7 @@ fun ExerciseScreen(
|
|||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
||||
var name by state.name
|
||||
var recordingMode by state.recordingMode
|
||||
|
|
@ -150,18 +155,18 @@ fun ExerciseScreen(
|
|||
}
|
||||
|
||||
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(
|
||||
title =
|
||||
if (editedExerciseId == null) {
|
||||
"Nouvel exercice"
|
||||
strings.getString(R.string.new_exercise)
|
||||
} else {
|
||||
"Modifier l'exercice"
|
||||
strings.getString(R.string.route_exercise_edit)
|
||||
}
|
||||
) {
|
||||
TrainlogField(
|
||||
label = "Nom",
|
||||
label = strings.getString(R.string.name),
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
|
|
@ -170,10 +175,10 @@ fun ExerciseScreen(
|
|||
)
|
||||
|
||||
TrainlogChoiceGroup(
|
||||
label = "Organisation",
|
||||
label = strings.getString(R.string.organization),
|
||||
) {
|
||||
TrainlogChoice(
|
||||
label = "Séries",
|
||||
label = strings.getString(R.string.organization_sets),
|
||||
selected =
|
||||
recordingMode ==
|
||||
RecordingMode.SETS,
|
||||
|
|
@ -189,7 +194,7 @@ fun ExerciseScreen(
|
|||
)
|
||||
|
||||
TrainlogChoice(
|
||||
label = "Continu",
|
||||
label = strings.getString(R.string.continuous),
|
||||
selected =
|
||||
recordingMode ==
|
||||
RecordingMode.CONTINUOUS,
|
||||
|
|
@ -207,7 +212,7 @@ fun ExerciseScreen(
|
|||
}
|
||||
|
||||
TrainlogChoiceGroup(
|
||||
label = "Mesure principale",
|
||||
label = strings.getString(R.string.primary_measurement),
|
||||
) {
|
||||
if (
|
||||
recordingMode ==
|
||||
|
|
@ -215,7 +220,7 @@ fun ExerciseScreen(
|
|||
) {
|
||||
TrainlogChoice(
|
||||
label =
|
||||
"Répétitions",
|
||||
strings.getString(R.string.field_reps),
|
||||
selected =
|
||||
trackingMode ==
|
||||
TrackingMode.REPS,
|
||||
|
|
@ -230,7 +235,7 @@ fun ExerciseScreen(
|
|||
}
|
||||
|
||||
TrainlogChoice(
|
||||
label = "Durée",
|
||||
label = strings.getString(R.string.field_duration),
|
||||
selected =
|
||||
trackingMode ==
|
||||
TrackingMode.DURATION,
|
||||
|
|
@ -250,10 +255,10 @@ fun ExerciseScreen(
|
|||
) {
|
||||
TrainlogChoiceGroup(
|
||||
label =
|
||||
"Données complémentaires",
|
||||
strings.getString(R.string.supplemental_data),
|
||||
) {
|
||||
TrainlogChoice(
|
||||
label = "Vitesse",
|
||||
label = strings.getString(R.string.field_speed_short),
|
||||
selected = speed,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
|
|
@ -263,7 +268,7 @@ fun ExerciseScreen(
|
|||
)
|
||||
|
||||
TrainlogChoice(
|
||||
label = "Distance",
|
||||
label = strings.getString(R.string.field_distance_short),
|
||||
selected = distance,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
|
|
@ -276,9 +281,9 @@ fun ExerciseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
TrainlogChoiceGroup(label = "Zone principale") {
|
||||
TrainlogChoiceGroup(label = strings.getString(R.string.primary_zone)) {
|
||||
TrainlogChoice(
|
||||
label = "Non renseignée",
|
||||
label = strings.getString(R.string.not_specified_feminine),
|
||||
selected = primaryZoneId == null,
|
||||
onClick = {
|
||||
primaryZoneId = null
|
||||
|
|
@ -288,7 +293,7 @@ fun ExerciseScreen(
|
|||
)
|
||||
BodyZoneChoices(zones) { zone, indented ->
|
||||
TrainlogChoice(
|
||||
label = (if (indented) " ↳ " else "") + zone.displayName,
|
||||
label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
|
||||
selected = primaryZoneId == zone.zoneId,
|
||||
enabled = zone.kind != BodyZoneKind.GROUP,
|
||||
onClick = {
|
||||
|
|
@ -300,10 +305,10 @@ fun ExerciseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
TrainlogChoiceGroup(label = "Zones secondaires") {
|
||||
TrainlogChoiceGroup(label = strings.getString(R.string.secondary_zones)) {
|
||||
BodyZoneChoices(zones) { zone, indented ->
|
||||
TrainlogChoice(
|
||||
label = (if (indented) " ↳ " else "") + zone.displayName,
|
||||
label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
|
||||
selected = zone.zoneId in secondaryZoneIds,
|
||||
enabled = primaryZoneId != null &&
|
||||
zone.kind != BodyZoneKind.GROUP && zone.zoneId != primaryZoneId,
|
||||
|
|
@ -359,7 +364,7 @@ fun ExerciseScreen(
|
|||
if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) {
|
||||
TrainlogInfo(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -367,17 +372,17 @@ fun ExerciseScreen(
|
|||
TrainlogPrimaryAction(
|
||||
label =
|
||||
if (editedExerciseId != null) {
|
||||
"Enregistrer les modifications"
|
||||
strings.getString(R.string.save_changes)
|
||||
} else if (inline) {
|
||||
"Créer et revenir à la séance"
|
||||
strings.getString(R.string.create_return_session)
|
||||
} else {
|
||||
"Enregistrer l'exercice"
|
||||
strings.getString(R.string.exercise_save)
|
||||
},
|
||||
description =
|
||||
if (editedExerciseId == null) {
|
||||
"Ajouter ce profil au catalogue local."
|
||||
strings.getString(R.string.add_profile_description)
|
||||
} else {
|
||||
"Conserver l'identité et mettre à jour le catalogue."
|
||||
strings.getString(R.string.identity_update_description)
|
||||
},
|
||||
onClick = save@{
|
||||
if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) {
|
||||
|
|
@ -386,7 +391,7 @@ fun ExerciseScreen(
|
|||
}
|
||||
if (editedExerciseId == 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
|
||||
}
|
||||
val current = editedExerciseId
|
||||
|
|
@ -427,12 +432,12 @@ fun ExerciseScreen(
|
|||
|
||||
CreateExerciseResult.Conflict -> {
|
||||
message =
|
||||
"Un exercice portant ce nom existe déjà."
|
||||
strings.getString(R.string.exercise_name_exists)
|
||||
}
|
||||
|
||||
CreateExerciseResult.Invalid -> {
|
||||
message =
|
||||
"Profil ou nom invalide."
|
||||
strings.getString(R.string.invalid_profile_name)
|
||||
}
|
||||
|
||||
is CreateExerciseResult.DatabaseError -> {
|
||||
|
|
@ -447,20 +452,20 @@ fun ExerciseScreen(
|
|||
}
|
||||
|
||||
EditExerciseResult.Conflict -> {
|
||||
message = "Un autre exercice porte déjà ce nom."
|
||||
message = strings.getString(R.string.other_exercise_name_exists)
|
||||
}
|
||||
|
||||
EditExerciseResult.InvalidNameOrProfile -> {
|
||||
message = "Nom ou profil invalide."
|
||||
message = strings.getString(R.string.invalid_name_profile)
|
||||
}
|
||||
|
||||
EditExerciseResult.IncompatibleProfileChange -> {
|
||||
message =
|
||||
"Le profil ne peut pas changer après utilisation."
|
||||
strings.getString(R.string.profile_used_error)
|
||||
}
|
||||
|
||||
EditExerciseResult.DatabaseError -> {
|
||||
message = "Enregistrement en base impossible."
|
||||
message = strings.getString(R.string.database_save_failed)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -468,8 +473,8 @@ fun ExerciseScreen(
|
|||
|
||||
if (editedExerciseId != null) {
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description = "Revenir au catalogue sans modification.",
|
||||
label = strings.getString(R.string.dialog_cancel),
|
||||
description = strings.getString(R.string.cancel_catalog_description),
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
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(
|
||||
label = "Recherche par préfixe",
|
||||
label = strings.getString(R.string.search_prefix),
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
)
|
||||
TrainlogChoiceGroup(label = "Filtre par zone") {
|
||||
TrainlogChoiceGroup(label = strings.getString(R.string.zone_filter)) {
|
||||
TrainlogChoice(
|
||||
label = "Toutes les zones",
|
||||
label = strings.getString(R.string.all_zones),
|
||||
selected = filterZoneId == null && !unclassifiedFilter,
|
||||
onClick = { filterZoneId = null; unclassifiedFilter = false },
|
||||
)
|
||||
BodyZoneChoices(zones, includeGroups = true) { zone, indented ->
|
||||
TrainlogChoice(
|
||||
label = (if (indented) " ↳ " else "") + zone.displayName,
|
||||
label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
|
||||
selected = filterZoneId == zone.zoneId && !unclassifiedFilter,
|
||||
onClick = { filterZoneId = zone.zoneId; unclassifiedFilter = false },
|
||||
)
|
||||
}
|
||||
TrainlogChoice(
|
||||
label = "Non renseignés",
|
||||
label = strings.getString(R.string.not_specified_plural),
|
||||
selected = unclassifiedFilter,
|
||||
onClick = { filterZoneId = null; unclassifiedFilter = true },
|
||||
)
|
||||
|
|
@ -526,20 +531,20 @@ fun ExerciseScreen(
|
|||
unclassifiedOnly = unclassifiedFilter,
|
||||
)
|
||||
if (exercises.isEmpty()) {
|
||||
TrainlogInfo("Aucun exercice enregistré.")
|
||||
TrainlogInfo(strings.getString(R.string.exercise_none_saved))
|
||||
} else {
|
||||
exercises.forEach { exercise ->
|
||||
TrainlogAction(
|
||||
label = "Modifier · ${exercise.name}",
|
||||
description = exerciseZoneSummary(repository, exercise),
|
||||
label = strings.getString(R.string.modify_named, exercise.name),
|
||||
description = exerciseZoneSummary(repository, exercise, strings),
|
||||
accent = colors.accent,
|
||||
onClick = { startEditing(exercise) },
|
||||
)
|
||||
repository.getExerciseKnowledge(exercise.exerciseId)?.let { knowledge ->
|
||||
val expanded = exercise.exerciseId in expandedKnowledgeIds
|
||||
TrainlogAction(
|
||||
label = if (expanded) "− Connaissances" else "+ Connaissances",
|
||||
description = knowledgeSummary(knowledge),
|
||||
label = strings.getString(if (expanded) R.string.knowledge_collapse else R.string.knowledge_expand),
|
||||
description = knowledgeSummary(knowledge, strings),
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
expandedKnowledgeIds = if (expanded) {
|
||||
|
|
@ -552,8 +557,8 @@ fun ExerciseScreen(
|
|||
if (expanded) KnowledgePanel(repository, knowledge)
|
||||
}
|
||||
TrainlogAction(
|
||||
label = "Voir les derniers MAX",
|
||||
description = "Ouvrir la liste agrégée existante dans Statistiques.",
|
||||
label = strings.getString(R.string.view_latest_max),
|
||||
description = strings.getString(R.string.view_latest_max_description),
|
||||
accent = colors.warning,
|
||||
onClick = onOpenMaxima,
|
||||
)
|
||||
|
|
@ -562,7 +567,7 @@ fun ExerciseScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Contrat",
|
||||
title = strings.getString(R.string.contract),
|
||||
active = false,
|
||||
) {
|
||||
TrainlogInfo(
|
||||
|
|
@ -570,20 +575,20 @@ fun ExerciseScreen(
|
|||
)
|
||||
|
||||
TrainlogInfo(
|
||||
"Aucune règle ne dépend du nom."
|
||||
strings.getString(R.string.name_no_rules)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (confirmingIncompatible) AlertDialog(
|
||||
onDismissRequest = { confirmingIncompatible = false },
|
||||
title = { Text("Modifier le mode de suivi ?") },
|
||||
text = { Text("Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.") },
|
||||
title = { Text(strings.getString(R.string.tracking_change_question)) },
|
||||
text = { Text(strings.getString(R.string.profile_change_dialog_detail)) },
|
||||
confirmButton = { TextButton(onClick = {
|
||||
confirmedIncompatible = true
|
||||
confirmingIncompatible = false
|
||||
message = "Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations."
|
||||
}) { Text("Modifier") } },
|
||||
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text("Annuler") } },
|
||||
message = strings.getString(R.string.profile_transition_confirmed)
|
||||
}) { Text(strings.getString(R.string.modify)) } },
|
||||
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text(strings.getString(R.string.dialog_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -595,6 +600,7 @@ fun ExerciseEditorRoute(
|
|||
inline: Boolean,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val profile = remember(exerciseId) {
|
||||
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 (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) {
|
||||
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Chargement du profil…") }
|
||||
TrainlogScreen(strings.getString(R.string.route_exercise_edit)) { TrainlogInfo(strings.getString(R.string.profile_loading)) }
|
||||
} else {
|
||||
ExerciseScreen(repository, state, inline, {}, onSaved, {}, catalogueVisible = false)
|
||||
}
|
||||
|
|
@ -613,6 +619,7 @@ fun ExerciseEditorRoute(
|
|||
@Composable
|
||||
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
/* WHY: conditional content is opened only under an explicit uncertainty
|
||||
* label; it cannot be mistaken for an ordinary resolved classification. */
|
||||
val interpretation = when (knowledge.resolutionStatus) {
|
||||
|
|
@ -621,11 +628,11 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
|
|||
ExerciseKnowledgeStatus.UNRESOLVED -> null
|
||||
}
|
||||
if (interpretation == null) {
|
||||
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
|
||||
TrainlogInfo("Confiance : ${confidenceLabel(knowledge.confidence)}", colors.muted)
|
||||
TrainlogInfo(strings.getString(R.string.science_unresolved), colors.warning)
|
||||
TrainlogInfo(strings.getString(R.string.confidence_inline, confidenceLabel(knowledge.confidence, strings)), colors.muted)
|
||||
} else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
|
||||
TrainlogInfo(
|
||||
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
|
||||
strings.getString(R.string.conditional_interpretation, interpretation.requiredConfirmation.orEmpty()),
|
||||
colors.warning,
|
||||
)
|
||||
}
|
||||
|
|
@ -635,24 +642,26 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
|
|||
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val stabilizers = resolved.stabilizerMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val actions = resolved.actionIds.mapNotNull(repository::getJointActionKnowledge)
|
||||
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
|
||||
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
||||
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.let { localizedBodyZoneName(strings, it.zoneId, it.displayName) } ?: resolved.primaryZoneId
|
||||
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 equipment = repository.listEquipmentForKnownExercise(knowledge.exerciseId)
|
||||
.map { runtimeEquipment[it.equipmentId]?.displayName ?: it.equipmentId }
|
||||
TrainlogInfo("Mouvements : ${actions.joinToString { it.displayNameFr }.ifEmpty { patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classés" } }}")
|
||||
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
||||
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||
TrainlogInfo("Stabilisateurs : ${stabilizers.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
|
||||
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
||||
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
|
||||
val french = LocalLanguagePresentation.current.language == AppLanguage.FRENCH
|
||||
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(strings.getString(R.string.primary_muscles, primary.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.unclassified) }))
|
||||
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(strings.getString(R.string.stabilizers, stabilizers.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.none_established) }))
|
||||
TrainlogInfo(strings.getString(R.string.scientific_zones, primaryZone,
|
||||
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 ->
|
||||
TrainlogInfo(
|
||||
"Source : ${reference.authorsOrOrganization} · ${reference.title}" +
|
||||
(reference.year?.let { " ($it)" } ?: ""),
|
||||
strings.getString(R.string.source_value, reference.authorsOrOrganization, reference.title,
|
||||
reference.year?.let { " ($it)" } ?: ""),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
|
|
@ -666,27 +675,28 @@ fun ExerciseCatalogueScreen(
|
|||
onCreate: () -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
var query by state.searchQuery
|
||||
var zoneId by state.filterZoneId
|
||||
var unclassified by state.unclassifiedFilter
|
||||
val zones = repository.listBodyZones()
|
||||
val exercises = repository.listExercises(query, zoneId, true, false, unclassified)
|
||||
TrainlogScreen("Catalogue d'exercices", scrollKey = "exercise-catalogue") {
|
||||
TrainlogPrimaryAction("Créer un exercice", "Ajouter un profil au catalogue local.", onCreate)
|
||||
TrainlogInputField("Recherche par préfixe", query, { query = it })
|
||||
TrainlogChoiceGroup("Filtrer") {
|
||||
TrainlogChoice("Toutes les zones", zoneId == null && !unclassified) {
|
||||
TrainlogScreen(strings.getString(R.string.exercise_catalog), scrollKey = "exercise-catalogue") {
|
||||
TrainlogPrimaryAction(strings.getString(R.string.route_exercise_create), strings.getString(R.string.create_exercise_description), onCreate)
|
||||
TrainlogInputField(strings.getString(R.string.search_prefix), query, { query = it })
|
||||
TrainlogChoiceGroup(strings.getString(R.string.filter)) {
|
||||
TrainlogChoice(strings.getString(R.string.all_zones), zoneId == null && !unclassified) {
|
||||
zoneId = null; unclassified = false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.")
|
||||
TrainlogFrame(strings.getString(R.string.catalog), active = exercises.isNotEmpty()) {
|
||||
if (exercises.isEmpty()) TrainlogInfo(strings.getString(R.string.exercise_none_found))
|
||||
if (exercises.isNotEmpty()) {
|
||||
/* CONTRACT: filtering and ordering stay repository-owned. This
|
||||
* fixed viewport bounds presentation only; its child owns the
|
||||
|
|
@ -699,7 +709,7 @@ fun ExerciseCatalogueScreen(
|
|||
.testTag("exercise-catalogue-scroll"),
|
||||
) {
|
||||
exercises.forEach { exercise ->
|
||||
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), {
|
||||
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise, strings), {
|
||||
state.selectedExerciseId = exercise.exerciseId
|
||||
onOpenDetail(exercise.exerciseId)
|
||||
})
|
||||
|
|
@ -718,24 +728,25 @@ fun ExerciseDetailScreen(
|
|||
onOpenMaxima: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
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) {
|
||||
TrainlogInfo("Exercice introuvable.", colors.error)
|
||||
TrainlogInfo(strings.getString(R.string.exercise_not_found), colors.error)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogFrame("Profil") {
|
||||
TrainlogFrame(strings.getString(R.string.profile)) {
|
||||
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))
|
||||
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) }
|
||||
?: 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()) {
|
||||
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
|
||||
TrainlogFrame(strings.getString(R.string.compatible_equipment), active = context.compatibleEquipment.isNotEmpty()) {
|
||||
if (context.compatibleEquipment.isEmpty()) TrainlogInfo(strings.getString(R.string.compatibility_none))
|
||||
context.compatibleEquipment.forEach { equipment ->
|
||||
val name = repository.listEquipment().firstOrNull { it.equipmentId == equipment.equipmentId }?.displayName
|
||||
?: listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
|
||||
|
|
@ -744,25 +755,25 @@ fun ExerciseDetailScreen(
|
|||
}
|
||||
context.latestExplicitMax?.let { 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 ->
|
||||
"Classification scientifique · confiance ${confidenceLabel(knowledge.confidence)}"
|
||||
context.getString(R.string.science_classification_confidence, confidenceLabel(knowledge.confidence, context))
|
||||
ExerciseKnowledgeStatus.CONDITIONAL ->
|
||||
"Classification conditionnelle · incertitude explicite"
|
||||
ExerciseKnowledgeStatus.UNRESOLVED -> "Classification scientifique non résolue"
|
||||
context.getString(R.string.conditional_classification)
|
||||
ExerciseKnowledgeStatus.UNRESOLVED -> context.getString(R.string.science_unresolved)
|
||||
}
|
||||
|
||||
private fun confidenceLabel(confidence: KnowledgeConfidence): String = when (confidence) {
|
||||
KnowledgeConfidence.HIGH -> "élevée"
|
||||
KnowledgeConfidence.MODERATE -> "modérée"
|
||||
KnowledgeConfidence.UNCERTAIN -> "incertaine"
|
||||
private fun confidenceLabel(confidence: KnowledgeConfidence, context: android.content.Context): String = when (confidence) {
|
||||
KnowledgeConfidence.HIGH -> context.getString(R.string.confidence_high)
|
||||
KnowledgeConfidence.MODERATE -> context.getString(R.string.confidence_moderate)
|
||||
KnowledgeConfidence.UNCERTAIN -> context.getString(R.string.confidence_uncertain)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -771,11 +782,12 @@ private fun BodyZoneChoices(
|
|||
includeGroups: Boolean = false,
|
||||
content: @Composable (BodyZone, Boolean) -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
zones.forEach { zone ->
|
||||
if (includeGroups || zone.kind != BodyZoneKind.GROUP) {
|
||||
content(zone, zone.parentZoneId != null)
|
||||
} else {
|
||||
TrainlogInfo(zone.displayName)
|
||||
TrainlogInfo(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -783,19 +795,26 @@ private fun BodyZoneChoices(
|
|||
private fun exerciseZoneSummary(
|
||||
repository: TrainlogRepository,
|
||||
exercise: ExerciseProfile,
|
||||
context: android.content.Context,
|
||||
): String {
|
||||
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 group = repository.bodyZoneAncestors(primary.zoneId).firstOrNull()
|
||||
return buildString {
|
||||
append("Zone principale : ${primary.displayName}")
|
||||
append(" · Zones secondaires : ")
|
||||
append(if (secondary.isEmpty()) "Aucune" else secondary.joinToString { it.displayName })
|
||||
group?.let { append(" · Groupe : ${it.displayName}") }
|
||||
append(context.getString(R.string.primary_zone_value, localizedBodyZoneName(context, primary.zoneId, primary.displayName)))
|
||||
append(context.getString(R.string.secondary_zones_value,
|
||||
if (secondary.isEmpty()) context.getString(R.string.value_none_feminine) else secondary.joinToString { localizedBodyZoneName(context, it.zoneId, 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
|
||||
private fun TrainlogField(
|
||||
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(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.widthIn(min = 132.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.background(
|
||||
if (selected) {
|
||||
colors.surfaceAlt
|
||||
} else {
|
||||
colors.surface
|
||||
}
|
||||
},
|
||||
androidx.compose.material3.MaterialTheme.shapes.medium,
|
||||
)
|
||||
.semantics { this.selected = selected }
|
||||
.clickable(
|
||||
|
|
|
|||
|
|
@ -45,16 +45,16 @@ class FeedbackDictationController(
|
|||
fun edit(text: String) { if (state.phase != DictationPhase.Listening) update(state.copy(
|
||||
phase = DictationPhase.Editing, committedText = text, partialText = "", message = null)) }
|
||||
|
||||
fun start(permissionGranted: Boolean) {
|
||||
fun start(permissionGranted: Boolean, languageTag: String = "fr-FR") {
|
||||
if (!permissionGranted) {
|
||||
update(state.copy(phase = DictationPhase.Error, partialText = "",
|
||||
message = "Microphone refusé : la saisie manuelle reste disponible."))
|
||||
message = "feedback_microphone_denied"))
|
||||
} else if (!recognizer.available) {
|
||||
update(state.copy(phase = DictationPhase.Error, partialText = "",
|
||||
message = "Reconnaissance vocale indisponible : utilisez la saisie manuelle."))
|
||||
message = "feedback_speech_unavailable"))
|
||||
} else {
|
||||
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? {
|
||||
val text = state.visibleText.trim()
|
||||
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 = ""))
|
||||
return text
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
|||
import androidx.core.content.ContextCompat
|
||||
import com.labfytools.trainlog.data.MAX_FEEDBACK_UTF8_BYTES
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
@Composable
|
||||
fun FeedbackEditor(
|
||||
|
|
@ -44,6 +45,15 @@ fun FeedbackEditor(
|
|||
) {
|
||||
val context = LocalContext.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)) }
|
||||
val controller = remember(initialText) { FeedbackDictationController(
|
||||
recognizerFactory?.invoke(context) ?: AndroidSpeechRecognitionAdapter(context),
|
||||
|
|
@ -51,7 +61,7 @@ fun FeedbackEditor(
|
|||
if (initialText.isNotEmpty()) it.edit(initialText)
|
||||
} }
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
controller.start(granted)
|
||||
controller.start(granted, speechTag)
|
||||
}
|
||||
DisposableEffect(controller) { onDispose { controller.destroy() } }
|
||||
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) {
|
||||
TrainlogInfo("● Enregistrement en cours", color = colors.error)
|
||||
TrainlogIconAction(TrainlogIcons.Stop, "Arrêter la dictée",
|
||||
TrainlogInfo(strings.getString(R.string.feedback_recording), color = colors.error)
|
||||
TrainlogIconAction(TrainlogIcons.Stop, strings.getString(R.string.feedback_stop),
|
||||
onClick = { controller.stop() }, accent = colors.warning,
|
||||
modifier = Modifier.testTag("feedback-stop"))
|
||||
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))
|
||||
}
|
||||
} 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)
|
||||
}, testTag = "feedback-edit-transcript", singleLine = false, minLines = 4, maxLines = 8)
|
||||
val resume = state.committedText.isNotBlank()
|
||||
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)
|
||||
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"))
|
||||
TrainlogAction(if (editing) "Enregistrer les modifications" else "Enregistrer",
|
||||
if (editing) "Créer une nouvelle révision immutable." else "Créer un enregistrement historique immutable.", onClick = {
|
||||
TrainlogAction(strings.getString(if (editing) R.string.save_changes else R.string.save),
|
||||
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) }
|
||||
}, 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)
|
||||
}
|
||||
state.message?.let { TrainlogInfo(it, color = colors.error) }
|
||||
state.message?.let { TrainlogInfo(message(it), color = colors.error) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ import androidx.compose.runtime.remember
|
|||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
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
|
||||
fun HistoryScreen(
|
||||
|
|
@ -17,6 +21,8 @@ fun HistoryScreen(
|
|||
onBack: () -> Unit,
|
||||
onOpenSession: (String) -> Unit,
|
||||
) {
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
||||
|
|
@ -30,16 +36,16 @@ fun HistoryScreen(
|
|||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "Séances effectuées"
|
||||
subtitle = strings.getString(R.string.route_completed_sessions)
|
||||
) {
|
||||
TrainlogFrame(
|
||||
title = "Séances",
|
||||
title = strings.getString(R.string.nav_sessions),
|
||||
active =
|
||||
sessions.isNotEmpty(),
|
||||
) {
|
||||
if (sessions.isEmpty()) {
|
||||
TrainlogInfo(
|
||||
"Aucune séance enregistrée."
|
||||
strings.getString(R.string.history_none)
|
||||
)
|
||||
} else {
|
||||
sessions.forEach {
|
||||
|
|
@ -54,8 +60,8 @@ fun HistoryScreen(
|
|||
sessionTypeLabel(
|
||||
session.sessionType
|
||||
) +
|
||||
" · " +
|
||||
"${session.exerciseCount} exercice(s)",
|
||||
" · " + strings.resources.getQuantityString(
|
||||
R.plurals.exercise_count, session.exerciseCount, session.exerciseCount),
|
||||
onClick = {
|
||||
onOpenSession(
|
||||
session.sessionId
|
||||
|
|
@ -76,21 +82,22 @@ fun HistoryScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Derniers MAX",
|
||||
title = strings.getString(R.string.route_latest_maxima),
|
||||
active = latestMaxima.isNotEmpty(),
|
||||
) {
|
||||
if (latestMaxima.isEmpty()) {
|
||||
TrainlogInfo("Aucun max explicite enregistré.")
|
||||
TrainlogInfo(strings.getString(R.string.max_none))
|
||||
} else {
|
||||
latestMaxima.forEach { max ->
|
||||
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg)
|
||||
val weight = "%.2f".format(locale, max.maxWeightKg)
|
||||
.trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo(
|
||||
text = "${max.exerciseName} · $weight kg · ${formatStartedAt(max.startedAt).take(10)}",
|
||||
text = "${max.exerciseName} · $weight kg · ${formatDate(max.startedAt)}",
|
||||
color = colors.warning,
|
||||
)
|
||||
TrainlogInfo(
|
||||
text = "Équipement : ${max.equipmentDisplayName ?: "aucun"}",
|
||||
text = strings.getString(R.string.equipment_value,
|
||||
max.equipmentDisplayName ?: strings.getString(R.string.value_none)),
|
||||
color = colors.muted,
|
||||
)
|
||||
}
|
||||
|
|
@ -99,6 +106,7 @@ fun HistoryScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun sessionTypeLabel(
|
||||
value: SessionType,
|
||||
): String =
|
||||
|
|
@ -106,18 +114,28 @@ internal fun sessionTypeLabel(
|
|||
value ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
"TEST MAX"
|
||||
uiString(R.string.session_max_test)
|
||||
} else {
|
||||
"ENTRAÎNEMENT"
|
||||
uiString(R.string.session_training)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun formatStartedAt(
|
||||
value: String,
|
||||
): String {
|
||||
return value
|
||||
.replace(
|
||||
'T',
|
||||
' '
|
||||
)
|
||||
.take(16)
|
||||
val locale = presentationLocale()
|
||||
val pattern = uiString(R.string.date_time_pattern)
|
||||
return runCatching {
|
||||
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||
}.getOrElse { value.replace('T', ' ').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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
|||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.BodyZoneHomeStatus
|
||||
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.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
activeDraft: ActiveSessionDraft?,
|
||||
draftError: String?,
|
||||
latestSession: SessionSummary?,
|
||||
latestMaximum: LatestExerciseMax?,
|
||||
bodyZoneOverview: BodyZoneHomeOverview,
|
||||
onSession: () -> Unit,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
onBody: () -> Unit,
|
||||
onOpenLatestSession: (String) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
) {
|
||||
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) }
|
||||
TrainlogFrame("Séance") {
|
||||
TrainlogFrame(strings.getString(R.string.home_session)) {
|
||||
if (activeDraft != null) {
|
||||
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
|
||||
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
|
||||
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) strings.getString(R.string.session_max_test) else strings.getString(R.string.training)
|
||||
TrainlogPrimaryAction(strings.getString(R.string.resume_current_session), "$kind · " + strings.resources.getQuantityString(R.plurals.exercise_count, activeDraft.exercises.size, activeDraft.exercises.size), onSession)
|
||||
} 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)
|
||||
latestSession?.let { session ->
|
||||
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) {
|
||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
|
||||
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
|
||||
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"))
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -115,78 +101,79 @@ fun HomeScreen(
|
|||
internal fun BodyZoneHomeSection(
|
||||
overview: BodyZoneHomeOverview,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
var showExposureInfo by remember { mutableStateOf(false) }
|
||||
var selectedId by remember(overview.zones) {
|
||||
mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId)
|
||||
}
|
||||
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 }) {
|
||||
Text("Exposition récente ⓘ")
|
||||
Text(strings.getString(R.string.recent_exposure) + " ⓘ")
|
||||
}
|
||||
if (!overview.hasTrainingHistory) {
|
||||
TrainlogInfo("Pas encore d’historique d’entraînement.")
|
||||
TrainlogInfo(strings.getString(R.string.no_training_history))
|
||||
}
|
||||
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 }
|
||||
selected?.let { zone ->
|
||||
BasicText(
|
||||
"Sélection : ${zone.displayName}",
|
||||
strings.getString(R.string.selection_value, localizedBodyZoneName(strings, zone.zoneId, zone.displayName)),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
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 ->
|
||||
TrainlogAction(
|
||||
"${index + 1}. ${zone.displayName} · ${homeStateLabel(zone)}",
|
||||
recommendationReason(zone),
|
||||
"${index + 1}. ${localizedBodyZoneName(strings, zone.zoneId, zone.displayName)} · ${homeStateLabel(zone, strings)}",
|
||||
recommendationReason(zone, strings),
|
||||
{ selectedId = zone.zoneId },
|
||||
)
|
||||
}
|
||||
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 ->
|
||||
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),
|
||||
) {
|
||||
BasicText(zone.displayName, style = TrainlogTypography.section.copy(color = colors.text))
|
||||
BasicText(homeStateLabel(zone), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
|
||||
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
|
||||
BasicText(homeStateLabel(zone, strings), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
|
||||
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),
|
||||
)
|
||||
BasicText(
|
||||
recentWorkLabel(zone.primaryWork7Days, "principal") + " · " +
|
||||
recentWorkLabel(zone.secondaryWork7Days, "secondaire"),
|
||||
recentWorkLabel(zone.primaryWork7Days, true, strings) + " · " +
|
||||
recentWorkLabel(zone.secondaryWork7Days, false, strings),
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
BasicText(
|
||||
availableExerciseLabel(zone.availableExerciseCount),
|
||||
availableExerciseLabel(zone.availableExerciseCount, strings),
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
|
||||
TrainlogAction("Voir les exercices", "", { onOpenExercises(zone.zoneId) })
|
||||
TrainlogAction("Voir les statistiques", "", onOpenStatistics)
|
||||
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showExposureInfo) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExposureInfo = false },
|
||||
title = { Text("Exposition récente") },
|
||||
title = { Text(strings.getString(R.string.recent_exposure)) },
|
||||
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 = {
|
||||
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,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val visibleZones = view.zoneIds.mapNotNull(zones::get)
|
||||
Column(modifier.semantics { contentDescription = "Carte corporelle, ${view.label}" }) {
|
||||
BasicText(view.label, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
val viewLabel = strings.getString(view.labelId)
|
||||
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)) {
|
||||
val panelWidth = maxWidth
|
||||
val panelHeight = maxHeight
|
||||
|
|
@ -281,14 +270,14 @@ private fun BodyMapPanel(
|
|||
role = Role.Button
|
||||
selected = chosen
|
||||
contentDescription = buildString {
|
||||
append(view.label)
|
||||
append(viewLabel)
|
||||
append(", ")
|
||||
append(zone.displayName)
|
||||
append(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
|
||||
append(", ")
|
||||
append(homeStateLabel(zone))
|
||||
append(if (chosen) ", sélectionnée" else ", non sélectionnée")
|
||||
append(homeStateLabel(zone, strings))
|
||||
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)
|
||||
true
|
||||
}
|
||||
|
|
@ -300,13 +289,13 @@ private fun BodyMapPanel(
|
|||
}
|
||||
|
||||
private enum class BodyView(
|
||||
val label: String,
|
||||
@param:androidx.annotation.StringRes val labelId: Int,
|
||||
val testName: String,
|
||||
val zoneIds: List<String>,
|
||||
val accessibilityBounds: Map<String, Rect>,
|
||||
) {
|
||||
FRONT(
|
||||
label = "Face",
|
||||
labelId = R.string.body_front,
|
||||
testName = "front",
|
||||
zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"),
|
||||
accessibilityBounds = mapOf(
|
||||
|
|
@ -319,7 +308,7 @@ private enum class BodyView(
|
|||
),
|
||||
),
|
||||
BACK(
|
||||
label = "Dos",
|
||||
labelId = R.string.body_back,
|
||||
testName = "back",
|
||||
zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"),
|
||||
accessibilityBounds = mapOf(
|
||||
|
|
@ -491,28 +480,31 @@ private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color
|
|||
}
|
||||
}
|
||||
|
||||
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
|
||||
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
|
||||
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
|
||||
else -> recentWorkLabel(zone.primaryWork7Days, "principal")
|
||||
internal fun recommendationReason(zone: BodyZoneHomeStatus, context: android.content.Context): String = when {
|
||||
zone.lastPrimaryExposure == null -> context.getString(R.string.no_primary_exposure)
|
||||
zone.primaryWork7Days == 0 -> context.getString(R.string.latest_primary_work, ageLabel(zone.primaryExposureAgeSeconds, context))
|
||||
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) {
|
||||
"Priorité relative"
|
||||
context.getString(R.string.relative_priority)
|
||||
} 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 =
|
||||
"Travail $kind sur 7 j : $count"
|
||||
internal fun recentWorkLabel(count: Int, primary: Boolean, context: android.content.Context): String =
|
||||
context.getString(if (primary) R.string.recent_primary_work else R.string.recent_secondary_work, count)
|
||||
|
||||
private fun availableExerciseLabel(count: Int): String =
|
||||
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
|
||||
private fun availableExerciseLabel(count: Int, context: android.content.Context): String =
|
||||
context.resources.getQuantityString(R.plurals.available_exercise_count, count, count)
|
||||
|
||||
internal fun ageLabel(seconds: Long?): String = when {
|
||||
seconds == null -> "jamais enregistrée"
|
||||
seconds < 86400L -> "aujourd’hui"
|
||||
seconds < 2L * 86400L -> "il y a 1 jour"
|
||||
else -> "il y a ${seconds / 86400L} jours"
|
||||
internal fun ageLabel(seconds: Long?, context: android.content.Context): String = when {
|
||||
seconds == null -> context.getString(R.string.age_never)
|
||||
seconds < 86400L -> context.getString(R.string.age_today)
|
||||
seconds < 2L * 86400L -> context.getString(R.string.age_yesterday)
|
||||
else -> context.resources.getQuantityString(R.plurals.age_days, (seconds / 86400L).toInt(), seconds / 86400L)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -9,16 +9,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.foundation.layout.Arrangement
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.StartAiSessionDraftResult
|
||||
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.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.R
|
||||
|
||||
@Composable
|
||||
fun SessionsHub(
|
||||
|
|
@ -43,25 +57,39 @@ fun SessionsHub(
|
|||
onManual: () -> Unit,
|
||||
onDrafts: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onPrograms: () -> Unit = {},
|
||||
onCardioCalibration: () -> Unit = {},
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Séances") {
|
||||
TrainlogFrame("Séance en cours", active = draft != null) {
|
||||
if (draft == null) TrainlogInfo("Aucune séance en cours.")
|
||||
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.nav_sessions)) {
|
||||
TrainlogFrame(strings.getString(R.string.route_session_editor), active = draft != null) {
|
||||
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"),
|
||||
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"))
|
||||
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,
|
||||
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"))
|
||||
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,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
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) }
|
||||
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 ->
|
||||
TrainlogFrame(draft.title ?: "Proposition de séance", active = true) {
|
||||
TrainlogInfo(listOfNotNull(
|
||||
draft.plannedFor?.let { "Prévue le $it" },
|
||||
"${draft.entries.size} exercice(s)",
|
||||
).joinToString(" · "))
|
||||
draft.notes?.let { TrainlogInfo(it) }
|
||||
draft.entries.forEach { entry ->
|
||||
val plan = checkNotNull(entry.plan)
|
||||
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.") {
|
||||
DraftSummaryCard(
|
||||
title = draft.title ?: strings.getString(R.string.session_proposal),
|
||||
provenance = strings.getString(R.string.session_proposal),
|
||||
plannedFor = draft.plannedFor,
|
||||
notes = draft.notes,
|
||||
entries = draft.entries,
|
||||
startDescription = strings.getString(R.string.start_proposal_description),
|
||||
onStart = {
|
||||
when (val result = repository.startAiSessionDraft(draft.draftId)) {
|
||||
StartAiSessionDraftResult.Started -> {
|
||||
onPendingChanged()
|
||||
onStarted()
|
||||
}
|
||||
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 -> {
|
||||
message = "Ce brouillon n'est plus disponible."
|
||||
message = strings.getString(R.string.draft_missing)
|
||||
revision++
|
||||
onPendingChanged()
|
||||
}
|
||||
is StartAiSessionDraftResult.Error -> message = result.message
|
||||
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = "Supprimer ce brouillon",
|
||||
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
|
||||
onClick = {
|
||||
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
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) ->
|
||||
DestructiveConfirmationDialog(
|
||||
title = "Supprimer ce brouillon ?",
|
||||
detail = "Le brouillon « $title » sera supprimé.\nCette action est irréversible.",
|
||||
confirmLabel = "Supprimer",
|
||||
title = strings.getString(R.string.delete_draft_question),
|
||||
detail = strings.getString(R.string.delete_draft_detail, title),
|
||||
confirmLabel = strings.getString(R.string.delete),
|
||||
onCancel = { pendingDeletion = null },
|
||||
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
|
||||
* tombstone mutation. Dismissal never reaches the repository. */
|
||||
|
|
@ -137,23 +189,174 @@ fun AiSessionDraftsScreen(
|
|||
revision++
|
||||
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
|
||||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val maxima = remember { repository.listLatestExerciseMaxima() }
|
||||
TrainlogScreen("Derniers MAX") {
|
||||
TrainlogFrame("CAPACITÉS / MAX", active = maxima.isNotEmpty()) {
|
||||
if (maxima.isEmpty()) TrainlogInfo("Aucun max explicite enregistré.")
|
||||
maxima.forEach { max ->
|
||||
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg).trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
|
||||
TrainlogInfo("${formatStartedAt(max.startedAt).take(10)} · Équipement : ${max.equipmentDisplayName ?: "aucun"}", colors.muted)
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
|
||||
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
||||
maxima.forEach { max ->
|
||||
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||
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,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val equipment = remember(state.query, state.revision) { repository.searchEquipment(state.query) }
|
||||
TrainlogScreen("Catalogue d'équipements", scrollKey = "equipment-catalogue") {
|
||||
TrainlogPrimaryAction("Créer un équipement", "Ajouter une machine personnelle.", onCreate)
|
||||
TrainlogInputField("Rechercher par nom, étiquette ou alias", state.query, { state.query = it })
|
||||
TrainlogFrame("Catalogue", active = equipment.isNotEmpty()) {
|
||||
if (equipment.isEmpty()) TrainlogInfo("Aucun équipement trouvé.")
|
||||
TrainlogScreen(strings.getString(R.string.equipment_catalog), scrollKey = "equipment-catalogue") {
|
||||
TrainlogPrimaryAction(strings.getString(R.string.create_equipment), strings.getString(R.string.create_personal_machine), onCreate)
|
||||
TrainlogInputField(strings.getString(R.string.search_equipment), state.query, { state.query = it })
|
||||
TrainlogFrame(strings.getString(R.string.catalog), active = equipment.isNotEmpty()) {
|
||||
if (equipment.isEmpty()) TrainlogInfo(strings.getString(R.string.equipment_none))
|
||||
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
|
||||
onOpenDetail(entry.equipmentId)
|
||||
})
|
||||
|
|
@ -197,31 +401,32 @@ fun EquipmentScreen(
|
|||
@Composable
|
||||
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == 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) {
|
||||
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
|
||||
TrainlogInfo(strings.getString(R.string.unknown_reference, equipmentId), colors.warning)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogFrame("Définition") {
|
||||
TrainlogFrame(strings.getString(R.string.definition)) {
|
||||
TrainlogInfo(entry.displayName, colors.accent)
|
||||
TrainlogInfo(if (entry.type == "custom_machine") "Personnel" else "Fourni")
|
||||
if (entry.labelName.isNotBlank()) TrainlogInfo("Étiquette : ${entry.labelName}")
|
||||
TrainlogInfo("Type : ${entry.type}")
|
||||
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
|
||||
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
|
||||
TrainlogInfo(strings.getString(if (entry.type == "custom_machine") R.string.personal else R.string.provided))
|
||||
if (entry.labelName.isNotBlank()) TrainlogInfo(strings.getString(R.string.label_value, entry.labelName))
|
||||
TrainlogInfo(strings.getString(R.string.type_value, entry.type))
|
||||
TrainlogInfo(strings.getString(R.string.load_value, equipmentSemanticsLabel(entry.loadSemantics, strings)))
|
||||
if (entry.aliases.isNotEmpty()) TrainlogInfo(strings.getString(R.string.aliases_value, entry.aliases.joinToString()), colors.muted)
|
||||
}
|
||||
TrainlogFrame("Exercices possibles", 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)
|
||||
TrainlogFrame(strings.getString(R.string.possible_exercises_plain), active = options.isNotEmpty()) {
|
||||
if (options.isEmpty()) TrainlogInfo(strings.getString(R.string.association_none), colors.muted)
|
||||
options.forEach { (relation, exercise) ->
|
||||
val confidence = when (relation.confidence) {
|
||||
KnowledgeConfidence.HIGH -> "élevée"
|
||||
KnowledgeConfidence.MODERATE -> "modérée"
|
||||
KnowledgeConfidence.UNCERTAIN -> "incertaine"
|
||||
KnowledgeConfidence.HIGH -> strings.getString(R.string.confidence_high)
|
||||
KnowledgeConfidence.MODERATE -> strings.getString(R.string.confidence_moderate)
|
||||
KnowledgeConfidence.UNCERTAIN -> strings.getString(R.string.confidence_uncertain)
|
||||
}
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
|
@ -229,20 +434,33 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||
fun EquipmentCreateScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: EquipmentScreenState,
|
||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Créer un équipement", scrollKey = "equipment-create") {
|
||||
TrainlogFrame("Équipement personnel") {
|
||||
TrainlogInputField("Nom", state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogPrimaryAction("Créer", "Créer une machine personnelle avec la sémantique actuelle.") {
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||
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)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
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.Conflict -> state.message = "Un équipement porte déjà ce nom."
|
||||
is CreateEquipmentResult.DatabaseError -> state.message = "Création impossible : ${result.message}."
|
||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||
is CreateEquipmentResult.DatabaseError -> state.message = strings.getString(R.string.creation_failed, result.message)
|
||||
}
|
||||
}
|
||||
state.message?.let { TrainlogInfo(it, colors.error) }
|
||||
|
|
@ -250,48 +468,134 @@ fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreen
|
|||
}
|
||||
}
|
||||
|
||||
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value) {
|
||||
EquipmentLoadSemantics.NONE -> "sans charge"
|
||||
EquipmentLoadSemantics.EXTERNAL -> "charge externe"
|
||||
EquipmentLoadSemantics.ASSISTANCE -> "assistance"
|
||||
EquipmentLoadSemantics.BODYWEIGHT -> "poids du corps"
|
||||
EquipmentLoadSemantics.CARDIO -> "cardio"
|
||||
}
|
||||
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics, context: android.content.Context) = context.getString(when (value) {
|
||||
EquipmentLoadSemantics.NONE -> R.string.load_none
|
||||
EquipmentLoadSemantics.EXTERNAL -> R.string.load_external
|
||||
EquipmentLoadSemantics.ASSISTANCE -> R.string.load_assistance
|
||||
EquipmentLoadSemantics.BODYWEIGHT -> R.string.load_bodyweight
|
||||
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
|
||||
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
onHeartRateDiagnostics: () -> Unit,
|
||||
onCatalogChanged: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current
|
||||
val strings = localizedContext()
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
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()) {
|
||||
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) {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
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()
|
||||
}
|
||||
CatalogInboxResult.FileNotFound -> message = "Dossier autorisé · aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé."
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
|
||||
CatalogInboxResult.FolderNotAuthorized -> message = strings.getString(R.string.settings_folder_revoked)
|
||||
is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
TrainlogScreen("Paramètres") {
|
||||
TrainlogFrame("DOSSIER D'ÉCHANGE") {
|
||||
TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning)
|
||||
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)) })
|
||||
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
|
||||
val backupService = remember { AndroidBackupService(context) }
|
||||
val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
|
||||
if (uri != null) {
|
||||
val result = runCatching { context.contentResolver.openOutputStream(uri, "w")!!.use { backupService.create(repository, it) } }.getOrElse { AndroidBackupResult.Error(it.message ?: "Backup export failed.") }
|
||||
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()) {
|
||||
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
|
||||
CatalogInboxResult.FileNotFound -> message = "Aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = "Le dossier n'est plus autorisé." }
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
is CatalogInboxResult.Imported -> { message = strings.getString(R.string.settings_catalog_reloaded,
|
||||
catalogCountsText(strings, result.imported, result.reconciled)); onCatalogChanged() }
|
||||
CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
|
||||
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) }
|
||||
}
|
||||
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")) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
|
|||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
@Composable
|
||||
fun SessionDetailScreen(
|
||||
|
|
@ -36,6 +37,8 @@ fun SessionDetailScreen(
|
|||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
|
||||
var revision by remember(sessionId) { mutableStateOf(0) }
|
||||
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
|
|
@ -61,15 +64,15 @@ fun SessionDetailScreen(
|
|||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "Détail de la séance"
|
||||
subtitle = strings.getString(R.string.route_session_detail)
|
||||
) {
|
||||
if (detail == null) {
|
||||
TrainlogFrame(
|
||||
title = "Erreur"
|
||||
title = strings.getString(R.string.error_title)
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Séance introuvable.",
|
||||
strings.getString(R.string.session_not_found),
|
||||
color =
|
||||
colors.error,
|
||||
)
|
||||
|
|
@ -79,7 +82,7 @@ fun SessionDetailScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Séance"
|
||||
title = strings.getString(R.string.home_session)
|
||||
) {
|
||||
TrainlogInfo(
|
||||
formatStartedAt(
|
||||
|
|
@ -89,11 +92,8 @@ fun SessionDetailScreen(
|
|||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Type : " +
|
||||
sessionTypeLabel(
|
||||
detail.summary
|
||||
.sessionType
|
||||
),
|
||||
strings.getString(R.string.session_type_value,
|
||||
sessionTypeLabel(detail.summary.sessionType)),
|
||||
color =
|
||||
if (
|
||||
detail.summary
|
||||
|
|
@ -107,21 +107,22 @@ fun SessionDetailScreen(
|
|||
)
|
||||
|
||||
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"))
|
||||
|
||||
if (detail.summary.sessionType == SessionType.MAX_TEST) {
|
||||
TrainlogAction(
|
||||
label = "Reprendre ce Test max",
|
||||
description = "Continuer la même séance en conservant son identifiant et sa date.",
|
||||
label = strings.getString(R.string.resume_max_test),
|
||||
description = strings.getString(R.string.resume_max_description),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
when (val result = repository.resumeMaxTestSession(detail.summary.sessionId)) {
|
||||
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}"
|
||||
) {
|
||||
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 ->
|
||||
TrainlogInfo(
|
||||
"Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " +
|
||||
"repos ${plan.restSeconds} s · " +
|
||||
(plan.weightKg?.let { "charge cible $it kg" }
|
||||
?: "sans charge numérique"),
|
||||
strings.getString(R.string.plan_summary, plan.sets,
|
||||
(plan.reps ?: plan.durationSeconds).toString(), plan.restSeconds,
|
||||
plan.weightKg?.let { strings.getString(R.string.target_load_inline, it) }
|
||||
?: strings.getString(R.string.no_numeric_load)),
|
||||
color = colors.muted,
|
||||
)
|
||||
}
|
||||
if (editingEntryId == exercise.entryId) {
|
||||
TrainlogInputField(
|
||||
label = "Rechercher une machine",
|
||||
label = strings.getString(R.string.search_machine),
|
||||
value = equipmentQuery,
|
||||
onValueChange = { equipmentQuery = it },
|
||||
)
|
||||
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}"),
|
||||
onClick = {
|
||||
pendingEquipmentChange = exercise.entryId to null
|
||||
|
|
@ -179,17 +180,17 @@ fun SessionDetailScreen(
|
|||
}
|
||||
} else {
|
||||
TrainlogAction(
|
||||
label = "Modifier l'équipement",
|
||||
description = "Choisir, remplacer ou retirer la machine de cette entrée.",
|
||||
label = strings.getString(R.string.modify_equipment),
|
||||
description = strings.getString(R.string.modify_equipment_description),
|
||||
accent = colors.muted,
|
||||
onClick = { editingEntryId = exercise.entryId },
|
||||
)
|
||||
}
|
||||
if (exercise.maxWeightKg != null) {
|
||||
val rendered = "%.2f".format(java.util.Locale.FRANCE, exercise.maxWeightKg)
|
||||
val rendered = "%.2f".format(locale, exercise.maxWeightKg)
|
||||
.trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo(
|
||||
text = "Max : $rendered kg",
|
||||
text = strings.getString(R.string.max_value, rendered),
|
||||
color = colors.warning,
|
||||
)
|
||||
} else if (
|
||||
|
|
@ -208,67 +209,70 @@ fun SessionDetailScreen(
|
|||
)
|
||||
}
|
||||
exercise.feedback.forEach { item ->
|
||||
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
|
||||
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.",
|
||||
val elapsed = exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)
|
||||
// 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)
|
||||
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
|
||||
initialText = item.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
} }, onCancel = { editingFeedbackId = null })
|
||||
}
|
||||
if (feedbackEntryId == exercise.entryId) {
|
||||
FeedbackEditor(onSave = { text ->
|
||||
when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}, onCancel = { feedbackEntryId = null })
|
||||
} 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}"),
|
||||
icon = TrainlogIcons.Mic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = "Suivi après séance") {
|
||||
TrainlogFrame(title = strings.getString(R.string.session_follow_up)) {
|
||||
detail.followUps.forEach { item ->
|
||||
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent)
|
||||
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "")
|
||||
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
|
||||
TrainlogInfo(item.rawText + if (item.modified) strings.getString(R.string.modified_suffix) else "")
|
||||
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_marker_description),
|
||||
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
|
||||
if (editingFollowUpId == item.followupId) FeedbackEditor(
|
||||
initialText = item.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
} }, onCancel = { editingFollowUpId = null })
|
||||
}
|
||||
if (addingFollowUp) {
|
||||
FeedbackEditor(onSave = { text ->
|
||||
when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}, 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,
|
||||
containerColor = colors.success)
|
||||
}
|
||||
pendingEquipmentChange?.let { (entryId, equipmentId) ->
|
||||
DestructiveConfirmationDialog(
|
||||
title = if (equipmentId == null) "Retirer la machine de cette entrée ?" else "Remplacer la machine associée ?",
|
||||
detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.",
|
||||
confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer",
|
||||
title = strings.getString(if (equipmentId == null) R.string.remove_machine_question else R.string.replace_machine_question),
|
||||
detail = strings.getString(R.string.machine_change_detail),
|
||||
confirmLabel = strings.getString(if (equipmentId == null) R.string.remove else R.string.replace),
|
||||
onCancel = { pendingEquipmentChange = null },
|
||||
deleteContentDescription = if (equipmentId == null) {
|
||||
"Retirer la machine de cet exercice"
|
||||
strings.getString(R.string.a11y_remove_machine)
|
||||
} else null,
|
||||
onConfirm = {
|
||||
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
|
||||
|
|
@ -287,6 +291,8 @@ private fun SetsDetail(
|
|||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
|
|
@ -294,9 +300,9 @@ private fun SetsDetail(
|
|||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
"Mode : séries · répétitions"
|
||||
strings.getString(R.string.mode_sets_reps)
|
||||
} else {
|
||||
"Mode : séries · durée"
|
||||
strings.getString(R.string.mode_sets_duration)
|
||||
},
|
||||
color = colors.accent,
|
||||
)
|
||||
|
|
@ -304,13 +310,13 @@ private fun SetsDetail(
|
|||
if (exercise.trackingMode == TrackingMode.REPS) {
|
||||
val loadHeading =
|
||||
if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
|
||||
"Assistance (kg)"
|
||||
strings.getString(R.string.assistance_kg)
|
||||
} else {
|
||||
"Charge (kg)"
|
||||
strings.getString(R.string.field_weight)
|
||||
}
|
||||
/* Readable row table: history must expose every persisted value and
|
||||
* 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
|
||||
|
|
@ -324,11 +330,11 @@ private fun SetsDetail(
|
|||
TrackingMode.REPS
|
||||
) {
|
||||
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"
|
||||
} 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 =
|
||||
LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Mode : continu",
|
||||
strings.getString(R.string.mode_continuous),
|
||||
color = colors.accent,
|
||||
)
|
||||
|
||||
TrainlogInfo(
|
||||
"Durée : ${formatDuration(exercise.continuousDurationSeconds)}"
|
||||
strings.getString(R.string.duration_value, formatDuration(exercise.continuousDurationSeconds))
|
||||
)
|
||||
|
||||
if (
|
||||
|
|
@ -356,9 +363,7 @@ private fun ContinuousDetail(
|
|||
ExerciseDataFields.SPEED_KMH != 0
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Vitesse : %.1f km/h".format(
|
||||
exercise.speedKmh ?: 0.0
|
||||
)
|
||||
strings.getString(R.string.speed_value, exercise.speedKmh ?: 0.0)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -367,13 +372,12 @@ private fun ContinuousDetail(
|
|||
ExerciseDataFields.DISTANCE_KM != 0
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Distance : %.2f km".format(
|
||||
exercise.distanceKm ?: 0.0
|
||||
)
|
||||
strings.getString(R.string.distance_value, exercise.distanceKm ?: 0.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatDuration(
|
||||
seconds: Int,
|
||||
): String {
|
||||
|
|
@ -381,8 +385,8 @@ private fun formatDuration(
|
|||
seconds > 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import java.time.OffsetDateTime
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
internal object SessionGeneratorPreviewController {
|
||||
fun remove(preview: SessionGenerationPreview, index: Int): SessionGenerationPreview {
|
||||
|
|
@ -45,10 +46,7 @@ internal object SessionGeneratorPreviewController {
|
|||
|
||||
internal object SessionGeneratorFormController {
|
||||
val goals = listOf(
|
||||
"general" to "Général",
|
||||
"strength" to "Force",
|
||||
"hypertrophy" to "Hypertrophie",
|
||||
"endurance" to "Endurance locale",
|
||||
"general", "strength", "hypertrophy", "endurance",
|
||||
)
|
||||
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)
|
||||
val value = normalized.toDoubleOrNull()
|
||||
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,
|
||||
onExistingDraft: () -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val colors = LocalTrainlogColors.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val options = remember { repository.sessionGenerationFormOptions() }
|
||||
|
|
@ -146,8 +145,8 @@ fun SessionGeneratorScreen(
|
|||
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
|
||||
message = null
|
||||
}
|
||||
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = result.message
|
||||
is SessionGenerationResult.DatabaseError -> 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 = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
} finally {
|
||||
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
|
||||
if (current == null) {
|
||||
TrainlogFrame("Zone corporelle") {
|
||||
TrainlogChoiceChips(zones.map { it.zoneId to it.displayName }, zoneId) { zoneId = it }
|
||||
TrainlogFrame(strings.getString(R.string.generator_zone)) {
|
||||
TrainlogChoiceChips(zones.map { it.zoneId to localizedBodyZoneName(strings, it.zoneId, it.displayName) }, zoneId) { zoneId = it }
|
||||
}
|
||||
TrainlogFrame("Objectif") {
|
||||
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it }
|
||||
TrainlogFrame(strings.getString(R.string.generator_goal)) {
|
||||
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 }
|
||||
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,
|
||||
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)
|
||||
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 {
|
||||
TrainlogFrame("Proposition") {
|
||||
TrainlogInfo("Durée cible : ${current.request.durationMinutes} min · estimation : ${current.estimatedDurationSeconds / 60} min")
|
||||
TrainlogFrame(strings.getString(R.string.proposal)) {
|
||||
TrainlogInfo(strings.getString(R.string.target_duration_estimate, current.request.durationMinutes, current.estimatedDurationSeconds / 60))
|
||||
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("Échauffement et retour au calme ne sont pas générés en V1.", colors.muted)
|
||||
TrainlogInfo(strings.getString(R.string.proposal_short), colors.warning)
|
||||
TrainlogInfo(strings.getString(R.string.warmup_not_generated), colors.muted)
|
||||
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() }
|
||||
?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it) }
|
||||
.orEmpty(),
|
||||
?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it, strings) }.orEmpty()),
|
||||
colors.warning,
|
||||
)
|
||||
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.")
|
||||
exposure.latestStartedAt?.let { TrainlogInfo("Dernière exposition observée : $it", colors.muted) }
|
||||
TrainlogInfo(strings.getString(R.string.exposure_summary, exposure.within24h.primarySetCount, exposure.within24h.secondarySetCount, exposure.within72h.primarySetCount, exposure.within72h.secondarySetCount))
|
||||
exposure.latestStartedAt?.let { TrainlogInfo(strings.getString(R.string.latest_exposure, formatStartedAt(it)), colors.muted) }
|
||||
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)
|
||||
if (!warningAcknowledged) TrainlogAction("Continuer malgré l'avertissement", "Conserver la proposition et autoriser son acceptation.", accent = colors.warning, onClick = {
|
||||
TrainlogInfo(strings.getString(R.string.recent_exposure_warning), colors.warning)
|
||||
if (!warningAcknowledged) TrainlogAction(strings.getString(R.string.continue_warning), strings.getString(R.string.continue_warning_description), accent = colors.warning, onClick = {
|
||||
if (!busy) warningAcknowledged = true
|
||||
})
|
||||
}
|
||||
}
|
||||
current.exercises.forEachIndexed { index, item ->
|
||||
TrainlogFrame("${index + 1}. ${item.exerciseName}") {
|
||||
TrainlogInfo("Équipement : ${item.equipmentName}")
|
||||
TrainlogInfo("Cible : ${item.plan.sets} × ${item.plan.reps} · repos ${item.plan.restSeconds} s")
|
||||
TrainlogInfo(if (item.plan.weightKg == null) "Charge cible : aucune charge numérique proposée."
|
||||
else "Charge cible : ${item.plan.weightKg} kg")
|
||||
TrainlogInfo("Zone principale : ${item.primaryZoneName}")
|
||||
TrainlogInfo("Mouvement : ${item.patternNames.joinToString()}")
|
||||
TrainlogInfo(strings.getString(R.string.equipment_generator, item.equipmentName))
|
||||
TrainlogInfo(strings.getString(R.string.target_summary, item.plan.sets, item.plan.reps.toString(), item.plan.restSeconds))
|
||||
TrainlogInfo(if (item.plan.weightKg == null) strings.getString(R.string.target_load_none_proposed)
|
||||
else strings.getString(R.string.target_load_proposed, item.plan.weightKg))
|
||||
TrainlogInfo(strings.getString(R.string.primary_zone_value_short, item.primaryZoneName))
|
||||
TrainlogInfo(strings.getString(R.string.movement_value, item.patternNames.joinToString()))
|
||||
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 {
|
||||
TrainlogInfo(
|
||||
"Charge issue d'une dose réellement observée le $it ; " +
|
||||
"son applicabilité aujourd'hui reste incertaine.",
|
||||
strings.getString(R.string.observed_load_note, formatDate(it)),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(
|
||||
"Raisons : ${item.rationaleCodes.joinToString { generationReasonLabel(it) }}",
|
||||
strings.getString(R.string.reasons_value, item.rationaleCodes.joinToString { generationReasonLabel(it, strings) }),
|
||||
colors.muted,
|
||||
)
|
||||
if (editingIndex == index) {
|
||||
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it })
|
||||
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it })
|
||||
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it })
|
||||
TrainlogInfo("Charge : choix utilisateur, jamais une recommandation.", colors.muted)
|
||||
TrainlogInputField(strings.getString(R.string.sets_label), setsText, onValueChange = { setsText = it })
|
||||
TrainlogInputField(strings.getString(R.string.field_reps), repsText, onValueChange = { repsText = it })
|
||||
TrainlogInputField(strings.getString(R.string.rest_seconds), restText, onValueChange = { restText = it })
|
||||
TrainlogInfo(strings.getString(R.string.user_load_note), colors.muted)
|
||||
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,
|
||||
) { selected ->
|
||||
loadChoice = GeneratorLoadChoice.valueOf(selected)
|
||||
if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = ""
|
||||
}
|
||||
if (loadChoice == GeneratorLoadChoice.PERCENT_MAX)
|
||||
TrainlogInputField("Pourcentage du MAX (1 à 100)", maxPercentText,
|
||||
TrainlogInputField(strings.getString(R.string.max_percentage_field), maxPercentText,
|
||||
onValueChange = { maxPercentText = it })
|
||||
if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
|
||||
TrainlogInputField(
|
||||
"Charge cible manuelle (vide = automatique)",
|
||||
strings.getString(R.string.manual_target_load),
|
||||
loadText,
|
||||
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)
|
||||
SessionGeneratorFormController.manualWeight(loadText)
|
||||
else Result.success(null)
|
||||
if (parsedWeight.isFailure) {
|
||||
message = parsedWeight.exceptionOrNull()?.message
|
||||
message = strings.getString(R.string.generator_weight_invalid)
|
||||
} else {
|
||||
val weight = parsedWeight.getOrNull()
|
||||
if (!busy) scope.launch {
|
||||
|
|
@ -260,8 +260,8 @@ fun SessionGeneratorScreen(
|
|||
maxPercentText.toIntOrNull())
|
||||
}) {
|
||||
is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null }
|
||||
is SessionGenerationResult.Invalid -> message = result.message
|
||||
is SessionGenerationResult.DatabaseError -> message = result.message
|
||||
is SessionGenerationResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
|
||||
is SessionGenerationResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
} finally {
|
||||
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) {
|
||||
editingIndex = index; setsText = item.plan.sets.toString(); repsText = item.plan.reps.toString()
|
||||
restText = item.plan.restSeconds.toString()
|
||||
|
|
@ -286,68 +286,64 @@ fun SessionGeneratorScreen(
|
|||
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)
|
||||
}, 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)
|
||||
}, 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
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("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 = {
|
||||
if (!warningAcknowledged) message = "Confirmez d'abord l'avertissement d'exposition récente."
|
||||
TrainlogAction(strings.getString(R.string.regenerate), strings.getString(R.string.regenerate_description), onClick = { generate(current.request) })
|
||||
TrainlogAction(strings.getString(R.string.accept_actual_values), strings.getString(R.string.accept_actual_description), accent = colors.success, onClick = {
|
||||
if (!warningAcknowledged) message = strings.getString(R.string.acknowledge_exposure_first)
|
||||
else if (!busy) scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
when (val result = withContext(Dispatchers.IO) { repository.acceptGeneratedSession(current) }) {
|
||||
AcceptGeneratedSessionResult.Accepted -> onAccepted()
|
||||
AcceptGeneratedSessionResult.ExistingActiveDraft -> onExistingDraft()
|
||||
is AcceptGeneratedSessionResult.Invalid -> message = result.message
|
||||
is AcceptGeneratedSessionResult.DatabaseError -> message = result.message
|
||||
is AcceptGeneratedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
|
||||
is AcceptGeneratedSessionResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
} finally {
|
||||
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()
|
||||
}, accent = colors.muted)
|
||||
}
|
||||
if (busy) TrainlogFrame("Traitement") { TrainlogInfo("Analyse en cours…", colors.muted) }
|
||||
message?.let { TrainlogFrame("Message") { TrainlogInfo(it, colors.error) } }
|
||||
if (busy) TrainlogFrame(strings.getString(R.string.processing)) { TrainlogInfo(strings.getString(R.string.analysis_running), colors.muted) }
|
||||
message?.let { TrainlogFrame(strings.getString(R.string.message_title)) { TrainlogInfo(it, colors.error) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun generationReasonLabel(code: String): String = when (code) {
|
||||
"observed_repeated_dose_anchor" -> "dose répétée observée"
|
||||
"explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique"
|
||||
"user_selected_max_percentage" -> "pourcentage de MAX choisi par l'utilisateur"
|
||||
"compatible_max_unavailable" -> "MAX compatible indisponible"
|
||||
"assistance_numeric_load_omitted" -> "charge d'assistance omise"
|
||||
"numeric_load_absent" -> "charge numérique absente"
|
||||
"manual_target_load" -> "charge saisie manuellement"
|
||||
"preferred_exercise" -> "exercice préféré"
|
||||
"requested_primary_zone" -> "zone principale demandée"
|
||||
"requested_secondary_zone" -> "zone secondaire demandée"
|
||||
"new_exact_pattern" -> "mouvement distinct"
|
||||
"recent_same_exercise_penalty" -> "même exercice observé récemment"
|
||||
"recent_same_pattern_penalty" -> "mouvement similaire observé récemment"
|
||||
else -> code.replace('_', ' ')
|
||||
}
|
||||
private fun generationReasonLabel(code: String, context: android.content.Context): String = context.getString(when (code) {
|
||||
"observed_repeated_dose_anchor" -> R.string.reason_observed_dose
|
||||
"explicit_max_present_no_numeric_prescription" -> R.string.reason_max_no_prescription
|
||||
"user_selected_max_percentage" -> R.string.reason_user_max
|
||||
"compatible_max_unavailable" -> R.string.reason_max_unavailable
|
||||
"assistance_numeric_load_omitted" -> R.string.reason_assistance_omitted
|
||||
"numeric_load_absent" -> R.string.reason_load_absent
|
||||
"manual_target_load" -> R.string.reason_manual_load
|
||||
"preferred_exercise" -> R.string.reason_preferred
|
||||
"requested_primary_zone" -> R.string.reason_primary_zone
|
||||
"requested_secondary_zone" -> R.string.reason_secondary_zone
|
||||
"new_exact_pattern" -> R.string.reason_distinct_movement
|
||||
"recent_same_exercise_penalty" -> R.string.reason_recent_exercise
|
||||
"recent_same_pattern_penalty" -> R.string.reason_recent_pattern
|
||||
else -> return code.replace('_', ' ')
|
||||
})
|
||||
|
||||
private fun generationShortageLabel(code: String): String = when (code) {
|
||||
"missing_upper_region" -> "région supérieure manquante"
|
||||
"missing_lower_region" -> "région inférieure manquante"
|
||||
"missing_core_region" -> "tronc manquant"
|
||||
"missing_upper_push" -> "poussée du haut du corps manquante"
|
||||
"missing_upper_pull" -> "tirage du haut du corps manquant"
|
||||
"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('_', ' ')
|
||||
}
|
||||
private fun generationShortageLabel(code: String, context: android.content.Context): String = context.getString(when (code) {
|
||||
"missing_upper_region" -> R.string.shortage_upper; "missing_lower_region" -> R.string.shortage_lower
|
||||
"missing_core_region" -> R.string.shortage_core; "missing_upper_push" -> R.string.shortage_push
|
||||
"missing_upper_pull" -> R.string.shortage_pull; "missing_lower_extension" -> R.string.shortage_lower_extension
|
||||
"missing_lower_flexion" -> R.string.shortage_lower_flexion; "fewer_than_max_exercises" -> R.string.shortage_exercises
|
||||
else -> return code.replace('_', ' ')
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
})
|
||||
|
|
@ -40,6 +40,7 @@ import com.labfytools.trainlog.data.StatisticsSeries
|
|||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import com.labfytools.trainlog.R
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
|
@ -47,6 +48,15 @@ import kotlin.math.max
|
|||
|
||||
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 fun sixWeekFrequency(
|
||||
|
|
@ -70,38 +80,40 @@ internal fun sixWeekFrequency(
|
|||
|
||||
@Composable
|
||||
fun StatisticsDashboard(repository: TrainlogRepository) {
|
||||
val strings = localizedContext()
|
||||
var period by remember { mutableStateOf(StatisticsPeriod.DAYS_30) }
|
||||
var detail by remember { mutableStateOf<StatisticsDetail?>(null) }
|
||||
val overview = remember(period) { repository.loadStatistics(period) }
|
||||
if (detail != null) return StatisticsDetailScreen(detail!!, overview) { detail = null }
|
||||
TrainlogScreen("Statistiques") {
|
||||
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to it.label }, period.name) {
|
||||
TrainlogScreen(strings.getString(R.string.nav_statistics)) {
|
||||
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to statisticsPeriodLabel(it) }, period.name) {
|
||||
period = StatisticsPeriod.valueOf(it)
|
||||
}
|
||||
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)
|
||||
PerformanceCard(overview) { detail = StatisticsDetail.PROGRESS }
|
||||
MeasurementsCard(overview) { detail = StatisticsDetail.BODY }
|
||||
FrequencyCard(overview) { detail = StatisticsDetail.FREQUENCY }
|
||||
TrainlogFrame("EXPLORER") {
|
||||
TrainlogAction("Répartition", "Zones et utilisation des exercices, uniquement quand elles sont classifiées.", { detail = StatisticsDetail.DISTRIBUTION })
|
||||
TrainlogAction("Résumé global", "Indicateurs prudents dérivés des données disponibles.", { detail = StatisticsDetail.SUMMARY })
|
||||
TrainlogFrame(strings.getString(R.string.explore)) {
|
||||
TrainlogAction(strings.getString(R.string.distribution), strings.getString(R.string.distribution_description), { detail = StatisticsDetail.DISTRIBUTION })
|
||||
TrainlogAction(strings.getString(R.string.global_summary), strings.getString(R.string.global_summary_description), { detail = StatisticsDetail.SUMMARY })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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)) {
|
||||
Metric("Séances", overview.summary.sessions.toString(), Modifier.weight(1f))
|
||||
Metric("Séries", overview.summary.performedSets.toString(), Modifier.weight(1f))
|
||||
Metric(strings.getString(R.string.nav_sessions), overview.summary.sessions.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)) {
|
||||
Metric("Exercices", overview.summary.distinctExercises.toString(), Modifier.weight(1f))
|
||||
Metric("MAX explicites", overview.summary.explicitMaxima.toString(), Modifier.weight(1f))
|
||||
Metric(strings.getString(R.string.nav_exercises), overview.summary.distinctExercises.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 work = overview.performanceEvents.sumOf { it.workingImprovements }
|
||||
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)) {
|
||||
PerformanceTimeline(overview)
|
||||
Text(
|
||||
if (work + maxima == 0) "Données insuffisantes pour établir une progression comparable."
|
||||
else "$work progression(s) travail · $maxima record(s) MAX",
|
||||
if (work + maxima == 0) strings.getString(R.string.progress_insufficient)
|
||||
else strings.getString(R.string.progress_counts, work, maxima),
|
||||
style = TrainlogTypography.small.copy(color = if (work + maxima == 0) colors.muted else colors.text),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
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),
|
||||
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 values = List(6 - recent.size) { 0 } + recent
|
||||
val description = if (values.all { it == 0 })
|
||||
"Progression sur six semaines : aucun événement comparable, six points vides"
|
||||
else "Progression sur six semaines : ${values.joinToString()} événements"
|
||||
uiString(R.string.progress_a11y_empty)
|
||||
else uiString(R.string.progress_a11y_values, values.joinToString())
|
||||
Canvas(Modifier.fillMaxWidth().height(54.dp).semantics { contentDescription = description }) {
|
||||
val step = size.width / values.size
|
||||
val peak = max(1, values.max())
|
||||
|
|
@ -160,10 +173,11 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
|
|||
@Composable
|
||||
internal fun MeasurementsCard(overview: StatisticsOverview, click: () -> Unit) {
|
||||
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)) {
|
||||
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 ->
|
||||
Row(
|
||||
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 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)) {
|
||||
Text(series.label, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
Text("$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))
|
||||
Text(localizedStatisticsSeriesLabel(series), style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
Text("${presentationNumber(latest)} ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text))
|
||||
if (delta == null) Text(uiString(R.string.measurement_single), style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -198,16 +212,18 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
|
|||
val colors = LocalTrainlogColors.current
|
||||
val weeks = sixWeekFrequency(overview.frequency, today)
|
||||
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)) {
|
||||
FrequencyBars(weeks)
|
||||
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),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
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),
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
|
|
@ -218,9 +234,12 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
|
|||
@Composable
|
||||
private fun FrequencyBars(weeks: List<DashboardWeek>) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val description = "Fréquence sur six semaines : " + weeks.mapIndexed { index, week ->
|
||||
"${if (index == weeks.lastIndex) "actuelle" else week.label} ${week.sessions} séances dont ${week.maxima} MAX"
|
||||
}.joinToString("; ")
|
||||
val strings = localizedContext()
|
||||
val description = strings.getString(R.string.frequency_a11y, weeks.mapIndexed { index, week ->
|
||||
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 }) {
|
||||
val peak = max(1, weeks.maxOf { it.sessions })
|
||||
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()) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 4.dp)) {
|
||||
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 {
|
||||
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
|
||||
private fun StatisticsDetailScreen(detail: StatisticsDetail, overview: StatisticsOverview, back: () -> Unit) {
|
||||
val strings = localizedContext()
|
||||
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.BODY -> Triple("Mensurations", overview.body, "Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.")
|
||||
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.DISTRIBUTION -> Triple("Répartition", emptyList(), "La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.")
|
||||
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.PROGRESS -> Triple(strings.getString(R.string.progression), overview.performance, strings.getString(R.string.progress_detail_description))
|
||||
StatisticsDetail.BODY -> Triple(strings.getString(R.string.route_body_measurements), overview.body, strings.getString(R.string.body_detail_description))
|
||||
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(strings.getString(R.string.distribution), emptyList(), strings.getString(R.string.distribution_detail_description))
|
||||
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) {
|
||||
TrainlogAction("← Retour au dashboard", "", back)
|
||||
TrainlogAction(strings.getString(R.string.back_dashboard), "", back)
|
||||
TrainlogInfo(description)
|
||||
if (series.isEmpty()) TrainlogInfo("Aucune donnée comparable dans cette période.")
|
||||
else series.forEach { DashboardCard(it.label, "${it.points.size} point(s) · ${it.unit}", listOf(it), {}) }
|
||||
if (series.isEmpty()) TrainlogInfo(strings.getString(R.string.comparable_none))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
|
||||
|
||||
import android.content.Context
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.SyncRequestOutbox
|
||||
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.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
|
@ -31,23 +49,119 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
/* Package-visible for orchestration tests: this is the production ordering
|
||||
* boundary, not a duplicate test-only implementation. */
|
||||
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,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
|
||||
when (val opened = exporter.openStorageSnapshot()) {
|
||||
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 ->
|
||||
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
|
||||
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
|
||||
is SyncExportResult.Error -> SyncRequestResult.Error(
|
||||
"Préparation Android → PC : ${publication.message}",
|
||||
)
|
||||
is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}")
|
||||
is SyncExportResult.Exported -> {
|
||||
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
|
||||
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
|
||||
fun SyncScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
|
|
@ -69,6 +201,18 @@ fun SyncScreen(
|
|||
LocalTrainlogColors.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
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
|
||||
remember {
|
||||
|
|
@ -97,6 +241,63 @@ fun SyncScreen(
|
|||
}
|
||||
|
||||
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(
|
||||
pendingRequestId
|
||||
|
|
@ -119,7 +320,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Dossier Trainlog non autorisé."
|
||||
strings.getString(R.string.sync_folder_unauthorized)
|
||||
|
||||
pendingRequestId =
|
||||
null
|
||||
|
|
@ -143,7 +344,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
receipt.summary
|
||||
visibleReceiptStatus(strings, receipt)
|
||||
|
||||
pendingRequestId =
|
||||
null
|
||||
|
|
@ -160,13 +361,10 @@ fun SyncScreen(
|
|||
success = true
|
||||
|
||||
status =
|
||||
(
|
||||
"Synchronisation terminée · " +
|
||||
receipt.summary +
|
||||
" · Android catalogue : " +
|
||||
"${catalog.imported} nouveau(x), " +
|
||||
"${catalog.reconciled} réconcilié(s), " +
|
||||
"${catalog.skipped} présent(s)."
|
||||
visibleReceiptStatus(
|
||||
strings,
|
||||
receipt,
|
||||
catalogCounts(catalog.imported, catalog.reconciled, catalog.skipped),
|
||||
)
|
||||
|
||||
onCatalogChanged()
|
||||
|
|
@ -176,26 +374,21 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
(
|
||||
"Sync PC terminée, mais catalogue reçu introuvable."
|
||||
)
|
||||
strings.getString(R.string.sync_pc_catalog_missing)
|
||||
}
|
||||
|
||||
CatalogInboxResult.FolderNotAuthorized -> {
|
||||
success = false
|
||||
|
||||
status =
|
||||
"Sync PC terminée, dossier Trainlog non autorisé."
|
||||
strings.getString(R.string.sync_pc_folder_unauthorized)
|
||||
}
|
||||
|
||||
is CatalogInboxResult.Error -> {
|
||||
success = false
|
||||
|
||||
status =
|
||||
(
|
||||
"Sync PC terminée, import Android : " +
|
||||
catalog.message
|
||||
)
|
||||
strings.getString(R.string.sync_pc_import_error, catalog.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +403,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Le PC n'a pas répondu dans les 60 secondes."
|
||||
strings.getString(R.string.sync_timeout)
|
||||
|
||||
pendingRequestId =
|
||||
null
|
||||
|
|
@ -223,20 +416,20 @@ fun SyncScreen(
|
|||
folderAuthorized = inbox.hasFolderAccess()
|
||||
success = folderAuthorized
|
||||
status = if (folderAuthorized) {
|
||||
"Accès fichiers autorisé · Documents/Trainlog prêt."
|
||||
strings.getString(R.string.sync_access_ready)
|
||||
} else {
|
||||
"Accès fichiers requis. Activez l'autorisation dans Android."
|
||||
strings.getString(R.string.sync_access_enable)
|
||||
}
|
||||
if (folderAuthorized) {
|
||||
coroutineScope.launch {
|
||||
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " +
|
||||
"${result.reconciled} réconcilié(s)."
|
||||
status = strings.getString(R.string.sync_access_catalog,
|
||||
catalogCounts(result.imported, result.reconciled))
|
||||
onCatalogChanged()
|
||||
}
|
||||
CatalogInboxResult.FileNotFound ->
|
||||
status = "Accès autorisé · aucun catalogue PC reçu."
|
||||
status = strings.getString(R.string.sync_access_no_catalog)
|
||||
CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false
|
||||
is CatalogInboxResult.Error -> status = result.message
|
||||
}
|
||||
|
|
@ -246,14 +439,14 @@ fun SyncScreen(
|
|||
|
||||
TrainlogScreen(
|
||||
subtitle =
|
||||
"Synchronisation"
|
||||
strings.getString(R.string.nav_sync)
|
||||
) {
|
||||
TrainlogFrame(
|
||||
title = "Synchroniser"
|
||||
title = strings.getString(R.string.sync_action)
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Le snapshot Android est maintenu automatiquement.",
|
||||
strings.getString(R.string.sync_snapshot_automatic),
|
||||
color =
|
||||
colors.accent,
|
||||
)
|
||||
|
|
@ -263,12 +456,12 @@ fun SyncScreen(
|
|||
if (
|
||||
pendingRequestId != null || syncRunning
|
||||
) {
|
||||
"Synchronisation en cours..."
|
||||
strings.getString(R.string.sync_running)
|
||||
} else {
|
||||
"Synchroniser maintenant"
|
||||
strings.getString(R.string.sync_now)
|
||||
},
|
||||
description =
|
||||
"Android → PC puis PC → Android, en une seule opération.",
|
||||
strings.getString(R.string.sync_flow_description),
|
||||
accent =
|
||||
colors.success,
|
||||
onClick = {
|
||||
|
|
@ -282,7 +475,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Accès fichiers requis. Autorisez d'abord Documents/Trainlog."
|
||||
strings.getString(R.string.sync_authorize_first)
|
||||
|
||||
return@TrainlogAction
|
||||
}
|
||||
|
|
@ -292,25 +485,51 @@ fun SyncScreen(
|
|||
* published from one prepared repository snapshot before
|
||||
* that signal becomes visible; otherwise the PC can mix a
|
||||
* 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
|
||||
status = strings.getString(R.string.sync_running)
|
||||
coroutineScope.launch {
|
||||
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 -> {
|
||||
success = true
|
||||
pendingRequestId = result.requestId
|
||||
status = "Demande envoyée · attente du PC..."
|
||||
status = strings.getString(R.string.sync_request_sent)
|
||||
}
|
||||
SyncRequestResult.Unsupported -> {
|
||||
success = false
|
||||
status = "Android non supporté."
|
||||
status = strings.getString(R.string.sync_android_unsupported)
|
||||
}
|
||||
is SyncRequestResult.Error -> {
|
||||
success = false
|
||||
status = result.message
|
||||
status = visibleError(result.message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
explicitClaim.close()
|
||||
syncRunning = false
|
||||
}
|
||||
}
|
||||
|
|
@ -320,11 +539,11 @@ fun SyncScreen(
|
|||
|
||||
TrainlogFrame(
|
||||
title =
|
||||
"DOSSIER D'ECHANGE"
|
||||
strings.getString(R.string.sync_folder_section)
|
||||
) {
|
||||
if (folderAuthorized) {
|
||||
TrainlogInfo(
|
||||
"Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé",
|
||||
strings.getString(R.string.settings_folder_granted),
|
||||
color =
|
||||
colors.success,
|
||||
)
|
||||
|
|
@ -332,9 +551,9 @@ fun SyncScreen(
|
|||
|
||||
TrainlogAction(
|
||||
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 =
|
||||
"Autoriser l'accès pour gérer tous les fichiers dans Android.",
|
||||
strings.getString(R.string.sync_manage_files),
|
||||
onClick = {
|
||||
permissionLauncher.launch(directStoragePermissionIntent(context))
|
||||
},
|
||||
|
|
@ -344,9 +563,9 @@ fun SyncScreen(
|
|||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Relire le catalogue PC",
|
||||
strings.getString(R.string.settings_reload_catalog),
|
||||
description =
|
||||
"Action de récupération manuelle si nécessaire.",
|
||||
strings.getString(R.string.sync_manual_recovery),
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
|
|
@ -354,12 +573,8 @@ fun SyncScreen(
|
|||
success = true
|
||||
|
||||
status =
|
||||
(
|
||||
"Catalogue PC : " +
|
||||
"${result.imported} nouveau(x), " +
|
||||
"${result.reconciled} réconcilié(s), " +
|
||||
"${result.skipped} présent(s)."
|
||||
)
|
||||
strings.getString(R.string.sync_catalog_result,
|
||||
catalogCounts(result.imported, result.reconciled, result.skipped))
|
||||
|
||||
onCatalogChanged()
|
||||
}
|
||||
|
|
@ -368,14 +583,14 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Accès fichiers requis pour Documents/Trainlog."
|
||||
strings.getString(R.string.sync_folder_access_required)
|
||||
}
|
||||
|
||||
CatalogInboxResult.FileNotFound -> {
|
||||
success = false
|
||||
|
||||
status =
|
||||
"Aucun catalogue PC reçu."
|
||||
strings.getString(R.string.settings_no_catalog)
|
||||
}
|
||||
|
||||
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) {
|
||||
TrainlogFrame(
|
||||
title = "État",
|
||||
title = strings.getString(R.string.sync_status),
|
||||
active = false,
|
||||
) {
|
||||
TrainlogInfo(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.R
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
|
|
@ -32,6 +33,8 @@ import kotlinx.coroutines.withContext
|
|||
|
||||
@Composable
|
||||
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
|
||||
val localized = localizedContext()
|
||||
val strings = localized.resources
|
||||
val navigation = appState.navigation
|
||||
val generatorState = appState.generator
|
||||
val equipmentState = appState.equipment
|
||||
|
|
@ -59,7 +62,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
||||
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||
val pendingAiDraftCount = remember(draftRevision, catalogRevision) {
|
||||
repository.listAiSessionDrafts().size
|
||||
repository.listAiSessionDrafts().size + repository.listPreparedSessions().size
|
||||
}
|
||||
|
||||
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)
|
||||
ActiveDraftLoadResult.None -> when (val result = repository.startActiveSessionDraft()) {
|
||||
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) {
|
||||
when (val route = navigation.route) {
|
||||
AppRoute.Home -> HomeScreen(
|
||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
|
||||
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
|
||||
repository.getBodyZoneHomeOverview(),
|
||||
::openManualSession,
|
||||
{ zoneId ->
|
||||
|
|
@ -99,14 +101,35 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
exerciseState.searchQuery.value = ""
|
||||
open(AppRoute.Exercises)
|
||||
},
|
||||
{ open(AppRoute.Statistics) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { 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) }, {
|
||||
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++
|
||||
/* WHY: ExistingActiveDraft is discovered only after the accept
|
||||
* 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) },
|
||||
)
|
||||
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.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
||||
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() }
|
||||
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.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Statistics -> StatisticsDashboard(repository)
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
|
||||
/* 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.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
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
|
||||
AlertDialog(
|
||||
onDismissRequest = navigationController::cancelPending,
|
||||
title = { Text(if (generator) "Proposition non acceptée" else "Modifications non enregistrées") },
|
||||
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.") },
|
||||
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text("Conserver et quitter") } },
|
||||
title = { Text(strings.getString(if (generator) R.string.dialog_unaccepted_proposal else R.string.dialog_unsaved_changes)) },
|
||||
text = { Text(strings.getString(if (generator) R.string.dialog_proposal_retained else R.string.dialog_fields_retained)) },
|
||||
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text(strings.getString(R.string.dialog_keep_leave)) } },
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
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) {
|
||||
is AppRoute.SessionDetail -> "session:${sessionId}"
|
||||
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
|
||||
is AppRoute.ProgramDetail -> "program:${programId}"
|
||||
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
||||
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
||||
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
|
|
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -94,6 +96,7 @@ fun TrainlogFrame(
|
|||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
active: Boolean = true,
|
||||
titleTrailing: (@Composable () -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
|
|
@ -112,15 +115,19 @@ fun TrainlogFrame(
|
|||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = title,
|
||||
style =
|
||||
TrainlogTypography.small.copy(
|
||||
color = accent,
|
||||
fontWeight =
|
||||
FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
BasicText(
|
||||
text = title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style =
|
||||
TrainlogTypography.small.copy(
|
||||
color = accent,
|
||||
fontWeight =
|
||||
FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
titleTrailing?.invoke()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
|
|
@ -159,7 +166,7 @@ fun TrainlogAction(
|
|||
.padding(vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(Color.Transparent)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
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. */
|
||||
@Composable
|
||||
fun TrainlogIconAction(
|
||||
|
|
@ -220,11 +267,13 @@ fun TrainlogDeleteButton(
|
|||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fullWidth: Boolean = true,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Button(
|
||||
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),
|
||||
) {
|
||||
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
|
||||
|
|
@ -235,9 +284,7 @@ fun TrainlogDeleteButton(
|
|||
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
||||
Text(label)
|
||||
}
|
||||
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
|
||||
if (description.isNotBlank()) {
|
||||
BasicText(
|
||||
description,
|
||||
|
|
@ -265,7 +312,7 @@ fun TrainlogActionTile(
|
|||
Column(
|
||||
modifier
|
||||
.heightIn(min = 112.dp)
|
||||
.background(colors.surface, RoundedCornerShape(12.dp))
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
|
|
@ -285,12 +332,34 @@ fun TrainlogButton(
|
|||
icon: ImageVector? = null,
|
||||
containerColor: Color? = null,
|
||||
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(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 52.dp),
|
||||
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
|
||||
?: ButtonDefaults.buttonColors(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
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),
|
||||
) {
|
||||
icon?.let {
|
||||
|
|
@ -327,12 +396,15 @@ fun TrainlogInputField(
|
|||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardOptions: KeyboardOptions =
|
||||
KeyboardOptions.Default,
|
||||
testTag: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
suffix: String? = null,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -343,8 +415,7 @@ fun TrainlogInputField(
|
|||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(bottom = 10.dp)
|
||||
modifier = modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = label.uppercase(),
|
||||
|
|
@ -388,7 +459,7 @@ fun TrainlogInputField(
|
|||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
}
|
||||
|
|
@ -399,11 +470,22 @@ fun TrainlogInputField(
|
|||
Modifier.testTag(testTag)
|
||||
}
|
||||
)
|
||||
.background(colors.surface)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.padding(
|
||||
horizontal = 12.dp,
|
||||
vertical = 10.dp,
|
||||
horizontal = if (compact) 10.dp else 12.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(
|
||||
|
|
@ -466,7 +548,7 @@ fun DestructiveConfirmationDialog(
|
|||
onDismissRequest = onCancel,
|
||||
title = { Text(title) },
|
||||
text = { Text(detail) },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text(localizedContext().getString(com.labfytools.trainlog.R.string.dialog_cancel)) } },
|
||||
confirmButton = {
|
||||
if (deleteContentDescription != null) {
|
||||
TrainlogDeleteButton(
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class TrainlogColors(
|
||||
val background: Color,
|
||||
|
|
@ -110,6 +113,19 @@ fun TrainlogTheme(
|
|||
error = TrainlogDarkColors.error,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
620
android/app/src/main/res/values-en/strings.xml
Normal file
620
android/app/src/main/res/values-en/strings.xml
Normal 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>
|
||||
|
|
@ -1,4 +1,630 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,41 @@ class AiSessionDraftRepositoryTest {
|
|||
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
|
||||
fun pendingDraftIsListed() {
|
||||
val exercise = exercise()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
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")) {
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
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
|
||||
* 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") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
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(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
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 ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -296,7 +296,7 @@ class StatisticsRepositoryTest {
|
|||
}
|
||||
|
||||
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(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue