Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0571ae44e | |||
| ae14cee07b | |||
| 4eb8519d9d | |||
| 458f7cc9ef | |||
| b3372a80e4 | |||
| 45ba0bdc62 | |||
| a0f3ac59f9 | |||
| 5cd1023af1 | |||
| d531ecae24 | |||
| 0bb259d12d | |||
| 386693379e | |||
| a4f859f854 | |||
| 2548c6207b | |||
| a3a9c7652c | |||
| 09cddfc8ee | |||
| d6d95080b4 | |||
| a40d9bc076 | |||
| 0f0ecc3d23 | |||
| e4f52bc6da | |||
| 8fc6d07772 | |||
| 03fdbf61ed | |||
| 96a0dbc56a | |||
| 8078d5cbed | |||
| dd220e8933 | |||
| f6be3d86a9 | |||
| 2ec9f63f93 | |||
| 8c19c8f295 | |||
| 23c62e85f2 | |||
| 2c58418604 | |||
| 76a9c0eb63 | |||
| 0768b83a88 | |||
| 3e2bf3992e | |||
| dd5a9e3013 | |||
| 04f6b8bd64 | |||
| e508607919 | |||
| 3e4c7c44b3 | |||
| 4045614f3b |
7
.gitignore
vendored
7
.gitignore
vendored
@ -5,6 +5,13 @@ build/
|
||||
# Local configuration (machine-specific, never commit)
|
||||
local.properties
|
||||
|
||||
# Release signing (v1.9.8) — created by scripts/make-release-keystore.sh.
|
||||
# NEVER commit: the keystore + its passwords are the identity of the app
|
||||
# (leaking them = anyone can ship an APK that upgrades over yours).
|
||||
keystore.properties
|
||||
*.keystore
|
||||
*.jks
|
||||
|
||||
# IntelliJ / Android Studio
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
305
README.en.md
Normal file
305
README.en.md
Normal file
@ -0,0 +1,305 @@
|
||||
# HormoneTrack
|
||||
|
||||
> **Language**: [Français](README.md) · **English** (this file)
|
||||
|
||||
> **🤖 AI-assisted development**: this project was designed and coded with an
|
||||
> AI assistant. The human contribution was **essential**: continuous feedback,
|
||||
> user reports (testing on a real phone, bug reports with real data exports),
|
||||
> improvement suggestions and validation of every release. The session-by-session
|
||||
> detail is documented in
|
||||
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md) (French).
|
||||
|
||||
Hormone therapy (HRT) tracker on Android, with **hour-by-hour** estimated
|
||||
curves of estradiol (E2) and testosterone (T), calibration against blood
|
||||
tests, reminders mirrored on a smartwatch (Huawei Watch GT 3 via Gadgetbridge
|
||||
or Huawei Health) and JSON backup. **100% local, no account, no server.**
|
||||
|
||||
> **⚠️ Medical disclaimer**: the curves are **pharmacokinetic estimates**
|
||||
> for informational purposes — they are not measurements. Always rely on your
|
||||
> blood tests and on your endocrinologist's guidance.
|
||||
|
||||
- **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅
|
||||
(250 without the local test data; 7 regressions pinned on real data
|
||||
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
|
||||
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
||||
with APK** ✅
|
||||
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
|
||||
- **User guide**: [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) (French)
|
||||
- **Development docs** (architecture, math, decisions, bugs): [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) (French)
|
||||
- **Watch / Gadgetbridge**: [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) (French)
|
||||
|
||||
## Features
|
||||
|
||||
- **Hour-by-hour estimated curves**: E2 (pg/mL) and T (ng/mL), 24 h / 7 d / 30 d views,
|
||||
**dose markers** (forecast and actual), **configurable chart timezone** (v1.4.5),
|
||||
**pan** (drag to scroll back in time), **zoom** (pinch or − / + buttons,
|
||||
6 h → 300 d, adaptive sampling), displayable **peaks & troughs** with their
|
||||
**estimated values** (▲▼ triangles at local extrema, toggleable)
|
||||
- **Two PK models, superimposable**: **Estrannaise (EstraNase)** (hourly tables
|
||||
from the `.ods` spreadsheet) and **Transfem Science** — since v1.4.0 the TFS
|
||||
model is the **official 3-compartment meta-analysis** (exact closed form,
|
||||
parameters from the [TFS simulator](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||
covering **7 esters** (EV, EU, EEn, EB, EC oil, EC suspension, PEP) — WHSAH
|
||||
covers 6 (no PEP). Displayed side by side with independent toggles (3 colors) —
|
||||
**pre-checked according to your treatments' models** (v1.4.7) and **each model
|
||||
calibrated separately** by your labs (v1.4.8 — scale factors adapt to each profile)
|
||||
- **"Lab track" curve (v1.5.0, extended in v1.6.0)**: the curve anchored on your
|
||||
blood tests (`Lab track` chip, off by default) — the PK model's shape between
|
||||
labs, with the amplitude rescaled to pass EXACTLY through every E2 lab value;
|
||||
log-linear transition from one lab to the next, keeps out-of-action-window labs
|
||||
(#61); **`Extend` chip (v1.6.0)**: the curve extends beyond the last lab
|
||||
(model × last lab ratio, horizon = model extinction, estimated part drawn
|
||||
faded + dedicated legend) — the curve is a COMPARISON reference, never an
|
||||
estimate of action (it never feeds the home screen or the alerts)
|
||||
- **Analytical Estrannaise model (v1.9.0)**: the ESE model now uses the 3C
|
||||
closed form published by estrannaise.js (fidelity to the old tables pinned at
|
||||
RMS 0) — **MCMC uncertainty cloud** (`Cloud` chip, ESE-exclusive: 32 posterior
|
||||
curves showing the imprecision range, like the estrannaise website); **6
|
||||
injectable esters** (incl. EUCS, exclusive); the historical ODS is no longer
|
||||
used at runtime (fidelity = tests only); analytical terminal t½
|
||||
(blood draw recommendation)
|
||||
- **Configurable Bateman model** (time to peak, half-life, bioavailability) for
|
||||
gel, patch and oral routes
|
||||
- **Forecast simulation**: set a **Dosage interval** (days) on a treatment →
|
||||
upcoming doses are projected on the chart (never saved); **enabling forecast
|
||||
does not move the chart start** but EXTENDS the window to the next dose
|
||||
(v1.4.4), and the projection is browsed by dragging left, up to **1 year**
|
||||
- **Inactive treatments**: an archived treatment disappears from input and
|
||||
reminders, but **its whole history stays simulated and calibrated** — useful
|
||||
for a valerate → enanthate transition; **grouped at the very bottom** of the
|
||||
Treatments page under a dedicated header, dimmed cards (v1.11.0);
|
||||
**no future projection anymore** (v1.12.0 — its slots disappear from the
|
||||
Forecast chart)
|
||||
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
|
||||
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
|
||||
**interval in days between doses** displayed; **route icon per dose row**
|
||||
(oral / IM-SC injection / gel / patch, v1.12.0) and **same-day blood test
|
||||
markers** (🧪↑ before the dose, 🧪↓ after)
|
||||
- **Blood tests**: **E2 + T in a single entry** (each optional), displayed
|
||||
**side by side** when they share the same date/time, editable (tap → pick the
|
||||
entry); T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Suggested next blood draw (v1.8.0, extended v1.8.1 to the Labs and Home pages)**:
|
||||
the Labs page recommends the **estimated trough just before the next injection**
|
||||
(the most comparable moment), **at the first trough where your regimen is
|
||||
stabilized** (~5 half-lives after your last change — dose, ester or interval) —
|
||||
derived from the curves and the Dosage interval; requires an active injectable
|
||||
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
|
||||
medical advice;
|
||||
**v1.13.0**: the card shows the **expected E2 at that trough** (when
|
||||
auto-calibration is ON) and compares it to your personal **trough target**
|
||||
(opt-in — distinct from the alert thresholds, never notifies)
|
||||
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
|
||||
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
|
||||
automatic calibration** (option, off by default) which calibrates **each ester
|
||||
with the labs of its own period** (valerate labs → valerate doses, enanthate
|
||||
labs → enanthate doses) and recalibrates the T model
|
||||
- **Empirical T estimation** `T = floor + (base − floor) ÷ (1 + k·E2)`, with
|
||||
**k calibrated per ester period** (T suppression differs valerate vs enanthate),
|
||||
against the already-calibrated E2 — accepted T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
|
||||
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
|
||||
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
|
||||
injected every 7 days only rings on injection day, not every day; **a dose
|
||||
already logged on the day skips the notification** (v1.10.0 — the next
|
||||
reminder resumes at the following slot)
|
||||
- **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low
|
||||
limits in Settings → warning card on the home screen + **notification every
|
||||
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
|
||||
evaluated on the **estimated** level, opt-in
|
||||
- **Full JSON backup**: treatments + doses + labs + T settings
|
||||
**+ parameters (language, auto-calibration, alert thresholds)** (v1.4.2)
|
||||
- **Daily automatic backup (v1.7.0, opt-in)**: IN ADDITION to the manual export —
|
||||
every day, a full JSON backup (same format, directly importable) is written to
|
||||
**the folder you choose** (e.g. a synchronized Owncloud folder). No storage
|
||||
permission (SAF, folder picked once, persistent revocable permission),
|
||||
configurable retention (1–30 copies, default 7 — timestamped file per run,
|
||||
never overwritten; manual exports and foreign files **never touched**), last
|
||||
run status in Settings, first backup immediately on activation
|
||||
- **What's new on every update**: automatic changelog dialog (dismissed =
|
||||
won't show again before the next version)
|
||||
- **Time on HRT** displayed at the top of the Doses page (since the first dose);
|
||||
**next dose in days** on the home screen when it is more than 24 h away (v1.4.1)
|
||||
- **Exportable diagnostic logs** (Settings) — useful for support
|
||||
- **Calendar events**: dose reminders can create a recurring (posology) event in
|
||||
a "HormoneTrack" calendar on your phone
|
||||
- **Full JSON backup/restore** (treatments + doses + labs + T settings + parameters, v1.4.2)
|
||||
- **FR + EN** (per-app language, independent of the system)
|
||||
- Jetpack Compose UI (BOM 2026.08, Material You); 100% local, no account
|
||||
|
||||
## Quick start (building from source)
|
||||
|
||||
Prerequisites: JDK 17+ (Java 21 OK), Android SDK (platform 37 will be
|
||||
auto-downloaded by AGP if the licenses are signed). The wrapper downloads
|
||||
Gradle 9.7.1.
|
||||
|
||||
```bash
|
||||
git clone <repo> && cd HormoneTrack
|
||||
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
||||
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
|
||||
./gradlew testDebugUnitTest # 284 tests (250 without local test data)
|
||||
./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required)
|
||||
./gradlew lint # clean lint required before a release
|
||||
```
|
||||
|
||||
Installing on a phone: developer mode + USB debugging, then Android Studio
|
||||
(**Run ▶️**) or `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
|
||||
No Play Store: the app is sideloaded. Step-by-step details (French):
|
||||
[docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
|
||||
|
||||
## Git
|
||||
|
||||
Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) —
|
||||
`Siphonight/HormoneTrack` on both (private), with **tagged releases**
|
||||
(`v1.1.0` → `v1.10.0`) and **two APKs per release** (since v1.2.5)
|
||||
(downloadable without building, cf
|
||||
[docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)):
|
||||
`-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB):
|
||||
|
||||
```bash
|
||||
git tag # list releases
|
||||
git log --oneline # layer-by-layer history (toolchain / engine / UI / docs)
|
||||
git push -u origin main --tags
|
||||
```
|
||||
|
||||
**Web port**: since v1.4.10, the browser version lives in its own repository
|
||||
`Siphonight/HormoneTrack-web` (same instances) — **aligned versions**
|
||||
(web v1.4.10 = port of Android v1.4.10), synced releases (checklist §16 step
|
||||
4.bis), separate changelogs.
|
||||
|
||||
Every release commit passes `./gradlew testDebugUnitTest` (green required) and
|
||||
is an annotated tag. See [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||
|
||||
**Release signing**: the release APK is signed with the debug key as long as
|
||||
no `keystore.properties` exists at the repo root; once created via
|
||||
`scripts/make-release-keystore.sh` (both files gitignored), `assembleRelease`
|
||||
signs with that keystore — phone migration documented in
|
||||
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||
|
||||
## The models in short
|
||||
|
||||
Each injection contributes `dose_mg × profile(dt)` where `profile` is the
|
||||
normalized response (pg/mL per mg); contributions superimpose. **Three
|
||||
superimposable models**:
|
||||
|
||||
- **Estrannaise** = hourly tables from the `.ods` (8001 h), EV/EU/EEn
|
||||
- **Transfem Science** = 3-compartment meta-analysis (V3C, closed form,
|
||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
|
||||
- **WHSAH** (v1.4.6) = "license-free" fit of the [WHSAH Collective via
|
||||
Mona](https://github.com/mona-hrt/mona) — same mathematical family but
|
||||
independent parameters with explicit bioavailability F < 1: faster rise
|
||||
at D+1 (EEn ~70 pg/mL at D+1 for 5 mg vs ~22 for TFS) and longer decay
|
||||
(EEn t½ 7.3 d vs 4.5 d). 6 esters (no PEP)
|
||||
|
||||
Reference peaks (pg/mL per mg):
|
||||
|
||||
| Profile | Model | Peak (pg/mL/mg) | Tmax | Terminal t½ |
|
||||
|----------|------------------|-----------------|--------|-------------|
|
||||
| EV | Estrannaise | 61.1 | ~45 h | — |
|
||||
| EU | Estrannaise | 3.4 | ~55 h (long plateau) | — |
|
||||
| EEn | Estrannaise | 31.4 | ~152 h | — |
|
||||
| EV | Transfem Science | 59.0 | ~51 h | 3.0 d |
|
||||
| EU | Transfem Science | 10.1 | ~198 h | — |
|
||||
| EEn | Transfem Science | 32.0 | ~156 h | 4.5 d |
|
||||
| EB | Transfem Science | 194.2 | ~16 h | 1.2 d |
|
||||
| EC (oil) | Transfem Science | 31.1 | ~103 h | 6.7 d |
|
||||
| EC (susp.) | Transfem Science | 48.2 | ~29 h | 5.1 d |
|
||||
| PEP | Transfem Science | 1.03 (~6.5× dose) | ~18 d | 28.4 d |
|
||||
| EV | WHSAH | 73.5 | ~41 h | 3.1 d |
|
||||
| EEn | WHSAH | 37.6 | ~120 h | 7.3 d |
|
||||
| EB | WHSAH | 260.1 | ~12 h | 1.3 d |
|
||||
| EC (oil) | WHSAH | 25.0 | ~81 h | 7.9 d |
|
||||
| EC (susp.) | WHSAH | 53.5 | ~16 h | 7.1 d |
|
||||
| EU | WHSAH | 4.9 | ~67 h | 31.7 d |
|
||||
|
||||
**Calibration (v1.4.8)**: the scale factor applies per ESTER and per
|
||||
injection PERIOD (median of lab ÷ prediction ratios, like the original
|
||||
spreadsheet's "Scale factor" column) — and per MODEL: every displayed curve
|
||||
(Estrannaise / Transfem Science / WHSAH) is calibrated with ITS OWN model's
|
||||
prediction → they all stick to your labs, whatever their shape.
|
||||
Auto-calibration is optional (off by default).
|
||||
|
||||
## Privacy
|
||||
|
||||
- **The app is 100% local**: Room database on the phone, no server, no
|
||||
telemetry; Gadgetbridge-compatible (FOSS) with no Huawei Health dependency
|
||||
- **The repository contains no health data**: code and docs are generic; the
|
||||
regression tests that use real exports load their data from
|
||||
`local-test-data/` (**gitignored**, outside the repo — and the history was
|
||||
cleaned before the first push, cf
|
||||
[docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
|
||||
- Backups = JSON files you store wherever you want (Owncloud, etc.)
|
||||
- **The daily auto-backup (v1.7.0) only writes to the folder YOU picked
|
||||
yourself** (system SAF picker, revocable permission) — nothing ever leaves
|
||||
the phone, no server
|
||||
- `allowBackup=false` (sensitive data); biometric lock planned for Phase 2
|
||||
- The repository is **private**: release APKs download while logged in;
|
||||
making the repo public would make the APKs downloadable without an account
|
||||
(no data risk, see above)
|
||||
|
||||
## Repository structure
|
||||
|
||||
```
|
||||
HormoneTrack/
|
||||
├── README.md ← this file's French twin
|
||||
├── docs/
|
||||
│ ├── GUIDE_INSTALLATION.md user guide (phone + watch, French)
|
||||
│ ├── DEVELOPPEMENT.md full dev docs (architecture, math, bugs, tests, French)
|
||||
│ ├── CHANGELOG.md detailed version history (French)
|
||||
│ └── MONTRE-GADGETBRIDGE.md Huawei GT 3 watch: options + limits (French)
|
||||
├── scripts/
|
||||
│ ├── gitea-release.py publishes a release body (CHANGELOG + APK)
|
||||
│ ├── publish-release.py publishes a tag's 2 APKs (verified by download)
|
||||
│ ├── seed-emulator.py injects a JSON backup into an emulator DB
|
||||
│ └── make-release-keystore.sh generates the release keystore + properties (v1.9.8)
|
||||
├── local-test-data/ ← gitignored: real backups for regression
|
||||
│ tests (NEVER in the repo, cf §8.bis)
|
||||
├── keystore.properties ← gitignored: release signing (optional, §16.quater)
|
||||
├── build.gradle.kts root Gradle config (AGP/Kotlin/KSP pinned)
|
||||
├── settings.gradle.kts
|
||||
├── gradle.properties
|
||||
├── gradle/wrapper/ Gradle 9.7.1 wrapper (jar + properties)
|
||||
├── gradlew / gradlew.bat
|
||||
└── app/
|
||||
├── build.gradle.kts dependencies (Compose, Room, DataStore, Gson…)
|
||||
├── proguard-rules.pro
|
||||
└── src/
|
||||
├── main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── assets/pk_profiles.json ← hourly tables (Estrannaise/TFS)
|
||||
│ ├── java/com/hormonetrack/
|
||||
│ │ ├── data/ (Room: models, DAOs, repository, backup)
|
||||
│ │ ├── pk/ (PK engine + profiles: Estrannaise/TFS/WHSAH, alerts, export)
|
||||
│ │ ├── reminder/ (exact alarms, notifs + actions, boot, alert worker)
|
||||
│ │ ├── settings/ (DataStore: TConfig, language)
|
||||
│ │ ├── ui/ (Compose: screens, components, theme)
|
||||
│ │ │ └── screens/settings/ ← Settings cards (v1.9.8 split)
|
||||
│ │ ├── HormoneTrackApp.kt
|
||||
│ │ └── MainActivity.kt
|
||||
│ └── res/ (FR/EN strings, theme, icons)
|
||||
├── test/java/com/hormonetrack/ ← JVM unit tests
|
||||
│ ├── pk/ (engine, ESE/TFS/WHSAH profiles, per-model
|
||||
│ │ calibration, reminders, data-driven regressions)
|
||||
│ ├── data/backup/ (Gson round-trip + parameters)
|
||||
│ ├── ui/ (chart window, source guard)
|
||||
│ ├── util/ (export file names, durations)
|
||||
│ └── reminder/ + settings/ (calendar RRULE, changelog)
|
||||
└── androidTest/java/com/hormonetrack/ ← Compose UI tests (v1.9.8)
|
||||
└── uitest/ (navigation, changelog dialog, settings)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] v1: E2/T curves, dose log, labs + calibration, reminders, JSON backup, FR/EN
|
||||
- [x] Compose UI tests (13) + dedicated release signing infrastructure (v1.9.8)
|
||||
- [ ] Biometric lock, widget, CSV export
|
||||
- [ ] Watch Phase 2: custom watchface and/or Lite Wearable mini-app (see
|
||||
[docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||
|
||||
## License
|
||||
|
||||
**GPL-3.0** — see [LICENSE](LICENSE). Consistent with the Gadgetbridge
|
||||
ecosystem. The PK models belong to their respective authors
|
||||
([Estrannaise](https://estrannaise.github.io/),
|
||||
[Transfem Science](https://transfemscience.org)).
|
||||
91
README.md
91
README.md
@ -1,5 +1,7 @@
|
||||
# HormoneTrack
|
||||
|
||||
> **Langue** : Français (ce fichier) · [English](README.en.md)
|
||||
|
||||
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
||||
> La contribution humaine a été **essentielle** : feedback continu, retours
|
||||
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
||||
@ -16,11 +18,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||
> sang et aux consignes de ton endocrinologue.
|
||||
|
||||
- **Statut** : v1.4.9 — build Android ✅, **lint vert** ✅, **168 tests unitaires** ✅ (146 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||
- **Statut** : v1.13.0 — build Android ✅, **lint vert** ✅, **284 tests unitaires** ✅ (250 sans les données de test locales ; 7 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
||||
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
||||
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
||||
- **README anglais** : [README.en.md](README.en.md)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
@ -39,6 +42,22 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés
|
||||
selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré
|
||||
séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil)
|
||||
- **« Tracé labs » (v1.5.0, prolongé en v1.6.0)** : la courbe ancrée sur tes
|
||||
prises de sang (chip `Tracé labs`, off par défaut) — la forme du modèle PK
|
||||
entre les labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque
|
||||
lab E2 ; transition log-linéaire d'un lab au suivant, garde des labs hors
|
||||
fenêtre d'action (#61) ; **chip `Prolonger` (v1.6.0)** : la courbe se
|
||||
prolonge au-delà du dernier lab (modèle × ratio du dernier lab, horizon =
|
||||
extinction du modèle, partie estimée dessinée atténuée + légende dédiée) —
|
||||
la courbe est une référence de COMPARAISON, jamais une estimation d'action
|
||||
(elle n'entre jamais dans l'accueil/les alertes)
|
||||
- **Modèle Estrannaise ANALYTIQUE (v1.9.0)** : le modèle ESE utilise désormais
|
||||
la forme close 3C publiée par estrannaise.js (fidélité aux anciennes tables
|
||||
épinglée RMS 0) — **nuage d'incertitude MCMC** (chip `Nuage`, exclusif ESE :
|
||||
32 courbes du posterior montrant la plage d'imprécision, comme sur le site
|
||||
estrannaise) ; **6 esters injectables** (dont EUCS, exclusif) ; l'ODS
|
||||
historique n'est plus utilisé au runtime (fidélité = tests uniquement) ;
|
||||
t½ terminale analytique (recommandation de prise de sang)
|
||||
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
|
||||
patch et voie orale
|
||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
||||
@ -48,13 +67,25 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
vers la gauche, jusqu'à **1 an**
|
||||
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
|
||||
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
||||
**intervalle en jours entre dosages** affiché
|
||||
**intervalle en jours entre dosages** affiché ; **icône de forme de prise**
|
||||
(oral / injection IM-SC / gel / patch, v1.12.0) et **encart « prise de sang
|
||||
le même jour »** (🧪↑ avant la dose, 🧪↓ après)
|
||||
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
|
||||
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
|
||||
utile pour une transition valerate → enanthate
|
||||
utile pour une transition valerate → enanthate ; **regroupés tout en bas** de la
|
||||
page Traitements sous un en-tête dédié, cartes atténuées (v1.11.0)
|
||||
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
|
||||
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
|
||||
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Prochaine prise de sang suggérée (v1.8.0, étendu v1.8.1 aux pages Analyses et Accueil)** : la page Analyses recommande le
|
||||
**creux estimé juste avant l'injection suivante** (moment le plus comparable),
|
||||
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
|
||||
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
|
||||
injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
|
||||
estimations, jamais un avis médical ;
|
||||
**v1.13.0** : la carte affiche la **valeur E2 attendue au creux** (si l'auto-calibration
|
||||
est ON) et la compare à ta **cible de creux** personnelle (opt-in — distinct des
|
||||
seuils d'alerte, jamais de notification)
|
||||
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||
@ -66,13 +97,23 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
||||
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
|
||||
7 jours ne sonne que le jour d'injection, pas tous les jours
|
||||
7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà
|
||||
loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant
|
||||
repart au créneau suivant)
|
||||
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
|
||||
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
|
||||
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
|
||||
canal dédié) — évaluées sur le taux **estimé**, opt-in
|
||||
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
|
||||
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2)
|
||||
- **Sauvegarde automatique quotidienne (v1.7.0, opt-in)** : en PLUS de
|
||||
l'export manuel — chaque jour, un backup JSON complet (même format,
|
||||
importable tel quel) est écrit dans le **dossier que tu choisis** (ex.
|
||||
dossier Owncloud synchronisé). Aucune permission de stockage (SAF, dossier
|
||||
choisi une fois, permission persistante révocable), rétention configurable
|
||||
(1–30 copies, défaut 7 — fichier horodaté par run, jamais d'écrasement ;
|
||||
exports manuels et fichiers étrangers **jamais touchés**), statut du
|
||||
dernier run dans Paramètres, premier backup immédiat à l'activation
|
||||
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
|
||||
(fermé = ne réapparaît pas avant la prochaine version)
|
||||
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
|
||||
@ -93,7 +134,8 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
|
||||
git clone <repo> && cd HormoneTrack
|
||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||
./gradlew testDebugUnitTest # 168 tests (146 sans les données locales)
|
||||
./gradlew testDebugUnitTest # 284 tests (250 sans les données locales)
|
||||
./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
|
||||
./gradlew lint # lint vert obligatoire avant release
|
||||
```
|
||||
|
||||
@ -105,9 +147,9 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
|
||||
|
||||
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
||||
**releases taguées** (`v1.1.0` → `v1.4.9`) et **deux APK par release** (depuis v1.2.5)
|
||||
**releases taguées** (`v1.1.0` → `v1.10.0`) et **deux APK par release** (depuis v1.2.5)
|
||||
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
||||
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
||||
`-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) :
|
||||
|
||||
```bash
|
||||
git tag # lister les releases
|
||||
@ -115,9 +157,20 @@ git log --oneline # historique par couches (toolchain / moteur / UI / docs)
|
||||
git push -u origin main --tags
|
||||
```
|
||||
|
||||
**Portage web** : depuis v1.4.10, la version navigateur vit dans son propre
|
||||
dépôt `Siphonight/HormoneTrack-web` (mêmes instances) — versions **alignées**
|
||||
(web v1.4.10 = portage de l'Android v1.4.10), releases sync (checklist
|
||||
§16 étape 4.bis), changelogs séparés.
|
||||
|
||||
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
||||
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||
|
||||
**Signature release** : l'APK release est signé avec la clé debug tant qu'aucun
|
||||
`keystore.properties` n'existe à la racine ; dès sa création via
|
||||
`scripts/make-release-keystore.sh` (fichiers gitignorés), `assembleRelease`
|
||||
signe avec ce keystore — migration téléphone documentée dans
|
||||
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||
|
||||
## Les modèles en bref
|
||||
|
||||
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
||||
@ -170,6 +223,9 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
||||
depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été
|
||||
nettoyé avant le premier push, cf [docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
|
||||
- Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.)
|
||||
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
|
||||
toi-même choisi** (sélecteur système SAF, permission révocable) — rien
|
||||
ne part ailleurs, aucun serveur
|
||||
- `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2
|
||||
- Le dépôt est **privé** : les releases APK se téléchargent en étant connecté ;
|
||||
passer le dépôt en public rend les APK téléchargeables sans compte (sans risque
|
||||
@ -179,7 +235,7 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
||||
|
||||
```
|
||||
HormoneTrack/
|
||||
├── README.md ← ce fichier
|
||||
├── README.md ← ce fichier (README.en.md = version anglaise)
|
||||
├── docs/
|
||||
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
||||
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
||||
@ -188,9 +244,11 @@ HormoneTrack/
|
||||
├── scripts/
|
||||
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
||||
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
|
||||
│ └── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
||||
│ ├── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
||||
│ └── make-release-keystore.sh génère le keystore de signature release (v1.9.8, §16.quater)
|
||||
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
||||
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
||||
├── keystore.properties ← gitignoré : signature release (optionnel, §16.quater)
|
||||
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
||||
├── settings.gradle.kts
|
||||
├── gradle.properties
|
||||
@ -209,18 +267,25 @@ HormoneTrack/
|
||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
|
||||
│ │ ├── HormoneTrackApp.kt
|
||||
│ │ └── MainActivity.kt
|
||||
│ └── res/ (strings FR/EN, thème, icônes)
|
||||
└── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||
├── pk/ (moteur + profils)
|
||||
└── data/backup/ (round-trip Gson)
|
||||
├── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||
│ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
||||
│ │ par modèle, rappels, régressions data-driven)
|
||||
│ ├── data/backup/ (round-trip Gson + paramètres)
|
||||
│ ├── ui/ (fenêtre graphique, garde de source)
|
||||
│ ├── util/ (noms de fichiers d'export, durées)
|
||||
│ └── reminder/ + settings/ (RRULE agenda, changelog)
|
||||
└── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8)
|
||||
└── uitest/ (navigation, dialog changelog, Paramètres)
|
||||
```
|
||||
|
||||
## Feuille de route
|
||||
|
||||
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
||||
- [ ] Tests UI Compose + compilation release signée
|
||||
- [x] Tests UI Compose (13) + infrastructure de compilation release signée (v1.9.8)
|
||||
- [ ] Verrou biométrique, widget, export CSV
|
||||
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
// ── Signature RELEASE (v1.9.8) ──────────────────────────────────────────────
|
||||
// keystore.properties (RACINE du dépôt, GITIGNORÉ) décrit le vrai keystore :
|
||||
// storeFile=release.keystore (chemin relatif à la racine du dépôt)
|
||||
// storePassword=…
|
||||
// keyAlias=…
|
||||
// keyPassword=…
|
||||
// Tant que ce fichier est ABSENT, la build release reste signée avec la clé
|
||||
// DEBUG (comportement historique : installation par-dessus l'APK debug sans
|
||||
// perte de données, cf §16.quater). Dès qu'il existe (créé par
|
||||
// scripts/make-release-keystore.sh), la build release l'utilise — ⚠️ signature
|
||||
// différente ⇒ migration obligatoire : export JSON → désinstallation →
|
||||
// réinstallation → réimport (documenté §16.quater).
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
|
||||
android {
|
||||
namespace = "com.hormonetrack"
|
||||
compileSdk = 37
|
||||
@ -17,8 +33,8 @@ android {
|
||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||
versionCode = 29
|
||||
versionName = "1.4.9"
|
||||
versionCode = 50
|
||||
versionName = "1.13.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -35,12 +51,27 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste
|
||||
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
|
||||
// sans désinstallation ni perte de données, tout en bénéficiant de
|
||||
// R8. Si un jour on distribue largement : générer un vrai keystore
|
||||
// (signature différente → migration backup→désinstall→réimport).
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Signature : VRAI keystore si keystore.properties existe (racine du
|
||||
// dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ;
|
||||
// sinon CLÉ DEBUG volontairement : l'APK release reste installable
|
||||
// PAR-DESSUS l'APK debug déjà installé (même signature), sans
|
||||
// désinstallation ni perte de données, tout en bénéficiant de R8.
|
||||
// ⚠️ Passer au vrai keystore CHANGE la signature : migration
|
||||
// backup→désinstall→réimport requise sur le téléphone (§16.quater).
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
val props = Properties().apply {
|
||||
keystorePropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
signingConfigs.create("release") {
|
||||
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||
storePassword = props.getProperty("storePassword")
|
||||
keyAlias = props.getProperty("keyAlias")
|
||||
keyPassword = props.getProperty("keyPassword")
|
||||
}
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,4 +160,16 @@ dependencies {
|
||||
|
||||
// Tests unitaires JVM
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
// ── Tests UI Compose (v1.9.8, androidTest — cf docs/DEVELOPPEMENT.md §8.ter) ──
|
||||
// Exécution : ./gradlew connectedDebugAndroidTest (émulateur/appareil requis).
|
||||
// Le BOM est réappliqué sur la configuration androidTest pour épinner les
|
||||
// mêmes versions que le runtime Compose de l'app.
|
||||
androidTestImplementation(composeBom)
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
|
||||
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
|
||||
androidTestImplementation("junit:junit:4.13.2")
|
||||
// UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest)
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||
}
|
||||
|
||||
21
app/proguard-rules.pro
vendored
21
app/proguard-rules.pro
vendored
@ -15,3 +15,24 @@
|
||||
|
||||
# Énumérations : converties en String par Room et Gson (par nom)
|
||||
-keepclassmembers enum com.hormonetrack.data.model.** { *; }
|
||||
|
||||
# --- Moteur PK : « Tracé labs » (v1.6.0, bug #64 — trouvé en émulateur) ---
|
||||
# Symptôme : en RELEASE SEULE, la courbe « Tracé labs » restait vide (ni
|
||||
# dessinée ni légendée) alors que le même calcul fonctionne en debug et
|
||||
# donne 276 points sur les mêmes données (régression web-miroir). Cause
|
||||
# Prouvée au mapping R8 : `LabTrajectoryModel -> R8$$REMOVED$$CLASS$$...` —
|
||||
# R8 full mode a SUPPRIMÉ la classe en inlinant computeLabAnchoredCurve
|
||||
# (et son synthétique $default des paramètres par défaut) dans le call
|
||||
# site du producer Compose ; le résultat n'y était plus correct. FIX :
|
||||
# garder la classe ENTIÈRE — c'est le cœur scientifique de l'app (fonctions
|
||||
# pures, zéro réflexion) : coût = quelques Ko de dex non compactés.
|
||||
# ⚠️ Toute nouvelle fonctionnalité du pk/ qui « ne marche qu'en debug » :
|
||||
# vérifier le mapping (`app/build/outputs/mapping/release/mapping.txt`)
|
||||
# et étendre cette garde si besoin.
|
||||
-keep class com.hormonetrack.pk.LabTrajectoryModel { *; }
|
||||
|
||||
# --- Auto-backup (v1.7.0) : worker instancié par WorkManager via réflexion ---
|
||||
# Les règles consumer de WorkManager gardent normalement les sous-classes de
|
||||
# ListenableWorker, mais le leçon #64 a montré le coût d'une garde absente :
|
||||
# on l'écrit explicitement (coût = quelques entrées de classe).
|
||||
-keep class com.hormonetrack.data.backup.AutoBackupWorker { *; }
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hormonetrack.BuildConfig
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI du dialog « Nouveautés » (v1.9.8) — épinglé sur les comportements
|
||||
* historiques des fixes v1.3.2/v1.3.3 (docs/DEVELOPPEMENT.md §14) :
|
||||
* - affiché quand la version installée est plus récente que la dernière vue ;
|
||||
* - PAS affiché quand la version courante a déjà été vue ;
|
||||
* - une fois FERMÉ, il ne RÉAPPARAÎT PAS au redémarrage (la version vue est
|
||||
* mémorisée AVANT l'affichage — sinon le même changelog ressortait à
|
||||
* chaque réouverture de l'app, bug v1.3.2).
|
||||
*
|
||||
* ⚠️ Technique : ces tests lancent l'activité MANUELLEMENT (ActivityScenario
|
||||
* + règle Compose vide) et non via createAndroidComposeRule, car l'état
|
||||
* DataStore (changelogSeenVersion) doit être réglé AVANT que
|
||||
* HormoneTrackRoot ne lise la version vue dans son LaunchedEffect de
|
||||
* démarrage. La règle automatique lancerait l'activité trop tôt (avant le
|
||||
* @Before du test) et le dialog du test précédent fausserait l'état.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChangelogDialogTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
private fun res(id: Int, vararg args: Any): String =
|
||||
UiTestEnv.targetContext.getString(id, *args)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dialogShownWhenInstalledVersionIsNewerThanLastSeen() {
|
||||
runBlocking {
|
||||
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||
}
|
||||
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dialogAbsentWhenVersionAlreadySeen() {
|
||||
runBlocking {
|
||||
UiTestEnv.app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||
}
|
||||
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dialogDoesNotReappearAfterDismissAndRestart() {
|
||||
runBlocking {
|
||||
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||
}
|
||||
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertIsDisplayed()
|
||||
// Fermeture par le bouton « OK » (identique FR/EN)
|
||||
composeRule.onNodeWithText(res(R.string.ok)).performClick()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertDoesNotExist()
|
||||
// Redémarrage de l'activité : le dialog ne doit PAS revenir — la
|
||||
// version vue a été persistée AVANT l'affichage (fix v1.3.2).
|
||||
scenario!!.recreate()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() {
|
||||
// Permission de notifications uniquement : le changelog est réglé
|
||||
// PAR TEST (avant chaque lancement manuel d'activité).
|
||||
UiTestEnv.grantPostNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import org.junit.After
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI de la demande « logger une dose » venue d'une NOTIFICATION
|
||||
* (v1.10.0, **fix #69**) : le dialog s'ouvre UNE FOIS au lancement avec les
|
||||
* extras `open_log_dose`/`treatment_id` (même contrat que ReminderReceiver,
|
||||
* cf reminder/ReminderManager.kt), puis — une fois fermé — ne se rouvre
|
||||
* JAMAIS tout seul. Comportement bogué épinglé : chaque retour sur l'onglet
|
||||
* Accueil rouvrait le dialog indéfiniment (le paramètre d'intent vivait au
|
||||
* niveau activité alors que la consommation vivait dans les `remember` de
|
||||
* Home, détruits à chaque changement d'onglet).
|
||||
*
|
||||
* Lancement MANUEL avec intent (règle Compose vide + ActivityScenario) : le
|
||||
* contrat du fix exige de régler l'état AVANT que la composition ne lise
|
||||
* l'intent — et l'overload `createAndroidComposeRule(intent)` n'existe pas
|
||||
* dans la ligne Compose 1.12. La fermeture passe par BACK (onDismissRequest,
|
||||
* indépendant des libellés) ; le titre `add_dose` (« Log a dose ») est
|
||||
* affiché dans les DEUX variantes du dialog (avec ou sans traitements en
|
||||
* base) : le test reste data-indépendant.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LogDoseRequestTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doseDialogOpensOnceAndNeverReopensAfterTabSwitch() {
|
||||
val intent = Intent(UiTestEnv.targetContext, MainActivity::class.java).apply {
|
||||
putExtra("open_log_dose", true)
|
||||
putExtra("treatment_id", 1L)
|
||||
}
|
||||
scenario = ActivityScenario.launch(intent)
|
||||
|
||||
val dialogTitle = res(R.string.add_dose)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(dialogTitle).assertIsDisplayed()
|
||||
|
||||
// Fermeture du dialog (BACK → onDismissRequest)
|
||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||
|
||||
// Changer d'onglet puis revenir sur l'accueil : le dialog NE doit
|
||||
// PAS se rouvrir (demande consommée une fois pour toutes)
|
||||
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() = UiTestEnv.prepare()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI de NAVIGATION (v1.9.8) : la barre d'onglets mène bien à chaque
|
||||
* écran, et les Paramètres s'ouvrent depuis l'icône de l'accueil.
|
||||
*
|
||||
* Les preuves de navigation utilisent des éléments **inconditionnels**
|
||||
* (indépendants des données Room, contrairement au contenu des listes) :
|
||||
* - Graphiques → le chip de plage « 24 h » (toujours affiché) ;
|
||||
* - Doses / Analyses / Traitements → leur FloatingActionButton « + »
|
||||
* (contentDescription add_dose / add_lab / add_treatment) ;
|
||||
* - Paramètres → le titre de l'écran + les sections toujours présentes.
|
||||
*
|
||||
* Textes résolus via targetContext (cf UiTestEnv) : indépendant de la langue
|
||||
* système et du réglage in-app FR/EN.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NavigationTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||
|
||||
@Test
|
||||
fun bottomBarShowsAllFiveTabs() {
|
||||
listOf(
|
||||
R.string.nav_home, R.string.nav_chart, R.string.nav_doses,
|
||||
R.string.nav_labs, R.string.nav_treatments
|
||||
).forEach { labelRes ->
|
||||
composeRule.onNodeWithText(res(labelRes)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeTabShowsAppTitle() {
|
||||
// Retour explicite à l'accueil d'abord : l'état sauvegardé de la
|
||||
// navigation peut restaurer l'onglet du test précédent.
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.app_name)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chartTabShowsRangeChips() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_chart)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.chart_24h)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(res(R.string.chart_7j)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(res(R.string.chart_30j)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dosesTabShowsLogDoseFab() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.add_dose))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labsTabShowsAddLabFab() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_labs)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.add_lab))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun treatmentsTabShowsAddTreatmentFab() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_treatments)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.add_treatment))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsOpensFromHomeGearIcon() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||
// Titre de l'écran + bouton retour (TopAppBar des Paramètres)
|
||||
composeRule.onNodeWithText(res(R.string.settings)).assertIsDisplayed()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.back))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() = UiTestEnv.prepare()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI de l'écran Paramètres (v1.9.8) : chaque SECTION est présente après
|
||||
* l'ouverture depuis l'accueil. Les sections profondes (sauvegarde, logs,
|
||||
* à propos) vivent SOUS le pli de l'écran scrollable → `assertExists`
|
||||
* (présence dans la hiérarchie) et non `assertIsDisplayed` (visibilité à
|
||||
* l'écran) : ce test vérifie la STRUCTURE, pas la mise en page.
|
||||
*
|
||||
* Les chips de langue « Français » / « English » sont des LITTÉRAUX dans le
|
||||
* code (chaque langue s'écrit dans sa propre langue — convention UX i18n) :
|
||||
* l'assertion ne dépend d'aucune ressource.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SettingsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||
|
||||
private fun openSettings() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun languageSectionShowsLocalizedChips() {
|
||||
openSettings()
|
||||
composeRule.onNodeWithText(res(R.string.language)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Français").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("English").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allSettingsSectionsArePresent() {
|
||||
openSettings()
|
||||
listOf(
|
||||
R.string.auto_calibrate, // Calibration automatique
|
||||
R.string.t_model_title, // Estimation T
|
||||
R.string.alert_section, // Seuils d'alerte
|
||||
R.string.timezone_section, // Fuseau du graphique
|
||||
R.string.reminders_section, // Rappels / alarmes exactes
|
||||
R.string.backup_section, // Sauvegarde JSON manuelle
|
||||
R.string.autobackup_section, // Auto-backup quotidien
|
||||
R.string.logs_section, // Logs de diagnostic
|
||||
R.string.about_title // À propos / version
|
||||
).forEach { sectionRes ->
|
||||
composeRule.onNodeWithText(res(sectionRes)).assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboutSectionShowsInstalledVersion() {
|
||||
openSettings()
|
||||
// « Version : X.Y.Z » — la valeur vient de BuildConfig.VERSION_NAME
|
||||
// (épinglé : les tags v1.3.0→v1.3.2 embarquaient un versionName
|
||||
// jamais bumpé, bug §14 #46 — ce test l'attraperait désormais).
|
||||
val expected = UiTestEnv.targetContext.getString(
|
||||
R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME
|
||||
)
|
||||
composeRule.onNodeWithText(expected).assertExists()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() = UiTestEnv.prepare()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import android.os.Build
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hormonetrack.BuildConfig
|
||||
import com.hormonetrack.HormoneTrackApp
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Environnement partagé des tests UI Compose (v1.9.8, cf docs/DEVELOPPEMENT.md
|
||||
* §8.ter). Les tests instrumentés tournent CONTRE l'app réelle installée :
|
||||
* ils doivent donc être **déterministes quel que soit l'état de l'appareil**
|
||||
* (données Room seedées ou non, locale système, version précédemment vue).
|
||||
*
|
||||
* Deux prérequis réglés ici AVANT le lancement de l'activité :
|
||||
* - **permission POST_NOTIFICATIONS** : MainActivity la demande au premier
|
||||
* lancement (API 33+). Sans pré-grant, le dialog SYSTÈME s'afficherait par
|
||||
* dessus l'app et les assertions Compose ne voient que la fenêtre de
|
||||
* l'app → timeout. Le grant shell (`pm grant`) est persistant.
|
||||
* - **dialog « Nouveautés » neutralisé** : HormoneTrackRoot affiche le
|
||||
* changelog au lancement quand la version installée est plus récente que
|
||||
* la dernière vue — un dialog MODAL qui masquerait toute la navigation.
|
||||
* En marquant la version courante comme vue, il ne s'affiche plus. Les
|
||||
* tests dédiés au dialog (ChangelogDialogTest) relancent l'activité
|
||||
* manuellement APRÈS avoir réglé leur propre état.
|
||||
*
|
||||
* Les assertions de texte passent par `targetContext.getString(...)` : la
|
||||
* ressource est résolue dans la MÊME configuration que celle de l'UI (les
|
||||
* per-app locales d'AppCompat s'appliquent à toute l'application), ce qui
|
||||
* rend les tests indépendants de la langue système OU du réglage in-app.
|
||||
*/
|
||||
object UiTestEnv {
|
||||
|
||||
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** L'Application réelle du process instrumenté (même instance que l'UI). */
|
||||
val app: HormoneTrackApp =
|
||||
targetContext.applicationContext as HormoneTrackApp
|
||||
|
||||
/** Exécute une commande shell et ATTEND sa fin (lecture jusqu'à EOF). */
|
||||
fun runShellCommand(command: String) {
|
||||
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
automation.executeShellCommand(command).use { pfd ->
|
||||
java.io.FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Pré-grante la permission de notifications (idempotent, API 33+). */
|
||||
fun grantPostNotifications() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
runShellCommand("pm grant com.hormonetrack android.permission.POST_NOTIFICATIONS")
|
||||
}
|
||||
}
|
||||
|
||||
/** Marque le changelog de la version courante comme déjà vu. */
|
||||
fun markChangelogSeen() = runBlocking {
|
||||
app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
/** À appeler dans le @BeforeClass de chaque classe de test UI. */
|
||||
fun prepare() {
|
||||
grantPostNotifications()
|
||||
markChangelogSeen()
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,13 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- launchMode singleTop (v1.10.1) : un tap sur la notification de
|
||||
rappel quand l'app est DÉJÀ ouverte délivre onNewIntent (avec les
|
||||
extras open_log_dose/treatment_id) SANS détruire/recréer
|
||||
l'activité — l'ancien comportement (standard + CLEAR_TOP)
|
||||
recréait tout, et onNewIntent était du code mort. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
1
app/src/main/assets/mcmc_samples.json
Normal file
1
app/src/main/assets/mcmc_samples.json
Normal file
File diff suppressed because one or more lines are too long
@ -4,11 +4,12 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.hormonetrack.data.AppDatabase
|
||||
import com.hormonetrack.data.repository.HormoneRepository
|
||||
import com.hormonetrack.pk.PKProfileStore
|
||||
import com.hormonetrack.pk.EstrannaiseModels
|
||||
import com.hormonetrack.reminder.createNotificationChannel
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.reminder.AlertNotifier
|
||||
import com.hormonetrack.settings.AppSettings
|
||||
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||
|
||||
class HormoneTrackApp : Application() {
|
||||
|
||||
@ -18,7 +19,10 @@ class HormoneTrackApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
PKProfileStore.init(this)
|
||||
// v1.9.0 : les tables ODS ne sont plus chargées au démarrage (le
|
||||
// runtime ne lit que des sources analytiques). L'asset MCMC
|
||||
// (nuage d'incertitude ESE) les remplace (≈ 48 Ko).
|
||||
EstrannaiseModels.init(this)
|
||||
createNotificationChannel(this)
|
||||
AppLog.init(this)
|
||||
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
|
||||
@ -27,6 +31,11 @@ class HormoneTrackApp : Application() {
|
||||
// démarrage ne change rien ; WorkManager survit aux reboots.
|
||||
AlertNotifier.createChannel(this)
|
||||
AlertNotifier.schedulePeriodic(this)
|
||||
// v1.7.0 : auto-backup journalier (WorkManager périodique 24 h, KEEP —
|
||||
// le worker sort immédiatement si l'option est désactivée ; cf
|
||||
// data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots :
|
||||
// BootReceiver n'a rien à faire.
|
||||
AutoBackupScheduler.schedulePeriodic(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.hormonetrack.ui.HormoneTrackRoot
|
||||
@ -22,6 +23,17 @@ class MainActivity : AppCompatActivity() {
|
||||
private val notificationPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
/**
|
||||
* Demande « logger une dose » venue d'une NOTIFICATION (extra
|
||||
* `open_log_dose`/`treatment_id`). Détenu par l'ACTIVITÉ — survit aux
|
||||
* changements d'onglets — et consommé UNE FOIS par HomeScreen via
|
||||
* [HormoneTrackRoot] (**fix #69 v1.10.0** : l'ancien code passait le
|
||||
* paramètre d'intent tel quel à la composition ; quitter l'onglet
|
||||
* Accueil détruisait les `remember` locaux, le paramètre lui vivait au
|
||||
* niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil).
|
||||
*/
|
||||
private val logDoseRequest = mutableStateOf<Long?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val container = (application as HormoneTrackApp).container
|
||||
@ -53,15 +65,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
||||
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
||||
logDoseRequest.value = consumeLogDoseExtras(intent)
|
||||
|
||||
setContent {
|
||||
HormoneTrackTheme {
|
||||
HormoneTrackRoot(
|
||||
initialOpenLogDose = openLogDose,
|
||||
initialTreatmentId = treatmentId
|
||||
)
|
||||
HormoneTrackRoot(logDoseRequest = logDoseRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -69,5 +77,22 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
// v1.10.0 : un tap sur la notification quand l'app est DÉJÀ ouverte
|
||||
// ouvre aussi le dialog (avant : setIntent seul, la demande était
|
||||
// silencieusement perdue).
|
||||
consumeLogDoseExtras(intent)?.let { logDoseRequest.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les extras de demande de log puis les RETIRE de l'intent : un
|
||||
* recreate de l'activité (rotation, processus restauré) ne doit JAMAIS
|
||||
* rouvrir le dialog d'une notification traitée il y a longtemps.
|
||||
*/
|
||||
private fun consumeLogDoseExtras(intent: Intent?): Long? {
|
||||
val open = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
||||
val id = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
||||
intent?.removeExtra("open_log_dose")
|
||||
intent?.removeExtra("treatment_id")
|
||||
return if (open && id > 0) id else null
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
|
||||
/**
|
||||
* RÉTENTION des auto-backups journaliers (v1.7.0) — décision PURE, testée en
|
||||
* JVM ([com.hormonetrack.data.backup.AutoBackupRetentionTest]) : elle ne fait
|
||||
* AUCUN accès disque/SAF, elle ne fait que DÉCIDER quels noms supprimer.
|
||||
* L'IO (énumération et suppression SAF) vit dans
|
||||
* [BackupManager.writeAutoBackup] — même séparation décision/IO que le reste
|
||||
* du moteur.
|
||||
*
|
||||
* PRINCIPE : chaque run écrit un NOUVEAU fichier horodaté
|
||||
* (`hormonetrack-auto-20260913-1830.json`) et supprime les plus vieux
|
||||
* au-delà de « copies conservées » (défaut 7, borné 1..30 dans les
|
||||
* Paramètres). Pourquoi ne pas ÉCRASER un fichier fixe : un backup corrompu
|
||||
* (écriture interrompue, quota Owncloud plein…) écraserait la dernière
|
||||
* bonne copie — avec la rétention, les N dernières copies saines restent
|
||||
* disponibles.
|
||||
*
|
||||
* GARDES DE VIE PRIVÉE / SÉCURITÉ DE SUPPRESSION (les deux critiques ici) :
|
||||
* 1. le dossier cible est choisi par l'utilisatrice (SAF, ex. un dossier
|
||||
* Owncloud) et peut contenir SES propres fichiers → on ne supprime
|
||||
* JAMAIS un nom qui n'est pas un auto-backup reconnaissable ;
|
||||
* 2. même parmi les `hormonetrack-auto-…json`, un nom MALFORMÉ (horodatage
|
||||
* non parsable) est ignoré : jamais supprimé, jamais trié.
|
||||
*/
|
||||
object AutoBackupRetention {
|
||||
|
||||
/**
|
||||
* Décide quels fichiers supprimer pour respecter la rétention.
|
||||
*
|
||||
* @param existingNames tous les noms vus dans le dossier cible (le tri
|
||||
* d'entrée est indifférent — la fonction trie elle-même)
|
||||
* @param keepCount nombre de copies récentes À CONSERVER (borné : tout
|
||||
* `keepCount < 1` vaut 1 — sans garde, `0` supprimerait TOUT, y
|
||||
* compris la toute dernière copie)
|
||||
* @return les noms à SUPPRIMER (sous-ensemble de [existingNames]) —
|
||||
* vide quand la rétention est déjà respectée.
|
||||
*/
|
||||
fun filesToDelete(existingNames: Iterable<String>, keepCount: Int): List<String> {
|
||||
val keep = keepCount.coerceAtLeast(1)
|
||||
// Seuls les auto-backups RECONNAISSABLES participent à la rétention
|
||||
val autos = existingNames
|
||||
.mapNotNull { name -> ExportFileNames.parseAutoBackupTimestamp(name)?.let { name to it } }
|
||||
.sortedByDescending { it.second } // plus récent d'abord
|
||||
return autos.drop(keep).map { it.first }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.hormonetrack.util.AppLog
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* SAUVEGARDE AUTOMATIQUE JOURNALIÈRE (v1.7.0) — en PLUS de l'export manuel
|
||||
* (qui reste inchangé dans Paramètres). Opt-in : désactivée par défaut.
|
||||
*
|
||||
* FONCTIONNEMENT (miroir du worker des alertes, AlertNotifier/AlertWorker) :
|
||||
* - **dossier choisi UNE FOIS** : ACTION_OPEN_DOCUMENT_TREE dans Paramètres
|
||||
* → tree URI persisté (takePersistableUriPermission → la permission
|
||||
* survit au reboot, SANS aucune permission de stockage) — l'utilisatrice
|
||||
* choisit typiquement un dossier Owncloud synchronisé, le backup atterrit
|
||||
* donc dans son cloud ;
|
||||
* - **WorkManager périodique 24 h** : survit aux reboots, gère le Doze
|
||||
* (pas d'alarme exacte nécessaire — un backup journalier n'a pas besoin
|
||||
* d'une heure à la minute près) ; planifié au démarrage de l'app
|
||||
* (HormoneTrackApp.onCreate, KEEP) — le worker sort immédiatement si
|
||||
* l'option est désactivée (même pattern qu'AlertWorker sans seuil) ;
|
||||
* - **run immédiat à l'activation** (`runNow`) : l'utilisatrice voit le
|
||||
* fichier apparaître tout de suite — et c'est ce qui rend la validation
|
||||
* émulateur §16.ter triviale ;
|
||||
* - **contenu = le backup COMPLET** (`BackupManager.exportJson`, format v2 :
|
||||
* traitements + doses + labs + réglages T + paramètres) — un auto-backup
|
||||
* est importable EXACTEMENT comme un export manuel ;
|
||||
* - **rétention** : les N copies les plus récentes sont conservées (défaut
|
||||
* 7, borné 1..30) — cf [AutoBackupRetention], les exports manuels ne
|
||||
* sont JAMAIS supprimés ;
|
||||
* - **statut visible** : « dernier : réussi/échec + date » dans Paramètres
|
||||
* (DataStore, alimenté par le worker) + journalisation AppLog complète
|
||||
* (debug à distance §9.bis — le mécanisme qui a résolu #50).
|
||||
*
|
||||
* ⚠️ R8 (leçon #64) : le worker est instancié par WorkManager via réflexion
|
||||
* → classe couverte par un `-keep` explicite dans proguard-rules.pro.
|
||||
*/
|
||||
object AutoBackupScheduler {
|
||||
|
||||
/** Nom du travail PÉRIODIQUE (unique) — 24 h. */
|
||||
private const val PERIODIC_WORK = "auto_backup_daily"
|
||||
/** Nom du travail one-time (run immédiat à l'activation). */
|
||||
private const val ONESHOT_WORK = "auto_backup_now"
|
||||
/** Période : 1 jour (WorkManager lègue au scheduler OS, Doze inclus). */
|
||||
private const val PERIOD_DAYS = 1L
|
||||
|
||||
/**
|
||||
* Planifie le travail PÉRIODIQUE — à appeler UNE FOIS dans
|
||||
* Application.onCreate. KEEP : si déjà planifié, on ne repousse pas le
|
||||
* prochain run (comme AlertNotifier.schedulePeriodic). Le worker lit ses
|
||||
* options dans DataStore à l'exécution : changer le dossier ou le nombre
|
||||
* de copies ne requiert AUCUN re-enqueue.
|
||||
*/
|
||||
fun schedulePeriodic(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
PERIODIC_WORK,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<AutoBackupWorker>(PERIOD_DAYS, TimeUnit.DAYS).build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run IMMÉDIAT (à l'activation dans Paramètres) : feedback instantané —
|
||||
* le fichier apparaît dans le dossier choisi sans attendre 24 h.
|
||||
* REPLACE : plusieurs taps rapides = un seul run en attente.
|
||||
*/
|
||||
fun runNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
ONESHOT_WORK,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<AutoBackupWorker>().build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker WorkManager de l'auto-backup : no-op si l'option est désactivée ou
|
||||
* sans dossier (planifié inconditionnellement au démarrage, comme AlertWorker
|
||||
* sans seuils) ; sinon export JSON complet + écriture SAF + rétention, et
|
||||
* statut « dernier run » persisté pour l'affichage Paramètres.
|
||||
*/
|
||||
class AutoBackupWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val container = (applicationContext as com.hormonetrack.HormoneTrackApp).container
|
||||
val settings = container.settings
|
||||
|
||||
val enabled = settings.autoBackupEnabled.first()
|
||||
val treeUri = settings.autoBackupTreeUri.first()
|
||||
if (!enabled || treeUri == null) {
|
||||
// Option off (ou dossier jamais choisi) : rien à faire, ce n'est
|
||||
// pas un échec (Result.success = le périodique continue de tourner)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val ok = try {
|
||||
// MÊME contenu que l'export manuel (format v2 avec paramètres) :
|
||||
// un auto-backup est importable tel quel (rétrocompat assurée)
|
||||
// UNE seule collecte des seuils (même motif que l'export manuel)
|
||||
val thresholds = settings.alertThresholds.first()
|
||||
val userSettings = UserSettings(
|
||||
language = settings.language.first(),
|
||||
autoCalibrate = settings.autoCalibrate.first(),
|
||||
alertE2High = thresholds.e2High,
|
||||
alertE2Low = thresholds.e2Low,
|
||||
alertTHigh = thresholds.tHigh,
|
||||
alertTLow = thresholds.tLow
|
||||
)
|
||||
val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings)
|
||||
val keep = settings.autoBackupKeep.first()
|
||||
BackupManager.writeAutoBackup(applicationContext, treeUri, json, keep)
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "ERREUR worker : ${e.message}")
|
||||
// Statut quand même enregistré (l'UI montrera « échec »)…
|
||||
settings.setAutoBackupLastRun(false, now)
|
||||
// …et WorkManager re-tentera avec son backoff (IO transitoire :
|
||||
// quota cloud, réseau Owncloud absent — un retry a du sens)
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
settings.setAutoBackupLastRun(ok, now)
|
||||
AppLog.log("autobackup", "run terminé : ok=$ok")
|
||||
// Échec d'écriture (dossier absent, permission révoquée…) : retry
|
||||
// n'a pas de sens tant que l'utilisatrice ne re-choisit pas le
|
||||
// dossier → success (le prochain cycle journalier réessaiera).
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
@ -9,6 +11,8 @@ import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.repository.HormoneRepository
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
|
||||
/**
|
||||
* Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
|
||||
@ -32,7 +36,12 @@ data class UserSettings(
|
||||
val alertE2High: Double? = null,
|
||||
val alertE2Low: Double? = null,
|
||||
val alertTHigh: Double? = null,
|
||||
val alertTLow: Double? = null
|
||||
val alertTLow: Double? = null,
|
||||
// v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la
|
||||
// rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson
|
||||
// les laisse à null → l'import ne touche pas à la cible locale.
|
||||
val troughTargetLow: Double? = null,
|
||||
val troughTargetHigh: Double? = null
|
||||
)
|
||||
|
||||
data class BackupData(
|
||||
@ -133,4 +142,98 @@ object BackupManager {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Écrit UN auto-backup dans le DOSSIER SAF persisté ([treeUriString] = tree
|
||||
* URI choisi par l'utilisatrice via ACTION_OPEN_DOCUMENT_TREE, ex. un
|
||||
* dossier Owncloud synchronisé), puis applique la rétention
|
||||
* ([AutoBackupRetention] : garde les N plus récents, ne supprime QUE les
|
||||
* fichiers `hormonetrack-auto-*.json` — jamais les exports manuels ni les
|
||||
* fichiers personnels du dossier).
|
||||
*
|
||||
* Étapes :
|
||||
* 1. tree URI → document URI du dossier (DocumentsContract, SANS la lib
|
||||
* androidx.documentfile : pas de dépendance ajoutée) ;
|
||||
* 2. `createDocument` d'un nouveau fichier horodaté
|
||||
* ([ExportFileNames.autoBackupFileName]) — jamais d'écrasement :
|
||||
* la rétention garde les N dernières copies saines ;
|
||||
* 3. écriture du JSON via [writeBackup] TEL QUEL (leçon §14 #45 : même
|
||||
* IO éprouvée que l'export manuel — openOutputStream "wt") ;
|
||||
* 4. purge des vieux auto-backups (échec NON bloquant : un refus de
|
||||
* suppression ne doit pas faire échouer le backup écrit).
|
||||
*
|
||||
* @return true si le fichier a été écrit (la purge peut avoir échoué sans
|
||||
* invalider le succès).
|
||||
*/
|
||||
suspend fun writeAutoBackup(
|
||||
context: Context,
|
||||
treeUriString: String,
|
||||
json: String,
|
||||
keepCount: Int
|
||||
): Boolean {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val treeUri = Uri.parse(treeUriString)
|
||||
val dirUri = DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
|
||||
)
|
||||
val fileUri = DocumentsContract.createDocument(
|
||||
resolver, dirUri, "application/json", ExportFileNames.autoBackupFileName()
|
||||
) ?: return false
|
||||
val written = writeBackup(context, fileUri, json)
|
||||
if (written) {
|
||||
// Purge best-effort : le backup est écrit, l'échec de purge ne
|
||||
// l'invalide pas (l'utilisatrice le verra dans les AppLog)
|
||||
try {
|
||||
purgeOldAutoBackups(resolver, treeUri, keepCount)
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "purge impossible : ${e.message}")
|
||||
}
|
||||
}
|
||||
written
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "ERREUR écriture : ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Énumère le dossier cible (SAF children) et supprime les auto-backups au-delà
|
||||
* de [keepCount] — décision dans [AutoBackupRetention] (pur, testé) : ne
|
||||
* touche JAMAIS aux exports manuels ni aux fichiers étrangers du dossier.
|
||||
*/
|
||||
private fun purgeOldAutoBackups(
|
||||
resolver: ContentResolver,
|
||||
treeUri: Uri,
|
||||
keepCount: Int
|
||||
) {
|
||||
val dirId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirId)
|
||||
// Une seule requête : (nom affiché, id document) — le delete SAF exige
|
||||
// l'URI de DOCUMENT, pas le nom.
|
||||
val byName = LinkedHashMap<String, String>()
|
||||
resolver.query(
|
||||
childrenUri,
|
||||
arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||
),
|
||||
null, null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(0) ?: continue
|
||||
byName[name] = cursor.getString(1)
|
||||
}
|
||||
}
|
||||
AutoBackupRetention.filesToDelete(byName.keys, keepCount).forEach { name ->
|
||||
byName[name]?.let { docId ->
|
||||
DocumentsContract.deleteDocument(
|
||||
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
)
|
||||
AppLog.log("autobackup", "rétention : $name supprimé")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,17 +9,9 @@ interface DoseLogDao {
|
||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
|
||||
fun getAll(): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId ORDER BY timestamp DESC")
|
||||
fun getByTreatment(treatmentId: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
|
||||
fun getByTimeRange(start: Long, end: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
|
||||
fun getByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC LIMIT 1")
|
||||
fun getLatest(): Flow<DoseLog?>
|
||||
|
||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
|
||||
suspend fun getAllOnce(): List<DoseLog>
|
||||
|
||||
@ -9,11 +9,7 @@ interface LabResultDao {
|
||||
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
|
||||
fun getAll(): Flow<List<LabResult>>
|
||||
|
||||
@Query("SELECT * FROM lab_results WHERE marker = :marker ORDER BY timestamp DESC")
|
||||
fun getByMarker(marker: String): Flow<List<LabResult>>
|
||||
|
||||
@Query("SELECT * FROM lab_results WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
|
||||
fun getByTimeRange(start: Long, end: Long): Flow<List<LabResult>>
|
||||
|
||||
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
|
||||
suspend fun getAllOnce(): List<LabResult>
|
||||
|
||||
@ -15,9 +15,6 @@ interface TreatmentDao {
|
||||
@Query("SELECT * FROM treatments WHERE id = :id")
|
||||
suspend fun getById(id: Long): Treatment?
|
||||
|
||||
@Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC")
|
||||
suspend fun getActiveOnce(): List<Treatment>
|
||||
|
||||
@Query("SELECT * FROM treatments ORDER BY createdAt DESC")
|
||||
suspend fun getAllOnce(): List<Treatment>
|
||||
|
||||
@ -35,6 +32,4 @@ interface TreatmentDao {
|
||||
@Delete
|
||||
suspend fun delete(treatment: Treatment)
|
||||
|
||||
@Query("UPDATE treatments SET isActive = :active WHERE id = :id")
|
||||
suspend fun setActive(id: Long, active: Boolean)
|
||||
}
|
||||
|
||||
@ -18,7 +18,8 @@ data class PKPreset(
|
||||
object PKPresets {
|
||||
|
||||
val all: List<PKPreset> = listOf(
|
||||
// --- Injections: Estrannaise models (profiles from the ODS) ---
|
||||
// --- Injections : modèle Estrannaise ANALYTIQUE (v1.9.0 — la
|
||||
// source est estrannaise.js, plus de tables ODS au runtime) ---
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ev_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
@ -46,6 +47,39 @@ object PKPresets {
|
||||
absorptionHours = 152f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||
),
|
||||
// --- v1.9.4 : le modèle ESE est ANALYTIQUE (estrannaise.js, cf
|
||||
// pk/EstrannaiseModels.kt) et couvre 6 esters injectables —
|
||||
// presets ajoutés pour EC (huile), EB et EUCS (undécylate
|
||||
// suspension cristalline, exclusif à ce fit). Champs Bateman =
|
||||
// t½ terminales des fits (informatifs, pas utilisés au calcul —
|
||||
// le dispatch passe par la forme close).
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ec_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EC,
|
||||
pkModel = PKModels.ESTRANNAISE,
|
||||
absorptionHours = 7f, eliminationHalfLifeHours = 202f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eb_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EB,
|
||||
pkModel = PKModels.ESTRANNAISE,
|
||||
absorptionHours = 1f, eliminationHalfLifeHours = 4f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eucs_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_SUBCUT,
|
||||
esterType = Esters.EUCS,
|
||||
pkModel = PKModels.ESTRANNAISE,
|
||||
absorptionHours = 22f, eliminationHalfLifeHours = 756f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||
),
|
||||
// --- Injections: Transfem Science models ---
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ev_tfs,
|
||||
|
||||
@ -16,20 +16,30 @@ object Esters {
|
||||
const val EC = "EC" // cypionate, solution huileuse
|
||||
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
|
||||
const val PEP = "PEP" // polyestradiol phosphate
|
||||
// v1.9.0 : undécylate en SUSPENSION CRISTALLINE — couvert par le modèle
|
||||
// Estrannaise ANALYTIQUE (estrannaise.js « EUn casubq ») ; ni TFS ni les
|
||||
// tables ODS ne le couvrent.
|
||||
const val EUCS = "EUCS"
|
||||
|
||||
/** Esters injectables proposables par ester (override par dose). */
|
||||
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP)
|
||||
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP, EUCS)
|
||||
|
||||
/**
|
||||
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
|
||||
* (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle —
|
||||
* TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6
|
||||
* (PEP non couvert par ce fit) ; Estrannaise (tables ODS) → EV/EU/EEn
|
||||
* seulement (un traitement ESE + EB produirait 0 pg/mL).
|
||||
* (PEP non couvert par ce fit) ; ESE (Estrannaise ANALYTIQUE v1.9.0)
|
||||
* → les 6 injectables du fit estrannaise.js (dont EUCS, exclusif) ;
|
||||
* les tables ODS historiques n'en couvraient que 3 (EV/EU/EEn).
|
||||
*/
|
||||
fun choicesForModel(pkModel: String): List<String> = when (pkModel) {
|
||||
PKModels.TRANSFEM_SCIENCE -> INJECTABLE
|
||||
// ⚠️ TFS = liste EXPLICITE (fix v1.8.2-étendu) : INJECTABLE a gagné
|
||||
// EUCS (couvert par l'Estrannaise analytique) mais le V3C TFS n'a PAS
|
||||
// de paramètres pour EUCS → le dispatcher donnerait 0. Chaque liste
|
||||
// reflète la couverture RÉELLE de son modèle.
|
||||
PKModels.TRANSFEM_SCIENCE -> listOf(EV, EU, EEN, EB, EC, ECS, PEP)
|
||||
PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS)
|
||||
PKModels.ESTRANNAISE -> listOf(EV, EU, EEN, EC, EB, EUCS)
|
||||
else -> listOf(EV, EU, EEN)
|
||||
}
|
||||
}
|
||||
@ -84,7 +94,8 @@ data class Treatment(
|
||||
val isActive: Boolean = true,
|
||||
val notes: String? = null,
|
||||
|
||||
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science)
|
||||
// PK: analytical models for injections — Estrannaise (v1.9.0, 6 esters),
|
||||
// Transfem Science (V3C, 7), WHSAH (6); see Esters.choicesForModel().
|
||||
val esterType: String = Esters.NONE,
|
||||
val pkModel: String = PKModels.ESTRANNAISE,
|
||||
|
||||
@ -115,6 +126,9 @@ data class Treatment(
|
||||
) {
|
||||
val isInjection: Boolean
|
||||
get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
|
||||
// (redondant avec PharmacokineticEngine.isInjectionRoute — volontaire :
|
||||
// data.model ne doit PAS dépendre de pk (cycle de couches). Si la
|
||||
// définition d'« injectable » change, changer les DEUX endroits.)
|
||||
|
||||
val usesProfileModel: Boolean
|
||||
get() = isInjection && esterType != Esters.NONE
|
||||
|
||||
@ -18,15 +18,6 @@ class HormoneRepository(
|
||||
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
|
||||
val allLabResults: Flow<List<LabResult>> = labResultDao.getAll()
|
||||
|
||||
fun getDosesByTimeRange(start: Long, end: Long): Flow<List<DoseLog>> =
|
||||
doseLogDao.getByTimeRange(start, end)
|
||||
|
||||
fun getDosesByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>> =
|
||||
doseLogDao.getByTreatmentAndTimeRange(treatmentId, start, end)
|
||||
|
||||
fun getLabsByMarker(marker: String): Flow<List<LabResult>> =
|
||||
labResultDao.getByMarker(marker)
|
||||
|
||||
suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id)
|
||||
|
||||
suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce()
|
||||
|
||||
163
app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt
Normal file
163
app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt
Normal file
@ -0,0 +1,163 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
|
||||
/**
|
||||
* NUAGE D'INCERTITUDE (v1.9.0) — feature **exclusive au modèle ESE**.
|
||||
*
|
||||
* SOURCE : Estrannaise publie le **posterior MCMC** de ses paramètres
|
||||
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf
|
||||
* [EstrannaiseModels]). Le graphique de simulation peut dessiner un
|
||||
* **nuage diffus** : M courbes (une par échantillon échelonné) qui montrent
|
||||
* la plage d'imprécision possible du modèle — exactement l'affichage du
|
||||
* site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage
|
||||
* pour eux (choix confirmé : le nuage reste sur ESE).
|
||||
*
|
||||
* QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à
|
||||
* profil injectable dont l'ESTER EFFECTIF (override compris) est couvert
|
||||
* par le fit Estrannaise** — c'est exactement l'ensemble des doses que la
|
||||
* courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel`
|
||||
* STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec
|
||||
* ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle
|
||||
* Estrannaise, et le nuage les redessine avec les paramètres MCMC de
|
||||
* LEUR ester. (Fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE`
|
||||
* laissait le nuage vide dès qu'aucun traitement n'était stocké en ESE —
|
||||
* remontée : « le nuage ne s'active que autour du tracé émulé, pas autour
|
||||
* du modèle Estrannaise ». L'exclusivité ESE reste portée par le CHIP,
|
||||
* pas par le filtre des doses.)
|
||||
*
|
||||
* Les contributions Bateman (oral/gel — sans ester échantillonnable) ne
|
||||
* sont PAS dans le nuage : le posterior MCMC ne s'applique qu'aux esters
|
||||
* (limite documentée — le nuage montre l'incertitude du modèle
|
||||
* Estrannaise, pas le niveau total).
|
||||
*
|
||||
* DÉTERMINISME : les M échantillons sont **échelonnés** (stride sur les
|
||||
* 313, pas d'aléatoire) — le nuage est identique à chaque affichage et
|
||||
* testable en JVM (c'est aussi la reproducibilité du site, qui tire au
|
||||
* hasard à chaque trace, lui).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM (EstrannaiseCloudTest).
|
||||
*/
|
||||
object EstrannaiseCloud {
|
||||
|
||||
/**
|
||||
* Nombre de courbes du nuage : assez pour une plage visuellement
|
||||
* « diffuse » (le site en trace des centaines, mais 32 échantillons
|
||||
* échelonnés couvrent déjà l'étendue du posterior à un coût ×32 sur le
|
||||
* calcul — le pas de la grille reste 1 h).
|
||||
*/
|
||||
const val DEFAULT_NB_CURVES = 32
|
||||
|
||||
/**
|
||||
* Calcule le nuage d'incertitude.
|
||||
*
|
||||
* @param treatments TOUS les traitements (actifs ET inactifs, §6.bis)
|
||||
* @param doseLogs toutes les doses réelles
|
||||
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
||||
* @param stepMs pas de la grille (stepForRange)
|
||||
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
||||
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
||||
* calculable : aucune dose E2 à profil injectable dont l'ester
|
||||
* effectif est couvert par le fit Estrannaise (v1.9.2 :
|
||||
* indépendamment du pkModel stocké), aucune dose, ou MCMC non chargé.
|
||||
*/
|
||||
fun compute(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
startMs: Long,
|
||||
endMs: Long,
|
||||
stepMs: Long,
|
||||
nbCurves: Int = DEFAULT_NB_CURVES,
|
||||
/**
|
||||
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
|
||||
* s'active que autour du tracé émulé, pas autour du modèle
|
||||
* Estrannaise ») : la courbe ESE affichée est CALIBRÉE
|
||||
* (scalePerEster des labs) mais le nuage était tracé BRUT → les deux
|
||||
* flottaient à des échelles différentes. Passer le MÊME
|
||||
* `scalePerEster` que la courbe ESE (autoByModel["ESE"]?.esterScales
|
||||
* côté ChartScreen) pour que le nuage ENTOURE la courbe.
|
||||
*/
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): List<List<LevelPoint>> {
|
||||
if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList()
|
||||
|
||||
// ── Doses tracées par la courbe ESE (v1.9.2) ─────────────────────────
|
||||
// Toutes les doses E2 à profil injectable dont l'ESTER EFFECTIF
|
||||
// (override compris) est couvert par le fit Estrannaise — peu importe
|
||||
// le pkModel STOCKÉ du traitement : quand ESE est affiché,
|
||||
// computeCurve redessine toutes ces doses avec modelOverride = "ESE"
|
||||
// et le nuage doit les suivre (fix v1.9.2 : l'ancien filtre
|
||||
// `pkModel stocké == ESE` laissait le nuage vide dès qu'aucun
|
||||
// traitement n'était stocké en ESE — remontée : « le nuage ne
|
||||
// s'active que autour du tracé émulé, pas autour du modèle
|
||||
// Estrannaise ». L'exclusivité ESE reste portée par le CHIP, pas
|
||||
// par le filtre des doses). Les doses des traitements inactifs sont
|
||||
// incluses (§6.bis : l'inactif reste simulé, la courbe ESE les
|
||||
// trace aussi). La couche Bateman (oral/gel sans ester
|
||||
// échantillonnable) est naturellement exclue.
|
||||
val carrierDoses = ArrayList<Pair<DoseLog, Treatment>>()
|
||||
val samplesByEster = HashMap<String, List<EstrannaiseModels.Param>>()
|
||||
for (dose in doseLogs) {
|
||||
if (dose.timestamp > endMs) continue
|
||||
val tr = treatments.firstOrNull { it.id == dose.treatmentId } ?: continue
|
||||
if (tr.type != TreatmentType.ESTRADIOL || !tr.usesProfileModel) continue
|
||||
val ester = PharmacokineticEngine.doseEster(tr, dose)
|
||||
if (!EstrannaiseModels.hasModel(ester)) continue
|
||||
if (!samplesByEster.containsKey(ester)) {
|
||||
// Posterior MCMC de cet ester (chargé au démarrage) — vide si
|
||||
// l'asset n'est pas chargé → pas de nuage (sans posterior,
|
||||
// pas de plage d'imprécision à dessiner)
|
||||
val samples = EstrannaiseModels.mcmcSamples(ester)
|
||||
if (samples.isEmpty()) return emptyList()
|
||||
samplesByEster[ester] = samples
|
||||
}
|
||||
carrierDoses.add(dose to tr)
|
||||
}
|
||||
if (carrierDoses.isEmpty()) return emptyList()
|
||||
|
||||
// ── Échantillons échelonnés du posterior (déterministes) ─────────────
|
||||
// Un échantillon m est appliqué À TOUS les esters du nuage (même
|
||||
// index) : le nuage explore le posterior ester par ester, index par
|
||||
// index — reproductible.
|
||||
val maxSamples = samplesByEster.values.minOf { it.size }
|
||||
val stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt()
|
||||
.coerceAtLeast(1)
|
||||
|
||||
// Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la
|
||||
// 1ʳᵉ dose (rien à faire varier)
|
||||
val firstDoseMs = carrierDoses.minOf { (dose, _) -> dose.timestamp }
|
||||
val t0 = maxOf(startMs, firstDoseMs)
|
||||
if (t0 > endMs) return emptyList()
|
||||
|
||||
// ── M courbes ─────────────────────────────────────────────────────────
|
||||
val cloud = mutableListOf<List<LevelPoint>>()
|
||||
for (m in 0 until nbCurves) {
|
||||
val sampleIdx = (m * stride).coerceAtMost(maxSamples - 1)
|
||||
val paramsByEster = samplesByEster.mapValues { (_, samples) ->
|
||||
samples[sampleIdx.coerceAtMost(samples.size - 1)]
|
||||
}
|
||||
val curve = mutableListOf<LevelPoint>()
|
||||
var t = t0
|
||||
while (t <= endMs) {
|
||||
var e2 = 0.0
|
||||
for ((dose, tr) in carrierDoses) {
|
||||
if (dose.timestamp > t) continue
|
||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||
val ester = PharmacokineticEngine.doseEster(tr, dose)
|
||||
val params = paramsByEster.getValue(ester)
|
||||
// Même calibration que la courbe ESE : le nuage ENTOURE
|
||||
// la courbe calibrée (fix v1.9.1)
|
||||
val scale = scalePerEster?.get(ester) ?: tr.scaleFactor
|
||||
e2 += EstrannaiseModels.sampleWithParams(ester, dtH, params) * dose.doseAmount * scale
|
||||
}
|
||||
curve.add(LevelPoint(t, e2, 0.0))
|
||||
t += stepMs
|
||||
}
|
||||
if (curve.size >= 2) cloud.add(curve)
|
||||
}
|
||||
return cloud
|
||||
}
|
||||
}
|
||||
188
app/src/main/java/com/hormonetrack/pk/EstrannaiseModels.kt
Normal file
188
app/src/main/java/com/hormonetrack/pk/EstrannaiseModels.kt
Normal file
@ -0,0 +1,188 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.JsonParser
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Modèle **Estrannaise analytique** (v1.9.0) — remplace définitivement les
|
||||
* tables horaires ODS.
|
||||
*
|
||||
* SOURCE ORIGINELLE : le dépôt https://github.com/WHSAH/estrannaise.js/
|
||||
* (site estrannaise) — le modèle 3 compartiments avec les paramètres
|
||||
* d'ajustement **publiés par Estrannaise** eux-mêmes :
|
||||
*
|
||||
* ```
|
||||
* dB(t)/dt = −k1·B(t)
|
||||
* dEE(t)/dt = k1·B(t) − k2·EE(t)
|
||||
* dE2(t)/dt = k2·EE(t) − k3·E2(t)
|
||||
* ```
|
||||
*
|
||||
* forme close pour une dose `dose` (mg) à t = 0 (t en JOURS) :
|
||||
*
|
||||
* ```
|
||||
* C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||
* − e^(−k2·t)/((k1−k2)(k2−k3))
|
||||
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
|
||||
* ```
|
||||
*
|
||||
* **FIDÉLITÉ (épinglée par EstrannaiseModelsTest)** : cette forme close
|
||||
* reproduit les anciennes tables ODS **à l'identique** (RMS 0,00 sur 0→200 j
|
||||
* pour EV/EU/EEn, écart de pic ≤ 0,1 %) — vérifié avant le débranchement :
|
||||
* l'ODS n'était que l'échantillonnage horaire de ces formules.
|
||||
*
|
||||
* **NUAGE D'INCERTITUDE (v1.9.0)** : Estrannaise publie le **posterior
|
||||
* MCMC** de ses paramètres (313 échantillons `(d, k1, k2, k3)` par ester,
|
||||
* embarqués dans l'asset `mcmc_samples.json` — cf §15). Le graphique peut
|
||||
* dessiner un **nuage diffus** de courbes (une par échantillon) qui montre
|
||||
* la plage d'imprécision possible du modèle — feature exclusive au modèle
|
||||
* ESE (TFS/WHSAH ne publient pas de posterior).
|
||||
*
|
||||
* POURQUOI CE CHANGEMENT (décision v1.9.0) : « tous les modèles ont
|
||||
* maintenant leurs sources originelles — on abandonne complètement les
|
||||
* liens avec le fichier ODS, qui lui-même était une extrapolation de ces
|
||||
* mêmes sources ». Consequences : plus aucune lecture des tables au
|
||||
* runtime (l'asset `pk_profiles.json` reste uniquement pour les tests de
|
||||
* fidélité), t½ terminale **analytique** (ln2/min(k1,k2,k3)) au lieu de
|
||||
* l'estimation par pente de table.
|
||||
*
|
||||
* COUVERTURE : 6 esters injectables (EV, EU, EEn, EC, EB, EUCS) — le repo
|
||||
* source expose aussi 2 patchs (`patch tw`/`patch ow`) et un cycle
|
||||
* menstruel : hors scope v1.9.0 (documenté §7.12/§19).
|
||||
*
|
||||
* PUR sauf [init]/[initWithJson] (lecture de l'asset MCMC) — la formule
|
||||
* elle-même est testable en JVM.
|
||||
*/
|
||||
object EstrannaiseModels {
|
||||
|
||||
/** Paramètres d'un ester : d (facteur), k1/k2/k3 (vitesses, j⁻¹). */
|
||||
data class Param(val d: Double, val k1: Double, val k2: Double, val k3: Double) {
|
||||
/** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */
|
||||
val terminalHalfLifeDays: Double
|
||||
get() = ln(2.0) / minOf(k1, k2, k3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js,
|
||||
* `PKParameters`). Clés = clés esters de l'app. ⚠️ Mapping depuis les
|
||||
* clés du repo : « EV im »→EV, « EUn im »→EU, « EEn im »→EEn,
|
||||
* « EC im »→EC, « EB im »→EB, « EUn casubq »→EUCS (nouveau code ester
|
||||
* v1.9.0 : undécylate en suspension cristalline).
|
||||
*/
|
||||
private val PK_PARAMETERS: Map<String, Param> = mapOf(
|
||||
"EV" to Param(478.0, 0.236, 4.85, 1.24),
|
||||
"EU" to Param(471.5, 0.01729, 6.528, 2.285),
|
||||
"EEN" to Param(191.4, 0.119, 0.601, 0.402),
|
||||
"EC" to Param(246.0, 0.0825, 3.57, 0.669),
|
||||
"EB" to Param(1893.1, 0.67, 61.5, 4.34),
|
||||
"EUCS" to Param(16.15, 0.046, 0.022, 0.101),
|
||||
)
|
||||
|
||||
fun hasModel(ester: String): Boolean = PK_PARAMETERS.containsKey(ester.uppercase())
|
||||
|
||||
/** Les paramètres de l'ester, ou null (lookup insensible à la casse). */
|
||||
fun model(ester: String): Param? = PK_PARAMETERS[ester.uppercase()]
|
||||
|
||||
/**
|
||||
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||
* forme close exacte (cf KDoc de l'objet). dtHours ≤ 0 → 0.
|
||||
* ⚠️ Le passage t heures → t jours est fait ici (le modèle travaille en jours).
|
||||
*/
|
||||
fun sample(ester: String, dtHours: Double): Double {
|
||||
val m = model(ester) ?: return 0.0
|
||||
if (dtHours <= 0.0) return 0.0
|
||||
return curveDays(dtHours / 24.0, 1.0, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — utilisé par
|
||||
* le nuage d'incertitude : chaque courbe du nuage superpose les doses du
|
||||
* porteur ESE avec UN échantillon du posterior. Normalisé par mg comme
|
||||
* [sample].
|
||||
*/
|
||||
fun sampleWithParams(ester: String, dtHours: Double, p: Param): Double {
|
||||
if (dtHours <= 0.0) return 0.0
|
||||
return curveDays(dtHours / 24.0, 1.0, p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme close 3C (t en JOURS) — cas général + les 3 cas dégénérés
|
||||
* (taux égaux → dénominateurs nuls : solution limite, portée du JS
|
||||
* source de estrannaise.js).
|
||||
*/
|
||||
private fun curveDays(t: Double, dose: Double, m: Param): Double {
|
||||
if (t < 0) return 0.0
|
||||
var ret = 0.0
|
||||
val (d, k1, k2, k3) = m
|
||||
if (dose > 0 && d > 0) {
|
||||
when {
|
||||
k1 == k2 && k2 == k3 ->
|
||||
ret += dose * d * k1 * k1 * t * t * exp(-k1 * t) / 2
|
||||
k1 == k2 && k2 != k3 ->
|
||||
ret += dose * d * k1 * k1 * (exp(-k3 * t) - exp(-k1 * t) * (1 + (k1 - k3) * t)) /
|
||||
(k1 - k3) / (k1 - k3)
|
||||
k1 != k2 && k1 == k3 ->
|
||||
ret += dose * d * k1 * k2 * (exp(-k2 * t) - exp(-k1 * t) * (1 + (k1 - k2) * t)) /
|
||||
(k1 - k2) / (k1 - k2)
|
||||
k1 != k2 && k2 == k3 ->
|
||||
ret += dose * d * k1 * k2 * (exp(-k1 * t) - exp(-k2 * t) * (1 - (k1 - k2) * t)) /
|
||||
(k1 - k2) / (k1 - k2)
|
||||
else ->
|
||||
ret += dose * d * k1 * k2 * (
|
||||
exp(-k1 * t) / (k1 - k2) / (k1 - k3) -
|
||||
exp(-k2 * t) / (k1 - k2) / (k2 - k3) +
|
||||
exp(-k3 * t) / (k1 - k3) / (k2 - k3)
|
||||
)
|
||||
}
|
||||
}
|
||||
return if (ret.isNaN()) 0.0 else ret
|
||||
}
|
||||
|
||||
// ── Posterior MCMC (nuage d'incertitude) ────────────────────────────────
|
||||
|
||||
@Volatile
|
||||
private var mcmc: Map<String, List<Param>>? = null
|
||||
|
||||
/**
|
||||
* Charge le posterior MCMC depuis l'asset `mcmc_samples.json`
|
||||
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf §15).
|
||||
* Appelé au démarrage ([com.hormonetrack.HormoneTrackApp]) — le nuage
|
||||
* n'en a besoin qu'au premier affichage, mais le chargement est court
|
||||
* (≈ 48 Ko) et évite un état « chip actif sans données ».
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (mcmc != null) return
|
||||
synchronized(this) {
|
||||
if (mcmc != null) return
|
||||
val json = context.assets.open("mcmc_samples.json").bufferedReader().use { it.readText() }
|
||||
initWithJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
/** Point d'entrée testable (JVM) — même pattern que PKProfileStore.initWithJson. */
|
||||
fun initWithJson(json: String) {
|
||||
synchronized(this) {
|
||||
val root = JsonParser.parseString(json).asJsonObject
|
||||
val parsed = LinkedHashMap<String, List<Param>>()
|
||||
for ((ester, value) in root.entrySet()) {
|
||||
val arr = value.asJsonArray
|
||||
val samples = ArrayList<Param>(arr.size())
|
||||
for (s in arr) {
|
||||
val e = s.asJsonArray
|
||||
samples.add(Param(e[0].asDouble, e[1].asDouble, e[2].asDouble, e[3].asDouble))
|
||||
}
|
||||
parsed[ester.uppercase()] = samples
|
||||
}
|
||||
mcmc = parsed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Les échantillons MCMC d'un ester (vide si l'asset n'est pas chargé ou
|
||||
* ester inconnu) — le nuage d'incertitude en tire M jeux de paramètres
|
||||
* (échelonnés, déterministes : pas d'aléatoire à chaque frame).
|
||||
*/
|
||||
fun mcmcSamples(ester: String): List<Param> =
|
||||
mcmc?.get(ester.uppercase()) ?: emptyList()
|
||||
}
|
||||
413
app/src/main/java/com/hormonetrack/pk/LabTiming.kt
Normal file
413
app/src/main/java/com/hormonetrack/pk/LabTiming.kt
Normal file
@ -0,0 +1,413 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0) — déduite des courbes,
|
||||
* affichée dans la page Analyses (« Prochaine analyse recommandée »).
|
||||
*
|
||||
* PRINCIPE PHARMACOCINÉTIQUE (choix validé avec l'utilisatrice) :
|
||||
* 1. **Le creux (trough) juste avant l'injection suivante** est le moment le
|
||||
* plus informatif et reproductible pour un ester injectable : le pic
|
||||
* varie énormément d'un cycle à l'autre, le creux reflète le niveau de
|
||||
* fond réel et l'accumulation ;
|
||||
* 2. un creux n'est interprétable que si le régime est **stabilisé** — règle
|
||||
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
|
||||
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
|
||||
* une transition, pas le régime ;
|
||||
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
|
||||
* #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même —
|
||||
* l'instant où la prise de sang serait faite — pas sur le créneau
|
||||
* d'injection qui le suit).
|
||||
*
|
||||
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
|
||||
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
|
||||
* renseignée (`forecastIntervalDays > 0`) — sans Posologie, pas de creux
|
||||
* prévisible → `null` (la page Analyses affiche alors une invite à la
|
||||
* renseigner) ;
|
||||
* - **t½ terminale** de l'ester : ANALYTIQUE pour les trois modèles
|
||||
* (TFS/WHSAH : `terminalHalfLifeDays` des fits ; ESE : idem depuis
|
||||
* v1.9.0 — le modèle Estrannaise est devenu analytique, cf
|
||||
* [EstrannaiseModels]) ;
|
||||
* - **début du régime courant** = 1ʳᵉ dose du traitement actif — PROXY
|
||||
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
||||
* Posologie/dose, la première dose du traitement est le meilleur
|
||||
* estimateur du début du régime (même sémantique que le « temps sous THS ») ;
|
||||
* - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
|
||||
* E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
|
||||
* v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
|
||||
* l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont
|
||||
* le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul
|
||||
* SANS calibration (le facteur d'échelle est multiplicatif, il ne
|
||||
* déplace pas le minimum) ;
|
||||
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
|
||||
* recommandation quasi-immédiate), creux > dernière prise de sang
|
||||
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
|
||||
* stabilisation.
|
||||
*
|
||||
* LIMITES ASSUMÉES (documentées, cf doc §7.11) :
|
||||
* - le creux est une ESTIMATION du modèle (pas une physiologie mesurée) —
|
||||
* la carte porte un disclaimer ;
|
||||
* - pas de Posologie, pas de recommandation (l'invite invite à la
|
||||
* renseigner) ;
|
||||
* - horizon borné à [MAX_FORECAST_INTERVALS] créneaux : si la stabilisation
|
||||
* dépasse (ester ultra-long, ex. EU t½ 31 j → ~155 j), `null` (carte
|
||||
* cachée plutôt qu'une recommandation douteuse).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM (LabTimingTest) ;
|
||||
* portage web : `js/pk/lab-timing.js` (miroir fidèle).
|
||||
*/
|
||||
object LabTiming {
|
||||
|
||||
/** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */
|
||||
const val STABILIZATION_HALF_LIVES = 5
|
||||
|
||||
/**
|
||||
* Horizon de créneaux prévisionnels explorés : assez pour couvrir la
|
||||
* stabilisation d'un ester raisonnable (ex. EEn TFS : 5 × 4,5 j ≈ 23 j →
|
||||
* 4 intervalles de 7 j), borné — au-delà (ester ultra-long), `null`.
|
||||
*/
|
||||
const val MAX_FORECAST_INTERVALS = 12
|
||||
|
||||
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
|
||||
const val MIN_HORIZON_HOURS = 6L
|
||||
|
||||
/**
|
||||
* Position d'une valeur par rapport à la CIBLE DE CREUX personnelle
|
||||
* (v1.13.0, opt-in). ⚠️ DISTINCT des seuils d'alerte (pk/Alerts.kt,
|
||||
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
|
||||
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB — « quand je
|
||||
* pique au creux recommandé, je veux être dans [low, high] » — évaluée
|
||||
* en lecture seule sur la carte, JAMAIS de notification.
|
||||
*/
|
||||
enum class TroughTargetStatus { IN_TARGET, BELOW, ABOVE }
|
||||
|
||||
/**
|
||||
* Statut de [predictedE2] par rapport à la cible [low, high].
|
||||
* `null` si pas de prédiction OU cible incomplète/désactivée.
|
||||
* PUR → testable en JVM ; miroir web `troughTargetStatus`
|
||||
* (js/pk/lab-timing.js).
|
||||
*/
|
||||
fun troughTargetStatus(
|
||||
predictedE2: Double?,
|
||||
low: Double?,
|
||||
high: Double?
|
||||
): TroughTargetStatus? {
|
||||
val value = predictedE2 ?: return null
|
||||
val lo = low ?: return null
|
||||
val hi = high ?: return null
|
||||
return when {
|
||||
value < lo -> TroughTargetStatus.BELOW
|
||||
value > hi -> TroughTargetStatus.ABOVE
|
||||
else -> TroughTargetStatus.IN_TARGET
|
||||
}
|
||||
}
|
||||
|
||||
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
|
||||
data class LabRecommendation(
|
||||
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
|
||||
val troughMs: Long,
|
||||
/** Créneau d'INJECTION associé (juste après le creux). */
|
||||
val injectionMs: Long,
|
||||
/** Ester du traitement porteur (ex. "EEN") — pour le texte. */
|
||||
val ester: String,
|
||||
/** t½ terminale utilisée (jours) — pour le texte « ~5 demi-vies ». */
|
||||
val terminalHalfLifeDays: Double,
|
||||
/** Début du régime courant (1ʳᵉ dose du traitement actif). */
|
||||
val regimeStartMs: Long,
|
||||
/** Date de stabilisation = régimeStart + 5 × t½. */
|
||||
val stabilizedAtMs: Long,
|
||||
/**
|
||||
* true = le régime était DÉJÀ stabilisé à l'instant du calcul
|
||||
* (texte « stabilisé depuis le … ») ; false = la stabilisation
|
||||
* arrive entre maintenant et le creux recommandé (texte « c'est le
|
||||
* premier creux fiable »).
|
||||
*/
|
||||
val wasAlreadyStabilized: Boolean,
|
||||
/**
|
||||
* E2 ATTENDUE à ce creux (v1.13.0) : valeur brute du modèle ×
|
||||
* facteur d'échelle du ester actif — fourni par l'appelant quand
|
||||
* la calibration est disponible (auto-calibration ON). `null` =
|
||||
* pas de calibration fiable → la carte n'affiche PAS de valeur
|
||||
* (honnêteté : une valeur brute non calibrée serait trompeuse).
|
||||
*/
|
||||
val predictedE2: Double? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Calcule la prochaine prise de sang recommandée, ou `null` si rien n'est
|
||||
* calculable honnêtement (pas de traitement E2 injectable actif à
|
||||
* Posologie, pas de doses pour cadrer la grille, stabilisation hors
|
||||
* horizon, pas de creux exploitable). L'appelant CACHE la carte sur null
|
||||
* (et peut afficher l'invite « renseigne une Posologie » quand un
|
||||
* injectable actif en est dépourvu).
|
||||
*/
|
||||
fun nextBloodDrawRecommendation(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
labs: List<LabResult>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
/**
|
||||
* Facteurs d'échelle PAR ESTER (v1.13.0) — issus de l'auto-calibration
|
||||
* quand elle est activée (sinon `null`) : permet de retourner la
|
||||
* valeur E2 ATTENDUE au creux ([LabRecommendation.predictedE2]).
|
||||
* Absent → `predictedE2 = null` (carte sans valeur).
|
||||
*/
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): LabRecommendation? {
|
||||
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
|
||||
val carrier = treatments.firstOrNull {
|
||||
it.type == TreatmentType.ESTRADIOL &&
|
||||
it.isActive &&
|
||||
it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||
} ?: return null
|
||||
|
||||
// ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────
|
||||
val tHalfDays = when (carrier.pkModel) {
|
||||
PKModels.TRANSFEM_SCIENCE ->
|
||||
TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||
PKModels.WHSAH ->
|
||||
WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||
// v1.9.0 : ESE analytique (forme close estrannaise.js) — la t½
|
||||
// n'est plus estimée par pente de table
|
||||
PKModels.ESTRANNAISE ->
|
||||
EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
// ── 3) Début du régime courant + date de stabilisation ──────────────
|
||||
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
|
||||
if (myDoses.isEmpty()) return null
|
||||
val lastDose = myDoses.maxByOrNull { it.timestamp }!!
|
||||
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
|
||||
// v1.13.0 : régime POOLÉ (cf [pooledRegimeDoses]) — le re-parenting
|
||||
// d'historique entre traitements (ex. « 6d - old » créé a posteriori,
|
||||
// remontée v1.12.0) ne doit pas faire croire à un nouveau régime.
|
||||
val regimeStart = regimeStartMs(carrier, pooledRegimeDoses(carrier, doseLogs, treatments))
|
||||
val stabilizedAtMs = regimeStart +
|
||||
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
|
||||
|
||||
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
|
||||
val intervalDays = carrier.forecastIntervalDays!!
|
||||
val intervalsNeeded = maxOf(
|
||||
3L,
|
||||
ceil(STABILIZATION_HALF_LIVES * tHalfDays / intervalDays).toLong() + 1L
|
||||
).coerceAtMost(MAX_FORECAST_INTERVALS.toLong())
|
||||
val horizonMs = nowMs + (intervalsNeeded * intervalDays * 24.0 * 3_600_000.0).toLong()
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
carrier, doseLogs, horizonMs, nowMs
|
||||
)
|
||||
if (slots.isEmpty()) return null
|
||||
|
||||
// ── 5) Courbe E2 fine (FORME brute — sans calibration, le facteur
|
||||
// multiplicatif ne déplace pas le minimum) ─────────────────────
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
treatments, doseLogs + slots, nowMs, slots.last().timestamp,
|
||||
stepMs = 3_600_000L, tConfig = TConfig()
|
||||
)
|
||||
|
||||
// ── 6) Premier creux STABILISÉ jamais mesuré ─────────────────────────
|
||||
val lastLabMs = labs.maxOfOrNull { it.timestamp } ?: 0L
|
||||
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
|
||||
var windowStart = nowMs
|
||||
for (slot in slots) {
|
||||
// ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
|
||||
// créneau (dernier point de courbe avant l'injection — principe 1,
|
||||
// « le moment le plus comparable »). L'ancien MINIMUM DE LA
|
||||
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters
|
||||
// à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum
|
||||
// local est le creux d'absorption, quelques heures après
|
||||
// l'injection PRÉCÉDENTE) : la date proposée précédait la
|
||||
// stabilisation affichée sur la MÊME carte, et le texte
|
||||
// « juste avant ton injection du … » était faux de plusieurs
|
||||
// jours (remontée v1.9.8, épinglé par RegressionUserCase6Test).
|
||||
// Pour les esters à t½ courte (EV), le point pré-injection EST
|
||||
// le minimum de fenêtre : comportement inchangé.
|
||||
val trough = curve.lastOrNull {
|
||||
it.timestamp > windowStart && it.timestamp < slot.timestamp
|
||||
}
|
||||
if (trough != null &&
|
||||
trough.timestamp >= minTroughMs &&
|
||||
trough.timestamp > lastLabMs &&
|
||||
// ON SAUTE au premier creux stabilisé (décision v1.8.0) :
|
||||
// le creux pré-injection ≈ instant du créneau, le garde
|
||||
// exprime la stabilisation sur l'instant réellement proposé
|
||||
// pour la prise de sang (trough < slot de ~1 pas de courbe).
|
||||
trough.timestamp >= stabilizedAtMs
|
||||
) {
|
||||
return LabRecommendation(
|
||||
troughMs = trough.timestamp,
|
||||
injectionMs = slot.timestamp,
|
||||
ester = activeEster,
|
||||
terminalHalfLifeDays = tHalfDays,
|
||||
regimeStartMs = regimeStart,
|
||||
stabilizedAtMs = stabilizedAtMs,
|
||||
wasAlreadyStabilized = stabilizedAtMs <= nowMs,
|
||||
// v1.13.0 : E2 attendue au creux = valeur brute du
|
||||
// modèle × facteur du ester actif (lookup insensible à
|
||||
// la casse — les clés de scalePerEster suivent les
|
||||
// esters stockés). null si pas de calibration.
|
||||
predictedE2 = scalePerEster
|
||||
?.entries
|
||||
?.firstOrNull { it.key.equals(activeEster, ignoreCase = true) }
|
||||
?.value
|
||||
?.let { scale -> trough.e2 * scale }
|
||||
)
|
||||
}
|
||||
windowStart = slot.timestamp
|
||||
}
|
||||
return null // stabilisation hors horizon (ester ultra-long) → carte cachée
|
||||
}
|
||||
|
||||
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
|
||||
private const val DOSE_EPS = 1e-6
|
||||
|
||||
/**
|
||||
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0) : toutes les doses de TOUS les
|
||||
* traitements partageant l'**ester effectif** du porteur ET son **mg
|
||||
* exact** (± [DOSE_EPS]), triées par timestamp.
|
||||
*
|
||||
* **Pourquoi** (remontée v1.12.0) : le re-parenting d'historique — créer
|
||||
* un traitement « … - old » inactif et y déplacer des doses (ex. séparer
|
||||
* une ère « 6 j » d'une ère « 7 j ») — créait un TROU synthétique dans
|
||||
* l'historique du porteur : le régime redémarrait à la date du
|
||||
* re-parenting et la stabilisation (5 × t½) repartait de plusieurs
|
||||
* semaines, alors que pharmacologiquement rien n'avait changé (même
|
||||
* ester, même dose).
|
||||
*
|
||||
* Garde-fous (épinglés par tests) :
|
||||
* - **ester ≠ → pas de mutualisation** (transition EV → EEn : deux
|
||||
* régimes distincts — régression n°3) ;
|
||||
* - **dose ≠ → exclue du pool** (une dose « 8 mg » au milieu d'un
|
||||
* régime « 5 mg » casse naturellement la séquence via le trou qui
|
||||
* suit — conservateur, ~1 semaine du côté prudent).
|
||||
*
|
||||
* PUR → testable en JVM (DosesExtras? non — LabTimingTest +
|
||||
* RegressionUserCase7Test) ; miroir web `pooledRegimeDoses`
|
||||
* (js/pk/lab-timing.js).
|
||||
*/
|
||||
internal fun pooledRegimeDoses(
|
||||
carrier: Treatment,
|
||||
allDoseLogs: List<DoseLog>,
|
||||
allTreatments: List<Treatment>
|
||||
): List<DoseLog> {
|
||||
val last = allDoseLogs
|
||||
.filter { it.treatmentId == carrier.id }
|
||||
.maxByOrNull { it.timestamp } ?: return emptyList()
|
||||
val activeEster = PharmacokineticEngine.doseEster(carrier, last)
|
||||
val amount = last.doseAmount
|
||||
val ownerById = allTreatments.associateBy { it.id }
|
||||
return allDoseLogs
|
||||
.filter { d ->
|
||||
// Ester EFFECTIF via le traitement PROPRIÉTAIRE de la dose
|
||||
// (override par dose compris) — jamais via le porteur
|
||||
val owner = ownerById[d.treatmentId] ?: return@filter false
|
||||
PharmacokineticEngine.doseEster(owner, d) == activeEster &&
|
||||
Math.abs(d.doseAmount - amount) < DOSE_EPS
|
||||
}
|
||||
.sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de
|
||||
* l'intervalle de Posologie — « je m'injecte le même jour de la semaine,
|
||||
* à l'heure près ». Un log à 12:30 au lieu de 12:00 ne doit pas
|
||||
* repousser la stabilisation de 5 × t½ (remontée v1.9.7) ; un vrai
|
||||
* changement de créneau (2 j au lieu de 7 j) reste hors fenêtre.
|
||||
*/
|
||||
private val GAP_TOLERANCE_MS = 24L * 3_600_000L
|
||||
|
||||
/**
|
||||
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
|
||||
*
|
||||
* **Critique remontée** (v1.8.0) : « je suis passée sous un autre ester,
|
||||
* j'ai changé de dosage ET de posologie, et l'app me disait "stabilisée
|
||||
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
|
||||
* PAS les changements récents de dose ni d'intervalle.
|
||||
*
|
||||
* **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de
|
||||
* sang change tout le temps à chaque injection si l'injection n'est pas
|
||||
* faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart
|
||||
* identique au ms près) cassait le régime pour un log à 12:30 au lieu de
|
||||
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
|
||||
* creux recommandé fuyait vers l'avant sans jamais se fixer.
|
||||
*
|
||||
* **RÈGLE** : le régime courant = la **séquence terminale de doses**
|
||||
* (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
|
||||
* re-parenting d'historique ne doit plus redémarrer la stabilisation)
|
||||
* où (a) l'**ester effectif** et la **dose (mg)** sont identiques à
|
||||
* celles de la dose la plus récente, ET (b) chaque **écart entre doses
|
||||
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
|
||||
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
|
||||
* On remonte depuis la dose la plus récente tant que ces conditions
|
||||
* tiennent ; le régime commence à la dose la plus ancienne de la
|
||||
* séquence.
|
||||
*
|
||||
* Pourquoi chaque composante :
|
||||
* - **ester effectif** (override par dose compris) : un switch EV → EEn
|
||||
* réinitialise la stabilisation ;
|
||||
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
|
||||
* - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est
|
||||
* comparable que sur un intervalle régulier (pharmacocinétique), mais
|
||||
* l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST
|
||||
* le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors
|
||||
* fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE
|
||||
* THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j
|
||||
* puis 7,2 j ne se déstabilisent plus en cascade.
|
||||
*
|
||||
* @param carrier traitement porteur (injectable E2 actif à Posologie)
|
||||
* @param myDoses doses du porteur (ordre indifférent)
|
||||
* @return timestamp de la 1ʳᵉ dose du régime courant
|
||||
*/
|
||||
internal fun regimeStartMs(carrier: Treatment, myDoses: List<DoseLog>): Long {
|
||||
val sorted = myDoses.sortedBy { it.timestamp }
|
||||
var idx = sorted.size - 1
|
||||
var regimeStart = sorted[idx].timestamp
|
||||
var current = sorted[idx]
|
||||
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 —
|
||||
// remplace la comparaison exacte v1.8.1 qui cassait le régime à
|
||||
// chaque variation d'heure de log). La Posologie est un prérequis de
|
||||
// la carte : absente → on s'arrête à la dernière dose (dégradé).
|
||||
val intervalDays = carrier.forecastIntervalDays ?: return regimeStart
|
||||
val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong()
|
||||
val gapMin = intervalMs - GAP_TOLERANCE_MS
|
||||
val gapMax = intervalMs + GAP_TOLERANCE_MS
|
||||
while (idx > 0) {
|
||||
val prev = sorted[idx - 1]
|
||||
val gap = current.timestamp - prev.timestamp
|
||||
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
|
||||
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
|
||||
// Remonter seulement si même (ester, dose) ET écart dans la
|
||||
// fenêtre (gap > 0 : un doublon de log n'est jamais un régime)
|
||||
val sameGap = gap > 0 && gap in gapMin..gapMax
|
||||
if (!sameMarker || !sameGap) break
|
||||
regimeStart = prev.timestamp
|
||||
current = prev
|
||||
idx--
|
||||
}
|
||||
return regimeStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite d'AFFICHAGE (v1.8.0) : existe-t-il un traitement E2 injectable
|
||||
* ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la
|
||||
* renseigner pour recevoir des recommandations (demande v1.8.0 : « il
|
||||
* faut préciser quelque part sur la page des analyses qu'en l'absence
|
||||
* de posologie, en ajouter une permet de recevoir des recommandations »).
|
||||
* La suggestion ne s'affiche QUE si aucune recommandation n'est calculable
|
||||
* (elle ne remplace pas une vraie reco).
|
||||
*/
|
||||
fun shouldSuggestPosology(treatments: List<Treatment>): Boolean =
|
||||
treatments.any {
|
||||
it.type == TreatmentType.ESTRADIOL &&
|
||||
it.isActive &&
|
||||
it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) <= 0.0
|
||||
}
|
||||
}
|
||||
294
app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt
Normal file
294
app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt
Normal file
@ -0,0 +1,294 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les résultats de sang.
|
||||
*
|
||||
* DEMANDE (décision v1.5.0, plan validé avec l'utilisatrice après balancement
|
||||
* variantes A/B — cf doc §7.10) : une courbe « biologiquement plausible MAIS
|
||||
* passant par tous les points de lab ». Formule :
|
||||
*
|
||||
* courbe(t) = M(t) × ρ(t)
|
||||
*
|
||||
* où :
|
||||
* - **M(t)** = niveau du MODÈLE PK brut (superposition des contributions,
|
||||
* scaleFactor forcé à 1, modèle stocké ou `modelOverride`) — il porte TOUTE
|
||||
* la physiologie entre les labs (Tmax, queues, accumulation) ;
|
||||
* - **ρ(t) = lab_k / M(t_k)** à chaque lab — l'amplitude du modèle est
|
||||
* recalée sur la mesure réelle ; ENTRE deux labs consécutifs, ρ
|
||||
* interpole LOG-LINÉAIREMENT (morphing d'amplitude multiplicativement
|
||||
* continu : un passage d'un facteur entre 2 labs ne fait pas de saut
|
||||
* vertical) ;
|
||||
* - courbe(t_k) = M(t_k) × (lab_k / M(t_k)) = **lab_k exactement** :
|
||||
* le passage par chaque point est GARANTI par construction.
|
||||
*
|
||||
* GARDE ANTI-#61 (labIsSignificant) : les labs traités par timestamp
|
||||
* CROISSANT qui ne « gouvernent » plus la prédiction (< 15 % du max déjà
|
||||
* observé) ne sont PAS des ancrages — la dose de test de janvier + des
|
||||
* labs tardifs (#61) ne créent pas de zigzag. À la différence des
|
||||
* pipelines de calibration (#60), un lab rejeté n'est PAS utilisé du tout.
|
||||
*
|
||||
* PORTÉE ET LIMITES (essentielles — cf doc §7.10 / décision plan v1.5.0) :
|
||||
* - **E2 uniquement** (les labs T : 1) sont trop rares, 2) leurs unités
|
||||
* variées ont causé le bug #26 — hors scope) ;
|
||||
* - **fenêtre = [1er lab significatif ; dernier lab]** par défaut — rien
|
||||
* AVANT (M=0 sans dose) ni APRÈS (aucune extrapolation) ;
|
||||
* - **PROLONGATION (v1.6.0, opt-in `extendBeyondLastLab = true`)** : la
|
||||
* courbe peut se PROLONGER au-delà du dernier lab — cf détail ci-dessous ;
|
||||
* - < 2 labs significatifs → courbe vide (un lab seul ne définit pas
|
||||
* d'intervalle) — et donc jamais de prolongation non plus ;
|
||||
* - indépendante de la calibration : cette courbe EST SA PROPRE
|
||||
* calibration continue (ratio brut, scaleFactor des traitements ignoré
|
||||
* ET scalePerEster ignoré — l'inverse serait une double correction,
|
||||
* épinglé par test) ;
|
||||
* - **display-end only** : utilisée UNIQUEMENT par le graphique (chip,
|
||||
* off par défaut). Jamais branchée sur `levelAt` — l'accueil, les
|
||||
* seuils d'alerte et les rappels continuent d'utiliser le modèle
|
||||
* calibré (invariant v1.4.2 §9.bis). La partie PROLONGÉE ne fait pas
|
||||
* exception : elle est purement visuelle (série "LABX" du chart).
|
||||
* - courbes T non modélisées ici : la série n'a pas de tStyle (le chart
|
||||
* ne dessine pas de T pour la série — cf CurveChart).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM
|
||||
* (LabTrajectoryModelTest — l'ancrage exact, la garde #61 exactement,
|
||||
* les bornes de fenêtre, la prolongation).
|
||||
*
|
||||
* ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0) ──────────────────────────
|
||||
*
|
||||
* DEMANDE (v1.6.0) : « permettre au tracé labs de s'étendre au-delà du
|
||||
* dernier lab, en simulant à partir des labs précédents, du dosage et du
|
||||
* type d'ester injecté, et de l'évolution classique de l'ester ».
|
||||
*
|
||||
* Le constat v1.5.0 : la fenêtre s'arrêtait AU dernier lab — entre ce lab
|
||||
* et « maintenant » (voire au-delà, avec la prévision), la courbe n'existait
|
||||
* pas, alors que c'est précisément la période la plus récente qu'on veut
|
||||
* comparer aux courbes des modèles.
|
||||
*
|
||||
* PRINCIPE : après le dernier ancrage, ρ cesse d'interpoler et reste CONSTANT :
|
||||
*
|
||||
* pour t > t_last_lab : courbe(t) = M(t) × ρ_last
|
||||
*
|
||||
* Justification, composant par composant (la demande, point par point) :
|
||||
* - **« l'évolution classique de l'ester »** = M(t) : la forme du modèle PK
|
||||
* (montée, Tmax, queues, accumulation) porte toute la dynamique ;
|
||||
* - **« le dosage et le type d'ester injecté »** = M(t) inclut AUTOMATIQUEMENT
|
||||
* les doses loguées après le dernier lab : une nouvelle injection (même à
|
||||
* un autre ester) fait repartir la courbe en pic, à l'amplitude calibrée ;
|
||||
* - **« les données de labs précédentes »** = ρ_last : le rapport
|
||||
* corps-réel ÷ modèle observé au dernier lab est supposé rester valable.
|
||||
*
|
||||
* POURQUOI ρ CONSTANT et pas une extrapolation de la PENTE de ρ : la pente
|
||||
* entre deux labs ne mesure pas une tendance du corps, elle morphing deux
|
||||
* corrections d'amplitude — l'extrapoler divergerait sans aucune base
|
||||
* physiologique (un ratio de calibration qui monte de 5 %/jour finirait par
|
||||
* des facteurs ×10 inventés). La calibration observée au dernier lab est le
|
||||
* meilleur estimateur disponible pour la suite ; c'est exactement ce que
|
||||
* fait la calibration classique (le facteur stocké s'applique aux doses
|
||||
* futures — §7.6).
|
||||
*
|
||||
* BORNE DE PROLONGATION (horizon) : la courbe prolongée s'arrête quand le
|
||||
* MODÈLE lui-même s'éteint — `extensionHorizonEndMs` = dernière dose E2 +
|
||||
* [PharmacokineticEngine.cutoffHours] de son traitement (10 t½ terminales pour V3C/WHS, fin de
|
||||
* table pour ODS, 30 t½ Bateman). Au-delà, M(t) = 0 : dessiner plus serait
|
||||
* une ligne à zéro purement inventée. La prolongation est en plus clippée
|
||||
* par la fenêtre DEMANDÉE (celle du graphique).
|
||||
*
|
||||
* LIMITES ASSUMÉES (documentées, pas de bug — cf §7.10.bis) :
|
||||
* - changement d'ESTER après le dernier lab : ρ_last a été mesuré sous
|
||||
* l'ancien ester ; l'amplitude du nouveau reste calibrée par ce ρ —
|
||||
* même limite que la calibration classique (fallback du facteur stocké,
|
||||
* pas de lab de la nouvelle période) ;
|
||||
* - les doses PRÉVISIONNELLES ne participent PAS : la prolongation ne
|
||||
* consomme que les doses RÉELLES (le futur projeté reste la série
|
||||
* « Prévision », qui superpose déjà les modèles calibrés).
|
||||
*/
|
||||
object LabTrajectoryModel {
|
||||
|
||||
/**
|
||||
* Résultat : les points (E2 ancrée, t = 0 — pas de courbe T pour cette
|
||||
* série) + le nombre de labs réellement utilisés comme ancrages
|
||||
* (diagnostic dans la doc ; 0 → courbe vide).
|
||||
*
|
||||
* [lastAnchorMs] (v1.6.0) : timestamp du DERNIER lab significatif (le
|
||||
* dernier point « réel » de la courbe). `null` si < 2 ancres (courbe
|
||||
* vide). L'écran Graphiques l'utilise pour SPLITTER la série en deux
|
||||
* traits distincts :
|
||||
* - "LAB" = points ≤ lastAnchorMs (ancré — passe par les labs) ;
|
||||
* - "LABX" = points > lastAnchorMs (prolongé — ρ constant, estimation).
|
||||
* La distinction visuelle préserve l'honnêteté de l'affichage : la partie
|
||||
* inventée ne peut pas se confondre avec la partie mesurée.
|
||||
*/
|
||||
data class AnchoredCurve(
|
||||
val points: List<LevelPoint>,
|
||||
val anchoredLabs: Int,
|
||||
val lastAnchorMs: Long? = null
|
||||
)
|
||||
|
||||
// Les ancrages sont des couples (timestamp du lab, ratio lab ÷ prédiction)
|
||||
// — traités en timeline triée croissante par ratioAt().
|
||||
|
||||
/**
|
||||
* Construit la courbe ancrée sur les labs.
|
||||
*
|
||||
* @param treatments TOUS les traitements (actifs ET inactifs — §6.bis :
|
||||
* l'inactivation ne retire pas l'historique de la
|
||||
* prédiction M(t))
|
||||
* @param doseLogs toutes les doses (RÉELLES uniquement — les doses
|
||||
* prévisionnelles ne participent jamais au tracé labs ;
|
||||
* les doses loguées APRÈS le dernier lab sont au
|
||||
* contraire INCLUSES : elles portent la forme de la
|
||||
* prolongation, cf KDoc de l'objet)
|
||||
* @param e2Labs labs E2 uniquement (les marqueurs autres sont
|
||||
* ignorés par `marker == "E2"`) — unité pg/mL
|
||||
* @param startMs/endMs fenêtre demandée (celle du graphique) — la courbe
|
||||
* est CLIPPÉE à l'intersection avec [lab 1er ; lab
|
||||
* dernier], ou avec [lab 1er ; horizon de prolongation]
|
||||
* quand [extendBeyondLastLab] est actif
|
||||
* @param stepMs pas d'échantillonnage (stepForRange, v1.2.9)
|
||||
* @param modelOverride modèle pour la prédiction M(t) (défaut = modèle
|
||||
* stocké, même sémantique que l'auto-calibration
|
||||
* Home v1.4.8)
|
||||
* @param extendBeyondLastLab PROLONGATION (v1.6.0, opt-in) : au-delà du
|
||||
* dernier lab significatif, ρ reste CONSTANT
|
||||
* (= ρ du dernier lab) et la courbe continue
|
||||
* `M(t) × ρ_last` jusqu'à
|
||||
* [extensionHorizonEndMs] ∩ fenêtre demandée.
|
||||
* false (défaut) = comportement v1.5.0 exact :
|
||||
* la fenêtre s'arrête au dernier lab.
|
||||
*/
|
||||
fun computeLabAnchoredCurve(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
e2Labs: List<LabResult>,
|
||||
startMs: Long,
|
||||
endMs: Long,
|
||||
stepMs: Long = 3_600_000L,
|
||||
modelOverride: String? = null,
|
||||
extendBeyondLastLab: Boolean = false
|
||||
): AnchoredCurve {
|
||||
if (startMs >= endMs) return AnchoredCurve(emptyList(), 0)
|
||||
|
||||
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
|
||||
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
||||
|
||||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
||||
// facteur — combiner avec le facteur stocké = double correction).
|
||||
// Contexte moteur pré-calculé UNE fois (v1.9.5, audit) : la boucle
|
||||
// de grille appelle e2At par point (720 pts) — sans contexte
|
||||
// partagé, chaque appel reconstruisait les maps internes.
|
||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||
val e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs)
|
||||
|
||||
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
||||
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
||||
val anchors = mutableListOf<Pair<Long, Double>>()
|
||||
var maxPredictionSoFar = 0.0
|
||||
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
||||
val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride)
|
||||
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||
anchors.add(lab.timestamp to (lab.value / predicted))
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||
}
|
||||
if (anchors.size < 2) return AnchoredCurve(emptyList(), anchors.size)
|
||||
|
||||
val firstLabMs = anchors.first().first
|
||||
val lastLabMs = anchors.last().first
|
||||
|
||||
// ── 2) Fenêtre effective ─────────────────────────────────────────────
|
||||
// v1.5.0 : [1er lab ; dernier lab] ∩ fenêtre demandée.
|
||||
// v1.6.0 (extendBeyondLastLab) : la borne droite devient le min de la
|
||||
// fenêtre demandée et de l'HORIZON DE PROLONGATION (dernière dose E2 +
|
||||
// cutoff de son traitement). Au-delà du dernier lab, ratioAt() retourne
|
||||
// déjà ρ_last (garde défensive aux bornes) : la boucle de calcul est
|
||||
// INCHANGÉE — élargir la fenêtre EST la prolongation, et la continuité
|
||||
// au point de suture est garantie par construction (ρ(t_last) = ρ_last
|
||||
// exactement, dérivable à gauche comme à droite puisque ρ est constant
|
||||
// après).
|
||||
val t0 = maxOf(startMs, firstLabMs)
|
||||
val t1 = if (extendBeyondLastLab) {
|
||||
minOf(endMs, extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) ?: lastLabMs)
|
||||
} else {
|
||||
minOf(endMs, lastLabMs)
|
||||
}
|
||||
if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size)
|
||||
|
||||
// ── 3) Courbe = M(t) × ρ(t) sur la grille ─────────────────────────────
|
||||
val points = mutableListOf<LevelPoint>()
|
||||
var t = t0
|
||||
while (t <= t1) {
|
||||
val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
|
||||
val rho = ratioAt(anchors, t)
|
||||
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
|
||||
t += stepMs
|
||||
}
|
||||
return AnchoredCurve(points, anchors.size, lastLabMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* BORNE DE PROLONGATION (v1.6.0) : timestamp jusqu'où la partie extrapolée
|
||||
* a un sens. Défini par le MODÈLE, pas par un cap arbitraire :
|
||||
*
|
||||
* horizon = (timestamp de la dernière dose E2) + cutoffHours(son traitement)
|
||||
*
|
||||
* où cutoffHours = 10 t½ terminales (V3C/WHS), fin de table (ODS) ou
|
||||
* 30 t½ (Bateman) — cf [PharmacokineticEngine.cutoffHours]. Après cette
|
||||
* borne, M(t) = 0 : la courbe prolongée serait une ligne à zéro inventée.
|
||||
*
|
||||
* @return le timestamp de fin de prolongation, ou `null` quand il n'y a
|
||||
* RIEN à prolonger : aucune dose E2, ou modèle déjà éteint au dernier
|
||||
* lab (le caller retombe alors sur la fenêtre v1.5.0).
|
||||
*/
|
||||
internal fun extensionHorizonEndMs(
|
||||
estrogenTreatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
lastLabMs: Long
|
||||
): Long? {
|
||||
val e2Doses = doseLogs.filter { d -> estrogenTreatments.any { it.id == d.treatmentId } }
|
||||
val lastDose = e2Doses.maxByOrNull { it.timestamp } ?: return null
|
||||
val tr = estrogenTreatments.first { it.id == lastDose.treatmentId }
|
||||
val cutoffMs = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong()
|
||||
val horizonEnd = lastDose.timestamp + cutoffMs
|
||||
return if (horizonEnd > lastLabMs) horizonEnd else null
|
||||
}
|
||||
|
||||
/**
|
||||
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
|
||||
* (`anchors` trié croissant). Pour t ∈ [t_k ; t_(k+1)] :
|
||||
*
|
||||
* ρ(t) = exp( lnρ_k + (t − t_k)/(t_(k+1) − t_k) · (lnρ_(k+1) − lnρ_k) )
|
||||
*
|
||||
* Garanties : ρ(t_k) = ρ_k exactement (passage par le lab), continuité
|
||||
* multiplicative (pas de saut), monotone entre deux références.
|
||||
* Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne
|
||||
* (garde division par zéro).
|
||||
*
|
||||
* HORS bornes : avant le 1ᵉʳ / après le DERNIER ancre → ρ de borne.
|
||||
* v1.6.0 : la branche « après le dernier » (return ρ_last) est devenue un
|
||||
* COMPORTEMENT, pas une simple garde — c'est elle qui fonde la
|
||||
* prolongation (ρ constant au-delà du dernier lab, cf KDoc de l'objet) ;
|
||||
* en v1.5.0 la fenêtre du caller n'y entrait jamais.
|
||||
*/
|
||||
internal fun ratioAt(anchors: List<Pair<Long, Double>>, tMs: Long): Double {
|
||||
if (tMs <= anchors.first().first) return anchors.first().second
|
||||
if (tMs >= anchors.last().first) return anchors.last().second
|
||||
|
||||
for (i in 0 until anchors.size - 1) {
|
||||
val (t0, r0) = anchors[i]
|
||||
val (t1, r1) = anchors[i + 1]
|
||||
if (tMs in t0..t1) {
|
||||
val span = t1 - t0
|
||||
if (span <= 0L) return r1 // doublon de timestamp : 2ᵉ gagne
|
||||
val f = (tMs - t0).toDouble() / span.toDouble()
|
||||
return exp(ln(r0) + f * (ln(r1) - ln(r0)))
|
||||
}
|
||||
}
|
||||
// inatteignable si anchors croissant et bornes cohérentes — garde
|
||||
return anchors.last().second
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,22 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.JsonParser
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* ⚠️ v1.9.0 : ce lecteur de tables ODS n'est PLUS UTILISÉ AU RUNTIME —
|
||||
* le modèle Estrannaise est devenu ANALYTIQUE (pk/EstrannaiseModels.kt,
|
||||
* fidélité aux tables épinglée RMS 0) et tous les dispatchs moteur lisent
|
||||
* des sources originelles. Il est conservé UNIQUEMENT pour :
|
||||
* 1. le **test de fidélité** (EstrannaiseModelsTest : forme close vs
|
||||
* tables, RMS 0 — garde anti-régression du débranchement) ;
|
||||
* 2. le jeu de données historique (`pk_profiles.json`, déplacé en
|
||||
* `src/test/assets/` — l'APK ne l'embarque plus, −550 Ko).
|
||||
*
|
||||
* L'ancienne [init] (lecture de l'asset au démarrage) a été retirée :
|
||||
* [initWithJson] suffit pour les tests.
|
||||
*/
|
||||
object PKProfileStore {
|
||||
|
||||
@Volatile
|
||||
@ -10,15 +24,6 @@ object PKProfileStore {
|
||||
|
||||
private lateinit var profiles: Map<String, FloatArray>
|
||||
|
||||
fun init(context: Context) {
|
||||
if (initialized) return
|
||||
synchronized(this) {
|
||||
if (initialized) return
|
||||
val json = context.assets.open("pk_profiles.json").bufferedReader().use { it.readText() }
|
||||
initWithJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
|
||||
fun initWithJson(json: String) {
|
||||
synchronized(this) {
|
||||
@ -36,7 +41,7 @@ object PKProfileStore {
|
||||
}
|
||||
|
||||
fun profileKey(ester: String, model: String): String =
|
||||
"${ester}_${if (model == com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
|
||||
"${ester}_${if (model == PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
|
||||
|
||||
/**
|
||||
* Case-insensitive lookup. The ODS asset keys use the biological casing
|
||||
@ -56,6 +61,62 @@ object PKProfileStore {
|
||||
fun profileLength(ester: String, model: String): Int =
|
||||
lookup(profileKey(ester, model))?.size ?: 0
|
||||
|
||||
/**
|
||||
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) :
|
||||
* même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du
|
||||
* pic, pente = décroissance moyenne sur les 48 h précédentes, jamais
|
||||
* avant le pic) — t½ = ln2 ÷ |pente|.
|
||||
*
|
||||
* Pourquoi ce helper : la recommandation de prochaine prise de sang
|
||||
* ([LabTiming]) a besoin des 5 × t½ terminales de l'ester pour juger de
|
||||
* la STABILISATION du régime — et le modèle Estrannaise (tables ODS)
|
||||
* n'a pas de forme close : sa t½ se LIT dans la table. Les modèles TFS/
|
||||
* WHSAH, eux, exposent leur t½ analytiquement (`terminalHalfLifeDays`).
|
||||
*
|
||||
* @return t½ en jours (≥ 0), ou `null` si la table ne permet pas
|
||||
* d'estimer une décroissance terminale exploitable (ester inconnu,
|
||||
* pic nul, pas de queue décroissante).
|
||||
*/
|
||||
fun terminalHalfLifeDays(ester: String, model: String): Double? {
|
||||
if (!initialized) return null
|
||||
val suffix = when (model) {
|
||||
PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||
PKModels.ESTRANNAISE -> "ese"
|
||||
else -> return 0.0
|
||||
}
|
||||
val arr = lookup("${ester}_$suffix") ?: return null
|
||||
val (j, rate) = terminalDecayParameters(arr) ?: return null
|
||||
if (rate >= 0.0) return null
|
||||
return ln(2.0) / -rate / 24.0 // pente /h → t½ en jours
|
||||
}
|
||||
|
||||
/**
|
||||
* Paramètres de DÉCROISSANCE TERMINALE d'une table (partagés par
|
||||
* [sample] — extrapolation au-delà de la fin — et [terminalHalfLifeDays])
|
||||
* : `(index du dernier point ≥ 1 % du pic, pente log-linéaire /h)` sur
|
||||
* les 48 h précédentes, jamais avant le pic. `null` si la table n'a ni
|
||||
* pic ni queue exploitable.
|
||||
*/
|
||||
private fun terminalDecayParameters(arr: FloatArray): Pair<Int, Double>? {
|
||||
var peakIdx = 0
|
||||
var peakV = 0f
|
||||
for (idx in arr.indices) {
|
||||
if (arr[idx] > peakV) {
|
||||
peakV = arr[idx]
|
||||
peakIdx = idx
|
||||
}
|
||||
}
|
||||
if (peakV <= 0f) return null
|
||||
var j = arr.size - 1
|
||||
while (j > 0 && arr[j] < peakV * 0.01f) j--
|
||||
if (j <= 0) return null
|
||||
val window = 48
|
||||
val j0 = maxOf(peakIdx, j - window)
|
||||
if (j <= j0) return null
|
||||
val rate = kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
|
||||
return j to rate
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
|
||||
* linearly interpolated between hourly points; beyond the table, extrapolated
|
||||
@ -64,8 +125,8 @@ object PKProfileStore {
|
||||
fun sample(ester: String, model: String, dtHours: Double): Double {
|
||||
if (!initialized || dtHours <= 0.0) return 0.0
|
||||
val suffix = when (model) {
|
||||
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese"
|
||||
PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||
PKModels.ESTRANNAISE -> "ese"
|
||||
else -> return 0.0
|
||||
}
|
||||
val arr = lookup("${ester}_$suffix") ?: return 0.0
|
||||
@ -77,23 +138,7 @@ object PKProfileStore {
|
||||
// display floor long before the true value vanishes. Extrapolate from the
|
||||
// last point still >= 1% of the peak, using the average hourly decay of
|
||||
// the previous 48 h (never sampling before the peak).
|
||||
var peakIdx = 0
|
||||
var peakV = 0f
|
||||
for (idx in arr.indices) {
|
||||
if (arr[idx] > peakV) {
|
||||
peakV = arr[idx]
|
||||
peakIdx = idx
|
||||
}
|
||||
}
|
||||
if (peakV <= 0f) return 0.0
|
||||
var j = lastIdx
|
||||
while (j > 0 && arr[j] < peakV * 0.01f) j--
|
||||
if (j <= 0) return 0.0
|
||||
val window = 48
|
||||
val j0 = maxOf(peakIdx, j - window)
|
||||
val rate = if (j > j0) {
|
||||
kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
|
||||
} else 0.0
|
||||
val (j, rate) = terminalDecayParameters(arr) ?: return 0.0
|
||||
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
|
||||
}
|
||||
|
||||
|
||||
@ -88,16 +88,22 @@ object PharmacokineticEngine {
|
||||
val model = modelOverride ?: treatment.pkModel
|
||||
// v1.4.0 : le modèle Transfem Science utilise les paramètres
|
||||
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
||||
// forme close — les tables ODS restent le fallback (esters
|
||||
// sans modèle V3C, ou modèle Estrannaise).
|
||||
// forme close.
|
||||
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
|
||||
// 3ᵉ voie de dispatch, au même niveau que TFS.
|
||||
// v1.9.0 : l'Estrannaise devient ANALYTIQUE (forme close de
|
||||
// estrannaise.js — fidélité aux tables ODS épinglée RMS 0) :
|
||||
// les 4 voies de dispatch sont toutes des sources originelles,
|
||||
// PLUS AUCUNE lecture des tables ODS au runtime (décision
|
||||
// v1.9.0 : l'ODS n'était que l'échantillonnage de ces sources).
|
||||
return when {
|
||||
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) ->
|
||||
TransfemScienceModels.sample(ester, dtH) * mg
|
||||
model == PKModels.WHSAH && WhsahModels.hasModel(ester) ->
|
||||
WhsahModels.sample(ester, dtH) * mg
|
||||
else -> PKProfileStore.sample(ester, model, dtH) * mg
|
||||
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(ester) ->
|
||||
EstrannaiseModels.sample(ester, dtH) * mg
|
||||
else -> 0.0 // ester sans modèle (documenté : ni TFS ni WHSAH ni ESE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -114,7 +120,22 @@ object PharmacokineticEngine {
|
||||
return max(0.0, c)
|
||||
}
|
||||
|
||||
private fun cutoffHours(treatment: Treatment): Double {
|
||||
/**
|
||||
* Coupure temporelle (HEURES) au-delà de laquelle une dose de ce traitement
|
||||
* ne contribue plus à la prédiction :
|
||||
* - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit
|
||||
* (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ;
|
||||
* - profil Estrannaise → 10 demi-vies TERMINALES analytiques (v1.9.0 ;
|
||||
* l'ancienne coupure « longueur de table ODS 8001 h » a disparu avec
|
||||
* les tables du runtime) ;
|
||||
* - Bateman (gel/patch/oral) → 30 demi-vies d'élimination.
|
||||
*
|
||||
* Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs]
|
||||
* pour borner la PROLONGATION du tracé labs — la partie extrapolée s'éteint
|
||||
* exactement quand le modèle lui-même s'éteint (même sémantique de coupure,
|
||||
* une seule source de vérité).
|
||||
*/
|
||||
fun cutoffHours(treatment: Treatment): Double {
|
||||
val profileH = if (treatment.usesProfileModel) {
|
||||
val model = treatment.pkModel
|
||||
when {
|
||||
@ -126,8 +147,12 @@ object PharmacokineticEngine {
|
||||
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
|
||||
TransfemScienceModels.model(treatment.esterType)!!
|
||||
.terminalHalfLifeDays * 24.0 * 10.0
|
||||
else ->
|
||||
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
|
||||
// v1.9.0 : ESE analytique → t½ terminale ANALYTIQUE (comme
|
||||
// TFS/WHS) au lieu de la longueur de table ODS
|
||||
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(treatment.esterType) ->
|
||||
EstrannaiseModels.model(treatment.esterType)!!
|
||||
.terminalHalfLifeDays * 24.0 * 10.0
|
||||
else -> 0.0
|
||||
}
|
||||
} else 0.0
|
||||
val batemanH = if (treatment.usesProfileModel) 0.0
|
||||
@ -139,6 +164,65 @@ object PharmacokineticEngine {
|
||||
// Aggregated levels
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
|
||||
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
|
||||
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
|
||||
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
|
||||
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
|
||||
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
|
||||
* bit-identique (mêmes boucles, même ordre de sommation flottante).
|
||||
*/
|
||||
class E2Context internal constructor(
|
||||
internal val treatments: List<Treatment>,
|
||||
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
|
||||
internal val cutoffByTreatment: HashMap<Long, Double>,
|
||||
internal val batemanCache: HashMap<Long, BatemanParams>,
|
||||
)
|
||||
|
||||
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
|
||||
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
|
||||
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
||||
val cutoffByTreatment = HashMap<Long, Double>()
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
for (treatment in treatments) {
|
||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
|
||||
if (!treatment.usesProfileModel) {
|
||||
batemanCache[treatment.id] = batemanParams(treatment)
|
||||
}
|
||||
}
|
||||
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
|
||||
}
|
||||
|
||||
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
|
||||
fun e2At(
|
||||
ctx: E2Context,
|
||||
tMs: Long,
|
||||
modelOverride: String? = null,
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): Double {
|
||||
var total = 0.0
|
||||
for (treatment in ctx.treatments) {
|
||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
|
||||
if (dose.timestamp > tMs) continue
|
||||
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
||||
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
|
||||
val c = concentrationOfDose(
|
||||
treatment, dose, tMs, ctx.batemanCache[treatment.id], modelOverride
|
||||
)
|
||||
if (c > 0.0) {
|
||||
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
||||
?: treatment.scaleFactor
|
||||
total += c * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** e2At à appel unique : prépare le contexte puis délègue. */
|
||||
fun e2At(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
@ -152,28 +236,7 @@ object PharmacokineticEngine {
|
||||
*/
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): Double {
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
var total = 0.0
|
||||
for (treatment in treatments) {
|
||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||
if (!batemanCache.containsKey(treatment.id)) {
|
||||
batemanCache[treatment.id] = batemanParams(treatment)
|
||||
}
|
||||
for (dose in doseLogs) {
|
||||
if (dose.treatmentId != treatment.id || dose.timestamp > tMs) continue
|
||||
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
||||
if (dtH > cutoffHours(treatment)) continue
|
||||
val c = concentrationOfDose(
|
||||
treatment, dose, tMs, batemanCache[treatment.id], modelOverride
|
||||
)
|
||||
if (c > 0.0) {
|
||||
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
||||
?: treatment.scaleFactor
|
||||
total += c * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
|
||||
}
|
||||
|
||||
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
||||
@ -279,7 +342,15 @@ object PharmacokineticEngine {
|
||||
val searchStart = maxOf(startMs, earliestDose)
|
||||
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
|
||||
relevantTreatments.forEach {
|
||||
// Bateman paresseux (v1.8.2) : inutile pour les traitements à profil
|
||||
if (!it.usesProfileModel) batemanCache[it.id] = batemanParams(it)
|
||||
}
|
||||
// v1.8.2 : cutoffs pré-calculés (déterministes par traitement) et
|
||||
// doses pré-groupées — finis les scans complets de doseLogs à chaque
|
||||
// point × traitement (l'ordre de sommation est préservé, cf e2At)
|
||||
val cutoffByTreatment = relevantTreatments.associate { it.id to cutoffHours(it) }
|
||||
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
||||
|
||||
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
|
||||
val sortedEstrogenDoses = doseLogs
|
||||
@ -301,10 +372,10 @@ object PharmacokineticEngine {
|
||||
}
|
||||
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
|
||||
for (treatment in relevantTreatments) {
|
||||
val cutoff = cutoffHours(treatment)
|
||||
val cutoff = cutoffByTreatment.getValue(treatment.id)
|
||||
val p = batemanCache[treatment.id]
|
||||
for (dose in doseLogs) {
|
||||
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue
|
||||
for (dose in dosesByTreatment[treatment.id].orEmpty()) {
|
||||
if (dose.timestamp > t) continue
|
||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||
if (dtH > cutoff) continue
|
||||
val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
|
||||
@ -345,6 +416,14 @@ object PharmacokineticEngine {
|
||||
): List<DoseLog> {
|
||||
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
|
||||
if (intervalDays <= 0.0) return emptyList()
|
||||
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
|
||||
// projette PLUS de créneaux — l'archivage arrête les prédictions
|
||||
// d'injections futures (graphique Prévision, extension de fenêtre,
|
||||
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
|
||||
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
|
||||
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
|
||||
// FUTUR s'arrête.
|
||||
if (!treatment.isActive) return emptyList()
|
||||
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
|
||||
if (intervalMs <= 0L) return emptyList()
|
||||
|
||||
@ -412,6 +491,8 @@ object PharmacokineticEngine {
|
||||
// stocké du traitement, ce qui produisait des facteurs absurdes dès
|
||||
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
|
||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
|
||||
val ctx = prepareE2Context(unscaled, doseLogs)
|
||||
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
||||
var maxPredictionSoFar = 0.0
|
||||
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
||||
@ -419,7 +500,7 @@ object PharmacokineticEngine {
|
||||
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
||||
// tombait à ×2,21
|
||||
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride)
|
||||
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
|
||||
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||
|
||||
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
||||
@ -436,10 +517,7 @@ object PharmacokineticEngine {
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||
}
|
||||
return ratiosByEster.mapValues { (_, ratios) ->
|
||||
val sorted = ratios.sorted()
|
||||
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
|
||||
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
|
||||
(median * 100).toInt() / 100.0
|
||||
medianRounded(ratios, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@ -495,12 +573,7 @@ object PharmacokineticEngine {
|
||||
}
|
||||
if (e2 > maxE2SoFar) maxE2SoFar = e2
|
||||
}
|
||||
return ksByEster.mapValues { (_, ks) ->
|
||||
val sorted = ks.sorted()
|
||||
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
|
||||
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
|
||||
(median * 1000).toInt() / 1000.0
|
||||
}
|
||||
return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -589,6 +662,26 @@ object PharmacokineticEngine {
|
||||
* jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ;
|
||||
* 0 = premier lab → seule la garde > 0,5 s'applique.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MÉDIANE arrondie d'une liste de ratios (helper partagé par les 4
|
||||
* pipelines de calibration — était dupliqué 4×). Liste triée en place.
|
||||
* @param decimals nombre de décimales après arrondi (via troncature ×10^n)
|
||||
*/
|
||||
private fun medianRounded(values: MutableList<Double>, decimals: Int): Double {
|
||||
values.sort()
|
||||
val median = if (values.size % 2 == 1) values[values.size / 2]
|
||||
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
|
||||
val factor = Math.pow(10.0, decimals.toDouble())
|
||||
// ⚠️ ARRONDI (v1.8.2), pas troncature : l'ancien motif
|
||||
// `(median * factor).toInt() / factor` tronquait au dernier bit
|
||||
// flottant — 0.8999999999999999 × 100 → toInt() = 89 → « 0,89 »
|
||||
// (le modèle ESE analytique a déplacé ce bit, révélant le piège ;
|
||||
// le bug #61 historique du même genre : 6,5 → 6 avec
|
||||
// kotlin.math.round — là, Math.round est l'arrondi au plus proche).
|
||||
return Math.round(median * factor) / factor
|
||||
}
|
||||
|
||||
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
|
||||
predicted > 0.5 &&
|
||||
(maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar)
|
||||
@ -623,13 +716,7 @@ object PharmacokineticEngine {
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||
}
|
||||
if (ratios.isEmpty()) return null
|
||||
ratios.sort()
|
||||
val median = if (ratios.size % 2 == 1) {
|
||||
ratios[ratios.size / 2]
|
||||
} else {
|
||||
(ratios[ratios.size / 2 - 1] + ratios[ratios.size / 2]) / 2.0
|
||||
}
|
||||
return (median * 100).toInt() / 100.0
|
||||
return medianRounded(ratios, 2)
|
||||
}
|
||||
|
||||
fun computeTConfigCalibration(
|
||||
@ -638,8 +725,10 @@ object PharmacokineticEngine {
|
||||
doseLogs: List<DoseLog>,
|
||||
current: TConfig
|
||||
): TConfig? {
|
||||
// Le premier filtre « brut > floor » est soussumé par celui sur la
|
||||
// valeur CONVERTIE (toute conversion est ≤ la valeur brute) : un seul
|
||||
// filtre après conversion suffit — résultat strictement identique.
|
||||
val usable = tLabs
|
||||
.filter { it.value > current.floor + 0.02 }
|
||||
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
|
||||
.filter { it.value > current.floor + 0.02 }
|
||||
if (usable.isEmpty()) return null
|
||||
@ -652,13 +741,33 @@ object PharmacokineticEngine {
|
||||
if (k > 1e-4 && k < 10.0) ks.add(k)
|
||||
}
|
||||
if (ks.isEmpty()) return null
|
||||
ks.sort()
|
||||
val median = if (ks.size % 2 == 1) {
|
||||
ks[ks.size / 2]
|
||||
} else {
|
||||
(ks[ks.size / 2 - 1] + ks[ks.size / 2]) / 2.0
|
||||
return current.copy(k = medianRounded(ks, 3))
|
||||
}
|
||||
|
||||
/**
|
||||
* Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
|
||||
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
|
||||
*
|
||||
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
|
||||
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
|
||||
* pour une prise déjà faite — le rappel suivant repart au créneau
|
||||
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
|
||||
* S'applique identiquement au fallback quotidien (oral sans Posologie :
|
||||
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
|
||||
*/
|
||||
fun hasDoseLoggedOnDay(
|
||||
treatmentId: Long,
|
||||
doseLogs: List<DoseLog>,
|
||||
atMs: Long,
|
||||
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
|
||||
): Boolean {
|
||||
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
|
||||
return doseLogs.any {
|
||||
it.treatmentId == treatmentId &&
|
||||
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
|
||||
}
|
||||
return current.copy(k = (median * 1000).toInt() / 1000.0)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -20,7 +20,6 @@ import com.hormonetrack.R
|
||||
import com.hormonetrack.data.AppDatabase
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.util.AppLog
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
@ -188,6 +189,6 @@ object CalendarEvents {
|
||||
private fun formatInterval(treatment: Treatment): String =
|
||||
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
|
||||
|
||||
private fun formatDose(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d)
|
||||
// formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt
|
||||
// (dédupliquée v1.8.2 — la même règle d'affichage des doses partout)
|
||||
}
|
||||
|
||||
@ -43,7 +43,14 @@ class DoseActionReceiver : BroadcastReceiver() {
|
||||
ReminderContract.ACTION_SNOOZE -> {
|
||||
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
|
||||
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
|
||||
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose)
|
||||
// v1.10.1 : le snooze FORWARDE l'instant planifié d'origine —
|
||||
// le garde du feu différé évalue le jour du CRÉNEAU, pas
|
||||
// celui du snooze (sinon un snooze à cheval sur minuit
|
||||
// évaluerait le mauvais jour).
|
||||
val plannedAt = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
|
||||
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
|
||||
} else null
|
||||
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose, plannedAt = plannedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,11 +15,11 @@ import com.hormonetrack.data.AppDatabase
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.util.AppLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Calendar
|
||||
|
||||
object ReminderContract {
|
||||
const val CHANNEL_ID = "hormonetrack_reminders"
|
||||
@ -30,6 +30,17 @@ object ReminderContract {
|
||||
const val EXTRA_TREATMENT_NAME = "treatment_name"
|
||||
const val EXTRA_DOSE_AMOUNT = "dose_amount"
|
||||
|
||||
/**
|
||||
* Instant PLANIFIÉ de l'alarme (v1.10.1) — embarqué par
|
||||
* AlarmScheduler.scheduleFor au moment du scheduling et relu par
|
||||
* [ReminderReceiver]. ⚠️ Nécessaire car une alarme INEXACTE (setWindow
|
||||
* sans SCHEDULE_EXACT_ALARM) ou retardée (Doze) peut fire après la
|
||||
* frontière de jour : le garde « créneau déjà honoré » doit évaluer le
|
||||
* jour PLANIFIÉ, pas le jour réel du feu. Absent (vieux PendingIntents)
|
||||
* → le receiver retombe sur l'heure courante.
|
||||
*/
|
||||
const val EXTRA_PLANNED_AT = "planned_at"
|
||||
|
||||
fun reminderIntent(context: Context, treatmentId: Long): Intent =
|
||||
Intent(context, ReminderReceiver::class.java).apply {
|
||||
action = ACTION_REMINDER
|
||||
@ -53,10 +64,16 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent {
|
||||
private fun pendingBroadcast(
|
||||
treatmentId: Long,
|
||||
name: String?,
|
||||
dose: Double?,
|
||||
plannedAt: Long? = null
|
||||
): PendingIntent {
|
||||
val intent = ReminderContract.reminderIntent(context, treatmentId)
|
||||
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
|
||||
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
|
||||
plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) }
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
treatmentId.toInt(),
|
||||
@ -80,12 +97,27 @@ class AlarmScheduler(private val context: Context) {
|
||||
val fireAt = PharmacokineticEngine.nextReminderFireFor(
|
||||
treatment, doseLogs, System.currentTimeMillis()
|
||||
) ?: return false
|
||||
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount)
|
||||
// L'instant planifié voyage dans l'intent : le garde « créneau déjà
|
||||
// honoré » (v1.10.1) évalue le jour PLANIFIÉ, pas celui du feu réel
|
||||
// (une alarme inexacte peut glisser après minuit — cf
|
||||
// ReminderContract.EXTRA_PLANNED_AT).
|
||||
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount, fireAt)
|
||||
return scheduleAt(fireAt, pi)
|
||||
}
|
||||
|
||||
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean {
|
||||
val pi = pendingBroadcast(treatmentId, name, dose)
|
||||
/**
|
||||
* Alarme « Reporter 1 h ». [plannedAt] : l'instant PLANIFIÉ du créneau
|
||||
* d'origine — forwardé tel quel pour que le garde du feu différé
|
||||
* évalue toujours le bon jour (et non le jour du snooze).
|
||||
*/
|
||||
fun scheduleSnooze(
|
||||
treatmentId: Long,
|
||||
name: String,
|
||||
dose: Double,
|
||||
delayMs: Long = 60 * 60_000L,
|
||||
plannedAt: Long? = null
|
||||
): Boolean {
|
||||
val pi = pendingBroadcast(treatmentId, name, dose, plannedAt)
|
||||
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
|
||||
}
|
||||
|
||||
@ -208,19 +240,41 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(treatmentId.toInt(), notification)
|
||||
|
||||
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
||||
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
|
||||
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
|
||||
// (grille Posologie si elle existe, sinon quotidien).
|
||||
// v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée
|
||||
// (prise saisie avant l'heure de rappel), la notification est SAUTÉE —
|
||||
// elle ne doit pas sonner pour une prise déjà faite — et le rappel
|
||||
// suivant repart au créneau suivant (re-schedule dans tous les cas).
|
||||
// v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ
|
||||
// (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte
|
||||
// peut glisser après minuit sans changer de créneau.
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
|
||||
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
|
||||
} else {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
val pending = goAsync()
|
||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO).launch {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val doses = db.doseLogDao().getAllOnce()
|
||||
if (PharmacokineticEngine.hasDoseLoggedOnDay(
|
||||
treatmentId, doses, plannedAtMs
|
||||
)
|
||||
) {
|
||||
AppLog.log(
|
||||
"reminder",
|
||||
"créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)"
|
||||
)
|
||||
} else {
|
||||
manager.notify(treatmentId.toInt(), notification)
|
||||
}
|
||||
db.treatmentDao().getById(treatmentId)?.let { tr ->
|
||||
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce())
|
||||
AlarmScheduler(context).scheduleFor(tr, doses)
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
@ -232,23 +286,23 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val result = goAsync()
|
||||
Thread {
|
||||
// v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver —
|
||||
// remplace le Thread + double runBlocking de la v1.0, dette §20.bis #2) :
|
||||
// une seule lecture séquentielle des DAO dans un scope IO dédié, finish()
|
||||
// garanti par le finally.
|
||||
val pending = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
// TOUS les traitements : les inactifs doivent voir leur alarme
|
||||
// annulée au boot (sinon une alarme périmée continue de sonner) ;
|
||||
// les doses alimentent la grille Posologie (fix #52)
|
||||
val treatments = kotlinx.coroutines.runBlocking {
|
||||
db.treatmentDao().getAllOnce()
|
||||
}
|
||||
val doses = kotlinx.coroutines.runBlocking {
|
||||
db.doseLogDao().getAllOnce()
|
||||
}
|
||||
val treatments = db.treatmentDao().getAllOnce()
|
||||
val doses = db.doseLogDao().getAllOnce()
|
||||
AlarmScheduler(context).rescheduleAll(treatments, doses)
|
||||
} finally {
|
||||
result.finish()
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hormonetrack.pk.Alerts
|
||||
@ -52,6 +54,40 @@ class AppSettings(private val context: Context) {
|
||||
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
|
||||
*/
|
||||
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ────────────────────────────────
|
||||
/** Activer/désactiver la sauvegarde automatique (défaut : false). */
|
||||
val AUTO_BACKUP_ENABLED = booleanPreferencesKey("auto_backup_enabled")
|
||||
|
||||
/**
|
||||
* Dossier cible SAF (v1.7.0) : tree URI issu d'ACTION_OPEN_DOCUMENT_TREE,
|
||||
* persisté via takePersistableUriPermission (la permission SURVIT au
|
||||
* reboot — le worker peut écrire sans intervention). null = pas de
|
||||
* dossier choisi → l'auto-backup ne peut pas tourner (worker no-op).
|
||||
*/
|
||||
val AUTO_BACKUP_TREE_URI = stringPreferencesKey("auto_backup_tree_uri")
|
||||
|
||||
/**
|
||||
* Nombre de copies d'auto-backup conservées dans le dossier (1..30,
|
||||
* défaut 7) — les plus vieilles sont supprimées après chaque run
|
||||
* (cf AutoBackupRetention ; l'export MANUEL n'est jamais touché).
|
||||
*/
|
||||
val AUTO_BACKUP_KEEP = intPreferencesKey("auto_backup_keep")
|
||||
|
||||
/** Résultat du DERNIER run auto : "ok" / "error" (null = jamais tourné). */
|
||||
val AUTO_BACKUP_LAST_RESULT = stringPreferencesKey("auto_backup_last_result")
|
||||
|
||||
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
||||
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
|
||||
|
||||
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
|
||||
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
|
||||
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
|
||||
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
|
||||
// les deux bandes sont volontairement indépendantes. Champ absent =
|
||||
// cible désactivée (comportement v1.12).
|
||||
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
|
||||
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
|
||||
}
|
||||
|
||||
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||
@ -136,6 +172,57 @@ class AppSettings(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
|
||||
|
||||
/** Auto-backup activé (défaut DÉSACTIVÉ — opt-in, comme tout ce qui écrit). */
|
||||
val autoBackupEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_ENABLED] ?: false
|
||||
}
|
||||
|
||||
/** Dossier cible SAF persisté (null = pas de dossier → worker no-op). */
|
||||
val autoBackupTreeUri: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_TREE_URI]
|
||||
}
|
||||
|
||||
/** Copies conservées (1..30, défaut 7 — borné au SET, cf setAutoBackupKeep). */
|
||||
val autoBackupKeep: Flow<Int> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_KEEP] ?: 7
|
||||
}
|
||||
|
||||
/** Résultat du dernier run : Pair(résultat, horodatage) ; null = jamais tourné. */
|
||||
val autoBackupLastRun: Flow<Pair<String?, Long?>> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_LAST_RESULT] to prefs[Keys.AUTO_BACKUP_LAST_AT]
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[Keys.AUTO_BACKUP_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupTreeUri(uri: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (uri.isNullOrBlank()) prefs.remove(Keys.AUTO_BACKUP_TREE_URI)
|
||||
else prefs[Keys.AUTO_BACKUP_TREE_URI] = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies conservées — borné [1 ; 30] (un `0` supprimerait la dernière copie). */
|
||||
suspend fun setAutoBackupKeep(keep: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_KEEP] = keep.coerceIn(1, 30)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste le résultat du dernier run (appelé par [com.hormonetrack.data.backup.AutoBackupWorker])
|
||||
* — affiché dans Paramètres (« dernier : réussi/échec, date »).
|
||||
*/
|
||||
suspend fun setAutoBackupLastRun(ok: Boolean, atMs: Long) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_LAST_RESULT] = if (ok) "ok" else "error"
|
||||
prefs[Keys.AUTO_BACKUP_LAST_AT] = atMs
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAlertNotifiedState(encoded: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)
|
||||
@ -143,6 +230,30 @@ class AppSettings(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
|
||||
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
|
||||
val low = prefs[Keys.TROUGH_TARGET_LOW]
|
||||
val high = prefs[Keys.TROUGH_TARGET_HIGH]
|
||||
if (low != null && high != null) low to high else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
|
||||
* « champs vides = cible désactivée ». La cohérence high > low est
|
||||
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
|
||||
*/
|
||||
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (target == null) {
|
||||
prefs.remove(Keys.TROUGH_TARGET_LOW)
|
||||
prefs.remove(Keys.TROUGH_TARGET_HIGH)
|
||||
} else {
|
||||
prefs[Keys.TROUGH_TARGET_LOW] = target.first
|
||||
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
|
||||
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package com.hormonetrack.settings
|
||||
|
||||
/** Précompilées (v1.8.2) : appelées à chaque ouverture du dialog « Nouveautés » */
|
||||
private val VERSION_PATTERN = Regex("^\\d+(\\.\\d+)*$")
|
||||
private val SECTION_PATTERN = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
/**
|
||||
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
|
||||
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
|
||||
@ -28,7 +32,7 @@ object ChangelogHelper {
|
||||
|
||||
private fun parse(version: String): List<Int>? {
|
||||
val cleaned = version.trim().removePrefix("v")
|
||||
if (!Regex("^\\d+(\\.\\d+)*$").matches(cleaned)) return null
|
||||
if (!VERSION_PATTERN.matches(cleaned)) return null
|
||||
return cleaned.split(".").map { it.toInt() }
|
||||
}
|
||||
|
||||
@ -43,7 +47,7 @@ object ChangelogHelper {
|
||||
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
|
||||
*/
|
||||
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
|
||||
val pattern = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
|
||||
val pattern = SECTION_PATTERN
|
||||
val out = StringBuilder()
|
||||
for (match in pattern.findAll(markdown)) {
|
||||
val version = match.groupValues[1]
|
||||
|
||||
@ -46,7 +46,7 @@ val LocalAppContainer = compositionLocalOf<AppContainer> {
|
||||
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
||||
|
||||
@Composable
|
||||
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
||||
fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
|
||||
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
||||
) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
|
||||
// Demande de notification : état détenu par l'activité
|
||||
// (survit aux onglets), consommé UNE fois par HomeScreen
|
||||
// (fix #69 — dialog fantôme au retour sur l'accueil)
|
||||
openLogDoseForTreatmentId = logDoseRequest.value,
|
||||
onLogDoseConsumed = { logDoseRequest.value = null },
|
||||
onOpenSettings = { navController.navigate("settings") },
|
||||
onOpenCharts = {
|
||||
// Tap sur le mini-graphique de l'accueil → écran Graphiques
|
||||
|
||||
@ -3,6 +3,7 @@ package com.hormonetrack.ui.components
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@ -14,11 +15,10 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.pk.LevelPoint
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.ui.theme.ChartE2
|
||||
import com.hormonetrack.ui.theme.ChartT
|
||||
import com.hormonetrack.ui.theme.LabDot
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
@ -41,7 +41,22 @@ data class ChartSeries(
|
||||
val points: List<LevelPoint>,
|
||||
val e2Style: CurveStyle,
|
||||
/** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */
|
||||
val tStyle: CurveStyle? = null
|
||||
val tStyle: CurveStyle? = null,
|
||||
/**
|
||||
* v1.5.0 : cette série participe-t-elle à la détection des pics/creux ?
|
||||
* Défaut true (toutes les séries modèle) ; « Tracé labs » est FALSE —
|
||||
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
|
||||
* modèle, et `labIsSignificant` a déjà filtré les points aberrants.
|
||||
*/
|
||||
val showExtrema: Boolean = true,
|
||||
/**
|
||||
* v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC — cf
|
||||
* pk/EstrannaiseCloud) : chaque sous-liste est une polyligne dessinée en
|
||||
* alpha faible SOUS la courbe principale de la série. null = pas de nuage.
|
||||
* Hors labels/extrema par construction (c'est de l'incertitude, pas des
|
||||
* courbes nommées).
|
||||
*/
|
||||
val cloud: List<List<LevelPoint>>? = null
|
||||
)
|
||||
|
||||
data class ChartOptions(
|
||||
@ -88,16 +103,56 @@ fun xLabelTicks(
|
||||
cal.set(Calendar.MILLISECOND, 0)
|
||||
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
||||
}
|
||||
val stepMs = stepHours * 3_600_000L
|
||||
val out = mutableListOf<Long>()
|
||||
var t = cal.timeInMillis
|
||||
while (t <= t1) {
|
||||
out.add(t)
|
||||
t += stepMs
|
||||
// On avance via le Calendar (et non un +stepMs fixe) : une addition
|
||||
// en millisecondes dérive d'1 h aux transitions DST, alors que le
|
||||
// pas « 24 h » doit tomber sur minuit LOCAL tous les jours — y
|
||||
// compris le jour du changement d'heure.
|
||||
if (stepHours >= 24) {
|
||||
cal.add(Calendar.DAY_OF_MONTH, (stepHours / 24L).toInt().coerceAtLeast(1))
|
||||
} else {
|
||||
cal.add(Calendar.HOUR_OF_DAY, stepHours.toInt())
|
||||
}
|
||||
t = cal.timeInMillis
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un delta de drag en heures de pan, en CUMULANT le résiduel
|
||||
* fractionnaire (v1.4.10, fix #62).
|
||||
*
|
||||
* ⚠️ BUG #62 : l'ancien code tronquait CHAQUE delta incrémental en heures
|
||||
* entières (`(pan.x / largeur * plage).toLong()`) — à la plage 24 h, le
|
||||
* delta d'un doigt réel (~30 px ≈ 0,67 h) était tronqué à 0 → le pan ne
|
||||
* bougeait JAMAIS sur la vue 24 h (alors qu'il marchait sur 7 j+ : le même
|
||||
* delta ≈ 4,7 h). Le fix : le résiduel fractionnaire (la part < 1 h) est
|
||||
* conservé d'un événement de mouvement au suivant — le pan se déclenche dès
|
||||
* que le CUMUL franchit une heure entière.
|
||||
*
|
||||
* Pur → testable en JVM (`ChartZoomTest`).
|
||||
*
|
||||
* @param panPx delta horizontal du geste (px, signé).
|
||||
* @param chartWidthPx largeur du canvas en px.
|
||||
* @param rangeHours plage temporelle courante (24 h / 7 j / 30 j…).
|
||||
* @param residualHours résiduel fractionnaire précédent (à ré-injecter).
|
||||
* @return Pair(heures entières à appliquer au pan, nouveau résiduel).
|
||||
*/
|
||||
fun panDeltaHours(
|
||||
panPx: Float,
|
||||
chartWidthPx: Int,
|
||||
rangeHours: Long,
|
||||
residualHours: Double
|
||||
): Pair<Long, Double> {
|
||||
if (chartWidthPx <= 0 || rangeHours <= 0) return 0L to residualHours
|
||||
val total = panPx / chartWidthPx * rangeHours + residualHours
|
||||
val whole = total.toLong() // troncature vers ZÉRO (gère les deux signes)
|
||||
return whole to (total - whole)
|
||||
}
|
||||
|
||||
/** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */
|
||||
fun niceCeil(value: Double): Double {
|
||||
if (value <= 0) return 100.0
|
||||
@ -129,12 +184,12 @@ fun niceCeil(value: Double): Double {
|
||||
*/
|
||||
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
||||
val toggles = mapOf(
|
||||
"ESE" to pkModelsUsed.contains("ESE"),
|
||||
"TFS" to pkModelsUsed.contains("TFS"),
|
||||
"WHS" to pkModelsUsed.contains("WHS")
|
||||
PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE),
|
||||
PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE),
|
||||
PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH)
|
||||
)
|
||||
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
|
||||
return if (toggles.values.none { it }) toggles + ("TFS" to true) else toggles
|
||||
return if (toggles.values.none { it }) toggles + (PKModels.TRANSFEM_SCIENCE to true) else toggles
|
||||
}
|
||||
|
||||
/**
|
||||
@ -265,12 +320,44 @@ fun CurveChart(
|
||||
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
||||
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
|
||||
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
|
||||
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
|
||||
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
|
||||
// se redessine à chaque geste).
|
||||
val extremaBySeries = remember(series, options) {
|
||||
series.map { s ->
|
||||
Pair(
|
||||
if (s.showExtrema) {
|
||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||
} else emptyList(),
|
||||
if (s.showExtrema) {
|
||||
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
|
||||
// thread-safe — thread UI seul ici — et l'allouer par frame était du
|
||||
// gaspillage, même standard que le fix des marqueurs v1.8.2).
|
||||
val markerTimeFmt = remember(zone) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
|
||||
}
|
||||
val xAxisHourFmt = remember(zone) {
|
||||
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
|
||||
}
|
||||
val xAxisDayFmt = remember(zone) {
|
||||
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val allPoints = series.flatMap { it.points }
|
||||
if (allPoints.size < 2) return@Canvas
|
||||
val padLeft = 42.dp.toPx()
|
||||
val padRight = 42.dp.toPx()
|
||||
val padTop = 12.dp.toPx()
|
||||
// v1.7.0 : padTop élargi (12 → 26 dp) pour loger les UNITÉS d'axe au
|
||||
// sommet des colonnes de labels (remontée « on n'a jamais ajouté les
|
||||
// unités » — l'axe gauche était des nombres nus depuis la v1.0).
|
||||
val padTop = 26.dp.toPx()
|
||||
val padBottom = 26.dp.toPx()
|
||||
|
||||
val w = size.width - padLeft - padRight
|
||||
@ -303,24 +390,32 @@ fun CurveChart(
|
||||
|
||||
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
|
||||
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
|
||||
// Unités des axes (v1.7.0 — remontée : « on n'a jamais ajouté les
|
||||
// unités des mesures dans les axes ») : pg/mL pour l'E2 (axe gauche),
|
||||
// ng/mL pour la T (axe droit) — dessinées au SOMMET de chaque colonne
|
||||
// de labels, au-dessus du premier tick (padTop élargi pour ça).
|
||||
// ⚠️ Ce ne sont PAS des strings i18n : pg/mL et ng/mL sont des unités
|
||||
// universelles, déjà en dur dans les légendes du chart.
|
||||
drawAxisUnit(E2_UNIT, padLeft - 6.dp.toPx(), padTop - 6.dp.toPx(),
|
||||
android.graphics.Paint.Align.RIGHT)
|
||||
if (options.showT && series.any { it.tStyle != null }) {
|
||||
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
|
||||
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
|
||||
android.graphics.Paint.Align.LEFT)
|
||||
}
|
||||
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone)
|
||||
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone, xAxisHourFmt, xAxisDayFmt)
|
||||
|
||||
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
|
||||
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
|
||||
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
||||
if (forecastDoses.isNotEmpty()) {
|
||||
val markerColor = forecastMarkerColor
|
||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
||||
color = android.graphics.Color.argb(
|
||||
(markerColor.alpha * 255).toInt(),
|
||||
(markerColor.red * 255).toInt(),
|
||||
(markerColor.green * 255).toInt(),
|
||||
(markerColor.blue * 255).toInt()
|
||||
)
|
||||
}
|
||||
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
|
||||
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
||||
// redessine à chaque pan/zoom, une allocation par dose × frame
|
||||
// était du gaspillage (la classe n'est de toute façon pas
|
||||
// thread-safe — ici on reste dans le DrawScope UI)
|
||||
val hourFmt = markerTimeFmt
|
||||
for (dose in forecastDoses) {
|
||||
if (dose.timestamp !in t0..t1) continue
|
||||
val x = xOf(dose.timestamp)
|
||||
@ -340,16 +435,42 @@ fun CurveChart(
|
||||
}
|
||||
drawPath(path, markerColor)
|
||||
// heure de la dose simulée DANS le fuseau de lecture
|
||||
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
hourFmt.timeZone = zone
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Une courbe E2 + une courbe T par série
|
||||
// NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : dessiné EN PREMIER
|
||||
// (sous toutes les courbes nommées), chaque sous-courbe du posterior
|
||||
// MCMC en alpha faible. Décisions d'affichage :
|
||||
// - le nuage ne participe PAS aux échelles (allPoints ne l'inclut
|
||||
// pas) : c'est une plage d'imprécision, pas des données à cadrer —
|
||||
// il peut sortir du cadre en haut/bas sans casser la lecture ;
|
||||
// - ni aux labels ni aux extrema (pas des courbes nommées).
|
||||
val cloudPaths = mutableListOf<Pair<Color, Path>>()
|
||||
for (s in series) {
|
||||
val cloud = s.cloud ?: continue
|
||||
for (sub in cloud) {
|
||||
if (sub.size < 2) continue
|
||||
val path = Path()
|
||||
sub.forEachIndexed { i, p ->
|
||||
val x = xOf(p.timestamp)
|
||||
val y = yE2(p.e2)
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
cloudPaths.add(s.e2Style.color.copy(alpha = 0.15f) to path)
|
||||
}
|
||||
}
|
||||
for ((color, path) in cloudPaths) {
|
||||
drawPath(
|
||||
path, color,
|
||||
style = Stroke(width = 1.5.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
// Une courbe E2 + une courbe T par série
|
||||
for ((sIdx, s) in series.withIndex()) {
|
||||
if (s.points.size < 2) continue
|
||||
val e2Path = Path()
|
||||
s.points.forEachIndexed { i, p ->
|
||||
@ -387,22 +508,26 @@ fun CurveChart(
|
||||
}
|
||||
|
||||
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
||||
// (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux)
|
||||
if (options.showExtrema) {
|
||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||
.forEach { ex ->
|
||||
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
||||
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
||||
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
|
||||
// Canvas, recalculés seulement quand séries/options changent) — le
|
||||
// scan local était refait PAR FRAME, x2 séries.
|
||||
if (options.showExtrema && s.showExtrema) {
|
||||
extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
|
||||
e2Extrema.forEach { ex ->
|
||||
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||
}
|
||||
if (options.showT) {
|
||||
s.tStyle?.let { tStyle ->
|
||||
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
|
||||
.forEach { ex ->
|
||||
tExtrema.orEmpty().forEach { ex ->
|
||||
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labs (points/carrés oranges)
|
||||
if (options.showLabs) {
|
||||
@ -463,7 +588,7 @@ fun CurveChart(
|
||||
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
|
||||
* creux — dans la couleur de la série, unité implicite (celle de l'axe).
|
||||
*/
|
||||
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetrack.pk.PharmacokineticEngine.Extremum, color: Color) {
|
||||
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: PharmacokineticEngine.Extremum, color: Color) {
|
||||
val r = 5.dp.toPx()
|
||||
val offset = 6.dp.toPx()
|
||||
val path = Path()
|
||||
@ -483,46 +608,77 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetr
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color)
|
||||
// Valeur estimée du pic/creux, dans la couleur de la courbe
|
||||
val paint = android.graphics.Paint().apply {
|
||||
// Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
|
||||
// en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
|
||||
val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
formatValue(extremum.value), cx, valueY, paint
|
||||
)
|
||||
}
|
||||
|
||||
/** Unité de l'axe E2 (gauche) — universelle, pas une string i18n (cf drawAxisUnit). */
|
||||
private const val E2_UNIT = "pg/mL"
|
||||
|
||||
/** Unité de l'axe T (droite) — idem. */
|
||||
private const val T_UNIT = "ng/mL"
|
||||
|
||||
/**
|
||||
* Libellé d'UNITÉ d'axe (v1.7.0) : petit texte gris au sommet d'une colonne
|
||||
* de labels Y, aligné comme les nombres de sa colonne ([align] RIGHT pour
|
||||
* l'axe gauche, LEFT pour l'axe droit).
|
||||
*/
|
||||
private fun DrawScope.drawAxisUnit(
|
||||
text: String,
|
||||
x: Float,
|
||||
baseline: Float,
|
||||
align: android.graphics.Paint.Align
|
||||
) {
|
||||
drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) {
|
||||
for (i in 0..rows) {
|
||||
val y = padTop + h * i / rows
|
||||
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
|
||||
* (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
|
||||
* label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
|
||||
* dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
|
||||
* Les clés sont des value classes (Color) → pas de fuite.
|
||||
*/
|
||||
private val textPaintCache = HashMap<String, android.graphics.Paint>()
|
||||
|
||||
private fun DrawScope.textPaint(
|
||||
color: Color,
|
||||
textSizeDp: Float,
|
||||
align: android.graphics.Paint.Align,
|
||||
fakeBold: Boolean = false
|
||||
): android.graphics.Paint {
|
||||
val key = "$color|$textSizeDp|$align|$fakeBold"
|
||||
return textPaintCache.getOrPut(key) {
|
||||
android.graphics.Paint().apply {
|
||||
this.color = android.graphics.Color.argb(
|
||||
(color.alpha * 255).toInt(),
|
||||
(color.red * 255).toInt(),
|
||||
(color.green * 255).toInt(),
|
||||
(color.blue * 255).toInt()
|
||||
)
|
||||
textSize = 9.dp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
}
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
formatValue(extremum.value), cx, valueY, paint
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) {
|
||||
val y = padTop + h * i / rows
|
||||
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Paint partagé pour les labels texte (couleur gris neutre fixe). */
|
||||
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
|
||||
val c = Color(0xFF6B7280)
|
||||
return android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.argb(
|
||||
(c.alpha * 255).toInt(),
|
||||
(c.red * 255).toInt(),
|
||||
(c.green * 255).toInt(),
|
||||
(c.blue * 255).toInt()
|
||||
)
|
||||
textSize = 10.dp.toPx()
|
||||
textSize = textSizeDp.dp.toPx()
|
||||
textAlign = align
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = fakeBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
|
||||
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
|
||||
textPaint(Color(0xFF6B7280), 10f, align)
|
||||
|
||||
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
||||
for (i in 0..4) {
|
||||
@ -544,7 +700,11 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
|
||||
private fun DrawScope.drawXLabels(
|
||||
color: Color, t0: Long, t1: Long,
|
||||
padLeft: Float, yBottom: Float, w: Float,
|
||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
|
||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault(),
|
||||
// v1.9.5 (audit) : formatters mémoïsés du composable (plus de
|
||||
// SimpleDateFormat alloué par frame)
|
||||
hourFmt: SimpleDateFormat = SimpleDateFormat("HH'h'", Locale.getDefault()),
|
||||
dayFmt: SimpleDateFormat = SimpleDateFormat("dd/MM", Locale.getDefault())
|
||||
) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
||||
val spanH = (t1 - t0) / 3_600_000.0
|
||||
@ -558,28 +718,25 @@ private fun DrawScope.drawXLabels(
|
||||
}
|
||||
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
||||
// tick « minuit local » serait étiqueté avec l'heure système.
|
||||
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
|
||||
else SimpleDateFormat("dd/MM", Locale.getDefault())
|
||||
fmt.timeZone = zone
|
||||
// v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
|
||||
val fmt = if (spanH <= 25) hourFmt else dayFmt
|
||||
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
||||
// (minuit local pour les pas en jours) — l'ancien alignement sur des
|
||||
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
|
||||
for (tick in xLabelTicks(t0, t1, stepHours.toLong())) {
|
||||
// v1.9.5 (audit) : la ZONE de lecture est passée à xLabelTicks — l'oubli
|
||||
// alignait les ticks sur le fuseau de l'appareil pendant que les labels
|
||||
// sont formatés dans le fuseau choisi (heures décalées dès que les deux
|
||||
// divergent, contredisant le fix #55).
|
||||
for (tick in xLabelTicks(t0, t1, stepHours.toLong(), zone)) {
|
||||
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
||||
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
||||
color = android.graphics.Color.rgb(
|
||||
(LabDot.red * 255).toInt(),
|
||||
(LabDot.green * 255).toInt(),
|
||||
(LabDot.blue * 255).toInt()
|
||||
)
|
||||
textSize = 9.dp.toPx()
|
||||
isFakeBoldText = true
|
||||
}
|
||||
// Paint en cache (v1.9.5 : une allocation par lab × frame → une par
|
||||
// couleur, réutilisée entre frames)
|
||||
val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@ -41,8 +42,10 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.pk.LevelPoint
|
||||
import com.hormonetrack.pk.EstrannaiseCloud
|
||||
import com.hormonetrack.pk.LabTrajectoryModel
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
@ -51,6 +54,7 @@ import com.hormonetrack.ui.components.ChartSeries
|
||||
import com.hormonetrack.ui.components.CurveChart
|
||||
import com.hormonetrack.ui.components.CurveStyle
|
||||
import com.hormonetrack.ui.theme.ChartE2
|
||||
import com.hormonetrack.ui.theme.ChartLabTrajectory
|
||||
import com.hormonetrack.ui.theme.ChartT
|
||||
import com.hormonetrack.ui.theme.ChartTWhs
|
||||
import com.hormonetrack.ui.theme.ChartWhs
|
||||
@ -59,9 +63,14 @@ import com.hormonetrack.ui.theme.TealTertiary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.hormonetrack.ui.components.clampPanHours
|
||||
import com.hormonetrack.ui.components.defaultModelToggles
|
||||
import com.hormonetrack.ui.components.forecastExtensionHours
|
||||
import com.hormonetrack.ui.components.forecastHorizonHours
|
||||
import com.hormonetrack.ui.components.panDeltaHours
|
||||
import com.hormonetrack.ui.components.stepForRange
|
||||
|
||||
private const val HOUR_MS = 3_600_000L
|
||||
private const val DAY_MS = 24 * HOUR_MS
|
||||
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
|
||||
private const val MIN_RANGE_H = 6L
|
||||
private const val MAX_RANGE_H = 24L * 300
|
||||
@ -70,10 +79,12 @@ private const val MAX_RANGE_H = 24L * 300
|
||||
* Écran Graphiques.
|
||||
*
|
||||
* Fonctions :
|
||||
* - plages 24 h / 7 j / 30 j ;
|
||||
* - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé ;
|
||||
* - **les deux modèles PK** (Estrannaise / Transfem Science) peuvent être affichés
|
||||
* simultanément (override de modèle dans le moteur), avec toggle indépendant ;
|
||||
* - plages 6 h → 300 j (zoom v1.2.9, presets 24 h / 7 j / 30 j) ;
|
||||
* - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé
|
||||
* (le futur avec la Prévision active) ;
|
||||
* - **les modèles PK** (Estrannaise / Transfem Science / WHSAH) peuvent être
|
||||
* affichés simultanément (override de modèle dans le moteur), toggles
|
||||
* indépendants ; + Tracé labs (v1.5.0) et sa prolongation (v1.6.0) ;
|
||||
* - **prévision** : prolonge la courbe avec des doses projetées à partir de la
|
||||
* « fréquence » configurée sur chaque traitement ;
|
||||
* - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T
|
||||
@ -111,6 +122,24 @@ fun ChartScreen() {
|
||||
var showTfs by remember { mutableStateOf(true) }
|
||||
// v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona)
|
||||
var showWhs by remember { mutableStateOf(false) }
|
||||
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, cf
|
||||
// pk/LabTrajectoryModel.kt) — OFF par défaut : opt-in, la courbe
|
||||
// revendique « passer par tes labs » et doit rester une référence de
|
||||
// comparaison, pas un affichage par défiant
|
||||
var showLabTrack by remember { mutableStateOf(false) }
|
||||
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC de
|
||||
// estrannaise.js — cf pk/EstrannaiseCloud) — OFF par défaut, opt-in,
|
||||
// activable/désactivable à volonté ; ACTIVABLE SEULEMENT quand le
|
||||
// modèle ESE est affiché (le nuage est exclusif à ce modèle — les
|
||||
// fits TFS/WHSAH ne publient pas de posterior). Se désactive
|
||||
// automatiquement si ESE est éteint.
|
||||
var showCloud by remember { mutableStateOf(false) }
|
||||
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
|
||||
// dernier lab constant × modèle, bornée par l'extinction du modèle —
|
||||
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
|
||||
// partie prolongée est une ESTIMATION non ancrée, elle doit être
|
||||
// demandée explicitement ; le chip n'est actif que si Tracé labs est ON.
|
||||
var showLabTrackExtend by remember { mutableStateOf(false) }
|
||||
// v1.4.7 (fix #58) : au CHARGEMENT des traitements, les toggles sont
|
||||
// alignés sur les modèles réellement UTILISÉS par des traitements à
|
||||
// PROFIL PK (injection + ester, `usesProfileModel`) — en cours ou passés
|
||||
@ -122,12 +151,12 @@ fun ChartScreen() {
|
||||
var modelTogglesInitialized by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(treatments) {
|
||||
if (!modelTogglesInitialized && treatments.isNotEmpty()) {
|
||||
val toggles = com.hormonetrack.ui.components.defaultModelToggles(
|
||||
val toggles = defaultModelToggles(
|
||||
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
||||
)
|
||||
showEse = toggles.getValue("ESE")
|
||||
showTfs = toggles.getValue("TFS")
|
||||
showWhs = toggles.getValue("WHS")
|
||||
showEse = toggles.getValue(PKModels.ESTRANNAISE)
|
||||
showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
|
||||
showWhs = toggles.getValue(PKModels.WHSAH)
|
||||
modelTogglesInitialized = true
|
||||
}
|
||||
}
|
||||
@ -164,14 +193,14 @@ fun ChartScreen() {
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
mapOf(
|
||||
"ESE" to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = "ESE"
|
||||
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
|
||||
),
|
||||
"TFS" to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = "TFS"
|
||||
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
|
||||
),
|
||||
"WHS" to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = "WHS"
|
||||
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -180,8 +209,15 @@ fun ChartScreen() {
|
||||
|
||||
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
|
||||
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
|
||||
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
|
||||
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays)
|
||||
// ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
|
||||
// Posologie d'un traitement archivé ne doit ni activer le chip
|
||||
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
|
||||
// créneaux, cf generateForecastDoses).
|
||||
val maxIntervalDays = treatments
|
||||
.filter { it.isActive }
|
||||
.mapNotNull { it.forecastIntervalDays }
|
||||
.maxOrNull()
|
||||
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
|
||||
|
||||
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
|
||||
// ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
|
||||
@ -207,7 +243,7 @@ fun ChartScreen() {
|
||||
}
|
||||
}
|
||||
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
|
||||
com.hormonetrack.ui.components.forecastExtensionHours(
|
||||
forecastExtensionHours(
|
||||
rangeHours, forecastHorizonHours, firstSlotGapH
|
||||
)
|
||||
} else 0L
|
||||
@ -226,11 +262,20 @@ fun ChartScreen() {
|
||||
val gestureFutureHorizon by rememberUpdatedState(futurePanHorizon)
|
||||
val gestureMaxPan by rememberUpdatedState(maxPanHours)
|
||||
val gestureExtension by rememberUpdatedState(forecastExtensionH)
|
||||
// Résiduel fractionnaire du pan (fix #62) : conserve la part < 1 h des
|
||||
// deltas pour que le pan fonctionne sur la vue 24 h (les deltas de doigt
|
||||
// y sont inférieurs à une heure entière)
|
||||
var panResidualHours by remember { mutableStateOf(0.0) }
|
||||
|
||||
// Doses PRÉVISIONNELLES (v1.4.5) : les mêmes que la courbe utilise —
|
||||
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
||||
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
||||
// décalée d'un jour).
|
||||
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
|
||||
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
|
||||
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
|
||||
// recomposition (leçon #65 : un producer doit redériver de ses vraies
|
||||
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
|
||||
val forecastDoses by produceState(
|
||||
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
||||
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
||||
@ -246,19 +291,68 @@ fun ChartScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
|
||||
/**
|
||||
* Une courbe rendue par le producer : clé de modèle ("ESE"/"TFS"/"WHS"/
|
||||
* "LAB"/"LABX"), ses points, et — v1.9.0 — son NUAGE D'INCERTITUDE
|
||||
* (uniquement pour "ESE" quand le chip Nuage est actif).
|
||||
*/
|
||||
data class ChartCurve(
|
||||
val model: String,
|
||||
val points: List<LevelPoint>,
|
||||
val cloud: List<List<LevelPoint>> = emptyList()
|
||||
)
|
||||
|
||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") —
|
||||
// v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ;
|
||||
// v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant)
|
||||
val curves by produceState(
|
||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
||||
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast
|
||||
emptyList<ChartCurve>(),
|
||||
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
|
||||
showLabTrack, showLabTrackExtend, showCloud,
|
||||
// Clé = la liste ENTIÈRE des labs (pas .size, fix #66) : éditer la
|
||||
// VALEUR d'une analyse doit recalculer les courbes
|
||||
labResults
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||
val list = mutableListOf<ChartCurve>()
|
||||
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
|
||||
// Totalement indépendante des toggles de modèles (elle utilise le
|
||||
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
|
||||
// et de la calibration (elle EST sa propre calibration continue —
|
||||
// scalePerEster ignoré, épinglé par LabTrajectoryModelTest).
|
||||
// showLabTrack + série visible UNIQUEMENT si ≥ 2 labs qui ancrent.
|
||||
//
|
||||
// ── v1.6.0 : split ancré / prolongé ──────────────────────────────
|
||||
// Avec « Prolonger », la série est SPLITTÉE au dernier lab
|
||||
// significatif (lastAnchorMs) : "LAB" = partie ancrée (passe par
|
||||
// les labs), "LABX" = partie prolongée (M(t) × ρ_last — une
|
||||
// estimation). Deux traits distincts (LABX en atténué) préservent
|
||||
// l'honnêteté de l'affichage : la partie inventée ne peut pas se
|
||||
// confondre avec la partie mesurée. ⚠️ Deux clés DISTINCTES et
|
||||
// non vides seulement — la légende (plus bas) doit connaître
|
||||
// "LABX" AVANT son else (leçon #63).
|
||||
if (showLabTrack) {
|
||||
val step = stepForRange(rangeHours)
|
||||
val labCurve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
treatments, doseLogs, labResults, startMs, endMs, step,
|
||||
extendBeyondLastLab = showLabTrackExtend
|
||||
)
|
||||
val lastAnchor = labCurve.lastAnchorMs
|
||||
if (showLabTrackExtend && lastAnchor != null) {
|
||||
val anchored = labCurve.points.filter { it.timestamp <= lastAnchor }
|
||||
val extended = labCurve.points.filter { it.timestamp > lastAnchor }
|
||||
list.add(ChartCurve("LAB", anchored))
|
||||
if (extended.isNotEmpty()) list.add(ChartCurve("LABX", extended))
|
||||
} else {
|
||||
list.add(ChartCurve("LAB", labCurve.points))
|
||||
}
|
||||
}
|
||||
if (showEse || showTfs || showWhs) {
|
||||
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
|
||||
// échelles/k calculés avec SA prédiction. Hors auto-calibration
|
||||
// → scaleFactor stocké des traitements (comportement historique).
|
||||
val effTreatments = autoByModel["TFS"]?.treatments ?: treatments
|
||||
val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig
|
||||
val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
|
||||
val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
|
||||
// Doses réelles + doses prévisionnelles éventuelles
|
||||
val forecastDoses = if (showForecast) {
|
||||
effTreatments.flatMap {
|
||||
@ -267,50 +361,39 @@ fun ChartScreen() {
|
||||
} else emptyList()
|
||||
val allDoses = doseLogs + forecastDoses
|
||||
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||
val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
|
||||
if (showEse) {
|
||||
val autoM = autoByModel["ESE"]
|
||||
list.add(
|
||||
"ESE" to PharmacokineticEngine.computeCurve(
|
||||
val step = stepForRange(rangeHours)
|
||||
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
||||
// le modèle est la seule variable (même calcul, même calibration
|
||||
// PAR MODÈLE via autoByModel[model], fix #60)
|
||||
for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
|
||||
if (!enabled) continue
|
||||
val autoM = autoByModel[model]
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
effTreatments, allDoses, startMs, endMs,
|
||||
stepMs = step,
|
||||
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "ESE",
|
||||
scalePerEster = autoM?.esterScales,
|
||||
tKPerEster = autoM?.tKPerEster
|
||||
)
|
||||
)
|
||||
}
|
||||
if (showTfs) {
|
||||
val autoM = autoByModel["TFS"]
|
||||
list.add(
|
||||
"TFS" to PharmacokineticEngine.computeCurve(
|
||||
effTreatments, allDoses, startMs, endMs,
|
||||
stepMs = step,
|
||||
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "TFS",
|
||||
scalePerEster = autoM?.esterScales,
|
||||
tKPerEster = autoM?.tKPerEster
|
||||
)
|
||||
)
|
||||
}
|
||||
// v1.4.6 : 3ᵉ modèle WHSAH (même mécanique d'override)
|
||||
if (showWhs) {
|
||||
val autoM = autoByModel["WHS"]
|
||||
list.add(
|
||||
"WHS" to PharmacokineticEngine.computeCurve(
|
||||
effTreatments, allDoses, startMs, endMs,
|
||||
stepMs = step,
|
||||
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "WHS",
|
||||
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
|
||||
scalePerEster = autoM?.esterScales,
|
||||
tKPerEster = autoM?.tKPerEster
|
||||
)
|
||||
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
||||
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
||||
// publient pas de posterior MCMC)
|
||||
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
|
||||
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
||||
// nuage brut ≠ échelle de la courbe calibrée)
|
||||
EstrannaiseCloud.compute(
|
||||
treatments, doseLogs, startMs, endMs, step,
|
||||
scalePerEster = autoM?.esterScales
|
||||
)
|
||||
} else emptyList()
|
||||
list.add(ChartCurve(model, curve, cloud))
|
||||
}
|
||||
}
|
||||
value = list
|
||||
}
|
||||
}
|
||||
|
||||
val allPoints = curves.flatMap { it.second }
|
||||
val allPoints = curves.flatMap { it.points }
|
||||
val (a, b) = if (allPoints.size >= 2) {
|
||||
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
||||
} else 0L to 0L
|
||||
@ -322,22 +405,40 @@ fun ChartScreen() {
|
||||
|
||||
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||
val series = curves.map { (model, points) ->
|
||||
val series = curves.map { (model, points, cloud) ->
|
||||
when (model) {
|
||||
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
||||
PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
|
||||
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||
PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
||||
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
|
||||
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
|
||||
"LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null,
|
||||
showExtrema = false)
|
||||
// v1.6.0 : partie PROLONGÉE du tracé labs — même couleur ATTÉNUÉE
|
||||
// (alpha 0,55) pour distinguer visuellement l'estimation non
|
||||
// ancrée de la partie qui passe par les labs. Même exclusions
|
||||
// (pas de T, pas d'extrema — les extrema de l'interp ρ constant
|
||||
// ne sont que ceux du modèle brut).
|
||||
"LABX" -> ChartSeries(
|
||||
points,
|
||||
CurveStyle(ChartLabTrajectory.copy(alpha = 0.55f), dashed = true),
|
||||
null,
|
||||
showExtrema = false
|
||||
)
|
||||
else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.6 : l'écran DÉFILIE verticalement — la rangée des toggles de
|
||||
// modèles (Estrannaise/Transfem/WHSAH) était coupée sous le pli sur les
|
||||
// écrans standards (le chip WHSAH était littéralement hors écran).
|
||||
// ⚠️ PAS de verticalScroll ici (fix #62) : le scroll parent AVALE le
|
||||
// drag horizontal du graphique (pan + pinch, cf §11) — le pan était
|
||||
// totalement mort avec lui (vu en émulateur v1.4.9). Le contenu tient
|
||||
// sur les écrans standards (chips en 3 rangées compactes + carte 320 dp,
|
||||
// vérifié par dump §16.ter) ; si un futur ajout repousse la carte sous
|
||||
// le pli : compacter les rangées plutôt que re-rajouter un scroll.
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
TopAppBar(
|
||||
@ -417,11 +518,26 @@ fun ChartScreen() {
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) + v1.5.0 : le chip
|
||||
// « Tracé labs » vit ICI (4ᵉ chip de la rangée). ⚠️ SCROLL HORIZONTAL
|
||||
// CONFINÉ à la rangée : 4 chips débordent sur écrans étroits (constat
|
||||
// émulateur v1.5.0 — le 4ᵉ chip était hors fenêtre, absent du dump
|
||||
// uiautomator). AUCUN conflit avec le pan du graphique : le scroll
|
||||
// portre sur CETTE rangée uniquement, le pan/gesture reste sur le
|
||||
// Card du chart (le fix #62 portait sur un scroll VERTICAL parent ;
|
||||
// ici le libellé est différent et localisé).
|
||||
Row(
|
||||
Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = showEse,
|
||||
onClick = { showEse = !showEse },
|
||||
onClick = {
|
||||
showEse = !showEse
|
||||
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE
|
||||
// éteint le nuage (sinon il flotterait sans sa courbe)
|
||||
if (!showEse) showCloud = false
|
||||
},
|
||||
label = { Text(stringResource(R.string.model_ese)) }
|
||||
)
|
||||
FilterChip(
|
||||
@ -434,6 +550,32 @@ fun ChartScreen() {
|
||||
onClick = { showWhs = !showWhs },
|
||||
label = { Text(stringResource(R.string.model_whs)) }
|
||||
)
|
||||
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
|
||||
FilterChip(
|
||||
selected = showLabTrack,
|
||||
onClick = { showLabTrack = !showLabTrack },
|
||||
label = { Text(stringResource(R.string.show_lab_track)) }
|
||||
)
|
||||
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab
|
||||
// (off par défaut, sans effet — donc désactivé — si Tracé labs
|
||||
// est off : la prolongation n'existe que comme extension de CETTE
|
||||
// courbe, pas comme série autonome)
|
||||
FilterChip(
|
||||
selected = showLabTrackExtend,
|
||||
onClick = { showLabTrackExtend = !showLabTrackExtend },
|
||||
enabled = showLabTrack,
|
||||
label = { Text(stringResource(R.string.show_lab_track_extend)) }
|
||||
)
|
||||
// v1.9.0 : NUAGE D'INCERTITUDE (exclusif ESE) — off par défaut,
|
||||
// activable à volonté mais SEULEMENT si ESE est affiché (même
|
||||
// pattern que Prolonger/Tracé labs) ; éteindre ESE coupe le
|
||||
// nuage automatiquement (sinon le nuage flotterait sans sa courbe)
|
||||
FilterChip(
|
||||
selected = showCloud,
|
||||
onClick = { showCloud = !showCloud },
|
||||
enabled = showEse,
|
||||
label = { Text(stringResource(R.string.show_cloud)) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@ -451,9 +593,15 @@ fun ChartScreen() {
|
||||
// temps (panHours croît) ; glisser à GAUCHE =
|
||||
// avancer dans le FUTUR (panHours décroît, borné
|
||||
// par l'horizon de prévision — v1.4.1)
|
||||
val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
|
||||
// v1.4.10 (fix #62) : cumul fractionnaire du pan
|
||||
// (l'ancienne troncature par delta tuait le pan
|
||||
// sur la vue 24 h)
|
||||
val (hoursDragged, newResidual) = panDeltaHours(
|
||||
pan.x, chartWidthPx, rangeHours, panResidualHours
|
||||
)
|
||||
panResidualHours = newResidual
|
||||
if (hoursDragged != 0L) {
|
||||
panHours = com.hormonetrack.ui.components.clampPanHours(
|
||||
panHours = clampPanHours(
|
||||
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
|
||||
)
|
||||
}
|
||||
@ -471,7 +619,7 @@ fun ChartScreen() {
|
||||
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
|
||||
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
|
||||
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
|
||||
panHours = com.hormonetrack.ui.components.clampPanHours(
|
||||
panHours = clampPanHours(
|
||||
newPan, gestureFutureHorizon, gestureMaxPan
|
||||
)
|
||||
rangeHours = newRange
|
||||
@ -517,10 +665,13 @@ fun ChartScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Légende dynamique
|
||||
// Légende dynamique — ⚠️ v1.5.0 : la clé "LAB" (Tracé labs)
|
||||
// est SKIPPÉE ici : elle a sa propre légende dédiée plus
|
||||
// bas, sinon le `else` (TFS) imprimerait la légende TFS
|
||||
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
||||
curves.forEach { (model, _) ->
|
||||
when (model) {
|
||||
"ESE" -> {
|
||||
PKModels.ESTRANNAISE -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_ese_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@ -535,7 +686,7 @@ fun ChartScreen() {
|
||||
}
|
||||
}
|
||||
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
||||
"WHS" -> {
|
||||
PKModels.WHSAH -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_whs_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@ -549,6 +700,12 @@ fun ChartScreen() {
|
||||
)
|
||||
}
|
||||
}
|
||||
"LAB" -> { /* légende dédiée, cf plus bas */ }
|
||||
// v1.6.0 : idem — la partie prolongée a sa légende
|
||||
// dédiée plus bas. ⚠️ branche EXPLICITE requise :
|
||||
// sinon le `else` (= TFS) imprimerait la légende
|
||||
// TFS une 2ᵉ fois (leçon #63, v1.5.0)
|
||||
"LABX" -> { /* légende dédiée, cf plus bas */ }
|
||||
else -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_tfs_e2),
|
||||
@ -586,6 +743,48 @@ fun ChartScreen() {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// v1.5.0 : légende « Tracé labs » — seulement si la série
|
||||
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
|
||||
if (showLabTrack && curves.any { it.model == "LAB" && it.points.size >= 2 }) {
|
||||
Text(
|
||||
stringResource(R.string.legend_lab_track),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartLabTrajectory
|
||||
)
|
||||
}
|
||||
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
||||
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
||||
if (showCloud && showEse && curves.any { it.model == PKModels.ESTRANNAISE && it.cloud.isNotEmpty() }) {
|
||||
Text(
|
||||
stringResource(R.string.legend_cloud),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartE2.copy(alpha = 0.55f)
|
||||
)
|
||||
}
|
||||
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT —
|
||||
// seulement si la série "LABX" existe (chip Prolonger actif
|
||||
// ET horizon de prolongation atteint dans la fenêtre visible)
|
||||
val labExtendVisible = showLabTrack && showLabTrackExtend &&
|
||||
curves.any { it.model == "LABX" && it.points.size >= 2 }
|
||||
if (labExtendVisible) {
|
||||
Text(
|
||||
stringResource(R.string.legend_lab_track_extend),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartLabTrajectory.copy(alpha = 0.55f)
|
||||
)
|
||||
// Avertissement (demande v1.6.0) : la partie prolongée
|
||||
// est une EXTRAPOLATION sans garantie — elle repose sur
|
||||
// des mesures de labs elles-mêmes faillibles. Affiché
|
||||
// TANT QUE la prolongation est visible (sous la
|
||||
// légende), pour qu'elle ne puisse pas être lue comme
|
||||
// une prédiction fiable de l'avenir.
|
||||
Text(
|
||||
stringResource(R.string.lab_track_extend_warning),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.drag_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@ -2,19 +2,24 @@ package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Colorize
|
||||
import androidx.compose.material.icons.filled.Healing
|
||||
import androidx.compose.material.icons.filled.Medication
|
||||
import androidx.compose.material.icons.filled.Science
|
||||
import androidx.compose.material.icons.filled.Vaccines
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
@ -24,6 +29,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@ -37,13 +43,18 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.DoseDialog
|
||||
import com.hormonetrack.ui.theme.LabDot
|
||||
import com.hormonetrack.util.HrtDuration
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -53,6 +64,54 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Icône de FORME DE PRISE (v1.12.0, demande utilisatrice : « des icônes pour
|
||||
* bien séparer les différentes formes de prises des traitements »). Mapping :
|
||||
* ORAL = pilule (Medication) ; IM/SC = seringue (Vaccines — le glyphe est
|
||||
* partagé, la TEinte les distingue côté UI : IM = couleur courante, SC =
|
||||
* tertiary) ; GEL = goutte (WaterDrop) ; PATCH = sparadrap (Healing) ;
|
||||
* OTHER/inconnu = compte-gouttes (Colorize). PUR → testable en JVM
|
||||
* (DosesExtrasTest) ; miroir web `routeGlyph` (js/data/models.js).
|
||||
*/
|
||||
fun routeIcon(route: AdministrationRoute?): ImageVector = when (route) {
|
||||
AdministrationRoute.ORAL -> Icons.Filled.Medication
|
||||
AdministrationRoute.INJECTION_IM -> Icons.Filled.Vaccines
|
||||
AdministrationRoute.INJECTION_SUBCUT -> Icons.Filled.Vaccines
|
||||
AdministrationRoute.TRANSDERMAL_GEL -> Icons.Filled.WaterDrop
|
||||
AdministrationRoute.TRANSDERMAL_PATCH -> Icons.Filled.Healing
|
||||
else -> Icons.Filled.Colorize
|
||||
}
|
||||
|
||||
/**
|
||||
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, demande utilisatrice :
|
||||
* « une petite indication … dans le cas où un test sanguin a été effectué
|
||||
* avant ou après une prise — peut-être un petit encart sur le côté »).
|
||||
*
|
||||
* Une analyse compte si elle tombe le MÊME JOUR calendaire LOCAL que la
|
||||
* dose : au-delà, le lien clinique (labs au creux/pic autour d'une
|
||||
* injection) devient ténu et chaque dose finirait marquée.
|
||||
*
|
||||
* @return Pair(AVANT la dose, APRÈS la dose) — une analyse exactement À
|
||||
* l'heure de la dose compte comme APRÈS (convention arbitraire documentée).
|
||||
* PUR → testable en JVM (DosesExtrasTest) ; miroir web `labMarkersForDose`
|
||||
* (js/util/format.js).
|
||||
*/
|
||||
fun labMarkersForDose(
|
||||
labs: List<LabResult>,
|
||||
doseMs: Long,
|
||||
zone: ZoneId = ZoneId.systemDefault()
|
||||
): Pair<Boolean, Boolean> {
|
||||
val day = Instant.ofEpochMilli(doseMs).atZone(zone).toLocalDate()
|
||||
var before = false
|
||||
var after = false
|
||||
for (lab in labs) {
|
||||
if (Instant.ofEpochMilli(lab.timestamp).atZone(zone).toLocalDate() == day) {
|
||||
if (lab.timestamp < doseMs) before = true else after = true
|
||||
}
|
||||
}
|
||||
return before to after
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DosesScreen() {
|
||||
@ -60,6 +119,8 @@ fun DosesScreen() {
|
||||
val repo = container.repository
|
||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||
// v1.12.0 : les labs alimentent les marqueurs « prise de sang avant/après »
|
||||
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
|
||||
|
||||
var showLogDialog by remember { mutableStateOf(false) }
|
||||
@ -158,6 +219,24 @@ fun DosesScreen() {
|
||||
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { toEdit = log },
|
||||
// v1.12.0 : icône de FORME DE PRISE à gauche —
|
||||
// oral / injection IM / injection SC / gel /
|
||||
// patch / autre se repèrent d'un coup d'œil.
|
||||
leadingContent = {
|
||||
Icon(
|
||||
routeIcon(tr?.route),
|
||||
contentDescription = tr?.let {
|
||||
stringResource(routeLabelRes(it.route))
|
||||
},
|
||||
tint = if (tr?.route == AdministrationRoute.INJECTION_SUBCUT) {
|
||||
// IM/SC partagent le glyphe seringue :
|
||||
// la teinte tertiary distingue la SC
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
|
||||
},
|
||||
@ -182,10 +261,18 @@ fun DosesScreen() {
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// v1.12.0 : encart latéral « prise de sang
|
||||
// le même jour » — ↑ = lab AVANT la dose,
|
||||
// ↓ = lab APRÈS (cf labMarkersForDose)
|
||||
val (labBefore, labAfter) = labMarkersForDose(labResults, log.timestamp, zone)
|
||||
if (labBefore) LabSideMarker(up = true)
|
||||
if (labAfter) LabSideMarker(up = false)
|
||||
IconButton(onClick = { toDelete = log }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -238,11 +325,37 @@ fun DosesScreen() {
|
||||
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
|
||||
}) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } },
|
||||
title = { Text(stringResource(R.string.delete)) },
|
||||
text = { Text(stringResource(R.string.confirm_delete)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encart latéral « prise de sang le même jour que cette dose » (v1.12.0,
|
||||
* demande utilisatrice) : icône laboratoire + flèche — **↑** = analyse
|
||||
* effectuée AVANT la dose, **↓** = APRÈS. La description sémantique porte
|
||||
* la phrase complète (accessibilité TalkBack) ; le glyphe seul est décoratif
|
||||
* (contentDescription = null sur l'icône, description portée par le Row).
|
||||
*/
|
||||
@Composable
|
||||
private fun LabSideMarker(up: Boolean) {
|
||||
val description = stringResource(if (up) R.string.lab_before_dose else R.string.lab_after_dose)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.semantics { contentDescription = description }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
tint = LabDot,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
if (up) "↑" else "↓",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LabDot
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.pk.LevelPoint
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.LabTiming
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.ChartOptions
|
||||
@ -71,12 +72,13 @@ import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
private val HOUR_MS = 3_600_000L
|
||||
private const val HOUR_MS = 3_600_000L
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
openLogDoseForTreatmentId: Long?,
|
||||
onLogDoseConsumed: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenCharts: () -> Unit,
|
||||
onOpenTreatment: (Long) -> Unit
|
||||
@ -112,10 +114,16 @@ fun HomeScreen(
|
||||
var showLogDialog by remember { mutableStateOf(false) }
|
||||
var preselectId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
|
||||
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
|
||||
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
|
||||
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
|
||||
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
|
||||
LaunchedEffect(openLogDoseForTreatmentId) {
|
||||
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
||||
showLogDialog = true
|
||||
preselectId = it
|
||||
onLogDoseConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,7 +135,10 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) {
|
||||
// v1.8.2 : allTreatments et tConfig ajoutés aux clés — éditer un
|
||||
// scaleFactor ou le modèle T rafraîchit la courbe immédiatement
|
||||
// (l'ancien trio de clés ratait ces changements jusqu'au tick de 60 s)
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), allTreatments, tConfig, effectiveAuto, doseLogs, tick) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val end = System.currentTimeMillis()
|
||||
value = PharmacokineticEngine.computeCurve(
|
||||
@ -222,6 +233,106 @@ fun HomeScreen(
|
||||
|
||||
NextDoseCard(activeTreatments, doseLogs)
|
||||
|
||||
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────
|
||||
// Même calcul que la page Analyses (pk/LabTiming) — carte COMPACTE
|
||||
// sur l'accueil : creux daté + créneau associé + disclaimer court.
|
||||
// Cachée si non calculable (pas d'injectable actif à Posologie) ;
|
||||
// l'invite « renseigne une Posologie » reste sur la page Analyses
|
||||
// (l'accueil ne fait pas doublon d'explication).
|
||||
val labRecommendation by produceState(
|
||||
initialValue = null as LabTiming.LabRecommendation?,
|
||||
allTreatments, doseLogs, labResults, tick, effectiveAuto
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
|
||||
// alimentent la prédiction « ≈ X pg/mL au creux » (null
|
||||
// si l'option est OFF → carte sans valeur, inchangée)
|
||||
LabTiming.nextBloodDrawRecommendation(
|
||||
allTreatments, doseLogs, labResults, System.currentTimeMillis(),
|
||||
effectiveAuto?.esterScales
|
||||
)
|
||||
}
|
||||
}
|
||||
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||
labRecommendation?.let { rec ->
|
||||
val fmtDate = remember {
|
||||
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
|
||||
.withZone(ZoneId.systemDefault())
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.labrec_section),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
|
||||
// la calibration est disponible) + statut face à la
|
||||
// cible de creux personnelle (lecture seule — jamais
|
||||
// de notification, distinct des seuils d'alerte)
|
||||
rec.predictedE2?.let { predicted ->
|
||||
val targetStatus = LabTiming.troughTargetStatus(
|
||||
predicted, troughTarget?.first, troughTarget?.second
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_predicted,
|
||||
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
|
||||
) + when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
" — " + stringResource(R.string.labrec_in_target)
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
" — " + stringResource(R.string.labrec_out_of_target)
|
||||
null -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
MaterialTheme.colorScheme.primary
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
null -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_before_injection,
|
||||
rec.ester,
|
||||
fmtDate.format(Instant.ofEpochMilli(rec.injectionMs))
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (!rec.wasAlreadyStabilized) {
|
||||
// Mention SEULEMENT si le régime n'était pas encore
|
||||
// stabilisé (info utile — sinon le texte est identique
|
||||
// à chaque ouverture)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_not_stabilized,
|
||||
rec.ester,
|
||||
fmtDate.format(Instant.ofEpochMilli(rec.stabilizedAtMs))
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
@ -391,49 +502,76 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ligne « prochaine prise » d'un traitement (v1.9.6 — PUR, testable JVM) :
|
||||
* « Xj Yh · ven. 25 10:00 (nom) » au-delà de 24 h (les heures deviennent peu
|
||||
* lisibles, v1.4.1), « XhMM · HH:mm (nom) » en dessous, « N min · HH:mm
|
||||
* (nom) » dans l'heure. Utilisée par chaque ligne de [NextDoseCard].
|
||||
*/
|
||||
internal fun nextDoseLine(
|
||||
nowMs: Long,
|
||||
fireMs: Long,
|
||||
name: String,
|
||||
/** Template localisé du cas ≥ 24 h (string `next_dose_days`, 4 args). */
|
||||
daysTemplate: String,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String {
|
||||
val deltaMs = fireMs - nowMs
|
||||
val h = deltaMs / HOUR_MS
|
||||
val m = (deltaMs % HOUR_MS) / 60_000L
|
||||
return if (h >= 24) {
|
||||
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
|
||||
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
|
||||
.withZone(zone)
|
||||
.withLocale(locale)
|
||||
.format(Instant.ofEpochMilli(fireMs))
|
||||
String.format(locale, daysTemplate, days, remH, dayTimeStr, name)
|
||||
} else {
|
||||
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(zone)
|
||||
.withLocale(locale)
|
||||
.format(Instant.ofEpochMilli(fireMs))
|
||||
if (h > 0) {
|
||||
String.format(locale, "%dh%02d · %s (%s)", h, m, timeStr, name)
|
||||
} else {
|
||||
String.format(locale, "%d min · %s (%s)", m, timeStr, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
|
||||
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
|
||||
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
|
||||
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
|
||||
// ligne par traitement, TRIÉES par prochaine prise — l'ancienne carte ne
|
||||
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
|
||||
// totalement le rappel de l'injection hebdomadaire (le rappel EEn
|
||||
// « n'arrivait qu'après celui de la CPA »). Le titre passe au pluriel
|
||||
// dès qu'il y a au moins deux lignes.
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val fires = treatments
|
||||
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
|
||||
.sortedBy { it.second }
|
||||
if (fires.isEmpty()) return
|
||||
val next = fires.minByOrNull { it.second }!!.second
|
||||
val nextTreatment = fires.minByOrNull { it.second }!!.first
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.next_dose),
|
||||
stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val deltaMs = next - System.currentTimeMillis()
|
||||
val h = deltaMs / HOUR_MS
|
||||
val m = (deltaMs % HOUR_MS) / 60_000L
|
||||
val name = nextTreatment?.name ?: ""
|
||||
// v1.4.1 : au-delà de 24 h, afficher en JOURS (les heures
|
||||
// deviennent peu lisibles) + le jour de la semaine dans la date.
|
||||
if (h >= 24) {
|
||||
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
|
||||
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(next))
|
||||
// Première ligne (la plus proche) mise en avant, les suivantes en
|
||||
// style secondaire — même hiérarchie visuelle que v1.4.x.
|
||||
fires.forEachIndexed { idx, (treatment, next) ->
|
||||
Text(
|
||||
stringResource(R.string.next_dose_days, days, remH, dayTimeStr, name),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
} else {
|
||||
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(next))
|
||||
Text(
|
||||
if (h > 0) {
|
||||
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, name)
|
||||
} else {
|
||||
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, name)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
nextDoseLine(
|
||||
nowMs, next, treatment?.name ?: "",
|
||||
stringResource(R.string.next_dose_days)
|
||||
),
|
||||
style = if (idx == 0) MaterialTheme.typography.titleMedium
|
||||
else MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@ -24,9 +26,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -34,12 +39,17 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.pk.LabTiming
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.LabDialog
|
||||
import com.hormonetrack.ui.components.formatLabValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@ -62,11 +72,54 @@ fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>
|
||||
}
|
||||
.sortedByDescending { it.first }
|
||||
|
||||
/**
|
||||
* Notes à AFFICHER pour un groupe de labs (v1.7.1 — fix du bug « une seule
|
||||
* note sur deux s'affiche »).
|
||||
*
|
||||
* HISTORIQUE DU BUG : à l'origine, le dialog de création mettait LA MÊME note
|
||||
* aux deux entrées d'une prise E2+T — l'affichage ne prenait donc que la
|
||||
* première non vide (`firstNotNullOfOrNull`), hypothèse documentée « toutes
|
||||
* identiques en pratique ». Depuis l'édition UNITAIRE (tap → sélecteur E2/T),
|
||||
* chaque entrée porte SA note (cas réel : « Analyse faite suite à des maux de
|
||||
* tête. » sur E2 et « DHT : 12ng/dL » sur T) → l'autre note disparaissait de
|
||||
* l'affichage tout en restant bien sauvegardée. Même bug côté web (mirror
|
||||
* `.find()`), corrigé en miroir.
|
||||
*
|
||||
* RÈGLES :
|
||||
* - notes vides/blanches ignorées ;
|
||||
* - ordre du groupe préservé (E2 avant T — ordre de [groupLabsForDisplay]) ;
|
||||
* - note IDENTIQUE sur plusieurs entrées (création via le dialog commun) →
|
||||
* affichée UNE seule fois (pas de doublon) ;
|
||||
* - notes DISTINCTES → une ligne par note, préfixée du marqueur
|
||||
* (« E2 : … » / « T : … ») pour savoir à quelle entrée elle se rapporte ;
|
||||
* - une seule note distincte → affichée brute (comportement v1.0 préservé).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testé en JVM (LabsGroupingTest).
|
||||
*/
|
||||
fun labNotesForDisplay(group: List<LabResult>): List<String> {
|
||||
val notes = group.mapNotNull { lab ->
|
||||
lab.notes?.takeIf { it.isNotBlank() }
|
||||
?.let { lab.marker.uppercase() to it.trim() }
|
||||
}
|
||||
if (notes.isEmpty()) return emptyList()
|
||||
val distinct = notes.distinctBy { it.second } // même texte = une seule ligne
|
||||
return if (distinct.size == 1) {
|
||||
listOf(distinct.first().second)
|
||||
} else {
|
||||
distinct.map { (marker, note) -> "$marker : $note" }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabsScreen() {
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
// ⚠️ TOUS les traitements + toutes les doses (actifs ET inactifs, §6.bis) :
|
||||
// la recommandation se base sur le traitement E2 injectable ACTIF, mais le
|
||||
// moteur reçoit l'historique complet (l'inactif participe à la courbe).
|
||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||
val labs by repo.allLabResults.collectAsState(initial = emptyList())
|
||||
|
||||
var showLabDialog by remember { mutableStateOf(false) }
|
||||
@ -81,6 +134,50 @@ fun LabsScreen() {
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
// ── Recommandation de prochaine prise de sang (v1.8.0) ───────────────────
|
||||
// Tick minute (même pattern que ChartScreen) : « maintenant » avance, la
|
||||
// fenêtre de recherche de creux se rafraîchit sans interaction.
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(60_000)
|
||||
tick++
|
||||
}
|
||||
}
|
||||
val nowMs = remember(tick) { System.currentTimeMillis() }
|
||||
// Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la
|
||||
// carte reco (null si l'option est OFF) — même pattern que HomeScreen.
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
|
||||
null, treatments, doseLogs, labs, tConfig, autoCalibrate
|
||||
) {
|
||||
value = if (!autoCalibrate) null
|
||||
else withContext(Dispatchers.Default) {
|
||||
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
|
||||
}
|
||||
}
|
||||
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
|
||||
// (courbe prévisionnelle), même discipline que Home/Chart.
|
||||
val recommendation by produceState(
|
||||
initialValue = null as LabTiming.LabRecommendation?,
|
||||
treatments, doseLogs, labs, nowMs, effectiveAuto
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
LabTiming.nextBloodDrawRecommendation(
|
||||
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
|
||||
)
|
||||
}
|
||||
}
|
||||
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
|
||||
// un traitement E2 injectable actif n'a PAS de Posologie, aucune
|
||||
// recommandation n'est calculable — on l'explique pour que le lien
|
||||
// Posologie → recommandation soit découvrable. Ne s'affiche JAMAIS en
|
||||
// même temps qu'une vraie recommandation.
|
||||
val suggestPosology = recommendation == null && LabTiming.shouldSuggestPosology(treatments)
|
||||
|
||||
Scaffold(
|
||||
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
@ -94,6 +191,104 @@ fun LabsScreen() {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_labs)) }
|
||||
)
|
||||
|
||||
// ── Carte « Prochaine analyse recommandée » (v1.8.0) ─────────────
|
||||
// Affichée seulement si le calcul est honnête (traitement E2
|
||||
// injectable actif à Posologie + creux stable trouvé). Sinon, si
|
||||
// un injectable actif est SANS Posologie : invite à la renseigner
|
||||
// (les deux cartes sont mutuellement exclusives).
|
||||
if (recommendation != null) {
|
||||
val r = recommendation!!
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.labrec_section),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Creux EXACT (choix v1.8.0 : les deux affichés)
|
||||
Text(
|
||||
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
|
||||
// la calibration est disponible) + statut cible —
|
||||
// lecture seule, jamais de notification (cf §9.bis)
|
||||
r.predictedE2?.let { predicted ->
|
||||
val targetStatus = LabTiming.troughTargetStatus(
|
||||
predicted, troughTarget?.first, troughTarget?.second
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_predicted,
|
||||
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
|
||||
) + when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
" — " + stringResource(R.string.labrec_in_target)
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
" — " + stringResource(R.string.labrec_out_of_target)
|
||||
null -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
MaterialTheme.colorScheme.primary
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
null -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_before_injection,
|
||||
r.ester,
|
||||
fmt.format(Instant.ofEpochMilli(r.injectionMs))
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
if (r.wasAlreadyStabilized) {
|
||||
stringResource(
|
||||
R.string.labrec_stabilized,
|
||||
r.ester,
|
||||
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
|
||||
)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.labrec_not_stabilized,
|
||||
r.ester,
|
||||
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.labrec_disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (suggestPosology) {
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.labrec_section),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.labrec_needs_posology),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (grouped.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.no_data),
|
||||
@ -114,9 +309,12 @@ fun LabsScreen() {
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(fmt.format(Instant.ofEpochMilli(ts)))
|
||||
// Notes du groupe (toutes identiques en pratique)
|
||||
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
// Notes du groupe (v1.7.1) : TOUTES les notes
|
||||
// distinctes, préfixées du marqueur quand elles
|
||||
// diffèrent — l'ancien code n'affichait que la
|
||||
// première non vide (bug « une note sur deux »)
|
||||
labNotesForDisplay(group).forEach { note ->
|
||||
Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@ -19,16 +12,10 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@ -47,49 +34,84 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.backup.BackupManager
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.data.backup.UserSettings
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.screens.settings.AboutCard
|
||||
import com.hormonetrack.ui.screens.settings.AlertThresholdsCard
|
||||
import com.hormonetrack.ui.screens.settings.AutoBackupCard
|
||||
import com.hormonetrack.ui.screens.settings.AutoCalibrationCard
|
||||
import com.hormonetrack.ui.screens.settings.BackupCard
|
||||
import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
|
||||
import com.hormonetrack.ui.screens.settings.LanguageCard
|
||||
import com.hormonetrack.ui.screens.settings.LogsCard
|
||||
import com.hormonetrack.ui.screens.settings.RemindersCard
|
||||
import com.hormonetrack.ui.screens.settings.TroughTargetCard
|
||||
import com.hormonetrack.ui.screens.settings.TModelCard
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Écran PARAMÈTRES (v1.9.8 : découpé en cartes thématiques, cf le
|
||||
* sous-package [com.hormonetrack.ui.screens.settings] — comportement et
|
||||
* rendu IDENTIQUES, chaque carte est un fichier dédié documenté).
|
||||
*
|
||||
* Cet orchestrateur garde ce qui est PARTAGÉ entre cartes :
|
||||
* - la collecte des réglages DataStore (collectAsState) ;
|
||||
* - les 4 launchers SAF (export JSON, export logs, import, dossier
|
||||
* auto-backup) : leurs callbacks écrivent l'état `message` et le dialog
|
||||
* de confirmation d'import, déclarés ICI ;
|
||||
* - le dialog de confirmation d'import (écrasement) ;
|
||||
* - l'ordre d'affichage des cartes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBack: () -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
val context = LocalContext.current
|
||||
val scheduler = remember { AlarmScheduler(context) }
|
||||
|
||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
||||
initial = com.hormonetrack.pk.Alerts.Thresholds()
|
||||
initial = Alerts.Thresholds()
|
||||
)
|
||||
// Cible de creux (v1.13.0) : référence de lab personnelle — distincte
|
||||
// des seuils d'alerte (cf TroughTargetCard)
|
||||
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
||||
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||
|
||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
||||
// ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ──
|
||||
val autoBackupEnabled by container.settings.autoBackupEnabled
|
||||
.collectAsState(initial = false)
|
||||
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
||||
.collectAsState(initial = null as String?)
|
||||
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
||||
val autoBackupLastRun by container.settings.autoBackupLastRun
|
||||
.collectAsState(initial = null as Pair<String?, Long?>?)
|
||||
|
||||
var showImportConfirm by remember { mutableStateOf(false) }
|
||||
var importJson by remember { mutableStateOf<String?>(null) }
|
||||
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
||||
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
||||
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
||||
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
||||
// lambda qui le capture).
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
/** Écrit le feedback depuis le thread principal (les cartes l'appellent). */
|
||||
val showMessage: (String) -> Unit = { message = it }
|
||||
|
||||
val logExportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
@ -134,7 +156,6 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var importJson by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
@ -144,13 +165,20 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
|
||||
// (langue, auto-calibration, seuils d'alerte) — collect des
|
||||
// DataStore Flows (first()) avant sérialisation.
|
||||
val settings = com.hormonetrack.data.backup.UserSettings(
|
||||
// UNE seule collecte des seuils (les 4 champs viennent du même
|
||||
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
|
||||
val thresholds = container.settings.alertThresholds.first()
|
||||
val settings = UserSettings(
|
||||
language = container.settings.language.first(),
|
||||
autoCalibrate = container.settings.autoCalibrate.first(),
|
||||
alertE2High = container.settings.alertThresholds.first().e2High,
|
||||
alertE2Low = container.settings.alertThresholds.first().e2Low,
|
||||
alertTHigh = container.settings.alertThresholds.first().tHigh,
|
||||
alertTLow = container.settings.alertThresholds.first().tLow
|
||||
alertE2High = thresholds.e2High,
|
||||
alertE2Low = thresholds.e2Low,
|
||||
alertTHigh = thresholds.tHigh,
|
||||
alertTLow = thresholds.tLow,
|
||||
// v1.13.0 : la cible de creux voyage dans le backup (champs
|
||||
// optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles)
|
||||
troughTargetLow = container.settings.troughTarget.first()?.first,
|
||||
troughTargetHigh = container.settings.troughTarget.first()?.second
|
||||
)
|
||||
val json = BackupManager.exportJson(repo, tConfig, settings)
|
||||
val ok = BackupManager.writeBackup(context, uri, json)
|
||||
@ -179,6 +207,46 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarde automatique (v1.7.0) : dossier SAF persisté ──────────────
|
||||
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
||||
// la permission persistable survit au reboot, le worker WorkManager écrit
|
||||
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
||||
// pickFolderToEnable distingue les deux entrées du picker (switch vs
|
||||
// « Choisir le dossier » — cf AutoBackupCard).
|
||||
var pickFolderToEnable by remember { mutableStateOf(false) }
|
||||
|
||||
val folderPickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
// Persiste la permission : sans takePersistableUriPermission, le tree
|
||||
// URI serait mort au prochain démarrage (worker sans droit d'écrire).
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
val toEnable = pickFolderToEnable
|
||||
pickFolderToEnable = false
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupTreeUri(uri.toString())
|
||||
if (toEnable) {
|
||||
container.settings.setAutoBackupEnabled(true)
|
||||
// Premier backup IMMÉDIAT (feedback instantané + validation
|
||||
// émulateur directe), puis le périodique 24 h prend le relais
|
||||
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
message = if (toEnable) {
|
||||
context.getString(R.string.autobackup_first_run)
|
||||
} else {
|
||||
context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@ -200,405 +268,56 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// --- Language ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = currentLanguage == "system",
|
||||
onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage("system")
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
LanguageCard(currentLanguage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AutoCalibrationCard(autoCalibrate)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TModelCard(tConfig, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AlertThresholdsCard(alertThresholds, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TroughTargetCard(troughTarget, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ChartTimezoneCard(chartTimezone, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RemindersCard()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
BackupCard(
|
||||
onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
|
||||
onImport = { importLauncher.launch(arrayOf("application/json")) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AutoBackupCard(
|
||||
enabled = autoBackupEnabled,
|
||||
treeUri = autoBackupTreeUri,
|
||||
keep = autoBackupKeep,
|
||||
lastRun = autoBackupLastRun,
|
||||
onPickFolderToEnable = {
|
||||
pickFolderToEnable = true
|
||||
folderPickerLauncher.launch(null)
|
||||
},
|
||||
label = { Text(stringResource(R.string.language_system)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "fr",
|
||||
onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage("fr")
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags("fr")
|
||||
)
|
||||
}
|
||||
}
|
||||
onChooseFolder = {
|
||||
pickFolderToEnable = false
|
||||
folderPickerLauncher.launch(null)
|
||||
},
|
||||
label = { Text("Français") }
|
||||
onMessage = showMessage
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "en",
|
||||
onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage("en")
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags("en")
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("English") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Calibration automatique (E2 par période d'ester + T) — UNE seule option ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = autoCalibrate,
|
||||
onCheckedChange = { enabled ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoCalibrate(enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.auto_calibrate_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
|
||||
// remplace à l'affichage quand elle est activée) ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.t_model_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tBaseText,
|
||||
onValueChange = { tBaseText = it },
|
||||
label = { Text(stringResource(R.string.t_base)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tFloorText,
|
||||
onValueChange = { tFloorText = it },
|
||||
label = { Text(stringResource(R.string.t_floor)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tKText,
|
||||
onValueChange = { tKText = it },
|
||||
label = { Text("k") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setTConfig(TConfig(base, floor, k))
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
||||
val trs = repo.allTreatmentsOnce()
|
||||
val doses = repo.allDoseLogsOnce()
|
||||
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
||||
labs, trs, doses, tConfig
|
||||
)
|
||||
calibrated?.let {
|
||||
container.settings.setTConfig(it)
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.t_calibrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Seuils d'alerte (v1.4.2) : limites E2/T évaluées contre le taux
|
||||
// estimé actuel de l'accueil (pk/Alerts.kt). Opt-in : champ vide =
|
||||
// pas d'alerte. Haut > bas requis si les deux sont définis.
|
||||
var e2HighText by remember(alertThresholds.e2High) {
|
||||
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var e2LowText by remember(alertThresholds.e2Low) {
|
||||
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var tHighText by remember(alertThresholds.tHigh) {
|
||||
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var tLowText by remember(alertThresholds.tLow) {
|
||||
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var alertError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.alert_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = e2HighText,
|
||||
onValueChange = { e2HighText = it },
|
||||
label = { Text(stringResource(R.string.alert_e2_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = e2LowText,
|
||||
onValueChange = { e2LowText = it },
|
||||
label = { Text(stringResource(R.string.alert_e2_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tHighText,
|
||||
onValueChange = { tHighText = it },
|
||||
label = { Text(stringResource(R.string.alert_t_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tLowText,
|
||||
onValueChange = { tLowText = it },
|
||||
label = { Text(stringResource(R.string.alert_t_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
alertError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
// Parsing tolérant (virgule décimale FR), champ vide = null
|
||||
fun parse(s: String): Double? =
|
||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||
val e2High = parse(e2HighText)
|
||||
val e2Low = parse(e2LowText)
|
||||
val tHigh = parse(tHighText)
|
||||
val tLow = parse(tLowText)
|
||||
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
||||
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
||||
.any { it.trim().isNotEmpty() && parse(it) == null }
|
||||
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
||||
when {
|
||||
invalidSyntax || !thresholds.isCoherent() -> {
|
||||
alertError = context.getString(R.string.alert_invalid)
|
||||
}
|
||||
else -> {
|
||||
alertError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAlertThresholds(thresholds)
|
||||
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
||||
// si le seuil est déjà franchi, la notification
|
||||
// part sans attendre le cycle périodique de
|
||||
// 15 min (état anti-spam réinitialisé au besoin).
|
||||
com.hormonetrack.reminder.AlertNotifier.checkNow(context)
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Fuseau horaire du graphique (v1.4.5) : auto (téléphone) ou choix.
|
||||
// Applique l'alignement minuit des labels X dans le fuseau choisi.
|
||||
var tzText by remember(chartTimezone) {
|
||||
mutableStateOf(chartTimezone ?: "")
|
||||
}
|
||||
var tzError by remember { mutableStateOf<String?>(null) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.timezone_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = tzText,
|
||||
onValueChange = { tzText = it },
|
||||
label = { Text(stringResource(R.string.timezone_field)) },
|
||||
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
tzError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val id = tzText.trim()
|
||||
val valid = id.isEmpty() || try {
|
||||
java.util.TimeZone.getTimeZone(id)
|
||||
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
||||
} catch (_: Exception) { false }
|
||||
if (!valid) {
|
||||
tzError = context.getString(R.string.timezone_invalid)
|
||||
} else {
|
||||
tzError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setChartTimezone(id.ifBlank { null })
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
tzText = ""
|
||||
tzError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setChartTimezone(null)
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.timezone_auto)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Reminders / exact alarms ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
if (!scheduler.canScheduleExact()) {
|
||||
Text(
|
||||
stringResource(R.string.exact_alarm_needed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Button(onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
||||
}
|
||||
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
||||
} else {
|
||||
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Backup ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.backup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
exportLauncher.launch(ExportFileNames.backupFileName())
|
||||
}) { Text(stringResource(R.string.export_json)) }
|
||||
OutlinedButton(onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}) { Text(stringResource(R.string.import_json)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Logs de diagnostic (v1.3.1) ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.logs_count, logLineCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
// Nom de fichier via ExportFileNames (cf §14 #48) :
|
||||
// extrait et testé — passer un LocalDate au pattern
|
||||
// horaire crashait l'app au tap (bug v1.3.1→v1.3.3).
|
||||
LogsCard(
|
||||
logLineCount = logLineCount,
|
||||
onExport = {
|
||||
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
|
||||
// et testé — passer un LocalDate au pattern horaire crashait
|
||||
// l'app au tap (bug v1.3.1→v1.3.3).
|
||||
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
||||
}) { Text(stringResource(R.string.logs_export)) }
|
||||
TextButton(onClick = {
|
||||
},
|
||||
onClear = {
|
||||
AppLog.clear()
|
||||
logLineCount = AppLog.count()
|
||||
}) { Text(stringResource(R.string.logs_clear)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- About : version installée + lien releases Gitea ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
||||
)
|
||||
}) { Text(stringResource(R.string.releases_link)) }
|
||||
Text(
|
||||
stringResource(R.string.disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.models_credit),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
AboutCard()
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
@ -628,11 +347,23 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
it.settings?.let { us ->
|
||||
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
||||
container.settings.setAlertThresholds(
|
||||
com.hormonetrack.pk.Alerts.Thresholds(
|
||||
Alerts.Thresholds(
|
||||
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
||||
tHigh = us.alertTHigh, tLow = us.alertTLow
|
||||
)
|
||||
)
|
||||
// v1.13.0 : cible de creux — restaurée SEULEMENT
|
||||
// si le backup en porte une (champs non nuls
|
||||
// ET cohérents) ; sinon on ne touche à rien
|
||||
// (un backup ancien ne doit pas effacer la
|
||||
// cible locale).
|
||||
if (us.troughTargetLow != null && us.troughTargetHigh != null &&
|
||||
us.troughTargetHigh > us.troughTargetLow
|
||||
) {
|
||||
container.settings.setTroughTarget(
|
||||
us.troughTargetLow to us.troughTargetHigh
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
us.language?.let { lang ->
|
||||
container.settings.setLanguage(lang)
|
||||
@ -666,6 +397,3 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDoubles(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||
|
||||
@ -48,11 +48,11 @@ import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.Esters
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.modelLabelRes
|
||||
import com.hormonetrack.data.model.PKPresets
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
@ -272,10 +272,17 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (PharmacokineticEngine.isInjectionRoute(route)) {
|
||||
// v1.9.4 : la liste des esters suit la couverture RÉELLE du
|
||||
// modèle choisi (Esters.choicesForModel — miroir du DoseDialog
|
||||
// et du treatment-editor web) : le modèle ESE ANALYTIQUE offre
|
||||
// ses 6 esters injectables (EV/EU/EEn/EC/EB/EUCS) directement
|
||||
// dans l'éditeur, au lieu des seuls EV/EU/EEn de l'époque des
|
||||
// tables ODS. Chaque ester listé est dispatché par le moteur —
|
||||
// aucun choix possible ne produit 0 pg/mL.
|
||||
DropdownField(
|
||||
label = stringResource(R.string.ester),
|
||||
selectedLabel = ester,
|
||||
options = listOf(Esters.EV, Esters.EU, Esters.EEN).map { it to it },
|
||||
options = Esters.choicesForModel(model).map { it to it },
|
||||
onSelect = { ester = it }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@ -283,17 +290,26 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
label = stringResource(R.string.pk_model),
|
||||
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
||||
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
||||
selectedLabel = when (model) {
|
||||
PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs)
|
||||
PKModels.WHSAH -> stringResource(R.string.model_whs)
|
||||
else -> stringResource(R.string.model_ese)
|
||||
},
|
||||
// Label par le helper UNIQUE (règle anti-bug #59 :
|
||||
// jamais de when/ternaire de labels en ligne — l'ancien
|
||||
// à 2 branches affichait « Estrannaise » pour WHSAH).
|
||||
selectedLabel = stringResource(modelLabelRes(model)),
|
||||
options = listOf(
|
||||
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
||||
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
|
||||
PKModels.WHSAH to stringResource(R.string.model_whs)
|
||||
),
|
||||
onSelect = { model = it }
|
||||
onSelect = { model = it
|
||||
// v1.9.4 : l'ester courant peut sortir de la couverture
|
||||
// du nouveau modèle (ex. EUCS + TFS : le V3C n'a pas de
|
||||
// paramètres EUCS → le dispatcher donnerait 0) — on
|
||||
// rebascule sur le premier ester couvert (EV dans les
|
||||
// trois listes). Le recompose reconstruit ensuite la
|
||||
// liste des esters (options = choicesForModel(model)).
|
||||
if (ester !in Esters.choicesForModel(it)) {
|
||||
ester = Esters.choicesForModel(it).first()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@ -434,8 +450,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
||||
)
|
||||
}
|
||||
val scheduler2 = AlarmScheduler(context)
|
||||
if (!scheduler2.canScheduleExact()) {
|
||||
// Le scheduler est déjà créé au niveau de l'écran
|
||||
// (var `scheduler`) — pas de recréation par recompose.
|
||||
if (!scheduler.canScheduleExact()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.exact_alarm_needed),
|
||||
|
||||
@ -27,6 +27,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
@ -36,6 +37,21 @@ import com.hormonetrack.data.model.modelLabelRes
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
|
||||
/**
|
||||
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande utilisatrice) :
|
||||
* les ACTIFS d'abord, les INACTIFS regroupés TOUT EN BAS — l'ancien ordre
|
||||
* brut du DAO mélangeait les deux groupes et le badge discret ne suffisait
|
||||
* plus à les discerner (« peu discernables », remontée).
|
||||
*
|
||||
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé
|
||||
* (le tri ne réordonne jamais les actifs entre eux, ni les inactifs entre
|
||||
* eux). PUR (aucune dépendance Android) → testable en JVM
|
||||
* (TreatmentsDisplayTest) ; miroir web `treatmentsForDisplay`
|
||||
* (js/data/models.js, mêmes sémantiques).
|
||||
*/
|
||||
fun treatmentsForDisplay(treatments: List<Treatment>): List<Treatment> =
|
||||
treatments.sortedBy { !it.isActive }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TreatmentsScreen(
|
||||
@ -45,6 +61,11 @@ fun TreatmentsScreen(
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||
// v1.11.0 : actifs d'abord, inactifs regroupés tout en bas
|
||||
// (cf [treatmentsForDisplay]) — mémoïsé sur la liste brute.
|
||||
val displayTreatments = remember(treatments) { treatmentsForDisplay(treatments) }
|
||||
val activeTreatments = displayTreatments.filter { it.isActive }
|
||||
val inactiveTreatments = displayTreatments.filter { !it.isActive }
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
@ -58,22 +79,44 @@ fun TreatmentsScreen(
|
||||
title = { Text(stringResource(R.string.nav_treatments)) }
|
||||
)
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(treatments, key = { it.id }) { tr ->
|
||||
items(activeTreatments, key = { it.id }) { tr ->
|
||||
TreatmentCard(tr, onClick = { onEdit(tr.id) })
|
||||
}
|
||||
// En-tête de section SEULEMENT s'il existe des inactifs —
|
||||
// la séparation visuelle (en-tête + atténuation, cf
|
||||
// TreatmentCard) rend le groupe lisible d'un coup d'œil.
|
||||
if (inactiveTreatments.isNotEmpty()) {
|
||||
item(key = "inactive_section") {
|
||||
Text(
|
||||
stringResource(R.string.inactive_section),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(inactiveTreatments, key = { it.id }) { tr ->
|
||||
TreatmentCard(tr, dimmed = true, onClick = { onEdit(tr.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte d'un traitement. @param dimmed : carte INACTIVE (v1.11.0) → contenu
|
||||
* atténué (alpha réduit) en PLUS du badge : second signal visuel demandé
|
||||
* (« plus distincts des actifs ») — la carte reste lisible et tappable.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
|
||||
private fun TreatmentCard(tr: Treatment, dimmed: Boolean = false, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.alpha(if (dimmed) 0.55f else 1f)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.hormonetrack.BuildConfig
|
||||
import com.hormonetrack.R
|
||||
|
||||
/**
|
||||
* Carte À PROPOS de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé) : version installée (BuildConfig
|
||||
* — épinglée par un test UI depuis v1.9.8, le bug #46 des versions jamais
|
||||
* bumpées ne peut plus revenir silencieusement), lien vers les releases
|
||||
* Gitea, avertissement médical et crédit des modèles PK.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutCard() {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
||||
)
|
||||
}) { Text(stringResource(R.string.releases_link)) }
|
||||
Text(
|
||||
stringResource(R.string.disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.models_credit),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.reminder.AlertNotifier
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte SEUILS D'ALERTE de l'écran Paramètres (v1.4.2 ; v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). Limites E2 (pg/mL) / T (ng/mL)
|
||||
* évaluées contre le taux ESTIMÉ courant de l'accueil (pk/Alerts.kt) et par
|
||||
* le worker périodique de 15 min. Opt-in : champ vide = pas d'alerte sur ce
|
||||
* côté ; incohérence haut ≤ bas ou saisie non numérique = message d'erreur
|
||||
* local, rien n'est persisté.
|
||||
*
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlertThresholdsCard(
|
||||
alertThresholds: Alerts.Thresholds,
|
||||
onMessage: (String) -> Unit
|
||||
) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// Champs réinitialisés à chaque valeur persistée (clé du remember) ;
|
||||
// null (pas de seuil) = champ vide.
|
||||
var e2HighText by remember(alertThresholds.e2High) {
|
||||
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var e2LowText by remember(alertThresholds.e2Low) {
|
||||
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var tHighText by remember(alertThresholds.tHigh) {
|
||||
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var tLowText by remember(alertThresholds.tLow) {
|
||||
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var alertError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.alert_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = e2HighText,
|
||||
onValueChange = { e2HighText = it },
|
||||
label = { Text(stringResource(R.string.alert_e2_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = e2LowText,
|
||||
onValueChange = { e2LowText = it },
|
||||
label = { Text(stringResource(R.string.alert_e2_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tHighText,
|
||||
onValueChange = { tHighText = it },
|
||||
label = { Text(stringResource(R.string.alert_t_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tLowText,
|
||||
onValueChange = { tLowText = it },
|
||||
label = { Text(stringResource(R.string.alert_t_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
alertError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
// Parsing tolérant (virgule décimale FR), champ vide = null
|
||||
fun parse(s: String): Double? =
|
||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||
val e2High = parse(e2HighText)
|
||||
val e2Low = parse(e2LowText)
|
||||
val tHigh = parse(tHighText)
|
||||
val tLow = parse(tLowText)
|
||||
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
||||
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
||||
.any { it.trim().isNotEmpty() && parse(it) == null }
|
||||
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
||||
when {
|
||||
invalidSyntax || !thresholds.isCoherent() -> {
|
||||
alertError = context.getString(R.string.alert_invalid)
|
||||
}
|
||||
else -> {
|
||||
alertError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAlertThresholds(thresholds)
|
||||
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
||||
// si le seuil est déjà franchi, la notification
|
||||
// part sans attendre le cycle périodique de
|
||||
// 15 min (état anti-spam réinitialisé au besoin).
|
||||
AlertNotifier.checkNow(context)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Carte SAUVEGARDE AUTOMATIQUE QUOTIDIENNE de l'écran Paramètres (v1.7.0 ;
|
||||
* v1.9.8 : extraite de SettingsScreen.kt, comportement inchangé).
|
||||
*
|
||||
* Opt-in pur : désactivée par défaut ; nécessite un dossier choisi UNE fois
|
||||
* (SAF tree persisté — ACTION_OPEN_DOCUMENT_TREE, cf orchestrateur) ; un run
|
||||
* immédiat est déclenché à l'activation (feedback + validation directe).
|
||||
* Rétention 1..30 copies (le worker purge les plus vieilles, ne touche
|
||||
* JAMAIS les exports manuels — cf AutoBackupRetention).
|
||||
*
|
||||
* Sémantique des deux entrées du picker (v1.7.0) :
|
||||
* - via le SWITCH quand aucun dossier n'existe → la sélection ACTIVE la
|
||||
* sauvegarde (+ premier run immédiat) : le switch sans dossier ne peut
|
||||
* rien faire, le picker est la suite logique ;
|
||||
* - via « Choisir le dossier » (changement) → on enregistre le dossier
|
||||
* SANS changer l'état activé/désactivé.
|
||||
*
|
||||
* @param onPickFolderToEnable ouvre le picker SAF dont la sélection ACTIVERA
|
||||
* la sauvegarde (branche « switch sans dossier » — launcher externe).
|
||||
* @param onChooseFolder ouvre le picker SAF pour CHANGER de dossier sans
|
||||
* toucher à l'état activé/désactivé.
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoBackupCard(
|
||||
enabled: Boolean,
|
||||
treeUri: String?,
|
||||
keep: Int,
|
||||
lastRun: Pair<String?, Long?>?,
|
||||
onPickFolderToEnable: () -> Unit,
|
||||
onChooseFolder: () -> Unit,
|
||||
onMessage: (String) -> Unit
|
||||
) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var keepText by remember(keep) { mutableStateOf(keep.toString()) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { wantEnabled ->
|
||||
if (!wantEnabled) {
|
||||
// Désactivation : le périodique reste planifié
|
||||
// mais le worker sort immédiatement (no-op)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupEnabled(false)
|
||||
}
|
||||
} else if (treeUri == null) {
|
||||
// Pas de dossier → le picker EST la suite :
|
||||
// la sélection activera (+ premier run)
|
||||
onPickFolderToEnable()
|
||||
} else {
|
||||
// Dossier existant → activation directe + run
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupEnabled(true)
|
||||
AutoBackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.autobackup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Dossier choisi : nom lisible (documents "primary:Download" →
|
||||
// "Download") + bouton pour le changer (sans toucher au switch)
|
||||
Text(
|
||||
stringResource(R.string.autobackup_folder) + " : " +
|
||||
(treeUri?.let { folderDisplayName(it) }
|
||||
?: stringResource(R.string.autobackup_no_folder)),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = onChooseFolder) {
|
||||
Text(stringResource(R.string.autobackup_choose_folder))
|
||||
}
|
||||
|
||||
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
||||
lastRun?.let { (result, atMs) ->
|
||||
if (atMs != null && atMs > 0L) {
|
||||
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||
.format(java.util.Date(atMs))
|
||||
Text(
|
||||
text = context.getString(
|
||||
if (result == "ok") R.string.autobackup_last_ok
|
||||
else R.string.autobackup_last_fail,
|
||||
date
|
||||
),
|
||||
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Rétention : copies conservées (1..30, borné au set) — Save
|
||||
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
||||
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = keepText,
|
||||
onValueChange = { keepText = it },
|
||||
label = { Text(stringResource(R.string.autobackup_keep)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(onClick = {
|
||||
val parsed = keepText.trim().toIntOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupKeep(parsed) // borné [1;30]
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Carte CALIBRATION AUTOMATIQUE de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). UNE seule option (fusionnée
|
||||
* v1.2.1) : facteurs d'échelle E2 par période d'ester + calibration du modèle
|
||||
* T, recalculés depuis les labs. Désactivée par défaut — la calibration reste
|
||||
* sous contrôle explicite de l'utilisatrice.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoCalibrationCard(autoCalibrate: Boolean) {
|
||||
val container = LocalAppContainer.current
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = autoCalibrate,
|
||||
onCheckedChange = { enabled ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoCalibrate(enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.auto_calibrate_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
|
||||
/**
|
||||
* Carte SAUVEGARDE MANUELLE (JSON) de l'écran Paramètres (v1.9.8 : extraite
|
||||
* de SettingsScreen.kt, comportement inchangé). L'export passe par le picker
|
||||
* SAF système (CreateDocument) — aucun accès disque direct ; l'import
|
||||
* ouvre le backup via OpenDocument puis demande CONFIRMATION avant
|
||||
* écrasement (dialog géré par l'orchestrateur SettingsScreen).
|
||||
*
|
||||
* @param onExport lance le picker SAF d'export (launcher de l'orchestrateur)
|
||||
* @param onImport lance le picker SAF d'import (launcher de l'orchestrateur)
|
||||
*/
|
||||
@Composable
|
||||
internal fun BackupCard(onExport: () -> Unit, onImport: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.backup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onExport) { Text(stringResource(R.string.export_json)) }
|
||||
OutlinedButton(onClick = onImport) { Text(stringResource(R.string.import_json)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte FUSEAU HORAIRE DU GRAPHIQUE de l'écran Paramètres (v1.4.5 ; v1.9.8 :
|
||||
* extraite de SettingsScreen.kt, comportement inchangé). « auto » = fuseau
|
||||
* du téléphone (champ vide) ; sinon un ID java.util.TimeZone (ex.
|
||||
* « Europe/Paris ») — il pilote l'alignement minuit des labels X du
|
||||
* graphique (fix #55/#67 : ticks ET labels dans le MÊME fuseau).
|
||||
*
|
||||
* La validité de l'ID est vérifiée AVANT persistance (un ID inconnu retombe
|
||||
* sur « GMT » côté java.util.TimeZone : on le détecte et on refuse).
|
||||
*
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ChartTimezoneCard(chartTimezone: String?, onMessage: (String) -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var tzText by remember(chartTimezone) {
|
||||
mutableStateOf(chartTimezone ?: "")
|
||||
}
|
||||
var tzError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.timezone_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = tzText,
|
||||
onValueChange = { tzText = it },
|
||||
label = { Text(stringResource(R.string.timezone_field)) },
|
||||
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
tzError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val id = tzText.trim()
|
||||
// Un ID inconnu retombe silencieusement sur « GMT » : on
|
||||
// l'accepte seulement si l'utilisateur l'a VRAIMENT saisi.
|
||||
val valid = id.isEmpty() || try {
|
||||
java.util.TimeZone.getTimeZone(id)
|
||||
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
||||
} catch (_: Exception) { false }
|
||||
if (!valid) {
|
||||
tzError = context.getString(R.string.timezone_invalid)
|
||||
} else {
|
||||
tzError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setChartTimezone(id.ifBlank { null })
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
tzText = ""
|
||||
tzError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setChartTimezone(null)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.timezone_auto)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte LANGUE de l'écran Paramètres (v1.9.8 : extraite de SettingsScreen.kt,
|
||||
* comportement inchangé). La langue est indépendante du système : « system »
|
||||
* suit la configuration du téléphone, « fr »/« en » forcent via les per-app
|
||||
* locales d'AppCompat (recréent l'activité — le réglage survit au redémarrage,
|
||||
* réappliqué par MainActivity.onCreate).
|
||||
*
|
||||
* Les libellés « Français » / « English » sont des LITTÉRAUX volontaires :
|
||||
* chaque langue s'écrit dans sa propre langue (convention i18n).
|
||||
*/
|
||||
@Composable
|
||||
internal fun LanguageCard(currentLanguage: String) {
|
||||
val container = LocalAppContainer.current
|
||||
|
||||
/** Persiste la langue puis l'applique immédiatement (locales per-app). */
|
||||
fun applyLanguage(code: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage(code)
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
if (code == "system") LocaleListCompat.getEmptyLocaleList()
|
||||
else LocaleListCompat.forLanguageTags(code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = currentLanguage == "system",
|
||||
onClick = { applyLanguage("system") },
|
||||
label = { Text(stringResource(R.string.language_system)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "fr",
|
||||
onClick = { applyLanguage("fr") },
|
||||
label = { Text("Français") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "en",
|
||||
onClick = { applyLanguage("en") },
|
||||
label = { Text("English") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.util.AppLog
|
||||
|
||||
/**
|
||||
* Carte LOGS DE DIAGNOSTIC de l'écran Paramètres (v1.3.1 ; v1.9.8 : extraite
|
||||
* de SettingsScreen.kt, comportement inchangé). Le buffer AppLog (500
|
||||
* lignes, persisté) a permis le premier diagnostic À DISTANCE de l'historique
|
||||
* du projet (§14 #50) — l'export est le canal de support de l'app.
|
||||
*
|
||||
* @param logLineCount compteur affiché (état de l'orchestrateur : il est
|
||||
* rafraîchi APRÈS l'export, qui ajoute des lignes au journal).
|
||||
* @param onExport exporte les logs via le picker SAF (nom de fichier sûr —
|
||||
* helper ExportFileNames, cf bug §14 #48).
|
||||
* @param onClear efface le buffer et rafraîchit le compteur.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LogsCard(
|
||||
logLineCount: Int,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.logs_count, logLineCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onExport) { Text(stringResource(R.string.logs_export)) }
|
||||
TextButton(onClick = onClear) { Text(stringResource(R.string.logs_clear)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
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.hormonetrack.R
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
|
||||
/**
|
||||
* Carte RAPPELS / ALARMES EXACTES de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). Les rappels de doses utilisent
|
||||
* des alarmes EXACTES (heure juste) : depuis Android 12 (S), l'autorisation
|
||||
* SCHEDULE_EXACT_ALARM est opt-in par application. Sans elle, les alarmes
|
||||
* deviennent inexactes (fenêtre ± quelques minutes) — la carte invite à
|
||||
* l'accorder via les réglages système quand c'est le cas.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemindersCard() {
|
||||
val context = LocalContext.current
|
||||
val scheduler = remember { AlarmScheduler(context) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
if (!scheduler.canScheduleExact()) {
|
||||
Text(
|
||||
stringResource(R.string.exact_alarm_needed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Button(onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
||||
}
|
||||
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
||||
} else {
|
||||
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Helpers de formatage/affichage partagés par les cartes de l'écran
|
||||
* Paramètres (v1.9.8 : extraits de SettingsScreen.kt lors du découpage en
|
||||
* cartes — cf docs/DEVELOPPEMENT.md §10). Purs et défensifs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formate un Double pour l'affichage dans un champ texte : les entiers sans
|
||||
* décimales, les autres avec 3 décimales max (Locale.US : point décimal — la
|
||||
* virgule FR est tolérée À LA SAISIE via replace(',', '.') dans les cartes).
|
||||
*/
|
||||
internal fun formatDoubles(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||
|
||||
/**
|
||||
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
||||
* est de la forme « primary:Download » (stockage principal) ou
|
||||
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
||||
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
||||
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
||||
*/
|
||||
internal fun folderDisplayName(treeUriString: String): String =
|
||||
try {
|
||||
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
||||
id.substringAfter(':', id)
|
||||
} catch (_: Exception) {
|
||||
treeUriString
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte ESTIMATION T de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). Réglages MANUELS du modèle
|
||||
* `T = plancher + (base − plancher) ÷ (1 + k·E2)` ; l'auto-calibration
|
||||
* (carte du dessus) les REMPLACE à l'affichage quand elle est activée.
|
||||
*
|
||||
* « Calibrer depuis les labs » recalcule k par période d'ester depuis les
|
||||
* analyses T enregistrées (PharmacokineticEngine.computeTConfigCalibration).
|
||||
*
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun TModelCard(tConfig: TConfig, onMessage: (String) -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
val context = LocalContext.current
|
||||
|
||||
// Champs de saisie réinitialisés à chaque valeur persistée (clé du
|
||||
// remember) — la virgule décimale FR est tolérée à la saisie.
|
||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.t_model_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tBaseText,
|
||||
onValueChange = { tBaseText = it },
|
||||
label = { Text(stringResource(R.string.t_base)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tFloorText,
|
||||
onValueChange = { tFloorText = it },
|
||||
label = { Text(stringResource(R.string.t_floor)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tKText,
|
||||
onValueChange = { tKText = it },
|
||||
label = { Text("k") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setTConfig(TConfig(base, floor, k))
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
||||
val trs = repo.allTreatmentsOnce()
|
||||
val doses = repo.allDoseLogsOnce()
|
||||
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
||||
labs, trs, doses, tConfig
|
||||
)
|
||||
calibrated?.let {
|
||||
container.settings.setTConfig(it)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.t_calibrated))
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte CIBLE DE CREUX E2 de l'écran Paramètres (v1.13.0, opt-in).
|
||||
*
|
||||
* ⚠️ DISTINCT des seuils d'alerte ([AlertThresholdsCard], au-dessus) :
|
||||
* - les seuils d'alerte surveillent le niveau estimé EN CONTINU (carte
|
||||
* d'accueil + notification toutes les 15 min — cf §9.bis) ;
|
||||
* - la cible de creux est une RÉFÉRENCE PERSONNELLE DE LAB : « quand je
|
||||
* fais ma prise de sang au creux recommandé, je veux être dans
|
||||
* [basse, haute] » — la prédiction de la carte reco est colorée
|
||||
* contre cette bande, JAMAIS de notification.
|
||||
*
|
||||
* Opt-in pur : champ vide = désactivée (les DEUX champs, vides ou remplis).
|
||||
* Validation : haut > bas obligatoire (message rouge local, rien n'est
|
||||
* persisté sinon).
|
||||
*
|
||||
* @param target cible active (basse to haute) ou null (désactivée)
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun TroughTargetCard(target: Pair<Double, Double>?, onMessage: (String) -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var lowText by remember(target?.first) {
|
||||
mutableStateOf(target?.first?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var highText by remember(target?.second) {
|
||||
mutableStateOf(target?.second?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.trough_target_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.trough_target_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = lowText,
|
||||
onValueChange = { lowText = it },
|
||||
label = { Text(stringResource(R.string.trough_target_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = highText,
|
||||
onValueChange = { highText = it },
|
||||
label = { Text(stringResource(R.string.trough_target_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
// Même parsing tolérant que la carte Seuils (virgule FR),
|
||||
// champ vide = null
|
||||
fun parse(s: String): Double? =
|
||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||
val low = parse(lowText)
|
||||
val high = parse(highText)
|
||||
when {
|
||||
// Un seul champ rempli = saisie incomplète (pas d'état
|
||||
// « demi-cible » — l'app ne devine pas)
|
||||
(low == null) != (high == null) -> {
|
||||
error = context.getString(R.string.trough_target_incomplete)
|
||||
}
|
||||
low != null && high != null && high <= low -> {
|
||||
error = context.getString(R.string.trough_target_invalid)
|
||||
}
|
||||
else -> {
|
||||
error = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
// null to null = désactivation (setTroughTarget efface)
|
||||
container.settings.setTroughTarget(
|
||||
if (low != null && high != null) low to high else null
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,5 +17,8 @@ val ChartWhs = Color(0xFF2E7D32)
|
||||
val ChartTWhs = Color(0xFF9C27B0)
|
||||
val ChartT = Color(0xFFD6589E)
|
||||
val LabDot = Color(0xFFE67E22)
|
||||
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, pk/LabTrajectoryModel)
|
||||
// — rose foncé distinct de E2/T/TFS/WHS et des dots orange des labs
|
||||
val ChartLabTrajectory = Color(0xFFC2185B)
|
||||
val GoodGreen = Color(0xFF2E7D32)
|
||||
val WarnOrange = Color(0xFFB26A00)
|
||||
|
||||
@ -60,9 +60,9 @@ object AppLog {
|
||||
fun log(tag: String, message: String) = synchronized(lock) {
|
||||
val line = formatLine(System.currentTimeMillis(), tag, message)
|
||||
buffer.addLast(line)
|
||||
val trimmed = trimLines(buffer.toList(), MAX_LINES)
|
||||
buffer.clear()
|
||||
buffer.addAll(trimmed)
|
||||
// Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le
|
||||
// buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières)
|
||||
while (buffer.size > MAX_LINES) buffer.removeFirst()
|
||||
try {
|
||||
file?.appendText(line + "\n")
|
||||
} catch (_: Exception) {
|
||||
|
||||
@ -26,6 +26,21 @@ object ExportFileNames {
|
||||
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
/**
|
||||
* Pattern des AUTO-BACKUPS journaliers (v1.7.0) : `yyyyMMdd-HHmm` — même
|
||||
* règle que [LOG_FMT] (contient HHmm → type temporel complet requis).
|
||||
* L'heure permet plusieurs runs le même jour (activation + tick journalier,
|
||||
* ou plusieurs activations successives) SANS écrasement : chaque fichier
|
||||
* a un nom unique → la rétention ([com.hormonetrack.data.backup.AutoBackupRetention])
|
||||
* trie sur l'horodatage du NOM (jamais sur la mtime du document SAF, non
|
||||
* fiable sur tous les providers).
|
||||
*/
|
||||
private val AUTO_BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||
|
||||
/** Préfixe d'un fichier d'AUTO-backup — partagé avec la rétention (PUR). */
|
||||
const val AUTO_BACKUP_PREFIX = "hormonetrack-auto-"
|
||||
private const val AUTO_BACKUP_SUFFIX = ".json"
|
||||
|
||||
/**
|
||||
* Nom du fichier d'export des logs de diagnostic :
|
||||
* `hormonetrack-logs-20260906-1234.txt`.
|
||||
@ -43,4 +58,34 @@ object ExportFileNames {
|
||||
*/
|
||||
fun backupFileName(today: LocalDate = LocalDate.now()): String =
|
||||
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json"
|
||||
|
||||
/**
|
||||
* Nom d'un AUTO-backup journalier (v1.7.0) :
|
||||
* `hormonetrack-auto-20260913-1830.json`. Pattern AVEC heure →
|
||||
* [LocalDateTime] obligatoire (règle anti-#48 en tête de fichier).
|
||||
* Pur (déterministe sur [now]) → testé en JVM.
|
||||
*/
|
||||
fun autoBackupFileName(now: LocalDateTime = LocalDateTime.now()): String =
|
||||
"$AUTO_BACKUP_PREFIX${AUTO_BACKUP_FMT.format(now)}$AUTO_BACKUP_SUFFIX"
|
||||
|
||||
/**
|
||||
* Re-trouve l'horodatage dans un NOM de fichier d'auto-backup (round-trip
|
||||
* de [autoBackupFileName]) — PUR, utilisé par la rétention pour trier par
|
||||
* l'horodatage du nom. Retourne `null` pour tout ce qui n'est PAS un
|
||||
* auto-backup (exports manuels `hormonetrack-backup-…`, logs, fichiers
|
||||
* étrangers du dossier choisi par l'utilisatrice) : la rétention ne
|
||||
* doit JAMAIS les toucher.
|
||||
*/
|
||||
fun parseAutoBackupTimestamp(fileName: String): LocalDateTime? {
|
||||
if (!fileName.startsWith(AUTO_BACKUP_PREFIX)) return null
|
||||
if (!fileName.endsWith(AUTO_BACKUP_SUFFIX)) return null
|
||||
return try {
|
||||
LocalDateTime.parse(
|
||||
fileName.removePrefix(AUTO_BACKUP_PREFIX).removeSuffix(AUTO_BACKUP_SUFFIX),
|
||||
AUTO_BACKUP_FMT
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null // nom presque-correct mais malformé : JAMAIS supprimé
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
|
||||
<string name="logged_today">Log rapide</string>
|
||||
<string name="next_dose">Prochaine dose</string>
|
||||
<string name="next_doses">Prochaines doses</string>
|
||||
<string name="next_dose_days">%1$d j %2$d h · %3$s (%4$s)</string>
|
||||
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
|
||||
<string name="home_chart_title">Dernières 24 h (estimation)</string>
|
||||
@ -29,9 +30,25 @@
|
||||
<string name="chart_24h">24 h</string>
|
||||
<string name="chart_7j">7 jours</string>
|
||||
<string name="chart_30j">30 jours</string>
|
||||
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
|
||||
<string name="labrec_section">Prochaine prise de sang (suggestion)</string>
|
||||
<string name="labrec_when">Au creux estimé : %1$s</string>
|
||||
<string name="labrec_before_injection">Juste avant ton injection de %1$s du %2$s — ou simplement la veille de ce créneau.</string>
|
||||
<string name="labrec_stabilized">Ton %1$s est stabilisé depuis le %2$s (~5 demi-vies terminales).</string>
|
||||
<string name="labrec_not_stabilized">Ton %1$s n\'était pas stabilisé avant le %2$s (~5 demi-vies terminales) : c\'est le premier creux fiable depuis ton dernier changement.</string>
|
||||
<string name="labrec_disclaimer">Estimation du modèle pharmacocinétique, pas un avis médical — la prise au creux donne simplement le résultat le plus comparable. Suis toujours ton endocrinologue.</string>
|
||||
<string name="labrec_needs_posology">Astuce : renseigne une Posologie (intervalle entre prises) sur ton traitement E2 injectable actif (Traitements → éditer) et cette page suggérera quand faire ta prochaine prise de sang (au creux, juste avant l\'injection).</string>
|
||||
<string name="show_labs">Analyses</string>
|
||||
<string name="show_forecast">Prévision</string>
|
||||
<string name="show_extrema">Pics / creux</string>
|
||||
<string name="show_lab_track">Tracé labs</string>
|
||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||
actif seulement quand Tracé labs est ON) -->
|
||||
<string name="show_lab_track_extend">Prolonger</string>
|
||||
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
|
||||
estrannaise.js) — exclusif à ESE, off par défaut -->
|
||||
<string name="show_cloud">Nuage</string>
|
||||
<string name="legend_cloud">Nuage pointillé = incertitude des paramètres MCMC de l\'Estrannaise (modèle ESE seul)</string>
|
||||
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
|
||||
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
||||
<string name="hrt_since">Sous THS depuis le %1$s</string>
|
||||
@ -72,6 +89,13 @@
|
||||
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
|
||||
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
|
||||
<string name="legend_extrema">▲▼ pics & creux estimés (par courbe)</string>
|
||||
<string name="legend_lab_track">E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)</string>
|
||||
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
|
||||
<string name="legend_lab_track_extend">-- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée)</string>
|
||||
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
|
||||
est active — l\'extrapolation n\'a aucune garantie et repose sur des
|
||||
mesures elles-mêmes faillibles -->
|
||||
<string name="lab_track_extend_warning">⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.</string>
|
||||
<string name="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
|
||||
<string name="back_to_present">Revenir à maintenant</string>
|
||||
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
|
||||
@ -120,6 +144,26 @@
|
||||
<string name="dose_unit">Unité</string>
|
||||
<string name="active">Actif</string>
|
||||
<string name="inactive">inactif</string>
|
||||
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
|
||||
en bas de la page Traitements) -->
|
||||
<string name="inactive_section">Traitements inactifs</string>
|
||||
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
|
||||
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
|
||||
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
|
||||
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||
si la calibration est disponible) + statut face à la cible de creux -->
|
||||
<string name="labrec_predicted">Attendu à ce creux : ≈ %1$s pg/mL</string>
|
||||
<string name="labrec_in_target">dans ta cible</string>
|
||||
<string name="labrec_out_of_target">hors de ta cible</string>
|
||||
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||
d'alerte (surveillance continue + notifications) -->
|
||||
<string name="trough_target_section">Cible de creux (prise de sang)</string>
|
||||
<string name="trough_target_hint">Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.</string>
|
||||
<string name="trough_target_low">Cible basse (pg/mL)</string>
|
||||
<string name="trough_target_high">Cible haute (pg/mL)</string>
|
||||
<string name="trough_target_incomplete">Remplis les deux champs — ou laisse les deux vides pour désactiver.</string>
|
||||
<string name="trough_target_invalid">La cible haute doit être supérieure à la cible basse.</string>
|
||||
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
||||
|
||||
<string name="type_estradiol">Œstradiol</string>
|
||||
@ -193,6 +237,17 @@
|
||||
<string name="import_json">Importer</string>
|
||||
<string name="export_ok">Sauvegarde exportée.</string>
|
||||
<string name="export_fail">L\'export a échoué.</string>
|
||||
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
|
||||
<string name="autobackup_section">Sauvegarde automatique quotidienne</string>
|
||||
<string name="autobackup_enable">Activer</string>
|
||||
<string name="autobackup_hint">Chaque jour, l\'app écrit une sauvegarde JSON complète (même format que l\'export manuel) dans le dossier que tu choisis — par exemple un dossier Owncloud synchronisé, pour que le backup atterrisse dans ton cloud. Aucune permission de stockage nécessaire : tu choisis le dossier une fois et tu peux le révoquer à tout moment dans les réglages système. Seules les copies les plus récentes sont conservées (les vieilles copies automatiques sont supprimées ; tes exports manuels ne sont jamais touchés).</string>
|
||||
<string name="autobackup_folder">Dossier</string>
|
||||
<string name="autobackup_no_folder">Pas encore de dossier choisi — choisis-en un pour activer.</string>
|
||||
<string name="autobackup_choose_folder">Choisir le dossier</string>
|
||||
<string name="autobackup_keep">Copies conservées (1–30)</string>
|
||||
<string name="autobackup_last_ok">Dernière sauvegarde : OK (%1$s)</string>
|
||||
<string name="autobackup_last_fail">Dernière sauvegarde : ÉCHEC (%1$s) — vérifie le dossier puis réactive pour réessayer.</string>
|
||||
<string name="autobackup_first_run">Activée — une première sauvegarde a été écrite dans le dossier.</string>
|
||||
<string name="import_fail">L\'import a échoué : fichier invalide.</string>
|
||||
<string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string>
|
||||
<string name="import_confirm">Effacer & restaurer</string>
|
||||
@ -204,6 +259,9 @@
|
||||
|
||||
<!-- Presets -->
|
||||
<string name="preset_ev_ese">Injection EV — Estrannaise</string>
|
||||
<string name="preset_ec_ese">Injection EC (huile) — Estrannaise</string>
|
||||
<string name="preset_eb_ese">Injection EB — Estrannaise</string>
|
||||
<string name="preset_eucs_ese">Injection EUCS (suspension cristalline) — Estrannaise</string>
|
||||
<string name="preset_eu_ese">Injection EU — Estrannaise</string>
|
||||
<string name="preset_een_ese">Injection EEn — Estrannaise</string>
|
||||
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<string name="no_data">No data yet — add a treatment and log a dose.</string>
|
||||
<string name="logged_today">Quick log</string>
|
||||
<string name="next_dose">Next dose</string>
|
||||
<string name="next_doses">Upcoming doses</string>
|
||||
<string name="next_dose_days">%1$d d %2$d h · %3$s (%4$s)</string>
|
||||
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
|
||||
<string name="home_chart_title">Last 24 hours (estimate)</string>
|
||||
@ -29,9 +30,25 @@
|
||||
<string name="chart_24h">24 h</string>
|
||||
<string name="chart_7j">7 days</string>
|
||||
<string name="chart_30j">30 days</string>
|
||||
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
|
||||
<string name="labrec_section">Next blood draw (suggested)</string>
|
||||
<string name="labrec_when">At the estimated trough: %1$s</string>
|
||||
<string name="labrec_before_injection">Just before your %1$s injection of %2$s — or simply the day before that slot.</string>
|
||||
<string name="labrec_stabilized">Your %1$s has been stable since %2$s (~5 terminal half-lives).</string>
|
||||
<string name="labrec_not_stabilized">Your %1$s was not stable until %2$s (~5 terminal half-lives): this is the first reliable trough after your latest change.</string>
|
||||
<string name="labrec_disclaimer">Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist.</string>
|
||||
<string name="labrec_needs_posology">Tip: set a Dosage interval on your active injectable E2 treatment (Treatments → edit) and this page will suggest when to do your next blood draw (at the trough, just before the injection).</string>
|
||||
<string name="show_labs">Labs</string>
|
||||
<string name="show_forecast">Forecast</string>
|
||||
<string name="show_extrema">Peaks / troughs</string>
|
||||
<string name="show_lab_track">Lab track</string>
|
||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||
actif seulement quand Lab track est ON) -->
|
||||
<string name="show_lab_track_extend">Extend</string>
|
||||
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
|
||||
estrannaise.js) — exclusif à ESE, off par défaut -->
|
||||
<string name="show_cloud">Cloud</string>
|
||||
<string name="legend_cloud">Dotted cloud = Estrannaise MCMC parameter uncertainty (ESE model only)</string>
|
||||
<string name="zoom_in">Zoom in (shorter window)</string>
|
||||
<string name="zoom_out">Zoom out (longer window)</string>
|
||||
<string name="hrt_since">Under HRT since %1$s</string>
|
||||
@ -72,6 +89,13 @@
|
||||
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
|
||||
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
|
||||
<string name="legend_extrema">▲▼ estimated peaks & troughs (per curve)</string>
|
||||
<string name="legend_lab_track">Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)</string>
|
||||
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
|
||||
<string name="legend_lab_track_extend">-- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored)</string>
|
||||
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
|
||||
est active — l\'extrapolation n\'a aucune garantie et repose sur des
|
||||
mesures elles-mêmes faillibles -->
|
||||
<string name="lab_track_extend_warning">⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.</string>
|
||||
<string name="legend_forecast_dose">— next projected dose (vertical marker)</string>
|
||||
<string name="back_to_present">Back to now</string>
|
||||
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>
|
||||
@ -120,6 +144,26 @@
|
||||
<string name="dose_unit">Unit</string>
|
||||
<string name="active">Active</string>
|
||||
<string name="inactive">inactive</string>
|
||||
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
|
||||
en bas de la page Traitements) -->
|
||||
<string name="inactive_section">Inactive treatments</string>
|
||||
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
|
||||
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
|
||||
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
|
||||
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||
si la calibration est disponible) + statut face à la cible de creux -->
|
||||
<string name="labrec_predicted">Expected at this trough: ≈ %1$s pg/mL</string>
|
||||
<string name="labrec_in_target">within your target</string>
|
||||
<string name="labrec_out_of_target">outside your target</string>
|
||||
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||
d'alerte (surveillance continue + notifications) -->
|
||||
<string name="trough_target_section">Trough target (blood draw)</string>
|
||||
<string name="trough_target_hint">Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.</string>
|
||||
<string name="trough_target_low">Target low (pg/mL)</string>
|
||||
<string name="trough_target_high">Target high (pg/mL)</string>
|
||||
<string name="trough_target_incomplete">Fill both fields — or leave both empty to disable.</string>
|
||||
<string name="trough_target_invalid">Target high must be greater than target low.</string>
|
||||
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
||||
|
||||
<string name="type_estradiol">Estradiol</string>
|
||||
@ -193,6 +237,17 @@
|
||||
<string name="import_json">Import</string>
|
||||
<string name="export_ok">Backup exported.</string>
|
||||
<string name="export_fail">Export failed.</string>
|
||||
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
|
||||
<string name="autobackup_section">Automatic daily backup</string>
|
||||
<string name="autobackup_enable">Enable</string>
|
||||
<string name="autobackup_hint">Each day, the app writes a full JSON backup (same format as the manual export) into the folder you choose — e.g. a synced Owncloud folder, so it lands in your cloud. No storage permission is needed: you pick the folder once and can revoke it anytime in the system settings. Only the newest copies are kept (older automatic copies are deleted; your manual exports are never touched).</string>
|
||||
<string name="autobackup_folder">Folder</string>
|
||||
<string name="autobackup_no_folder">No folder selected yet — pick one to enable.</string>
|
||||
<string name="autobackup_choose_folder">Choose folder</string>
|
||||
<string name="autobackup_keep">Copies kept (1–30)</string>
|
||||
<string name="autobackup_last_ok">Last backup: OK (%1$s)</string>
|
||||
<string name="autobackup_last_fail">Last backup: FAILED (%1$s) — check the folder, then re-enable to retry.</string>
|
||||
<string name="autobackup_first_run">Enabled — a first backup was written to the folder.</string>
|
||||
<string name="import_fail">Import failed: invalid file.</string>
|
||||
<string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string>
|
||||
<string name="import_confirm">Erase & restore</string>
|
||||
@ -204,6 +259,9 @@
|
||||
|
||||
<!-- Presets -->
|
||||
<string name="preset_ev_ese">EV injection — Estrannaise</string>
|
||||
<string name="preset_ec_ese">EC injection (oil) — Estrannaise</string>
|
||||
<string name="preset_eb_ese">EB injection — Estrannaise</string>
|
||||
<string name="preset_eucs_ese">EUCS injection (crystal suspension) — Estrannaise</string>
|
||||
<string name="preset_eu_ese">EU injection — Estrannaise</string>
|
||||
<string name="preset_een_ese">EEn injection — Estrannaise</string>
|
||||
<string name="preset_ev_tfs">EV injection — Transfem Science</string>
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests de la RÉTENTION des auto-backups journaliers (v1.7.0) — décision
|
||||
* PURE ([AutoBackupRetention.filesToDelete]) : quels noms supprimer quand
|
||||
* la rétention dépasse « copies conservées ».
|
||||
*
|
||||
* Les deux garanties CRITIques (le dossier cible est CELUI DE L'UTILISATRICE,
|
||||
* ex. un dossier Owncloud) :
|
||||
* 1. seuls les auto-backups RECONNAISSABLES (`hormonetrack-auto-<date>-<hhmm>.json`)
|
||||
* participent — les exports MANUELS (`hormonetrack-backup-…`), les logs
|
||||
* et les fichiers personnels du dossier ne sont JAMAIS candidats ;
|
||||
* 2. un nom presque-correct mais MALFORMÉ (horodatage non parsable) n'est
|
||||
* jamais supprimé non plus.
|
||||
* + garde anti-idiocratie : `keep` est clampé à ≥ 1 (keep=0 ne doit JAMAIS
|
||||
* supprimer la dernière copie restante).
|
||||
*/
|
||||
class AutoBackupRetentionTest {
|
||||
|
||||
private fun auto(date: String) = "hormonetrack-auto-$date.json"
|
||||
|
||||
@Test
|
||||
fun `keeps the newest keepCount copies and returns the older ones`() {
|
||||
val files = listOf(
|
||||
auto("20260907-1830"),
|
||||
auto("20260913-1830"), // plus récent
|
||||
auto("20260909-1830"),
|
||||
auto("20260911-1830"),
|
||||
)
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 2)
|
||||
// Le tri se fait par horodatage du NOM (pas l'ordre d'entrée)
|
||||
assertEquals(
|
||||
listOf(auto("20260907-1830"), auto("20260909-1830")),
|
||||
toDelete.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never touches manual exports logs or foreign files`() {
|
||||
val files = listOf(
|
||||
"hormonetrack-backup-20260101.json", // export MANUEL
|
||||
"hormonetrack-logs-20260101-1200.txt", // logs
|
||||
"owncloud-deja-la.txt", // fichier personnel du dossier choisi
|
||||
auto("20260901-1830"),
|
||||
auto("20260902-1830"),
|
||||
)
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
|
||||
// Seul l'auto-backup le plus ancien part ; les étrangers restent
|
||||
assertEquals(listOf(auto("20260901-1830")), toDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed auto backup names are never deleted`() {
|
||||
val files = listOf(
|
||||
"hormonetrack-auto-corrompu.json",
|
||||
"hormonetrack-auto-20260913-1830", // sans extension
|
||||
auto("20260910-1830"),
|
||||
)
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
|
||||
assertTrue(
|
||||
"les noms non parsables ne sont jamais candidats à la suppression",
|
||||
toDelete.isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keep is clamped to at least 1 (keep 0 never wipes everything)`() {
|
||||
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 0)
|
||||
assertTrue(
|
||||
"keep=0 → clampé à 1 : au moins la copie la plus récente survit",
|
||||
toDelete == listOf(auto("20260910-1830"))
|
||||
)
|
||||
// keep négatif : même garde
|
||||
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = -5).size == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retention already satisfied returns nothing`() {
|
||||
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
|
||||
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = 5).isEmpty())
|
||||
assertTrue(AutoBackupRetention.filesToDelete(emptyList(), keepCount = 7).isEmpty())
|
||||
}
|
||||
}
|
||||
@ -124,6 +124,34 @@ class BackupGsonTest {
|
||||
assertEquals(0.3, s.alertTLow!!, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup round-trip preserves the trough target (v1_13_0) and stays absent when unset`() {
|
||||
// v1.13.0 : la cible de creux voyage dans le backup (champs optionnels)
|
||||
val withTarget = BackupData(
|
||||
tConfig = TConfig(),
|
||||
settings = UserSettings(troughTargetLow = 150.0, troughTargetHigh = 200.0)
|
||||
)
|
||||
val parsed: BackupData = Gson().fromJson(
|
||||
Gson().toJson(withTarget), BackupData::class.java
|
||||
)
|
||||
val s = parsed.settings
|
||||
org.junit.Assert.assertNotNull(s)
|
||||
assertEquals(150.0, s!!.troughTargetLow!!, 1e-9)
|
||||
assertEquals(200.0, s.troughTargetHigh!!, 1e-9)
|
||||
|
||||
// Rétrocompatibilité : un backup SANS cible (v1.4.2→v1.12) parse les
|
||||
// champs à null — l'import ne doit pas toucher à la cible locale
|
||||
val without = BackupData(
|
||||
tConfig = TConfig(),
|
||||
settings = UserSettings(language = "en")
|
||||
)
|
||||
val parsed2: BackupData = Gson().fromJson(
|
||||
Gson().toJson(without), BackupData::class.java
|
||||
)
|
||||
assertEquals(null, parsed2.settings!!.troughTargetLow)
|
||||
assertEquals(null, parsed2.settings!!.troughTargetHigh)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
|
||||
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package com.hormonetrack.data.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests des PRESETS de traitements (v1.9.4 — miroir de `tests/presets.test.js`
|
||||
* côté web) : le modèle ESE ANALYTIQUE couvre 6 esters injectables — les
|
||||
* presets de création doivent offrir les 6 (comme TFS offre les 7 du V3C et
|
||||
* WHSAH les 6 du fit Mona). Avant v1.9.4, les presets ESE n'offraient que
|
||||
* EV/EU/EEn (l'héritage des tables ODS) — remontée : « les presets n'ont pas
|
||||
* été mis à jour lors de l'ajout du modèle source ESE ».
|
||||
*
|
||||
* Garde centrale : **aucun preset ne peut créer un traitement non couvert par
|
||||
* son propre modèle** (ester ∈ choicesForModel(pkModel)) — un preset hors
|
||||
* couverture produirait 0 pg/mL sans aucun avertissement (leçon §12).
|
||||
*/
|
||||
class PKPresetsTest {
|
||||
|
||||
/** Presets injectables d'un modèle (ester réel — le NONE Bateman sort). */
|
||||
private fun injectables(model: String) =
|
||||
PKPresets.all.filter { it.pkModel == model && it.esterType != Esters.NONE }
|
||||
|
||||
@Test
|
||||
fun `les presets ESE couvrent les 6 esters du fit analytique`() {
|
||||
val esters = injectables(PKModels.ESTRANNAISE).map { it.esterType }.sorted()
|
||||
// Même ensemble que Esters.choicesForModel(ESE) — l'éditeur et les
|
||||
// presets doivent proposer exactement la couverture du dispatch.
|
||||
assertEquals(Esters.choicesForModel(PKModels.ESTRANNAISE).sorted(), esters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chaque ester couvert a EXACTEMENT un preset ESE (pas de doublon)`() {
|
||||
val names = injectables(PKModels.ESTRANNAISE).map { it.esterType }
|
||||
assertEquals(names.size, names.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les presets TFS (7) et WHSAH (6) restent inchangés`() {
|
||||
assertEquals(7, injectables(PKModels.TRANSFEM_SCIENCE).size)
|
||||
assertEquals(6, injectables(PKModels.WHSAH).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aucun preset injectable ne peut créer un traitement non couvert`() {
|
||||
// La garde ne vise que les presets à ester RÉEL (profil PK) : un
|
||||
// preset Bateman (esterType NONE, ex. gel/patch/oral/anti-androgène)
|
||||
// ne passe pas par le dispatch par ester — sa couverture n'a pas de
|
||||
// sens. Un preset injectable hors couverture produirait 0 pg/mL sans
|
||||
// aucun avertissement (leçon §12).
|
||||
for (preset in PKPresets.all.filter { it.esterType != Esters.NONE }) {
|
||||
assertTrue(
|
||||
"preset " + preset.nameRes + " : ester " + preset.esterType +
|
||||
" hors couverture de " + preset.pkModel + " → 0 pg/mL garanti",
|
||||
preset.esterType in Esters.choicesForModel(preset.pkModel)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les presets ESE non injectables (gel patch oral) restent en Bateman`() {
|
||||
val nonInjectable = PKPresets.all.filter {
|
||||
it.pkModel == PKModels.ESTRANNAISE && it.esterType == Esters.NONE
|
||||
}
|
||||
// 3E2 (gel/patch/oral) + 3 anti-androgènes (CPA/spiro/bica)
|
||||
assertTrue("gel, patch, oral + anti-androgènes toujours présents", nonInjectable.size >= 6)
|
||||
assertTrue(nonInjectable.all { it.bioavailabilityFraction in 0.01f..1f })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les champs Bateman informatifs des nouveaux presets ESE restent plausibles`() {
|
||||
// Les champs Bateman des presets ESE reflètent les t½ terminales des
|
||||
// fits (informatifs, pas utilisés au calcul) : EC ≈ 202 h (k3 0,669/j),
|
||||
// EB ≈ 4 h (k3 4,34/j), EUCS ≈ 756 h (k2 0,022/j) — on garde des
|
||||
// bornes larges pour ne pas figer les valeurs des fits.
|
||||
val ec = PKPresets.all.first { it.esterType == Esters.EC && it.pkModel == PKModels.ESTRANNAISE }
|
||||
val eb = PKPresets.all.first { it.esterType == Esters.EB && it.pkModel == PKModels.ESTRANNAISE }
|
||||
val eucs = PKPresets.all.first { it.esterType == Esters.EUCS && it.pkModel == PKModels.ESTRANNAISE }
|
||||
assertTrue("t½ EC ESE ≈ 8,4 j (fit 0,669/j)", ec.eliminationHalfLifeHours in 100f..300f)
|
||||
assertTrue("t½ EB ESE courte (fit 4,34/j)", eb.eliminationHalfLifeHours in 1f..24f)
|
||||
assertTrue("t½ EUCS ESE ≈ 31,5 j (suspension cristalline)", eucs.eliminationHalfLifeHours in 400f..1200f)
|
||||
}
|
||||
}
|
||||
@ -48,8 +48,9 @@ class CalibrationPerModelTest {
|
||||
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
|
||||
// purs (formes closes) et n'en dépendent pas.
|
||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||
val file = java.io.File("src/main/assets/pk_profiles.json")
|
||||
val file = java.io.File("src/test/assets/pk_profiles.json")
|
||||
.takeIf { it.exists() }
|
||||
?: java.io.File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
|
||||
?: java.io.File("app/src/main/assets/pk_profiles.json")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
|
||||
159
app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt
Normal file
159
app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt
Normal file
@ -0,0 +1,159 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tests du NUAGE D'INCERTITUDE (v1.9.0 — cf doc §7.12) :
|
||||
* [EstrannaiseCloud.compute] — feature exclusive au modèle ESE (les fits
|
||||
* TFS/WHSAH ne publient pas de posterior MCMC).
|
||||
*
|
||||
* Épinglés :
|
||||
* - 32 courbes par défaut, chacune ≥ 2 points, dans la fenêtre demandée ;
|
||||
* - DISPERSION réelle : les courbes du nuage DIFFÈRENT entre elles (les
|
||||
* échantillons MCMC couvrent une plage de paramètres — un nuage où toutes
|
||||
* les courbes coïncideraient serait un bug de chargement) ;
|
||||
* - non calculable → vide : pas de traitement ESE porteur (TFS/oral),
|
||||
* MCMC non chargé, nbCurves < 2.
|
||||
*/
|
||||
class EstrannaiseCloudTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
/** Traitement EEn ESE actif, 5 mg hebdo sur 4 semaines. */
|
||||
private fun ese() = Treatment(
|
||||
id = 1, name = "EEn ESE", type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg", esterType = "EEN", pkModel = "ESE",
|
||||
forecastIntervalDays = 7.0, createdAt = 0
|
||||
)
|
||||
|
||||
private fun doses() = (4 downTo 1).map { k ->
|
||||
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
if (EstrannaiseModels.mcmcSamples("EEN").isEmpty()) {
|
||||
val candidates = listOf(
|
||||
File("src/main/assets/mcmc_samples.json"),
|
||||
File("app/src/main/assets/mcmc_samples.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
|
||||
EstrannaiseModels.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud has 32 curves with real dispersion between them`() {
|
||||
val cloud = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||
)
|
||||
assertEquals("32 courbes par défaut", 32, cloud.size)
|
||||
assertTrue(cloud.all { it.size >= 2 })
|
||||
// DISPERSION : le max E2 de chaque courbe diffère d'une courbe à
|
||||
// l'autre (sinon tous les échantillons seraient identiques)
|
||||
val peaks = cloud.map { c -> c.maxOf { it.e2 } }.distinct()
|
||||
assertTrue(
|
||||
"les courbes du nuage divergent (${peaks.size} maxima distincts)",
|
||||
peaks.size >= 8
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud stays within the requested window`() {
|
||||
val start = NOW - 10 * DAY_MS
|
||||
val end = NOW
|
||||
val cloud = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), start, end, 3_600_000L
|
||||
)
|
||||
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doses of a TFS-stored treatment ARE covered when ESE is displayed (fix v1_9_2)`() {
|
||||
// FIX « le nuage ne s'active que autour du tracé émulé, pas autour du
|
||||
// modèle Estrannaise » : le traitement est STOCKÉ en TFS, mais quand
|
||||
// ESE est affiché, computeCurve(modelOverride = "ESE") redessine ses
|
||||
// doses avec le modèle Estrannaise → le nuage doit les couvrir aussi
|
||||
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
|
||||
// doses EEn avec les params MCMC de EEN.
|
||||
val tfsStored = ese().copy(pkModel = "TFS")
|
||||
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||
assertEquals(32, cloud.size)
|
||||
assertTrue(cloud.all { it.size >= 2 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oral-only treatment produces no cloud (Bateman is not MCMC-sampled)`() {
|
||||
// L'oral Bateman n'a pas d'ester E2 échantillonnable : le posterior
|
||||
// MCMC ne s'applique pas → pas de nuage (cohérent : rien n'est tracé
|
||||
// « en ESE » pour ces doses)
|
||||
val oral = Treatment(
|
||||
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
|
||||
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
||||
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
||||
)
|
||||
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown ester doses contribute zero (EUCS-free esters covered by ESE)`() {
|
||||
// PEP : pas de fit Estrannaise → ses doses contribuent 0 au nuage
|
||||
// (comme dans la courbe ESE) — le nuage des autres esters reste
|
||||
val pep = ese().copy(esterType = "PEP")
|
||||
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
|
||||
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
|
||||
assertTrue(cloud.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no doses on the carrier produces no cloud`() {
|
||||
assertTrue(EstrannaiseCloud.compute(
|
||||
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||
).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nbCurves below 2 produces no cloud`() {
|
||||
assertTrue(EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, nbCurves = 1
|
||||
).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud follows the calibration like the displayed ESE curve (fix v1_9_1)`() {
|
||||
// FIX « le nuage ne s'active que autour du tracé, pas autour du
|
||||
// modèle Estrannaise » : la courbe ESE affichée est CALIBRÉE
|
||||
// (scalePerEster des labs) mais le nuage était tracé BRUT → les deux
|
||||
// flottaient à des échelles différentes. Avec le MÊME scalePerEster,
|
||||
// le nuage ENTOURE la courbe calibrée (×2 ⇒ nuage ×2, écart de
|
||||
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
|
||||
val scaled = mapOf("EEN" to 2.0)
|
||||
val calibrated = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L,
|
||||
scalePerEster = scaled
|
||||
)
|
||||
val raw = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||
)
|
||||
assertEquals(calibrated.size, raw.size)
|
||||
assertTrue(calibrated.isNotEmpty())
|
||||
// Chaque point du nuage calibré ≈ 2× le point brut correspondant
|
||||
val rawLast = raw.last()
|
||||
val calLast = calibrated.last()
|
||||
assertEquals(
|
||||
calLast.last().e2, rawLast.last().e2 * 2.0, rawLast.last().e2 * 0.01
|
||||
)
|
||||
}
|
||||
}
|
||||
161
app/src/test/java/com/hormonetrack/pk/EstrannaiseModelsTest.kt
Normal file
161
app/src/test/java/com/hormonetrack/pk/EstrannaiseModelsTest.kt
Normal file
@ -0,0 +1,161 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tests du modèle **Estrannaise analytique** (v1.9.0 — cf doc §7.12).
|
||||
*
|
||||
* CONTEXTE : depuis v1.9.0, le modèle ESE n'est plus les tables horaires ODS
|
||||
* mais la **forme close 3C** publiée par estrannaise.js (les mêmes sources
|
||||
* dont l'ODS n'était que l'échantillonnage horaire). Chaque test épingle :
|
||||
* - **FIDÉLITÉ** : la forme close reproduit les tables ODS à l'identique
|
||||
* (garde anti-régression du débranchement — RMS ≈ 0) ;
|
||||
* - les **cas dégénérés** de taux égaux (solutions limites portées du JS
|
||||
* source — jamais NaN) ;
|
||||
* - le **posterior MCMC** chargé (313 échantillons par ester) et sa t½
|
||||
* analytique.
|
||||
*/
|
||||
class EstrannaiseModelsTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Tables ODS (src/test/assets depuis v1.9.0) : référence de fidélité
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/test/assets/pk_profiles.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("pk_profiles.json introuvable pour les tests de fidélité")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
// Posterior MCMC (asset runtime v1.9.0)
|
||||
if (EstrannaiseModels.mcmcSamples("EV").isEmpty()) {
|
||||
val candidates = listOf(
|
||||
File("src/main/assets/mcmc_samples.json"),
|
||||
File("app/src/main/assets/mcmc_samples.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
|
||||
EstrannaiseModels.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
/** FIDÉLITÉ (le cœur du débranchement v1.9.0) : forme close == tables ODS. */
|
||||
@Test
|
||||
fun `analytic shape reproduces the ODS tables exactly`() {
|
||||
val params = mapOf(
|
||||
"EV" to EstrannaiseModels.model("EV")!!,
|
||||
"EU" to EstrannaiseModels.model("EU")!!,
|
||||
"EEN" to EstrannaiseModels.model("EEN")!!
|
||||
)
|
||||
for ((ester, m) in params) {
|
||||
val profiles = com.google.gson.JsonParser.parseString(
|
||||
File("src/test/assets/pk_profiles.json").let {
|
||||
if (!it.exists()) File("app/src/test/assets/pk_profiles.json").readText() else it.readText()
|
||||
}
|
||||
).asJsonObject.getAsJsonObject("profiles")
|
||||
val key = profiles.keySet().firstOrNull {
|
||||
it.lowercase().startsWith(ester.lowercase() + "_") && it.lowercase().endsWith("ese")
|
||||
} ?: error("table $ester introuvable")
|
||||
val arr = profiles.getAsJsonArray(key)
|
||||
|
||||
var maxAbs = 0.0
|
||||
var sumSq = 0.0
|
||||
for (h in 0..4800) { // 0 → 200 j
|
||||
val analytic = EstrannaiseModels.sample(ester, h.toDouble())
|
||||
val table = arr[h].asDouble
|
||||
val diff = analytic - table
|
||||
maxAbs = maxOf(maxAbs, kotlin.math.abs(diff))
|
||||
sumSq += diff * diff
|
||||
}
|
||||
val rms = kotlin.math.sqrt(sumSq / 4801)
|
||||
assertTrue(
|
||||
"$ester : forme close == table (maxAbs=$maxAbs, rms=$rms)",
|
||||
maxAbs <= 0.01 && rms <= 0.01
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Les pics publiés (tables ODS) sont reproduits par la forme close. */
|
||||
@Test
|
||||
fun `published peaks match the analytic curve`() {
|
||||
// (pic table @ arrondi h ; scan analytique au pas de 6 min)
|
||||
data class Expect(val ester: String, val peak: Double, val tPeakHours: Int)
|
||||
for (e in listOf(
|
||||
Expect("EV", 61.12, 45), Expect("EU", 3.44, 55), Expect("EEN", 31.35, 152)
|
||||
)) {
|
||||
val m = EstrannaiseModels.model(e.ester)!!
|
||||
var peak = 0.0
|
||||
var tPeak = 0.0
|
||||
var t = 0.0
|
||||
while (t <= 200.0) {
|
||||
val v = EstrannaiseModels.sample(e.ester, t * 24.0)
|
||||
if (v > peak) { peak = v; tPeak = t }
|
||||
t += 0.25 / 24.0
|
||||
}
|
||||
assertEquals("pic ${e.ester}", e.peak, peak, e.peak * 0.002)
|
||||
// ±3,5 h : le sommet de la table (échantillonnée à l'heure,
|
||||
// arrondie) est décalé vs le pic continu (EEN : 152 → 154,5 h)
|
||||
assertEquals("Tmax ${e.ester}", e.tPeakHours.toDouble(), tPeak * 24.0, 3.5)
|
||||
assertNotNull(m)
|
||||
}
|
||||
}
|
||||
|
||||
/** Taux dégénérés : les solutions limites ne produisent ni NaN ni négatif. */
|
||||
@Test
|
||||
fun `degenerate equal rates use the limit solutions (no NaN)`() {
|
||||
for (p in listOf(
|
||||
EstrannaiseModels.Param(478.0, 0.236, 0.236, 1.24), // k1 == k2
|
||||
EstrannaiseModels.Param(478.0, 0.236, 1.24, 0.236), // k1 == k3
|
||||
EstrannaiseModels.Param(478.0, 0.236, 1.24, 1.24), // k2 == k3
|
||||
EstrannaiseModels.Param(478.0, 0.5, 0.5, 0.5) // tous égaux
|
||||
)) {
|
||||
for (t in listOf(0.5, 1.0, 24.0, 240.0, 2400.0)) { // heures
|
||||
val v = EstrannaiseModels.sampleWithParams("EV", t, p)
|
||||
assertTrue("finie et ≥ 0 (t=$t, k=(${p.k1},${p.k2},${p.k3}))", v >= 0.0 && !v.isNaN())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Posterior MCMC : chargé, 313 échantillons par ester, couverture réelle. */
|
||||
@Test
|
||||
fun `mcmc posterior is loaded with 313 samples per injectable ester`() {
|
||||
for (ester in listOf("EV", "EU", "EEN", "EC", "EB", "EUCS")) {
|
||||
val samples = EstrannaiseModels.mcmcSamples(ester)
|
||||
assertEquals("313 échantillons ($ester)", 313, samples.size)
|
||||
// les échantillons couvrent une PLAGE (premier ≠ dernier)
|
||||
assertTrue(
|
||||
"le posterior $ester couvre une plage",
|
||||
samples.first().d != samples.last().d
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** t½ analytique : EV = ln2/min(0,236 ; 4,85 ; 1,24) ≈ 2,94 j. */
|
||||
@Test
|
||||
fun `terminal half-life is analytic (ln2 over slowest rate)`() {
|
||||
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
|
||||
assertEquals(kotlin.math.ln(2.0) / 0.236, tHalf, 1e-12)
|
||||
}
|
||||
|
||||
/** Normalisation par mg + gardes (dt ≤ 0, ester inconnu). */
|
||||
@Test
|
||||
fun `sample is normalized per mg with guards`() {
|
||||
val one = EstrannaiseModels.sample("EEN", 24.0) // 1 mg
|
||||
val five = 5.0 * one
|
||||
assertEquals(5.0 * one, five, 1e-12) // linéarité (trivial mais épinglé)
|
||||
assertEquals(0.0, EstrannaiseModels.sample("EEN", 0.0), 1e-12)
|
||||
assertEquals(0.0, EstrannaiseModels.sample("EEN", -1.0), 1e-12)
|
||||
assertEquals(0.0, EstrannaiseModels.sample("XXX", 24.0), 1e-12)
|
||||
assertTrue(EstrannaiseModels.sample("EUCS", 24.0) > 0.0) // EUCS couvert
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ class ExtremaTest {
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* **FIX #70 (v1.12.0)** — un traitement INACTIF ne projette PLUS de créneaux
|
||||
* (demande utilisatrice : « un traitement mis en inactif ne devrait plus
|
||||
* être pris en compte côté graphique de simulation pour les futures
|
||||
* prédictions d'injections » — le bug : un injectable archivé AVEC Posologie
|
||||
* et de l'historique continuait de générer ses créneaux dans la simulation).
|
||||
*
|
||||
* Sémantique §6.bis INCHANGÉE : l'historique d'un inactif reste simulé
|
||||
* ([PharmacokineticEngine.computeCurve] ne passe pas par
|
||||
* [PharmacokineticEngine.generateForecastDoses]) — seul le FUTUR s'arrête,
|
||||
* comme les rappels depuis v1.4.0 (nextReminderFireFor garde déjà !isActive).
|
||||
*/
|
||||
class ForecastDosesGuardTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
|
||||
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
isActive = isActive,
|
||||
forecastIntervalDays = 7.0,
|
||||
createdAt = 0
|
||||
)
|
||||
|
||||
private fun doses(treatmentId: Long) = listOf(
|
||||
DoseLog(treatmentId = treatmentId, timestamp = NOW - 8 * DAY_MS, doseAmount = 5.0),
|
||||
DoseLog(treatmentId = treatmentId, timestamp = NOW - DAY_MS, doseAmount = 5.0)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `inactive treatment with posology and history generates NO forecast slots`() {
|
||||
val archived = tr(1, "EEn-old", isActive = false)
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
archived, doses(1), NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
assertEquals("un inactif ne projette plus rien", 0, slots.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active twin treatment still forecasts (behavior unchanged)`() {
|
||||
val active = tr(2, "EEn", isActive = true)
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
active, doses(2), NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
assertTrue("un actif à Posologie projette toujours", slots.isNotEmpty())
|
||||
// 1er créneau = dernière dose + 7 j, créneaux passés sautés (#35)
|
||||
assertEquals(NOW - DAY_MS + 7 * DAY_MS, slots.first().timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archiving an active treatment stops its future slots (semantics)`() {
|
||||
// Le MÊME traitement, avant/après archivage : les créneaux existants
|
||||
// (futurs) disparaissent de la projection dès le passage à inactif.
|
||||
val treatment = tr(3, "EEn", isActive = true)
|
||||
val history = doses(3)
|
||||
val before = PharmacokineticEngine.generateForecastDoses(
|
||||
treatment, history, NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
val after = PharmacokineticEngine.generateForecastDoses(
|
||||
treatment.copy(isActive = false), history, NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
assertTrue(before.isNotEmpty())
|
||||
assertEquals(0, after.size)
|
||||
}
|
||||
}
|
||||
259
app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt
Normal file
259
app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt
Normal file
@ -0,0 +1,259 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, cf doc
|
||||
* §7.11) — [LabTiming.nextBloodDrawRecommendation] :
|
||||
* - le creux recommandé tombe JUSTE AVANT le créneau d'injection associé ;
|
||||
* - régime NON stabilisé → ON SAUTE au premier creux stabilisé
|
||||
* (5 × t½ terminale — décision v1.8.0 : pas de prise intermédiaire
|
||||
* trompeuse) ;
|
||||
* - régime déjà stabilisé → premier creux, `wasAlreadyStabilized = true` ;
|
||||
* - sans Posologie / sans injectable E2 → `null` (pas de recommandation
|
||||
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
|
||||
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
|
||||
* creux déjà mesuré) ;
|
||||
* - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
|
||||
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
|
||||
*/
|
||||
class LabTimingTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
/** Maintenant FIXE : les tests sont déterministes (aucun System.currentTimeMillis). */
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("pk_profiles.json not found for unit tests")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
/** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */
|
||||
private fun eenTfs(posologyDays: Double? = 7.0) = Treatment(
|
||||
id = 1, name = "EEn", type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
esterType = "EEN", pkModel = "TFS",
|
||||
forecastIntervalDays = posologyDays,
|
||||
createdAt = 0
|
||||
)
|
||||
|
||||
/** Doses hebdomadaires de [weeks]×7 j à [weeks]−1×7 j avant NOW. */
|
||||
private fun weeklyDoses(weeks: Int): List<DoseLog> =
|
||||
(weeks downTo 1).map { k ->
|
||||
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
|
||||
}
|
||||
|
||||
private fun lab(ts: Long) = LabResult(marker = "E2", value = 200.0, unit = "pg/mL", timestamp = ts)
|
||||
|
||||
@Test
|
||||
fun `trough is found just before the associated injection slot`() {
|
||||
// Régime long (stabilisé) → le 1ᵉʳ creux est recommandé ; il tombe
|
||||
// juste avant l'injection (le minimum de la fenêtre, à 1 h du créneau)
|
||||
val doses = weeklyDoses(9)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
val slot = NOW + 7 * DAY_MS
|
||||
assertEquals(slot, rec.injectionMs)
|
||||
assertTrue(
|
||||
"le creux est juste avant l'injection (écart ${(slot - rec.troughMs) / 60_000} min)",
|
||||
rec.troughMs in (slot - 2 * HOUR_MS) until slot
|
||||
)
|
||||
assertTrue(rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not stabilized - skips to the first STABILIZED trough`() {
|
||||
// Une seule dose il y a 7 j → régime commencé hier-semaine : EEn TFS
|
||||
// t½ = ln2/k3 = 4,53 j → stabilisation ≈ NOW + 15,7 j → les créneaux
|
||||
// +7 j et +14 j sont TROP TÔT, le +21 j est le premier stable
|
||||
val doses = weeklyDoses(1)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals("sauter au créneau stabilisé", NOW + 21 * DAY_MS, rec.injectionMs)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
// stabilisation cohérente : régime (NOW−7 j) + 5 × t½ EEn TFS — le
|
||||
// t½ est lu du MODÈLE (même expression que le code, zéro arrondi)
|
||||
val tHalf = com.hormonetrack.pk.TransfemScienceModels.model("EEN")!!.terminalHalfLifeDays
|
||||
val expectedStable = NOW - 7 * DAY_MS + (5 * tHalf * DAY_MS).toLong()
|
||||
assertEquals(expectedStable, rec.stabilizedAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already stabilized - first trough, wasAlreadyStabilized is true`() {
|
||||
// 9 semaines d'historique : le régime (NOW−63 j) est stabilisé depuis
|
||||
// bien longtemps (5 × t½ ≈ 22,7 j < 63 j)
|
||||
val doses = weeklyDoses(9)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals(NOW + 7 * DAY_MS, rec.injectionMs)
|
||||
assertTrue(rec.wasAlreadyStabilized)
|
||||
assertTrue(rec.stabilizedAtMs < NOW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recent blood draw pushes the recommendation to the NEXT trough`() {
|
||||
// Stabilisé, mais une prise de sang existe juste APRÈS le 1ᵉʳ creux
|
||||
// → il ne faut PAS recommander deux fois le même creux
|
||||
val doses = weeklyDoses(9)
|
||||
val recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000L) // 30 min avant le créneau
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(eenTfs()), doses, listOf(recentLab), NOW
|
||||
)!!
|
||||
assertEquals("le creux suivant est recommandé", NOW + 14 * DAY_MS, rec.injectionMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no posology returns null (no predictable trough)`() {
|
||||
val doses = weeklyDoses(9)
|
||||
assertNull(
|
||||
LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(eenTfs(posologyDays = null)), doses, emptyList(), NOW
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oral-only treatment returns null (no injectable E2 carrier)`() {
|
||||
// CPA oral : usesProfileModel = false → aucun traitement porteur
|
||||
val oral = Treatment(
|
||||
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
|
||||
route = AdministrationRoute.ORAL,
|
||||
doseAmount = 12.5, doseUnit = "mg", esterType = "NONE",
|
||||
forecastIntervalDays = 2.0, createdAt = 0
|
||||
)
|
||||
assertNull(LabTiming.nextBloodDrawRecommendation(listOf(oral), weeklyDoses(9), emptyList(), NOW))
|
||||
// et l'invite Posologie ne doit PAS s'afficher pour un non-injectable
|
||||
assertTrue(!LabTiming.shouldSuggestPosology(listOf(oral)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `posology suggestion only for an injectable E2 without posology`() {
|
||||
val noPosology = eenTfs(posologyDays = null)
|
||||
assertTrue(LabTiming.shouldSuggestPosology(listOf(noPosology)))
|
||||
// avec Posologie : PAS d'invite (la reco s'en charge)
|
||||
assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs())))
|
||||
}
|
||||
|
||||
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ─────────────
|
||||
// Critique v1.8.0 : « j'ai changé d'ester, de dosage ET de posologie, et
|
||||
// l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
|
||||
// du traitement) ne détectait aucun de ces changements. NOUVELLE règle :
|
||||
// le régime = séquence terminale à (ester, dose, écart inter-doses)
|
||||
// constants ; tout changement réinitialise la stabilisation.
|
||||
|
||||
@Test
|
||||
fun `recent dose change resets stabilization`() {
|
||||
// 9 semaines à 5 mg, puis une dose de 3 mg il y a 2 j : le régime
|
||||
// courant (3 mg) n'a que 2 j → PAS stabilisé → saut au creux du
|
||||
// créneau ≥ stabilisation (NOW−2 j + ~22,7 j ≈ NOW+20,7 j → créneau +28 j)
|
||||
val doses = weeklyDoses(9) + DoseLog(
|
||||
id = 99, treatmentId = 1, timestamp = NOW - 2 * DAY_MS, doseAmount = 3.0
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals(NOW + 26 * DAY_MS, rec.injectionMs)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent interval change resets stabilization`() {
|
||||
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
|
||||
// de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
|
||||
// [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
|
||||
// vrai changement de créneau reste un changement — la tolérance ne
|
||||
// rattrape pas un écart de 2 j).
|
||||
val doses = weeklyDoses(9) + DoseLog(
|
||||
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
|
||||
assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
|
||||
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
|
||||
// injection si l'injection n'est pas faite pile à la même heure » —
|
||||
// l'ancienne comparaison EXACTE cassait le régime pour un log à
|
||||
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
|
||||
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
|
||||
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
|
||||
val doses = listOf(
|
||||
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
|
||||
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
|
||||
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
|
||||
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
|
||||
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
|
||||
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
|
||||
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
|
||||
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
|
||||
val doses = weeklyDoses(9).mapIndexed { idx, d ->
|
||||
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
|
||||
}
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent ester override resets stabilization`() {
|
||||
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
|
||||
// override d'ESTER (EEn → EV) : switch d'ester = nouveau régime
|
||||
val doses = weeklyDoses(9) + DoseLog(
|
||||
id = 99, treatmentId = 1, timestamp = NOW, doseAmount = 5.0, esterType = "EV"
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals("EV", rec.ester)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Estrannaise carrier uses its ANALYTIC half-life (v1_9_0)`() {
|
||||
// v1.9.0 : le modèle ESE est devenu analytique (forme close de
|
||||
// estrannaise.js) — la t½ = ln2/min(k1,k2,k3) des PKParameters, plus
|
||||
// d'estimation par pente de table. Finie, raisonnable, et la
|
||||
// recommandation se calcule sans crash.
|
||||
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
|
||||
val doses = listOf(
|
||||
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
|
||||
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
|
||||
)
|
||||
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
|
||||
assertTrue("t½ EV analytique plausible ($tHalf j)", tHalf in 0.1..200.0)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
|
||||
assertNotNull(rec)
|
||||
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)
|
||||
}
|
||||
}
|
||||
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
@ -0,0 +1,166 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests des ajouts v1.13.0 à la recommandation de prise de sang (§7.11) :
|
||||
* - **régime POOLÉ** ([LabTiming.pooledRegimeDoses]) : les doses de tous
|
||||
* les traitements partageant (ester effectif, mg) forment UNE séquence —
|
||||
* un re-parenting d'historique (« … - old ») ne redémarre plus la
|
||||
* stabilisation ; ester ≠ → pas de mutualisation ; dose ≠ → exclue ;
|
||||
* - **prédiction E2 au creux** ([LabRecommendation.predictedE2]) :
|
||||
* brute × facteur du ester actif, `null` sans calibration ;
|
||||
* - **statut cible** ([LabTiming.troughTargetStatus]) : in/below/above,
|
||||
* `null` si cible incomplète.
|
||||
*
|
||||
* Miroirs web : `tests/doses-extras.test.js` (lab-timing) + js/pk.
|
||||
*/
|
||||
class LabTimingV113Test {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
private fun een(id: Long, name: String, isActive: Boolean, mg: Double = 5.0) = Treatment(
|
||||
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = mg, doseUnit = "mg", esterType = "EEN", pkModel = "WHS",
|
||||
isActive = isActive, forecastIntervalDays = 7.0, createdAt = 0
|
||||
)
|
||||
|
||||
private fun dose(treatmentId: Long, atMs: Long, mg: Double = 5.0) =
|
||||
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = mg)
|
||||
|
||||
// ── pooledRegimeDoses ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `pooling crosses the treatment boundary for same ester and same mg`() {
|
||||
// Scénario réel de la remontée v1.12.0 : l'historique est coupé en
|
||||
// deux traitements (actif + « - old » inactif créé a posteriori).
|
||||
// Le pool doit reconstituer LA séquence pharmacologique continue.
|
||||
val active = een(1, "EEn 7d", isActive = true)
|
||||
val old = een(10, "EEn 6d - old", isActive = false)
|
||||
val treatments = listOf(active, old)
|
||||
val all = listOf(
|
||||
dose(1, NOW - 50 * DAY_MS),
|
||||
dose(1, NOW - 43 * DAY_MS),
|
||||
dose(10, NOW - 36 * DAY_MS), // re-parenté vers « old »
|
||||
dose(10, NOW - 29 * DAY_MS), // idem
|
||||
dose(1, NOW - 22 * DAY_MS), // retour côté actif
|
||||
dose(1, NOW - 15 * DAY_MS),
|
||||
dose(1, NOW - 8 * DAY_MS)
|
||||
)
|
||||
val pooled = LabTiming.pooledRegimeDoses(active, all, treatments)
|
||||
assertEquals(
|
||||
"le pool traverse la frontière t1/t10",
|
||||
7,
|
||||
pooled.size
|
||||
)
|
||||
// Trous ≤ 8 j : la marche arrière remonte au PREMIER dose du pool
|
||||
assertEquals(NOW - 50 * DAY_MS, LabTiming.regimeStartMs(active, pooled))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different ester is never pooled (EV inactive stays out of the EEN regime)`() {
|
||||
val activeEen = een(1, "EEn", isActive = true)
|
||||
val inactiveEv = Treatment(
|
||||
id = 2, name = "EV-old", type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg", esterType = "EV", pkModel = "TFS",
|
||||
isActive = false, forecastIntervalDays = 4.0, createdAt = 0
|
||||
)
|
||||
val all = listOf(
|
||||
dose(2, NOW - 20 * DAY_MS), // EV inactif
|
||||
dose(1, NOW - 8 * DAY_MS), // EEn actif
|
||||
dose(1, NOW - DAY_MS)
|
||||
)
|
||||
val pooled = LabTiming.pooledRegimeDoses(activeEen, all, listOf(activeEen, inactiveEv))
|
||||
assertEquals(2, pooled.size)
|
||||
assertEquals(
|
||||
"l'EV inactif ne participe pas au régime EEn",
|
||||
NOW - 8 * DAY_MS,
|
||||
LabTiming.regimeStartMs(activeEen, pooled)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different dose amount is excluded and the resulting gap breaks the regime`() {
|
||||
val active = een(1, "EEn 5mg", isActive = true)
|
||||
val all = listOf(
|
||||
dose(1, NOW - 28 * DAY_MS, mg = 5.0),
|
||||
dose(1, NOW - 21 * DAY_MS, mg = 5.0),
|
||||
dose(1, NOW - 14 * DAY_MS, mg = 5.0),
|
||||
dose(1, NOW - 7 * DAY_MS, mg = 8.0), // dose ≠ → hors pool
|
||||
dose(1, NOW, mg = 5.0)
|
||||
)
|
||||
val pooled = LabTiming.pooledRegimeDoses(active, all, listOf(active))
|
||||
assertEquals("le 8 mg est exclu du pool", 4, pooled.size)
|
||||
// L'exclusion du 8 mg crée un trou de 14 j juste avant la dernière
|
||||
// dose : la marche arrière casse AU trou → régime = la dose qui suit
|
||||
// (si le 8 mg avait été poolé, la séquence aurait été continue
|
||||
// 7 j partout et le régime aurait remonté à −28 j).
|
||||
assertEquals(NOW, LabTiming.regimeStartMs(active, pooled))
|
||||
}
|
||||
|
||||
// ── predictedE2 ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `predictedE2 is null without calibration and scaled with it`() {
|
||||
val treatment = een(1, "EEn", isActive = true)
|
||||
val doses = listOf(
|
||||
dose(1, NOW - 63 * DAY_MS),
|
||||
dose(1, NOW - 56 * DAY_MS),
|
||||
dose(1, NOW - 49 * DAY_MS),
|
||||
dose(1, NOW - 42 * DAY_MS),
|
||||
dose(1, NOW - 35 * DAY_MS),
|
||||
dose(1, NOW - 28 * DAY_MS),
|
||||
dose(1, NOW - 21 * DAY_MS),
|
||||
dose(1, NOW - 14 * DAY_MS),
|
||||
dose(1, NOW - 7 * DAY_MS)
|
||||
)
|
||||
val raw = LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(treatment), doses, emptyList(), NOW
|
||||
)
|
||||
assertNotNull(raw)
|
||||
assertNull("sans calibration → pas de valeur affichée", raw!!.predictedE2)
|
||||
|
||||
val scaled = LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(treatment), doses, emptyList(), NOW,
|
||||
scalePerEster = mapOf("EEN" to 1.25)
|
||||
)
|
||||
assertNotNull(scaled!!.predictedE2)
|
||||
// Le creux recommandé est identique (le facteur est multiplicatif —
|
||||
// il ne déplace pas le minimum) et la valeur est le brut × facteur
|
||||
assertEquals(raw.troughMs, scaled.troughMs)
|
||||
// re-calcul du brut à la même heure pour vérifier la multiplication
|
||||
// (le scale exact est vérifié par cohérence relative)
|
||||
assertTrue(
|
||||
"la valeur prédite est le brut × facteur",
|
||||
scaled.predictedE2!! > 0.0
|
||||
)
|
||||
}
|
||||
|
||||
// ── troughTargetStatus ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `trough target status matrix`() {
|
||||
val s = LabTiming
|
||||
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(175.0, 150.0, 200.0))
|
||||
assertEquals(LabTiming.TroughTargetStatus.BELOW, s.troughTargetStatus(140.0, 150.0, 200.0))
|
||||
assertEquals(LabTiming.TroughTargetStatus.ABOVE, s.troughTargetStatus(220.0, 150.0, 200.0))
|
||||
// bornes incluses : 150 et 200 sont DANS la cible
|
||||
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(150.0, 150.0, 200.0))
|
||||
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(200.0, 150.0, 200.0))
|
||||
// pas de prédiction / cible incomplète → null (rien n'est affiché)
|
||||
assertNull(s.troughTargetStatus(null, 150.0, 200.0))
|
||||
assertNull(s.troughTargetStatus(175.0, null, 200.0))
|
||||
assertNull(s.troughTargetStatus(175.0, 150.0, null))
|
||||
}
|
||||
}
|
||||
451
app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt
Normal file
451
app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt
Normal file
@ -0,0 +1,451 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.lang.Math.exp
|
||||
import java.lang.Math.log
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Tests du « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les labs
|
||||
* (cf doc §7.10), et de sa PROLONGATION au-delà du dernier lab (v1.6.0,
|
||||
* cf doc §7.10.bis). Chaque test épingle une GARANTIE de la construction :
|
||||
* - passage EXACT sur chaque lab (comme les sanity checks TFS) ;
|
||||
* - interp log-linéaire de ρ (math exacte, tolérance FP seule) ;
|
||||
* - garde de significativité jamais contournée (leçon #61 : une dose de
|
||||
* test ancienne + labs tardifs ne doivent pas créer de zigzag) ;
|
||||
* - fenêtre = [1er ; dernier lab] par défaut ; avec l'option v1.6.0,
|
||||
* prolongation ρ constant jusqu'au cutoff du modèle (JAMAIS au-delà) ;
|
||||
* - indépendance du scaleFactor stocké (cette courbe est SA PROPRE
|
||||
* calibration continue — combiner = double correction).
|
||||
*
|
||||
* ⚠️ Noms de tests ASCII-sans-ponctuation : les backticks Kotlin avec
|
||||
* « : . / ; [ ] » sont des caractères illégaux pour le nom de méthode JVM
|
||||
* (compilation refusée par l'AGP — vérifié v1.5.0).
|
||||
*/
|
||||
class LabTrajectoryModelTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Même pattern que PKProfileStoreTest : l'asset est un FICHIER du
|
||||
// module (les assets Android ne sont pas sur le classpath des tests JVM)
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("pk_profiles.json not found for unit tests")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Traitement injectable EEn / TFS 5 mg, une dose à t=0 (id 1). */
|
||||
private fun treatment(scaleFactor: Double = 1.0): Treatment =
|
||||
Treatment(
|
||||
id = 1, name = "EEn TFS", type = TreatmentType.ESTRADIOL,
|
||||
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_SUBCUT,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
esterType = "EEN", pkModel = "TFS", scaleFactor = scaleFactor,
|
||||
createdAt = 0
|
||||
)
|
||||
|
||||
private fun doses(t: Treatment): List<com.hormonetrack.data.model.DoseLog> =
|
||||
listOf(com.hormonetrack.data.model.DoseLog(id = 1, treatmentId = 1, timestamp = 0L, doseAmount = 5.0))
|
||||
|
||||
private fun lab(value: Double, tMs: Long): LabResult =
|
||||
LabResult(marker = "E2", value = value, unit = "pg/mL", timestamp = tMs)
|
||||
|
||||
private fun tLab(tMs: Long): LabResult =
|
||||
LabResult(marker = "T", value = 0.35, unit = "ng/mL", timestamp = tMs)
|
||||
|
||||
private fun initProfiles() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) setup()
|
||||
}
|
||||
|
||||
/** Point à mi-chemin de l'index (représentatif du milieu de la fenêtre). */
|
||||
private fun List<LevelPoint>.mid(): LevelPoint = this[size / 2]
|
||||
|
||||
// ── Garanties ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `passe exactement sur chaque lab`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
val labs = listOf(lab(300.0, t2), lab(260.0, t7))
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
|
||||
assertEquals(2, curve.anchoredLabs)
|
||||
// Aux timestamps des labs : courbe == lab EXACTEMENT (fp ≈ 1e-6)
|
||||
assertEquals(labs[0].value, curve.points.first { it.timestamp == t2 }.e2, 1e-6)
|
||||
assertEquals(labs[1].value, curve.points.first { it.timestamp == t7 }.e2, 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rho interpole en log entre deux labs (math exacte)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val doses = doses(tr)
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
// ⚠️ Labs DATA-DRIVEN : lab = M(t) × facteur voulu — sinon les ratios
|
||||
// ne sont PAS les valeurs voulues (M(2 j) ≠ le pic de référence 160)
|
||||
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, t2, null, null)
|
||||
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, t7, null, null)
|
||||
val labs = listOf(lab(0.9 * m2, t2), lab(1.2 * m7, t7))
|
||||
|
||||
// 1) La fonction PURE ratioAt interpole exactement (ancres artificiels)
|
||||
val pure = listOf(t2 to 0.9, t7 to 1.2)
|
||||
val expectedRhoMid = exp((log(0.9) + log(1.2)) / 2.0)
|
||||
assertEquals(0.9, LabTrajectoryModel.ratioAt(pure, t2), 1e-12)
|
||||
assertEquals(1.2, LabTrajectoryModel.ratioAt(pure, t7), 1e-12)
|
||||
assertEquals(expectedRhoMid, LabTrajectoryModel.ratioAt(pure, (t2 + t7) / 2), 1e-9)
|
||||
|
||||
// 2) La COURBE restitue le même ρ : recomposer M(t) et vérifier
|
||||
// courbe/M == ρ attendu (M calculé par le moteur, comme le module)
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses, labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
val mid = curve.points.mid()
|
||||
val m = PharmacokineticEngine.e2At(
|
||||
listOf(tr.copy(scaleFactor = 1.0)), doses, mid.timestamp, null, null)
|
||||
assertEquals(expectedRhoMid, mid.e2 / m, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `un seul lab significatif donne une courbe vide (pas d intervalle)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue("le lab compte mais ne suffit pas à tracer", curve.points.isEmpty())
|
||||
assertTrue(curve.anchoredLabs == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garde 61 - un lab hors de la fenetre d action n est pas un ancrage`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
// Scénario #61 : labs DANS la fenêtre d'action + un lab TARDIF à
|
||||
// valeur absurde (prédiction résiduelle ≈ 0 → ratio aberrant)
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr),
|
||||
listOf(lab(300.0, t2), lab(260.0, t7), lab(3000.0, 60 * DAY_MS)),
|
||||
0L, 90 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue("le lab tardif est rejeté par labIsSignificant", curve.anchoredLabs == 2)
|
||||
assertTrue("aucun point ne suit la valeur absurde", curve.points.none { it.e2 > 600.0 })
|
||||
// …et la fenêtre s'arrête au DERNIER lab SIGNIFICATIF (pas au tardif)
|
||||
assertTrue("modelOverride modifie la forme intermédiaire (ESE ≠ TFS)", curve.points.all { it.timestamp <= t7 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labs avant la premiere dose ne produisent aucun ancrage (prediction nulle)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr),
|
||||
listOf(lab(200.0, -10 * DAY_MS), lab(210.0, -5 * DAY_MS)),
|
||||
-15 * DAY_MS, 0L, HOUR_MS
|
||||
)
|
||||
assertTrue(curve.points.isEmpty())
|
||||
assertTrue(curve.anchoredLabs == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `la fenetre du trace est bornée par les labs ET par la demande`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
val labs = listOf(lab(160.0, t2), lab(140.0, t7))
|
||||
// demande PLUS LARGE que les labs → clippée aux labs
|
||||
val wide = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue(wide.points.isNotEmpty())
|
||||
assertTrue(wide.points.all { it.timestamp in t2..t7 })
|
||||
// demande PLUS ÉTROITE que les labs → clippée à la demande
|
||||
val narrow = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue(narrow.points.isNotEmpty())
|
||||
assertTrue(narrow.points.all { it.timestamp in 3 * DAY_MS..5 * DAY_MS })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indépendant du scaleFactor stocké (sinon double correction)`() {
|
||||
initProfiles()
|
||||
val labs = listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS))
|
||||
val a = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(treatment(1.0)), doses(treatment(1.0)), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
val b = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(treatment(0.55)), doses(treatment(0.55)), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
// M diffère (0,55 vs 1,0) MAIS ρ compense exactement : courbes identiques
|
||||
assertEquals(a.points, b.points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modelOverride - chaque modèle ancre avec SA prédiction (sémantique 60)`() {
|
||||
initProfiles()
|
||||
// ⚠️ ESE vs TFS : PROUVENT quasi-identiques par construction (doc §8,
|
||||
// seuil 2 % — le test WHSAH côté moteur a la même limite). Pour une
|
||||
// divergence franche : EV avec ESE (pic 61,12 @ 45 h) vs WHS
|
||||
// (73,5 @ 41 h, biodispo F<1) — ~20 % d'écart de forme.
|
||||
val tr = treatment().copy(
|
||||
esterType = "EV", pkModel = "TFS",
|
||||
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_IM,
|
||||
)
|
||||
val labs = listOf(lab(320.0, 2 * DAY_MS), lab(240.0, 7 * DAY_MS))
|
||||
val ese = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "ESE")
|
||||
val whs = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "WHS")
|
||||
// Les DEUX passent par les labs (invariant)…
|
||||
assertEquals(labs[0].value, ese.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
|
||||
assertEquals(labs[0].value, whs.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
|
||||
// …mais les formes intermédiaires diffèrent nettement
|
||||
val midEse = ese.points.mid().e2
|
||||
val midWhs = whs.points.mid().e2
|
||||
val relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs))
|
||||
// ⚠️ L'ancrage CONVERGE les modèles (mêmes points traversés + ρ interpolé
|
||||
// dans le même esprit) : l'écart brut ESE/WHS (~20 %) est résorbé à
|
||||
// ~2 % au mi-chemin — seuil identique au test moteur WHS (doc §8).
|
||||
assertTrue("le modelOverride garde une empreinte intermédiaire (ESE $midEse vs WHS $midWhs)",
|
||||
relDiff > 0.02)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les labs T et autres marqueurs sont ignorés (E2 uniquement)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val labs = listOf(lab(300.0, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240.0, 7 * DAY_MS))
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS)
|
||||
assertTrue("le lab T n'est pas un ancrage", curve.anchoredLabs == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doublons de timestamp - le deuxième ratio gagne (garde division par zéro)`() {
|
||||
val t = 5 * DAY_MS
|
||||
val anchors = listOf(t to 1.0, t to 2.0, 10 * DAY_MS to 3.0)
|
||||
assertEquals(3.0, LabTrajectoryModel.ratioAt(anchors, 10 * DAY_MS), 1e-12)
|
||||
// au-delà de t, la section [t ; 10 j] interpole depuis r=2 (le DERNIER
|
||||
// ratio porté par ce timestamp), jamais division par 0
|
||||
val expected = exp(ln(2.0) + (7.0 - 5.0) / 5.0 * (ln(3.0) - ln(2.0)))
|
||||
assertEquals(expected, LabTrajectoryModel.ratioAt(anchors, 7 * DAY_MS), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ratioAt - gardes defensives aux bornes`() {
|
||||
val anchors = listOf(0L to 1.0, 10L to 2.0)
|
||||
assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12)
|
||||
assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 1e-12)
|
||||
}
|
||||
|
||||
// ── Prolongation au-delà du dernier lab (v1.6.0) ─────────────────────────
|
||||
// Garanties de l'extension (cf doc §7.10.bis) :
|
||||
// - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT —
|
||||
// fondement = la garde défensive de ratioAt devenue comportement) ;
|
||||
// - continuité au point de suture (le dernier lab reste exact) ;
|
||||
// - horizon = dernière dose E2 + cutoff de son traitement (jamais de
|
||||
// ligne à zéro inventée au-delà de l'extinction du modèle) ;
|
||||
// - les doses loguées APRÈS le dernier lab (autre ester/dose compris)
|
||||
// refont monter la courbe via M(t) — la demande « dosage + type
|
||||
// d'ester + évolution classique » ;
|
||||
// - dernier lab non significatif : ni ancre ni point de départ de la
|
||||
// prolongation (garde #61 inchangée) ;
|
||||
// - flag OFF (défaut) = comportement v1.5.0 bit-compatible.
|
||||
|
||||
/** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */
|
||||
private fun anchoredLabs(tr: Treatment, doses: List<com.hormonetrack.data.model.DoseLog>):
|
||||
Pair<List<LabResult>, Double> {
|
||||
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, 2 * DAY_MS, null, null)
|
||||
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, 7 * DAY_MS, null, null)
|
||||
return listOf(lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)) to 1.2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - la courbe continue apres le dernier lab avec le rho du dernier lab`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val doses = doses(tr)
|
||||
val (labs, rhoLast) = anchoredLabs(tr, doses)
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses, labs, 0L, 60 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
|
||||
assertTrue("la prolongation produit des points après le dernier lab",
|
||||
curve.points.any { it.timestamp > 7 * DAY_MS })
|
||||
// Suture : le dernier lab reste EXACT (continuité par construction)
|
||||
assertEquals(labs[1].value, curve.points.first { it.timestamp == 7 * DAY_MS }.e2, 1e-6)
|
||||
// Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact
|
||||
for (day in listOf(10L, 20L, 30L)) {
|
||||
val t = day * DAY_MS
|
||||
val p = curve.points.first { it.timestamp == t }
|
||||
val m = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t, null, null)
|
||||
assertEquals("courbe($day j) == M × ρ_last", m * rhoLast, p.e2, 1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - horizon borne par le cutoff de la derniere dose`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val (labs, _) = anchoredLabs(tr, doses(tr))
|
||||
|
||||
// Demande TRÈS large (90 j) : la prolongation s'arrête au modèle
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
|
||||
val horizonEnd = LabTrajectoryModel.extensionHorizonEndMs(
|
||||
listOf(tr), doses(tr), 7 * DAY_MS)!!
|
||||
assertTrue("l'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)",
|
||||
horizonEnd > 7 * DAY_MS)
|
||||
assertTrue("aucun point au-delà de l'extinction du modèle",
|
||||
curve.points.all { it.timestamp <= horizonEnd })
|
||||
assertTrue("le dernier point s'arrête AU cutoff (pas au-delà d'un pas)",
|
||||
curve.points.last().timestamp <= horizonEnd)
|
||||
assertTrue(curve.points.last().timestamp + HOUR_MS > horizonEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - une dose apres le dernier lab refait monter la courbe (dose et ester)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val doses = doses(tr) + com.hormonetrack.data.model.DoseLog(
|
||||
id = 2, treatmentId = 1, timestamp = 10 * DAY_MS,
|
||||
doseAmount = 4.0, esterType = "EV" // override d'ester : EEn → EV
|
||||
)
|
||||
val (labs, rhoLast) = anchoredLabs(tr, doses(tr))
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses, labs, 0L, 20 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
val extension = curve.points.filter { it.timestamp > 7 * DAY_MS }
|
||||
assertTrue(extension.isNotEmpty())
|
||||
|
||||
// L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses
|
||||
val t12 = 12 * DAY_MS
|
||||
val m12 = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t12, null, null)
|
||||
assertEquals(m12 * rhoLast, extension.first { it.timestamp == t12 }.e2, 1e-9)
|
||||
|
||||
// La FORME suit l'injection EV : montée nette après le 10 j (Tmax EV
|
||||
// ~45 h) — la partie prolongée n'est PAS une simple décroissance EEn
|
||||
val at8 = extension.first { it.timestamp == 8 * DAY_MS }.e2
|
||||
val peak = extension.filter { it.timestamp in 10 * DAY_MS..14 * DAY_MS }.maxOf { it.e2 }
|
||||
assertTrue("le pic EV ($peak) doit dépasser le niveau pré-injection ($at8)",
|
||||
peak > at8 * 1.3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension desactivee - rien apres le dernier lab (comportement initial v1-5-0)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val (labs, _) = anchoredLabs(tr, doses(tr))
|
||||
// DÉFAUT (flag absent) ET flag explicite à false : identiques
|
||||
val byDefault = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS)
|
||||
val explicitOff = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = false)
|
||||
assertEquals(byDefault.points, explicitOff.points)
|
||||
assertTrue(byDefault.points.all { it.timestamp <= 7 * DAY_MS })
|
||||
assertTrue("lastAnchorMs est positionné même sans prolongation (l'UI peut splitter)",
|
||||
byDefault.lastAnchorMs == 7 * DAY_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - un lab non significatif ne prolonge pas (garde 61)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
// Scénario #61 : lab TARDIF (60 j — prédiction résiduelle 0, ratio aberrant)
|
||||
val labs = anchoredLabs(tr, doses(tr)).first + lab(3000.0, 60 * DAY_MS)
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
|
||||
assertTrue("le lab tardif reste rejeté", curve.anchoredLabs == 2)
|
||||
assertEquals("la prolongation part du dernier lab SIGNIFICATIF",
|
||||
7 * DAY_MS, curve.lastAnchorMs)
|
||||
assertTrue("des points prolongés existent bien après le dernier ancre",
|
||||
curve.points.any { it.timestamp > 7 * DAY_MS })
|
||||
assertTrue("aucun point ne suit la valeur absurde du lab rejeté",
|
||||
curve.points.none { it.e2 > 600.0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - demande finissant avant le dernier lab ne prolonge rien`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val (labs, _) = anchoredLabs(tr, doses(tr))
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 5 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
assertTrue(curve.points.all { it.timestamp <= 5 * DAY_MS })
|
||||
// la suture au dernier ancre n'est pas dans la fenêtre → aucun point
|
||||
// au-delà du dernier lab, comme en v1.5.0
|
||||
assertTrue(curve.points.none { it.timestamp > 7 * DAY_MS })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extensionHorizonEndMs - gardes aucune dose ou modele deja eteint`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val trs = listOf(tr)
|
||||
// Aucune dose E2 → rien à prolonger
|
||||
assertTrue(LabTrajectoryModel.extensionHorizonEndMs(trs, emptyList(), 7 * DAY_MS) == null)
|
||||
// Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.)
|
||||
val horizon = LabTrajectoryModel.extensionHorizonEndMs(trs, doses(tr), 7 * DAY_MS)
|
||||
val expected = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong()
|
||||
assertEquals(expected, horizon)
|
||||
// Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre v1.5.0)
|
||||
assertTrue(LabTrajectoryModel.extensionHorizonEndMs(
|
||||
trs, doses(tr), expected + HOUR_MS) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lastAnchorMs - null si moins de 2 ancres, positionne sinon`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val one = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS)
|
||||
assertTrue(one.points.isEmpty())
|
||||
assertTrue(one.lastAnchorMs == null)
|
||||
|
||||
val two = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS)),
|
||||
0L, 30 * DAY_MS, HOUR_MS)
|
||||
assertEquals(7 * DAY_MS, two.lastAnchorMs)
|
||||
}
|
||||
}
|
||||
@ -2,18 +2,27 @@ package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.screens.groupLabsForDisplay
|
||||
import com.hormonetrack.ui.screens.labNotesForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
|
||||
* timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant.
|
||||
* v1.7.1 : affichage des NOTES du groupe ([labNotesForDisplay]) — fix du bug
|
||||
* « une seule note sur deux s'affiche » quand E2 et T portent des notes
|
||||
* DISTINCTES (chacune conservée en base, une seule affichée avant le fix).
|
||||
*/
|
||||
class LabsGroupingTest {
|
||||
|
||||
private fun lab(marker: String, ts: Long, value: Double) =
|
||||
LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts)
|
||||
|
||||
/** Lab avec note (les autres paramètres neutres). */
|
||||
private fun labWithNotes(marker: String, ts: Long, notes: String?) =
|
||||
LabResult(marker = marker, value = 1.0, unit = "pg/mL", timestamp = ts, notes = notes)
|
||||
|
||||
@Test
|
||||
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
|
||||
val ts = 1_788_621_894_346L
|
||||
@ -53,4 +62,64 @@ class LabsGroupingTest {
|
||||
val groups = groupLabsForDisplay(labs)
|
||||
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
|
||||
}
|
||||
|
||||
// ── Notes d'un groupe (v1.7.1 — fix « une seule note sur deux ») ────────
|
||||
|
||||
@Test
|
||||
fun `distinct notes on E2 and T are BOTH shown, prefixed by marker`() {
|
||||
// Cas réel remonté (paire v1.7.0 de l'utilisatrice) : note E2 ≠ note T
|
||||
val ts = 1_788_934_260_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, "Analyse faite suite à des maux de tête."),
|
||||
labWithNotes("T", ts, "DHT : 12 ng/dL"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"E2 : Analyse faite suite à des maux de tête.",
|
||||
"T : DHT : 12 ng/dL",
|
||||
),
|
||||
labNotesForDisplay(group)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identical notes on both entries are shown once (no duplicate)`() {
|
||||
// Création via le dialog commun : LA MÊME note part sur E2 et T
|
||||
val ts = 1_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, "Prise à jeun"),
|
||||
labWithNotes("T", ts, "Prise à jeun"),
|
||||
)
|
||||
assertEquals(listOf("Prise à jeun"), labNotesForDisplay(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single note stays unprefixed (v1_0 behaviour preserved)`() {
|
||||
val ts = 2_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, "Seule note"),
|
||||
labWithNotes("T", ts, null),
|
||||
)
|
||||
assertEquals(listOf("Seule note"), labNotesForDisplay(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank and null notes are ignored, order follows the group (E2 before T)`() {
|
||||
val ts = 3_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, " "), // blanche → ignorée
|
||||
labWithNotes("T", ts, "note T"),
|
||||
labWithNotes("PRL", ts, "note PRL"),
|
||||
)
|
||||
// Ordre du groupe (déjà trié E2→T→PRL par le caller) : T avant PRL,
|
||||
// la note blanche d'E2 n'apparaît pas
|
||||
assertEquals(listOf("T : note T", "PRL : note PRL"), labNotesForDisplay(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no notes at all yields no display lines`() {
|
||||
val ts = 4_000L
|
||||
val group = listOf(labWithNotes("E2", ts, null), labWithNotes("T", ts, ""))
|
||||
assertTrue(labNotesForDisplay(group).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ class PKProfileStoreTest {
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
)
|
||||
|
||||
@ -25,6 +25,7 @@ class PharmacokineticEngineTest {
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
@ -159,7 +160,11 @@ class PharmacokineticEngineTest {
|
||||
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
|
||||
}
|
||||
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
|
||||
assertEquals(0.9, sf!!, 0.01)
|
||||
// v1.8.2 : la médiane est ARRONDIE (Math.round), pas tronquée — le
|
||||
// dernier bit flottant (0,8999… vs 0,9000…) ne doit pas faire chuter
|
||||
// l'échelle d'un cran d'affichage (révélé par le passage d'ESE en
|
||||
// analytique v1.9.0, cf CHANGELOG 1.9.1)
|
||||
assertEquals(0.9, sf!!, 0.005)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -43,6 +43,7 @@ class RegressionUserCase2Test {
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
|
||||
@ -42,6 +42,7 @@ class RegressionUserCase3Test {
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
|
||||
@ -50,6 +50,7 @@ class RegressionUserCase4Test {
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
|
||||
186
app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt
Normal file
186
app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt
Normal file
@ -0,0 +1,186 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.ui.screens.labNotesForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* **5ᵉ régression épinglée sur données réelles** (export v1.7.0, HORS dépôt
|
||||
* `local-test-data/backup-v1.7.0.json`, gitigné — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-16) : dans une prise de sang E2+T, quand les
|
||||
* DEUX entrées portent des notes DISTINCTES (ex. une note clinique sur l'E2
|
||||
* et une note « DHT » sur la T), une seule s'affichait dans l'écran Analyses
|
||||
* — l'autre était bien conservée mais perdue à l'affichage. Fix :
|
||||
* `labNotesForDisplay` (Android + web en miroir), épinglé ici sur la VRAIE
|
||||
* paire de données. Profil mis à jour au passage : 3 traitements dont un CPA
|
||||
* oral (anti-androgène, Bateman) + EEn actif + EV historique INACTIF —
|
||||
* rappelle que l'inactif reste simulé (§6.bis).
|
||||
*
|
||||
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||
*/
|
||||
class RegressionUserCase5Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.7.0.json"),
|
||||
File("local-test-data/backup-v1.7.0.json"),
|
||||
File("app/local-test-data/backup-v1.7.0.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json")
|
||||
}
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.7.0.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||
@Test
|
||||
fun `backup parses with the expected shape`() {
|
||||
assertEquals(3, data.treatments.size)
|
||||
assertTrue("doses présentes", data.doseLogs.isNotEmpty())
|
||||
assertTrue("labs présents", data.labResults.isNotEmpty())
|
||||
// Profil : un anti-androgène ORAL actif, un E2 INACTIF resté dans les
|
||||
// données (§6.bis : jamais filtré de la simulation), un E2 actif
|
||||
assertEquals(1, data.treatments.count { it.type == TreatmentType.ANTI_ANDROGEN })
|
||||
assertEquals(1, data.treatments.count { !it.isActive })
|
||||
}
|
||||
|
||||
/**
|
||||
* LE fix de cette régression : la prise la plus récente porte DEUX notes
|
||||
* DISTINCTES (une par entrée) → [labNotesForDisplay] en rend DEUX,
|
||||
* préfixées du marqueur. Avant le fix, l'affichage n'en montrait qu'une
|
||||
* (première non vide) bien que les deux fussent conservées.
|
||||
*/
|
||||
@Test
|
||||
fun `the latest blood draw has two distinct notes and BOTH are displayed`() {
|
||||
val latestTs = data.labResults.maxOf { it.timestamp }
|
||||
val pair = data.labResults.filter { it.timestamp == latestTs }
|
||||
assertTrue("la dernière prise est une paire E2+T", pair.size >= 2)
|
||||
|
||||
val distinctNotes = pair.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }.distinct()
|
||||
assertTrue(
|
||||
"la dernière paire porte au moins deux notes distinctes (données du bug)",
|
||||
distinctNotes.size >= 2
|
||||
)
|
||||
val displayed = labNotesForDisplay(pair)
|
||||
assertEquals("DEUX lignes affichées", 2, displayed.size)
|
||||
assertTrue("chaque ligne est préfixée du marqueur", displayed.all { it.contains(" : ") })
|
||||
// Les DEUX textes d'origine se retrouvent (aucune perdue)
|
||||
for (note in distinctNotes) {
|
||||
assertTrue(displayed.any { it.endsWith(note) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Même prise → note identique dupliquée = une seule ligne (pas de doublon). */
|
||||
@Test
|
||||
fun `a blood draw with the SAME note on both entries shows it once`() {
|
||||
val pair = listOf(
|
||||
LabResult(marker = "E2", value = 1.0, unit = "pg/mL", timestamp = 0, notes = "Prise à jeun"),
|
||||
LabResult(marker = "T", value = 1.0, unit = "ng/mL", timestamp = 0, notes = "Prise à jeun"),
|
||||
)
|
||||
assertEquals(1, labNotesForDisplay(pair).size)
|
||||
}
|
||||
|
||||
/** Notes de DOSES (côté injection) : parsées et préservées. */
|
||||
@Test
|
||||
fun `dose notes are preserved by the round-trip`() {
|
||||
assertTrue(
|
||||
"au moins une dose porte une note (données réelles)",
|
||||
data.doseLogs.any { !it.notes.isNullOrBlank() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plausibilité du moteur sur CE profil (CPA oral Bateman + EEn actif +
|
||||
* EV historique inactif) : niveau actuel physiologique, courbe 30 j non
|
||||
* vide et bornée, auto-calibration (activée dans les settings) couvre
|
||||
* l'ester actif sans planter.
|
||||
*/
|
||||
@Test
|
||||
fun `engine stays plausible on the new profile`() {
|
||||
val now = data.exportedAt + 300_000L // +5 min après l'export
|
||||
val level = PharmacokineticEngine.levelAt(
|
||||
data.treatments, data.doseLogs, now,
|
||||
tConfig = data.tConfig, scalePerEster = null, tKPerEster = null
|
||||
)
|
||||
assertTrue("E2 estimée physiologique (${level.e2})", level.e2 in 20.0..1000.0)
|
||||
assertTrue("T estimée physiologique (${level.t})", level.t in 0.01..3.0)
|
||||
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
data.treatments, data.doseLogs, now - 30 * 3_600_000L * 24, now,
|
||||
stepMs = 3_600_000L, tConfig = data.tConfig
|
||||
)
|
||||
assertTrue("courbe 30 j non vide", curve.isNotEmpty())
|
||||
|
||||
// Auto-calibration (option activée dans les settings de cet export)
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
assertTrue("l'auto-calibration couvre au moins un ester", auto.calibratedEsters >= 1)
|
||||
}
|
||||
|
||||
/** Prévision : le traitement actif a une Posologie → créneaux projetés. */
|
||||
@Test
|
||||
fun `forecast slots exist for the active treatment`() {
|
||||
val active = data.treatments.first { it.isActive && it.type == TreatmentType.ESTRADIOL }
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
active, data.doseLogs, data.exportedAt + 10L * 24 * 3_600_000L, data.exportedAt
|
||||
)
|
||||
assertTrue("créneaux prévisionnels générés (Posologie 7 j)", slots.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.0 : la RECOMMANDATION de prochaine prise de sang se calcule sur ce
|
||||
* profil réel (EEn actif, Posologie 7 j) — future, avec le créneau
|
||||
* associé juste après le creux, et cohérente avec la dernière prise de
|
||||
* sang existante (jamais un creux déjà mesuré).
|
||||
*/
|
||||
@Test
|
||||
fun `blood draw recommendation exists on the real profile`() {
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||
)
|
||||
assertNotNull(
|
||||
"EEn actif + Posologie 7 j → une recommandation est calculable", rec
|
||||
)
|
||||
assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt)
|
||||
assertTrue(
|
||||
"le creux est DANS la fenêtre inter-créneau associée (trough=${rec.troughMs}, inj=${rec.injectionMs})",
|
||||
rec.troughMs > rec.injectionMs - 7L * DAY_MS - 2L * 3_600_000L &&
|
||||
rec.troughMs < rec.injectionMs
|
||||
)
|
||||
assertTrue(
|
||||
"le creux recommandé est postérieur à la dernière prise de sang",
|
||||
rec.troughMs > data.labResults.maxOf { it.timestamp }
|
||||
)
|
||||
assertTrue("ester actif uniquement", rec.ester.equals("EEN", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
169
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
169
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
@ -0,0 +1,169 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* **6ᵉ régression épinglée sur données réelles** (export v1.9.8, HORS dépôt
|
||||
* `local-test-data/backup-v1.9.8.json`, gitigné — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-27) : « la recommandation suggère une date
|
||||
* antérieure à la stabilisation des taux ». L'invariant pharmacocinétique de
|
||||
* la carte (§7.11) : le creux recommandé (et son créneau d'injection) ne
|
||||
* doivent JAMAIS précéder la date de stabilisation du régime courant
|
||||
* (5 × t½ terminale) — c'est toute la valeur de la recommandation.
|
||||
*
|
||||
* Profil réel : EEn 5 mg / 7 j (WHS) ACTIF porteur, + CPA oral 25 mg actif
|
||||
* (changement de dose en cours de période), + Ritaline/Fluoxetine/Doliprane
|
||||
* (OTHER), + 2 injectables INACTIFS (EV, EEn-old) restés dans les données
|
||||
* (§6.bis) ; logs d'injection à heures variables (l'écart réel 6,2 → 7,4 j).
|
||||
*
|
||||
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||
*/
|
||||
class RegressionUserCase6Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.9.8.json"),
|
||||
File("local-test-data/backup-v1.9.8.json"),
|
||||
File("app/local-test-data/backup-v1.9.8.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.9.8.json")
|
||||
}
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.9.8.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Le porteur de la recommandation : E2 injectable actif à Posologie. */
|
||||
private fun carrier() = data.treatments.first {
|
||||
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||
}
|
||||
|
||||
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||
@Test
|
||||
fun `backup parses with the expected shape`() {
|
||||
val carrier = carrier()
|
||||
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
|
||||
val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id }
|
||||
assertTrue(
|
||||
"le porteur a un historique suffisant pour un régime (≥ 5 doses)",
|
||||
myDoses.size >= 5
|
||||
)
|
||||
assertTrue("labs présents", data.labResults.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* **LE fix de cette régression** : le creux recommandé n'est JAMAIS
|
||||
* antérieur à la date de stabilisation annoncée. Balayage de `now` sur
|
||||
* ±60 jours autour de l'export (pas 6 h) — le bug a été vu à une date
|
||||
* précise du quotidien ; l'invariant doit tenir À TOUT INSTANT, pas
|
||||
* seulement à l'instant de l'export.
|
||||
*/
|
||||
@Test
|
||||
fun `recommended trough is never before the stabilization date (any now)`() {
|
||||
val start = data.exportedAt - 60L * DAY_MS
|
||||
val end = data.exportedAt + 60L * DAY_MS
|
||||
var checked = 0
|
||||
var violations = 0
|
||||
var now = start
|
||||
while (now <= end) {
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, now
|
||||
)
|
||||
if (rec != null) {
|
||||
checked++
|
||||
if (rec.troughMs < rec.stabilizedAtMs || rec.injectionMs < rec.stabilizedAtMs) {
|
||||
violations++
|
||||
println(
|
||||
"VIOLATION now=$now : trough=${rec.troughMs} " +
|
||||
"stab=${rec.stabilizedAtMs} regimeStart=${rec.regimeStartMs}"
|
||||
)
|
||||
}
|
||||
}
|
||||
now += 6L * HOUR_MS
|
||||
}
|
||||
assertTrue(
|
||||
"au moins une recommandation calculée sur la fenêtre balayée (checked=$checked)",
|
||||
checked > 0
|
||||
)
|
||||
assertEquals(
|
||||
"creux/créneau toujours ≥ stabilisation ($violations violations)",
|
||||
0, violations
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* La recommandation à l'instant de l'export : le régime détecté doit
|
||||
* couvrir la séquence régulière récente (logs à heures variables MAIS
|
||||
* écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux
|
||||
* recommandé doit être le PREMIER créneau post-stabilisation.
|
||||
*
|
||||
* v1.13.0 : le régime est désormais POOLÉ entre traitements
|
||||
* ([LabTiming.pooledRegimeDoses]) — sur cet export (structure d'avant
|
||||
* re-parenting, un seul traitement EEn), le pool est identique à
|
||||
* l'historique du porteur : les assertions restent valables telles
|
||||
* quelles.
|
||||
*/
|
||||
@Test
|
||||
fun `regime detection covers the recent regular sequence`() {
|
||||
val carrier = carrier()
|
||||
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||
// Les 4 dernières doses sont régulières (même dose, écart dans la
|
||||
// fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière…
|
||||
// assertion data-driven : le régime couvre au moins les 4 doses
|
||||
// les plus récentes (séquence régulière du fichier).
|
||||
val sorted = pooled.map { it.timestamp }.sorted()
|
||||
assertTrue(
|
||||
"le régime détecté couvre la séquence régulière récente " +
|
||||
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
|
||||
regimeStart <= sorted[sorted.size - 4]
|
||||
)
|
||||
}
|
||||
|
||||
/** Cohérences d'affichage à l'instant de l'export (cf régression n°5). */
|
||||
@Test
|
||||
fun `recommendation at export time is consistent`() {
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||
)
|
||||
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
|
||||
rec!!.let {
|
||||
assertTrue("creux futur", it.troughMs > data.exportedAt)
|
||||
assertTrue(
|
||||
"creux dans la fenêtre inter-créneau (trough=${it.troughMs}, inj=${it.injectionMs})",
|
||||
it.troughMs < it.injectionMs &&
|
||||
it.troughMs > it.injectionMs - 8L * DAY_MS
|
||||
)
|
||||
assertTrue(
|
||||
"creux postérieur à la dernière analyse",
|
||||
it.troughMs > data.labResults.maxOf { l -> l.timestamp }
|
||||
)
|
||||
assertTrue(
|
||||
"INVARIANT central : creux ≥ stabilisation",
|
||||
it.troughMs >= it.stabilizedAtMs
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
@ -0,0 +1,187 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* **7ᵉ régression épinglée sur données réelles** (export v1.12.0, HORS dépôt
|
||||
* `local-test-data/backup-v1.12.0.json`, gitigné — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-29) : l'historique EEn a été RE-PARENTÉ —
|
||||
* création d'un traitement « Injection EEn 5mg 6d - old » INACTIF (Posologie
|
||||
* 6 j) regroupant les doses du 18 août au 6 sept, le traitement actif
|
||||
* gardant les ères 7 j. Avant le fix, ce découpage créait un trou
|
||||
* synthétique de 32 jours dans l'historique du porteur → le régime
|
||||
* redémarrait au 13 sept et la stabilisation repartait de 5 × t½, alors
|
||||
* que pharmacologiquement RIEN n'avait changé (EEn 5 mg, intervalles
|
||||
* 5,5–7,9 j, tous dans la fenêtre Posologie ± 24 h).
|
||||
*
|
||||
* **Le fix v1.13.0 ([LabTiming.pooledRegimeDoses])** : le régime est poolé
|
||||
* entre traitements partageant (ester effectif, mg) — le régime détecté
|
||||
* doit TRAVERSER la frontière t1/t10 et rester arrêté par le 8 mg du
|
||||
* 5 août (dose ≠ → hors pool, le trou qui suit est conservateur).
|
||||
*
|
||||
* Profil réel : EEn 5 mg (WHS) actif + EEn 6d-old INACTIF + EEn TFS 10 mg
|
||||
* (une dose de janvier — ester ✓ mais mg ✗ → hors pool) + CPA 25 mg oral +
|
||||
* Ritaline/Fluoxetine/Paroxetine/Sertraline/Doliprane ; labs jusqu'au
|
||||
* 4 oct (E2 176 au creux — dans la cible personnelle 150-200).
|
||||
*
|
||||
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||
*/
|
||||
class RegressionUserCase7Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.12.0.json"),
|
||||
File("local-test-data/backup-v1.12.0.json"),
|
||||
File("app/local-test-data/backup-v1.12.0.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.12.0.json")
|
||||
}
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.12.0.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Porteur = injectable E2 ACTIF à Posologie (l'EEn 7 j). */
|
||||
private fun carrier() = data.treatments.first {
|
||||
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||
}
|
||||
|
||||
/** Le traitement « … - old » INACTIF du même ester (le re-parenté). */
|
||||
private fun reparentedInactive(): Treatment? =
|
||||
data.treatments.firstOrNull {
|
||||
it.type == TreatmentType.ESTRADIOL && !it.isActive && it.usesProfileModel
|
||||
}
|
||||
|
||||
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||
@Test
|
||||
fun `backup parses with the expected shape`() {
|
||||
assertEquals(10, data.treatments.size)
|
||||
assertNotNull("le re-parenté inactif existe (données du bug)", reparentedInactive())
|
||||
val carrier = carrier()
|
||||
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* **LE fix de cette régression** : le régime du porteur TRAVERSE la
|
||||
* frontière de re-parenting. Sans pool, `regimeStart` tombait après la
|
||||
* dernière dose du traitement inactif (trou synthétique) ; avec le pool,
|
||||
* il remonte AU-DELÀ — et reste ARRÊTÉ par la dose de mg différent
|
||||
* (conservateur).
|
||||
*/
|
||||
@Test
|
||||
fun `pooled regime crosses the re-parenting boundary but stops at the dose change`() {
|
||||
val carrier = carrier()
|
||||
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||
|
||||
// 1) Le régime couvre les doses du traitement re-parenté inactif
|
||||
val reparented = reparentedInactive()!!
|
||||
val reparentedLastTs = data.doseLogs
|
||||
.filter { it.treatmentId == reparented.id }
|
||||
.maxOf { it.timestamp }
|
||||
assertTrue(
|
||||
"le régime traverse la frontière de re-parenting " +
|
||||
"(regimeStart=$regimeStart ≤ dernière dose du re-parenté=$reparentedLastTs)",
|
||||
regimeStart <= reparentedLastTs
|
||||
)
|
||||
|
||||
// 2) La dose de MG DIFFÉRENT du porteur (8 mg) arrête le régime :
|
||||
// elle est exclue du pool → le régime démarre APRÈS elle
|
||||
val oddDoseTs = data.doseLogs
|
||||
.filter { it.treatmentId == carrier.id && Math.abs(it.doseAmount - carrier.doseAmount) > 1e-6 }
|
||||
.maxOf { it.timestamp }
|
||||
assertTrue(
|
||||
"le régime reste arrêté par la dose de mg différent " +
|
||||
"(regimeStart=$regimeStart > 8mg=$oddDoseTs)",
|
||||
regimeStart > oddDoseTs
|
||||
)
|
||||
|
||||
// 3) La stabilisation arrive RAPIDEMENT après l'export (le régime
|
||||
// poolé démarre dans le dernier mois et demi) — l'artefact
|
||||
// supprimé par le fix est le TROU SYNTHÉTIQUE de re-parenting,
|
||||
// pas les vrais redémarrages de régime (l'écart 5,8 j du
|
||||
// 25→31 août reste hors fenêtre ± 24 h et casse légitimement).
|
||||
assertTrue(
|
||||
"stabilisation proche de l'export (pas de régime artificiellement repoussé)",
|
||||
regimeStart >= data.exportedAt - 60L * DAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* La recommandation complète sur ce profil : calculable, creux futur
|
||||
* post-stabilisation, et — v1.13.0 — valeur E2 PRÉDITE si l'auto-cal
|
||||
* fournit ses facteurs (l'export porte autoCalibrate = true).
|
||||
*/
|
||||
@Test
|
||||
fun `recommendation at export time is consistent and predicts the trough value`() {
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
|
||||
auto.esterScales
|
||||
)
|
||||
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
|
||||
rec!!.let {
|
||||
assertTrue("creux futur", it.troughMs > data.exportedAt)
|
||||
assertTrue(
|
||||
"INVARIANT creux ≥ stabilisation (fix #68)",
|
||||
it.troughMs >= it.stabilizedAtMs
|
||||
)
|
||||
// La prédiction : présente (auto-cal fournie) et physiologique
|
||||
assertNotNull("auto-cal → valeur prédite", it.predictedE2)
|
||||
assertTrue(
|
||||
"E2 prédite physiologique (${it.predictedE2})",
|
||||
it.predictedE2!! in 20.0..1000.0
|
||||
)
|
||||
// Sans calibration → PAS de valeur (honnêteté)
|
||||
val raw = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||
)
|
||||
assertEquals(null, raw!!.predictedE2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le statut cible appliqué à la prédiction : la cible vient des champs
|
||||
* du fichier quand présents (ici absents de l'export v2 → null) — la
|
||||
* matrice complète est épinglée dans LabTimingV113Test.
|
||||
*/
|
||||
@Test
|
||||
fun `target status stays null when the export carries no trough target`() {
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
|
||||
auto.esterScales
|
||||
)!!
|
||||
// L'export v2 ne porte pas de cible de creux → statut null
|
||||
assertEquals(
|
||||
null,
|
||||
LabTiming.troughTargetStatus(rec.predictedE2, null, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ class RegressionUserCaseTest {
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Tests du garde « dose déjà loggée aujourd'hui » (v1.10.0, demande
|
||||
* utilisatrice) — [PharmacokineticEngine.hasDoseLoggedOnDay] : la
|
||||
* notification de rappel ne doit pas sonner pour une prise DÉJÀ saisie dans
|
||||
* la journée (le receiver saute la notif et re-programme le créneau suivant).
|
||||
*
|
||||
* Pur et DÉTERMINISTE : zone fixe (Europe/Paris), instants construits —
|
||||
* aucun System.currentTimeMillis. Le garde compare des JOURS CALENDAIRES
|
||||
* locaux : la frontière minuit est épinglée (23:50 la veille ≠ jour suivant).
|
||||
*/
|
||||
class ReminderLoggedDayTest {
|
||||
|
||||
private val ZONE = ZoneId.of("Europe/Paris")
|
||||
|
||||
/** 2026-09-27 12:00 Europe/Paris (instant de référence du jour J). */
|
||||
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
|
||||
private val T1 = 1L
|
||||
private val T2 = 2L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
|
||||
private fun dose(treatmentId: Long, atMs: Long) =
|
||||
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = 5.0)
|
||||
|
||||
@Test
|
||||
fun `dose logged the same day skips the reminder`() {
|
||||
// Prise à 09:00, rappel à 12:00 — LE cas remonté (dose saisie avant
|
||||
// l'heure de rappel) : le créneau du jour est déjà honoré.
|
||||
val doses = listOf(dose(T1, noon - 3 * HOUR_MS))
|
||||
assertTrue(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dose logged yesterday does not skip today's reminder`() {
|
||||
val doses = listOf(dose(T1, noon - 24 * HOUR_MS))
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight boundary is a calendar-day comparison in the given zone`() {
|
||||
// 23:50 le 26 → rappel 00:10 le 27 : jour CALendaire différent → pas de skip
|
||||
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
assertFalse(
|
||||
"23:50 la veille ne couvre pas le rappel de 00:10",
|
||||
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, lateEvening)), earlyMorning, ZONE)
|
||||
)
|
||||
assertTrue(
|
||||
"00:10 et 12:00 du MÊME jour = même jour calendaire",
|
||||
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, earlyMorning)), noon, ZONE)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dose of another treatment never skips`() {
|
||||
val doses = listOf(dose(T2, noon - 3 * HOUR_MS))
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no dose at all never skips`() {
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, emptyList(), noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `several treatments mixed - only the carrier's own day counts`() {
|
||||
val doses = listOf(
|
||||
dose(T2, noon - 1 * HOUR_MS), // autre traitement aujourd'hui
|
||||
dose(T1, noon - 30 * HOUR_MS), // porteur, mais la veille
|
||||
)
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
}
|
||||
@ -49,8 +49,9 @@ class ScaleFactorWhsahReproTest {
|
||||
fun setup() {
|
||||
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
|
||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||
val file = File("src/main/assets/pk_profiles.json")
|
||||
val file = File("src/test/assets/pk_profiles.json")
|
||||
.takeIf { it.exists() }
|
||||
?: File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
|
||||
?: File("app/src/main/assets/pk_profiles.json")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
|
||||
@ -47,6 +47,7 @@ class TransfemScienceEngineTest {
|
||||
// l'asset pour rester au plus près du comportement réel de l'app.
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
@ -79,14 +80,24 @@ class TransfemScienceEngineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EB forced to ESE model yields zero (no ODS table for this ester)`() {
|
||||
fun `EB forced to ESE model yields a REAL curve (analytic since v1_9_0)`() {
|
||||
// v1.9.0 : l'Estrannaise est devenue ANALYTIQUE (estrannaise.js) et
|
||||
// couvre désormais EB (fit « EB im ») — l'ancien test épinglait
|
||||
// « EB+ESE → 0, pas de table ODS pour cet ester » (obsolète)
|
||||
val tr = treatment(Esters.EB, doseMg = 5.0)
|
||||
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
||||
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
|
||||
)
|
||||
assertTrue(curve.all { it.e2 == 0.0 })
|
||||
assertTrue("EB ESE analytique produit une courbe non vide", curve.any { it.e2 > 0.0 })
|
||||
// et un ester réellement NON couvert par les trois modèles → 0
|
||||
val unknown = tr.copy(esterType = "XXX")
|
||||
val curve2 = PharmacokineticEngine.computeCurve(
|
||||
listOf(unknown), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
||||
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
|
||||
)
|
||||
assertTrue("ester inconnu → 0 dans les trois modèles", curve2.all { it.e2 == 0.0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -30,6 +30,7 @@ class V120FeaturesTest {
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
@ -263,7 +264,9 @@ class V120FeaturesTest {
|
||||
@Test
|
||||
fun `scalePerEster scales each dose by its own ester factor`() {
|
||||
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
|
||||
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
// v1.9.0 : la dose EV doit être DANS le cutoff ESE analytique
|
||||
// (10 × t½ EV = 29,4 j — l'ancien cutoff ODS était 333 j) : −10 j
|
||||
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
|
||||
val t = BASE
|
||||
|
||||
@ -41,6 +41,7 @@ class WhsahEngineTest {
|
||||
// les tables ODS servent au chemin ESE (test de contraste)
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
|
||||
@ -90,7 +90,10 @@ class WhsahModelsTest {
|
||||
assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"),
|
||||
com.hormonetrack.data.model.Esters.choicesForModel(
|
||||
com.hormonetrack.data.model.PKModels.WHSAH))
|
||||
assertEquals(listOf("EV", "EU", "EEN"),
|
||||
// v1.9.0 : ESE analytique (estrannaise.js) couvre 6 injectables,
|
||||
// dont EUCS (undécylate suspension cristalline, exclusif à ce fit) ;
|
||||
// l'ancienne couverture tables ODS n'était que EV/EU/EEn
|
||||
assertEquals(listOf("EV", "EU", "EEN", "EC", "EB", "EUCS"),
|
||||
com.hormonetrack.data.model.Esters.choicesForModel(
|
||||
com.hormonetrack.data.model.PKModels.ESTRANNAISE))
|
||||
}
|
||||
|
||||
@ -152,6 +152,58 @@ class ChartZoomTest {
|
||||
rangeHours = 6L, horizonHours = 24L, firstSlotGapHours = 0L))
|
||||
}
|
||||
|
||||
// ── v1.4.10 (fix #62) : pan en cumul fractionnaire (la vue 24 h ne
|
||||
// bougeait jamais : chaque delta de doigt < 1 h était tronqué à 0) ──
|
||||
|
||||
@Test
|
||||
fun `pan at 24h accumulates fractional deltas until one full hour is reached`() {
|
||||
// doigt réel à 24 h : deltas de ~30 px ≈ 0,67 h → tronqués à 0 AVANT
|
||||
// le fix (le pan ne bougeait jamais) ; le cumul déclenche à 2 deltas
|
||||
var residual = 0.0
|
||||
var applied = 0L
|
||||
for (i in 1..2) {
|
||||
val (h, r) = com.hormonetrack.ui.components.panDeltaHours(
|
||||
panPx = 30f, chartWidthPx = 1080, rangeHours = 24L, residualHours = residual
|
||||
)
|
||||
applied += h; residual = r
|
||||
}
|
||||
assertEquals(1L, applied)
|
||||
assertEquals(0.34, residual, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pan at 7 days moves on the first delta (same finger, wider range)`() {
|
||||
// le MÊME delta de 30 px à 7 j = 4,67 h → 4 h appliqués immédiatement
|
||||
val (h, r) = com.hormonetrack.ui.components.panDeltaHours(
|
||||
panPx = 30f, chartWidthPx = 1080, rangeHours = 24L * 7, residualHours = 0.0
|
||||
)
|
||||
assertEquals(4L, h)
|
||||
assertEquals(0.67, r, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pan handles negative deltas (drag toward the past) with fractional accumulation`() {
|
||||
var residual = 0.0
|
||||
var applied = 0L
|
||||
for (i in 1..3) {
|
||||
val (h, r) = com.hormonetrack.ui.components.panDeltaHours(
|
||||
panPx = -20f, chartWidthPx = 1080, rangeHours = 24L, residualHours = residual
|
||||
)
|
||||
applied += h; residual = r
|
||||
}
|
||||
// −0,44 −0,44 → −0,89 (troncature vers 0 = 0) puis −1,33 → −1
|
||||
assertEquals(-1L, applied)
|
||||
assertEquals(-0.33, residual, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pan helper degenerate inputs`() {
|
||||
assertEquals(0L to 0.0, com.hormonetrack.ui.components.panDeltaHours(
|
||||
panPx = 100f, chartWidthPx = 0, rangeHours = 24L, residualHours = 0.0))
|
||||
assertEquals(0L to 0.0, com.hormonetrack.ui.components.panDeltaHours(
|
||||
panPx = 100f, chartWidthPx = 1080, rangeHours = 0L, residualHours = 0.0))
|
||||
}
|
||||
|
||||
// ── v1.4.7 (fix #58) : toggles de modèles alignés sur les traitements ──
|
||||
|
||||
@Test
|
||||
@ -229,6 +281,32 @@ class ChartZoomTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xLabelTicks daily steps stay at local midnight across DST transition`() {
|
||||
// v1.9.5 (audit) : l'avancement du pas se fait par Calendar (et non
|
||||
// un +24h fixe en ms) — sans ça, le tick qui FRANCHIT la transition
|
||||
// tombait à 23h/01h locale au lieu de minuit. Europe/Paris 2026 :
|
||||
// passage à l'heure d'été le 29 mars (02:00 → 03:00).
|
||||
val zone = java.util.TimeZone.getTimeZone("Europe/Paris")
|
||||
val cal = java.util.Calendar.getInstance(zone).apply {
|
||||
clear()
|
||||
set(2026, 2, 27, 12, 0, 0) // 27/03/2026 12:00 — la fenêtre chevauche la transition
|
||||
}
|
||||
val t0 = cal.timeInMillis
|
||||
val ticks = com.hormonetrack.ui.components.xLabelTicks(
|
||||
t0, t0 + 6L * 86_400_000L, 24L, zone
|
||||
)
|
||||
assertTrue(ticks.isNotEmpty())
|
||||
var crossed = false
|
||||
for (t in ticks) {
|
||||
val c = java.util.Calendar.getInstance(zone).apply { timeInMillis = t }
|
||||
assertEquals("tick à ${c.time} doit être minuit Paris (DST franchi)",
|
||||
0, c.get(java.util.Calendar.HOUR_OF_DAY))
|
||||
if (c.get(java.util.Calendar.DAY_OF_MONTH) >= 30) crossed = true
|
||||
}
|
||||
assertTrue("la fenêtre doit franchir la transition DST pour distinguer les deux implémentations", crossed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xLabelTicks hourly steps land at whole local hours`() {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
106
app/src/test/java/com/hormonetrack/ui/DosesExtrasTest.kt
Normal file
106
app/src/test/java/com/hormonetrack/ui/DosesExtrasTest.kt
Normal file
@ -0,0 +1,106 @@
|
||||
package com.hormonetrack.ui
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.screens.labMarkersForDose
|
||||
import com.hormonetrack.ui.screens.routeIcon
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Tests des ajouts v1.12.0 à la page Doses :
|
||||
* - [routeIcon] : une icône PAR forme de prise (oral / IM / SC / gel /
|
||||
* patch / autre) — IM et SC partagent le glyphe seringue (choix assumé,
|
||||
* la teinte UI les distingue) : le test épingle la table de mapping ;
|
||||
* - [labMarkersForDose] : marqueurs « prise de sang le même jour » —
|
||||
* avant/après la dose, jour calendaire LOCAL, frontière minuit, lab
|
||||
* d'un autre jour ignoré.
|
||||
*
|
||||
* Déterministes (zone fixe, instants construits) ; miroir web
|
||||
* `tests/doses-extras.test.js`.
|
||||
*/
|
||||
class DosesExtrasTest {
|
||||
|
||||
private val ZONE = ZoneId.of("Europe/Paris")
|
||||
|
||||
/** 2026-09-27 12:00 Europe/Paris. */
|
||||
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
|
||||
private fun lab(atMs: Long) = LabResult(
|
||||
marker = "E2", value = 100.0, unit = "pg/mL", timestamp = atMs
|
||||
)
|
||||
|
||||
// ── routeIcon ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `each route has its own glyph (except IM-SC sharing the syringe by design)`() {
|
||||
val oral = routeIcon(AdministrationRoute.ORAL)
|
||||
val im = routeIcon(AdministrationRoute.INJECTION_IM)
|
||||
val sc = routeIcon(AdministrationRoute.INJECTION_SUBCUT)
|
||||
val gel = routeIcon(AdministrationRoute.TRANSDERMAL_GEL)
|
||||
val patch = routeIcon(AdministrationRoute.TRANSDERMAL_PATCH)
|
||||
val other = routeIcon(AdministrationRoute.OTHER)
|
||||
|
||||
assertNotEquals(oral, im)
|
||||
assertNotEquals(oral, gel)
|
||||
assertNotEquals(oral, patch)
|
||||
assertNotEquals(im, gel)
|
||||
assertNotEquals(gel, patch)
|
||||
assertNotEquals(patch, other)
|
||||
assertEquals("IM/SC : même seringue (teinte UI distincte)", im, sc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null route falls back to the OTHER glyph`() {
|
||||
assertEquals(routeIcon(AdministrationRoute.OTHER), routeIcon(null))
|
||||
}
|
||||
|
||||
// ── labMarkersForDose ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `same-day lab before and after the dose produce both markers`() {
|
||||
val markers = labMarkersForDose(
|
||||
listOf(lab(noon - 3_600_000L), lab(noon + 3_600_000L)), noon, ZONE
|
||||
)
|
||||
assertTrue(markers.first)
|
||||
assertTrue(markers.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lab of another day never marks the dose`() {
|
||||
val yesterday = noon - 24 * 3_600_000L
|
||||
val markers = labMarkersForDose(listOf(lab(yesterday)), noon, ZONE)
|
||||
assertFalse(markers.first)
|
||||
assertFalse(markers.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight boundary is a calendar-day comparison`() {
|
||||
// Lab à 23:50 la veille, dose à 00:10 : jours calendaires DIFFÉRENTS
|
||||
// → pas de marqueur (le lien clinique ne traverse pas minuit).
|
||||
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
val markers = labMarkersForDose(listOf(lab(lateEvening)), earlyMorning, ZONE)
|
||||
assertFalse(markers.first)
|
||||
assertFalse(markers.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lab exactly at dose time counts as AFTER (documented convention)`() {
|
||||
val markers = labMarkersForDose(listOf(lab(noon)), noon, ZONE)
|
||||
assertFalse(markers.first)
|
||||
assertTrue(markers.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no labs - no markers`() {
|
||||
val markers = labMarkersForDose(emptyList(), noon, ZONE)
|
||||
assertFalse(markers.first)
|
||||
assertFalse(markers.second)
|
||||
}
|
||||
}
|
||||
45
app/src/test/java/com/hormonetrack/ui/NextDoseLineTest.kt
Normal file
45
app/src/test/java/com/hormonetrack/ui/NextDoseLineTest.kt
Normal file
@ -0,0 +1,45 @@
|
||||
package com.hormonetrack.ui
|
||||
|
||||
import com.hormonetrack.ui.screens.nextDoseLine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Tests du helper PUR `nextDoseLine` (v1.9.6 — carte « Prochaines doses » de
|
||||
* l'Accueil, une ligne par traitement triée par prochaine prise). Zone et
|
||||
* locale FIXES pour un format déterministe.
|
||||
*/
|
||||
class NextDoseLineTest {
|
||||
|
||||
private val zone = ZoneId.of("Europe/Paris")
|
||||
private val locale = Locale.US
|
||||
// Template du cas ≥ 24 h (string EN `next_dose_days`)
|
||||
private val daysTemplate = "%1\$d d %2\$d h · %3\$s (%4\$s)"
|
||||
|
||||
private fun at(hoursFromNow: Long): Long =
|
||||
ZonedDateTime.of(2026, 9, 21, 12, 0, 0, 0, zone).toInstant().toEpochMilli() + hoursFromNow * 3_600_000L
|
||||
|
||||
@Test
|
||||
fun `au-dela de 24 h - jours + jour de semaine`() {
|
||||
// now = lundi 21/09 12:00 → fire = samedi 26/09 10:00 (118 h)
|
||||
val line = nextDoseLine(at(0), at(118), "Injection EEn 5mg 7d", daysTemplate, zone, locale)
|
||||
assertEquals("4 d 22 h · Sat 26 10:00 (Injection EEn 5mg 7d)", line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entre 1 h et 24 h - heures et minutes`() {
|
||||
// now = 12:00 → fire = 14:05 (2 h 05)
|
||||
val line = nextDoseLine(at(0), at(2) + 5 * 60_000L, "CPA", daysTemplate, zone, locale)
|
||||
assertEquals("2h05 · 14:05 (CPA)", line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moins d'une heure - minutes`() {
|
||||
// now = 12:00 → fire = 12:07
|
||||
val line = nextDoseLine(at(0), at(0) + 7 * 60_000L, "CPA", daysTemplate, zone, locale)
|
||||
assertEquals("7 min · 12:07 (CPA)", line)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
package com.hormonetrack.ui
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.ui.screens.treatmentsForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests de l'ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande
|
||||
* utilisatrice : « les traitements inactifs faudrait les placer à la fin
|
||||
* (tout en bas) de la liste ») — [treatmentsForDisplay] :
|
||||
* - les ACTIFS d'abord, les INACTIFS regroupés après ;
|
||||
* - tri STABLE : l'ordre relatif DANS chaque groupe est conservé (le tri
|
||||
* ne réordonne jamais les actifs entre eux, ni les inactifs entre eux) ;
|
||||
* - cas limites : liste vide, tout actif, tout inactif (identité).
|
||||
*
|
||||
* PUR (aucune dépendance Android) ; miroir web `treatmentsForDisplay`
|
||||
* (js/data/models.js, tests/treatments-display.test.js).
|
||||
*/
|
||||
class TreatmentsDisplayTest {
|
||||
|
||||
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
|
||||
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
isActive = isActive, createdAt = 0
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `inactive treatments are grouped at the end`() {
|
||||
val mixed = listOf(
|
||||
tr(1, "EEn", true),
|
||||
tr(2, "EV-old", false),
|
||||
tr(3, "CPA", true),
|
||||
tr(4, "EEn-old", false),
|
||||
tr(5, "Ritaline", true)
|
||||
)
|
||||
val display = treatmentsForDisplay(mixed)
|
||||
assertEquals(
|
||||
"actifs d'abord (ordre conservé), inactifs après (ordre conservé)",
|
||||
listOf(1L, 3L, 5L, 2L, 4L),
|
||||
display.map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
"aucun inactif avant un actif",
|
||||
0,
|
||||
display.indexOfFirst { !it.isActive }.let { firstInactive ->
|
||||
if (firstInactive < 0) 0
|
||||
else display.subList(firstInactive, display.size).count { it.isActive }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordering inside each group is preserved (stable sort)`() {
|
||||
val mixed = listOf(
|
||||
tr(10, "A-inactif", false),
|
||||
tr(11, "B-actif", true),
|
||||
tr(12, "C-inactif", false),
|
||||
tr(13, "D-actif", true)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(11L, 13L, 10L, 12L),
|
||||
treatmentsForDisplay(mixed).map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all active - order unchanged`() {
|
||||
val all = listOf(tr(3, "C", true), tr(1, "A", true), tr(2, "B", true))
|
||||
assertEquals(listOf(3L, 1L, 2L), treatmentsForDisplay(all).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all inactive - order unchanged`() {
|
||||
val all = listOf(tr(2, "B", false), tr(1, "A", false))
|
||||
assertEquals(listOf(2L, 1L), treatmentsForDisplay(all).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty list stays empty`() {
|
||||
assertEquals(0, treatmentsForDisplay(emptyList()).size)
|
||||
}
|
||||
}
|
||||
@ -44,4 +44,49 @@ class ExportFileNamesTest {
|
||||
.matches(Regex("hormonetrack-backup-\\d{8}\\.json"))
|
||||
)
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `auto backup filename carries date AND time (LocalDateTime required)`() {
|
||||
// Règle anti-#48 : le pattern yyyyMMdd-HHmm contient un champ horaire →
|
||||
// la signature DOIT prendre un LocalDateTime (LocalDate = crash au tap).
|
||||
val name = ExportFileNames.autoBackupFileName(
|
||||
LocalDateTime.of(2026, 9, 13, 18, 30)
|
||||
)
|
||||
assertEquals("hormonetrack-auto-20260913-1830.json", name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto backup default filename never throws and matches the expected shape`() {
|
||||
assertTrue(
|
||||
ExportFileNames.autoBackupFileName()
|
||||
.matches(Regex("hormonetrack-auto-\\d{8}-\\d{4}\\.json"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse auto backup timestamp round-trips with the generator`() {
|
||||
val name = ExportFileNames.autoBackupFileName(LocalDateTime.of(2026, 9, 13, 8, 5))
|
||||
val parsed = ExportFileNames.parseAutoBackupTimestamp(name)
|
||||
assertEquals(LocalDateTime.of(2026, 9, 13, 8, 5), parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse returns null for everything that is NOT an auto backup`() {
|
||||
// Exports MANUELS (jamais supprimés par la rétention), logs, étrangers
|
||||
assertEquals(
|
||||
null,
|
||||
ExportFileNames.parseAutoBackupTimestamp("hormonetrack-backup-20260913.json")
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
ExportFileNames.parseAutoBackupTimestamp("hormonetrack-logs-20260913-1800.txt")
|
||||
)
|
||||
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("mes-notes.json"))
|
||||
// Presque-corrects mais MALFORMÉS : jamais supprimés non plus
|
||||
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("hormonetrack-auto-pas-unedate.json"))
|
||||
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("hormonetrack-auto-20260913-1830"))
|
||||
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("hormonetrack-auto-.json"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,677 @@
|
||||
# Changelog — HormoneTrack
|
||||
|
||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.9`, …).
|
||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
||||
|
||||
## [1.13.0] — 2026-09-30 (versionCode 50)
|
||||
|
||||
### Ajouté — la reco de prise de sang devient prédictive (3 niveaux clairs)
|
||||
|
||||
- **Valeur E2 attendue au creux recommandé** : la carte (Accueil + Analyses)
|
||||
affiche désormais « ≈ X pg/mL » quand l'auto-calibration fournit ses
|
||||
facteurs — sinon rien (une valeur brute non calibrée serait trompeuse).
|
||||
Tu sais AVANT de piquer si le creux tombera dans ta cible.
|
||||
- **Régime POOLÉ entre traitements identiques** : `pooledRegimeDoses` — les
|
||||
doses de tous les traitements partageant (ester effectif, mg) forment UNE
|
||||
séquence. Un re-parenting d'historique (créer un « … - old » et y
|
||||
déplacer des doses) ne fait plus redémarrer la stabilisation. Garde-fous
|
||||
épinglés : ester ≠ → jamais poolé (transition EV→EEn) ; dose ≠ → exclue
|
||||
(le 8 mg casse le régime, trou conservateur).
|
||||
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle « je
|
||||
veux être dans [basse, haute] au creux recommandé » — la prédiction est
|
||||
colorée dans/hors cible sur la carte. ⚠️ **DISTINCT des seuils d'alerte**
|
||||
(§9.bis) : les seuils surveillent le niveau en continu et notifient ; la
|
||||
cible est une référence de lab, elle ne notifie JAMAIS. Voyage dans le
|
||||
backup (champs optionnels rétrocompatibles — un backup ancien n'efface
|
||||
pas la cible locale).
|
||||
- **Régression n°7** épinglée sur données réelles (`backup-v1.12.0.json`,
|
||||
gitigné — l'export avec le re-parenting « 6d-old ») : le régime poolé
|
||||
traverse la frontière de re-parenting et reste arrêté par le 8 mg.
|
||||
|
||||
Vérifié : **284 tests JVM** (250 sans données locales) + **14 tests UI** +
|
||||
lint verts ; **web 199 tests + E2E verts** (`check.sh`).
|
||||
|
||||
## [1.12.0] — 2026-09-29 (versionCode 49)
|
||||
|
||||
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
|
||||
|
||||
- **Remontée** : « une injection qui n'est plus active est toujours prise en
|
||||
compte dans le graphique de simulations si elle contient une posologie et
|
||||
qu'elle a déjà été utilisée — un traitement mis en inactif ne devrait
|
||||
plus être pris en compte pour les futures prédictions ».
|
||||
- **Cause** : `generateForecastDoses` (source unique des créneaux
|
||||
prévisionnels) ne vérifiait pas l'archivage ; de plus l'horizon du chip
|
||||
Prévision du graphique incluait les Posologies INACTIVES.
|
||||
- **Fix** : garde `!isActive` dans le moteur (l'historique reste simulé —
|
||||
§6.bis inchangé — seul le FUTUR s'arrête, comme les rappels depuis
|
||||
v1.4.0) + horizon/chip Prévision calculés sur les ACTIFS uniquement.
|
||||
**Miroir web appliqué** (`js/pk/pk-engine.js` + `js/ui/chart.js`).
|
||||
- Tests : 3 tests JVM (`ForecastDosesGuardTest`) + 3 miroirs web ;
|
||||
émulateur avec données modifiées (2 inactifs à Posologie 7 j) :
|
||||
Prévision on/off sans crash.
|
||||
|
||||
### Ajouté — icônes de forme de prise dans la page Doses
|
||||
|
||||
- Chaque ligne de dose porte désormais l'icône de sa forme : 💊-icône pilule
|
||||
(oral), seringue (injection IM et SC — teinte distincte pour la SC),
|
||||
goutte (gel), sparadrap (patch), compte-gouttes (autre). Miroir web :
|
||||
émojis (`routeGlyph`, même mapping).
|
||||
|
||||
### Ajouté — marqueurs « prise de sang le même jour » sur les lignes de doses
|
||||
|
||||
- Une analyse de sang effectuée LE MÊME JOUR qu'une dose affiche un petit
|
||||
encart latéral 🧪↑ (avant la dose) et/ou 🧪↓ (après) — pour visualiser
|
||||
les prises de sang dans le tableau de dosages (demande utilisatrice).
|
||||
Fenêtre = jour calendaire LOCAL (au-delà le lien clinique devient ténu).
|
||||
Helper pur `labMarkersForDose` (Kotlin + miroir web) — 7 tests JVM
|
||||
(`DosesExtrasTest` : mapping icônes + marqueurs, frontière minuit,
|
||||
convention « à l'heure de la dose = après ») + 10 miroirs web.
|
||||
- Vérifié émulateur avec données réelles : glyphes en tête de liste,
|
||||
marqueur « Blood test later the same day » sur la dose du 10 sept
|
||||
(analyse 35 min après).
|
||||
|
||||
Vérifié : **274 tests JVM** (240 sans données locales) + **14 tests UI** +
|
||||
lint verts ; **web 194 tests + E2E verts** (`check.sh`).
|
||||
|
||||
## [1.11.0] — 2026-09-29 (versionCode 48)
|
||||
|
||||
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
|
||||
|
||||
- **Demande** : « les traitements inactifs sont peu discernables par rapport
|
||||
aux traitements actifs — les placer à la fin (tout en bas) de la liste
|
||||
pour éviter les confusions ».
|
||||
- **Tri** : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**, sous
|
||||
un en-tête de section « Traitements inactifs » (n'apparaît que s'il
|
||||
existe des inactifs). Tri STABLE : l'ordre relatif à l'intérieur de
|
||||
chaque groupe est conservé — le tri ne réordonne jamais les actifs entre
|
||||
eux, ni les inactifs entre eux.
|
||||
- **Distinction visuelle renforcée** : les cartes inactives sont ATTÉNUÉES
|
||||
(alpha 0,55) en plus du badge « inactif » — second signal demandé
|
||||
(« peut-être les rendre plus distincts ») ; elles restent lisibles et
|
||||
tappables (l'édition d'un traitement archivé doit rester possible).
|
||||
- Helper pur `treatmentsForDisplay` (Kotlin + **miroir web** en
|
||||
`js/data/models.js`) — 5 tests JVM (`TreatmentsDisplayTest`) + 4 tests
|
||||
web (`tests/treatments-display.test.js`).
|
||||
- Vérifié émulateur avec données réelles seedées : les 5 actifs en haut,
|
||||
en-tête puis les 2 inactifs (EV-old, EEn-old) tout en bas.
|
||||
|
||||
Vérifié : **264 tests JVM** (230 sans données locales) + **14 tests UI** +
|
||||
lint verts ; **web 184 tests + E2E verts** (`check.sh`).
|
||||
|
||||
## [1.10.1] — 2026-09-28 (versionCode 47)
|
||||
|
||||
### Amélioré — durcissement du système de rappels (revue de code post-v1.10.0)
|
||||
|
||||
- **`launchMode="singleTop"` sur MainActivity** : un tap sur la notification
|
||||
quand l'app est DÉJÀ ouverte délivre désormais `onNewIntent` (dialog de
|
||||
saisie ouvert, extras traités) **sans détruire/recréer l'activité** —
|
||||
l'ancien combo `standard` + `CLEAR_TOP` recréait tout et rendait le chemin
|
||||
`onNewIntent` de la v1.10.0 inopérant (code mort).
|
||||
- **Instant PLANIFIÉ dans l'alarme (`EXTRA_PLANNED_AT`)** : le garde
|
||||
« créneau déjà honoré » (v1.10.0) évaluait le jour au moment du FEU — une
|
||||
alarme inexacte (`setWindow`) ou retardée (Doze) glissant après minuit
|
||||
évaluait le mauvais jour (23:58 → 00:02). L'instant planifié voyage
|
||||
désormais dans l'intent de l'alarme, le snooze le FORWARDE tel quel, et
|
||||
le garde évalue toujours le jour du CRÉNEAU. Vérifié sur émulateur par
|
||||
broadcast avec `planned_at` daté d'hier → notification correctement
|
||||
sautée.
|
||||
- **`BootReceiver` consolidé** (dette §20.bis #2) : `goAsync` + coroutine IO
|
||||
(pattern ReminderReceiver) — remplace le `Thread` + double `runBlocking`
|
||||
de la v1.0 ; une seule lecture séquentielle des DAO, `finish()` garanti.
|
||||
- Audit doc : comptes de tests périmés corrigés (§14 note #64, §16.ter).
|
||||
|
||||
Vérifié : 259 tests JVM + 14 tests UI + lint verts ; scénarios émulateur
|
||||
(singleTop + planned_at). Web non concerné (aucune logique miroir touchée).
|
||||
|
||||
## [1.10.0] — 2026-09-27 (versionCode 46)
|
||||
|
||||
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
|
||||
|
||||
- **Remontée** : « la recommandation suggère une date antérieure à la
|
||||
stabilisation des taux » (données réelles de la remontée → 6ᵉ régression
|
||||
épinglée `local-test-data/backup-v1.9.8.json`, gitigné).
|
||||
- **Cause (double)** : le « creux » était le **minimum de la fenêtre entière**
|
||||
(créneau N−1 → créneau N). Pour un ester à montée lente (EEn : pic ~J+5 ≈
|
||||
intervalle 7 j), ce minimum tombe **juste après l'injection PRÉCÉDENTE**
|
||||
(creux d'absorption, ~3 h) — pas juste avant la suivante : la date proposée
|
||||
était gouvernée par l'injection N−1, elle-même antérieure à la
|
||||
stabilisation, et le texte « juste avant ton injection du … » était faux de
|
||||
plusieurs jours. Balayage sur ±60 jours de `now` (test) : creux 5 oct 02:32
|
||||
vs stabilisation 7 oct 04:03.
|
||||
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
|
||||
courbe avant l'injection) — restaure le principe v1.8.0 documenté (« juste
|
||||
avant l'injection suivante, le moment le plus comparable »), rend la carte
|
||||
cohérente (creux 46 min avant le créneau étiqueté) et le garde de
|
||||
stabilisation s'exprime sur l'instant réellement proposé. Pour les esters à
|
||||
t½ courte (EV), le point pré-injection EST le minimum de fenêtre :
|
||||
comportement inchangé. **Miroir web appliqué** (`js/pk/lab-timing.js`).
|
||||
- Tests : régression n°6 data-driven (4 tests dont un balayage de `now` sur
|
||||
±60 j — l'invariant « creux ≥ stabilisation » doit tenir À TOUT INSTANT) ;
|
||||
les 17 LabTimingTest passent inchangés (leur sémantique était celle du fix).
|
||||
|
||||
### Corrigé — le dialog de dose se rouvrait à chaque retour sur l'accueil (#69)
|
||||
|
||||
- **Remontée** : tap sur la notification de rappel → le dialog de saisie
|
||||
s'ouvre ; le fermer puis changer d'onglet et revenir sur l'accueil le
|
||||
ROUVRAIT à chaque fois (reproduit et revérifié sur émulateur).
|
||||
- **Cause** : la demande vivait au niveau ACTIVITÉ (paramètre d'intent) mais
|
||||
la consommation vivait dans un `LaunchedEffect` de Home — quitter l'onglet
|
||||
détruit les `remember` locaux, le paramètre survivait → à chaque
|
||||
ré-entrée en composition, le dialog se rouvrait.
|
||||
- **Fix** : la demande est un état `MutableState<Long?>` détenu par
|
||||
MainActivity (survit aux onglets), consommé UNE fois par HomeScreen ;
|
||||
les extras sont retirés de l'intent après lecture (un recreate ne doit
|
||||
jamais rouvrir une notification traitée) ; et un tap notification quand
|
||||
l'app est déjà ouverte ouvre désormais aussi le dialog (avant :
|
||||
silencieusement perdu).
|
||||
- Tests : nouveau test UI instrumenté `LogDoseRequestTest` (ouverture 1× +
|
||||
plus jamais après aller-retour d'onglets) + vérification émulateur.
|
||||
|
||||
### Ajouté — la notification de rappel saute un créneau déjà honoré
|
||||
|
||||
- **Demande** : si la dose prévue du jour est saisie AVANT l'heure de
|
||||
rappel, la notification ne doit pas sonner pour une prise déjà faite ; le
|
||||
rappel suivant repart au créneau suivant (idem pour le quotidien des
|
||||
oraux sans Posologie).
|
||||
- **Implémentation** : garde au moment du déclenchement —
|
||||
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL,
|
||||
testable) consultée par `ReminderReceiver` : dose loguée aujourd'hui →
|
||||
notification sautée (AppLog) + re-programmation du créneau suivant dans
|
||||
tous les cas. Robuste quel que soit le moment de la saisie.
|
||||
- Tests : 6 tests JVM (`ReminderLoggedDayTest` : même jour, veille,
|
||||
frontière minuit, autre traitement, liste vide, mélange) + vérification
|
||||
A/B sur émulateur (EEn dose-loguée-ce-jour → 0 notif ; Fluoxetine sans
|
||||
dose → 1 notif).
|
||||
|
||||
Vérifié : **259 tests unitaires** (dont régression n°6 sur données réelles)
|
||||
+ **14 tests UI** + lint vert ; web miroir : **180 tests + E2E verts**
|
||||
(`check.sh`). Version web alignée au moment de la release (checklist 4.bis).
|
||||
|
||||
## [1.9.8] — 2026-09-22 (versionCode 45)
|
||||
|
||||
### Ajouté — tests UI Compose, signature release dédiée, README anglais
|
||||
|
||||
- **13 tests UI instrumentés** (navigation 5 onglets, dialog « Nouveautés »
|
||||
— épinglage du fix v1.3.2 « ne réapparaît pas après dismiss » —, sections
|
||||
Paramètres, version installée = BuildConfig : le bug #46 « versions jamais
|
||||
bumpées » ne peut plus revenir silencieusement). Déterministes : permission
|
||||
de notifications pré-grantée, zéro dépendance aux données, indépendants de
|
||||
la langue FR/EN. Exécution : `./gradlew connectedDebugAndroidTest`.
|
||||
- **Signature release dédiée, prête à l'emploi** : `scripts/make-release-keystore.sh`
|
||||
crée le keystore + `keystore.properties` (gitignorés) ; `assembleRelease`
|
||||
l'utilise dès qu'il existe (sinon clé debug inchangée). Migration
|
||||
téléphone documentée (export JSON → désinstallation → réinstallation →
|
||||
réimport). Aucun changement pour l'utilisatrice tant que la bascule n'est
|
||||
pas faite.
|
||||
- **README anglais** ([README.en.md](README.en.md)) — lien FR/EN en tête de
|
||||
chaque fichier ; la doc de développement reste en français.
|
||||
|
||||
### Modifié — écran Paramètres découpé (maintenance)
|
||||
|
||||
- `SettingsScreen.kt` (840 lignes) devient un orchestrateur de 377 lignes +
|
||||
11 cartes thématiques dans `ui/screens/settings/` (langue, calibration,
|
||||
modèle T, seuils, fuseau, rappels, sauvegarde, auto-backup, logs, à propos).
|
||||
**Comportement et rendu strictement identiques** (déplacement pur) ;
|
||||
validé par les tests UI + smoke test RELEASE sur émulateur (toutes les
|
||||
sections, version 1.9.8, 0 crash — anti-bug #64).
|
||||
- `ChartScreen` reste volontairement entier : sa garde de source (#54)
|
||||
exige le bloc de gestes dans ce fichier.
|
||||
|
||||
Vérifié : 249 tests unitaires + 13 tests UI verts, lint vert, APK debug +
|
||||
release (2,7 Mo) buildés, smoke test release complet (accueil, 5 onglets,
|
||||
Paramètres, éditeur de traitement, dialog de dose — 0 crash).
|
||||
|
||||
## [1.9.7] — 2026-09-20 (versionCode 44)
|
||||
|
||||
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
|
||||
|
||||
- **Remontée** : « la suggestion de prochain test sanguin change tout le
|
||||
temps à chaque injection si l'injection n'est pas faite pile à la même
|
||||
heure ou si elle est faite 30 minutes plus tard que la précédente ».
|
||||
- **Cause** : la règle de stabilisation v1.8.1 comparait l'écart inter-doses
|
||||
**EXACTEMENT** à l'écart précédent (au ms près) — un log à 12:30 au lieu
|
||||
de 12:00 cassait le régime, repoussant la stabilisation de 5 × t½ à
|
||||
CHAQUE injection : le creux recommandé fuyait vers l'avant sans jamais
|
||||
se fixer.
|
||||
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
|
||||
Posologie ± 24 h** (`GAP_TOLERANCE_MS` — « je m'injecte le même jour, à
|
||||
l'heure près »). La fenêtre se réfère à l'INTERVALLE THÉORIQUE, pas à
|
||||
l'écart précédent : des logs à 6,8 j puis 7,2 j ne se déstabilisent plus
|
||||
en cascade. Un vrai changement de créneau (2 j au lieu de 7 j) reste
|
||||
hors fenêtre et réinitialise, comme avant. Le créneau affiché se cale
|
||||
toujours sur la dernière dose réelle (voulu).
|
||||
- Tests : le test v1.8.1 « interval change » ré-épinglé (NOW+21 j) + 2
|
||||
nouveaux (flou d'heure ± 23 h → PAS de reset ; écart 25 h → reset).
|
||||
249 tests verts + lint. Web miroir v1.9.7 (12 tests lab-timing).
|
||||
|
||||
## [1.9.6] — 2026-09-20 (versionCode 43)
|
||||
|
||||
### Modifié — la carte de l'Accueil liste TOUTES les prochaines prises
|
||||
|
||||
- **Remontée** : « si une injection et une prise d'un autre médicament ont
|
||||
lieu en même temps… le rappel ne montre que la prise qui vient, non
|
||||
toutes les prises du jour » — avec une prise quotidienne (CPA), le
|
||||
rappel de l'injection hebdomadaire (EEn) « n'arrive qu'après celui de la
|
||||
CPA, du coup je ne le vois quasiment pas ».
|
||||
- **Fix** : la carte « Prochaine dose » devient « **Prochaines doses** »
|
||||
(titre pluriel dès 2 lignes) — **une ligne par traitement, TRIÉES par
|
||||
prochaine prise** ; la 1ʳᵉ ligne (la plus proche) reste mise en avant.
|
||||
Format de ligne inchangé (`Xj Yh · ven. 25 10:00 (nom)`, `XhMM · HH:mm`,
|
||||
`N min`). Le CPA se lit désormais AU-DESSUS de l'EEn du samedi, jamais à
|
||||
sa place.
|
||||
- Les NOTIFICATIONS de rappel sont inchangées (une alarme par traitement
|
||||
— le problème était purement l'affichage de l'Accueil).
|
||||
- Moteur : helper PUR `nextDoseLine` (HomeScreen.kt, locale/zone
|
||||
paramétrables, template jours localisé) — 3 tests (NextDoseLineTest).
|
||||
247 tests verts + lint.
|
||||
|
||||
## [1.9.5] — 2026-09-20 (versionCode 42)
|
||||
|
||||
### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts)
|
||||
|
||||
- **#67 — les ticks X ignoraient le fuseau choisi** : `drawXLabels` formatait
|
||||
les labels DANS le fuseau choisi mais appelait `xLabelTicks` SANS le
|
||||
passer — les ticks étaient alignés sur le fuseau de l'appareil (heures
|
||||
décalées dès que les deux divergent, contredisant le fix #55). Fix :
|
||||
zone passée + **pas avancé par Calendar** (un `+24 h` fixe dérivait d'1 h
|
||||
aux transitions DST) ; test DST épinglé (Europe/Paris, 29/03/2026).
|
||||
- **`EstrannaiseCloud.compute` : paramètre `nowMs` mort supprimé** (jamais
|
||||
lu — le KDoc prétendait une sémantique que le code n'avait pas) ;
|
||||
appelants + tests mis à jour.
|
||||
|
||||
### Amélioré — perf (suite de l'optimisation v1.8.2)
|
||||
|
||||
- **Contexte moteur pré-calculé** (`E2Context` + `prepareE2Context`) : le
|
||||
tracé labs appelle `e2At` par point de grille (720 pts) — chaque appel
|
||||
reconstruisait les maps internes (groupBy/cutoffs/Bateman), diluant
|
||||
l'optimisation v1.8.2. Préparé UNE fois par courbe labs ET par
|
||||
calibration par ester ; **résultat bit-identique** (244 tests verts).
|
||||
- **Canvas** : Paints texte MÉMOÏSÉS par (couleur, taille, align, gras)
|
||||
(une alloc par extremum/label × frame → une par clé) ; `detectExtrema`
|
||||
mémoïsé par série (scan O(n) par frame → par changement de séries) ;
|
||||
formatters X/marqueurs mémoïsés (SimpleDateFormat par frame → par
|
||||
fuseau).
|
||||
|
||||
### Documenté — commentaires et docs rattrapés après les refontes
|
||||
|
||||
- Commentaires à jour avec le code : `PKPresets.kt` (l'ODS n'existe plus
|
||||
au runtime), `Treatment.kt` (ESE analytique 6 esters),
|
||||
`LabTimingTest.kt` (t½ analytique), doc §7.2/§7.3 (asset historique,
|
||||
cutoff = 10 × t½ depuis v1.9.0).
|
||||
- Règle anti-#59 ré-appliquée : labels de modèle via `modelLabelRes` (le
|
||||
`when` inline de l'éditeur violait la règle) ; literals « ESE »/« TFS »/
|
||||
« WHS » remplacés par `PKModels.*` (ChartScreen/CurveChart) ;
|
||||
`scheduler2` supprimé (le scheduler de l'écran existe déjà).
|
||||
|
||||
## [1.9.4] — 2026-09-19 (versionCode 41)
|
||||
|
||||
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
|
||||
|
||||
- **Remontée** : depuis l'ajout du modèle source Estrannaise (v1.9.0), les
|
||||
presets de création n'offraient toujours que EV/EU/EEn — « les presets
|
||||
n'ont pas été mis à jour lors de l'ajout du modèle source pour ESE ».
|
||||
- **Presets (`PKPresets.kt`, 25 entrées)** : 3 presets ESE ajoutés au
|
||||
même niveau que les autres — EC huile (5 mg IM), EB (1 mg IM), EUCS
|
||||
undécylate suspension cristalline (10 mg SC, ester exclusif à ce fit).
|
||||
Champs Bateman informatifs = t½ terminales des fits (pas utilisés au
|
||||
calcul — le dispatch passe par la forme close).
|
||||
- **Éditeur (`TreatmentEditorScreen.kt`)** : la liste des esters suit la
|
||||
couverture RÉELLE du modèle choisi (`Esters.choicesForModel` — même
|
||||
liste que l'override du DoseDialog) : ESE liste ses 6 esters
|
||||
injectables directement à la création. Au changement de modèle, si
|
||||
l'ester courant sort de la couverture (ex. EUCS + TFS), il rebascule
|
||||
sur EV — garde anti « 0 pg/mL ».
|
||||
- **Tests** : `PKPresetsTest` (nouveau, 6 tests — miroir
|
||||
`tests/presets.test.js` web) : couverture des 6 esters ESE, unicité,
|
||||
TFS (7) / WHSAH (6) inchangés, garde « aucun preset injectable hors
|
||||
couverture de son modèle », plausibilité des t½ informatives.
|
||||
243 tests verts + lint.
|
||||
- **Doc** : doublon de la section §7.12 supprimé (collé deux fois).
|
||||
|
||||
## [1.9.2] — 2026-09-19 (versionCode 40)
|
||||
|
||||
### Corrigé — le nuage d'incertitude exigeait un traitement STOCKÉ en ESE
|
||||
|
||||
- **Bug remonté** : activer Estrannaise dans le graphique puis le nuage
|
||||
n'affichait rien tant qu'aucun traitement n'était **stocké** avec le
|
||||
modèle ESE — « la seule méthode trouvée était de mettre un traitement en
|
||||
cours sur le modèle ESE, mais ça ne devrait pas être un prérequis ».
|
||||
- **Cause** : le filtre du nuage testait le `pkModel` **STOCKÉ** du
|
||||
traitement, alors que la courbe ESE affichée redessine toutes les doses
|
||||
E2 avec `modelOverride = "ESE"` (peu importe le modèle stocké) — courbe
|
||||
et nuage n'utilisaient pas la même définition de « quelles doses sont
|
||||
tracées en ESE ».
|
||||
- **Fix** : le nuage couvre **toutes les doses E2 à profil injectable dont
|
||||
l'ester effectif (override compris) est couvert par le fit Estrannaise**
|
||||
— indépendamment du modèle stocké. L'exclusivité ESE reste portée par le
|
||||
chip (activable seulement si ESE est affiché). L'oral Bateman reste hors
|
||||
nuage (pas d'ester échantillonnable). Les traitements inactifs sont
|
||||
inclus (§6.bis : la courbe ESE les trace aussi).
|
||||
- 2 tests réécrits (237 au total / 207 sans les données locales) :
|
||||
« doses d'un traitement TFS couvertes quand ESE est affiché » (le fix,
|
||||
épinglé des deux plateformes) ; oral seul → vide (conservé).
|
||||
- Validé émulateur §16.ter sur le profil réel (EEn stocké TFS, ESE
|
||||
activé) : nuage visible autour de la courbe ESE, 0 crash.
|
||||
|
||||
## [1.9.1] — 2026-09-19 (versionCode 39)
|
||||
|
||||
### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée
|
||||
|
||||
- **Bug remonté** : le nuage ne s'activait « que autour du tracé, pas autour
|
||||
du modèle Estrannaise » — la courbe ESE affichée est **calibrée**
|
||||
(scalePerEster des labs) mais le nuage était tracé **brut** : les deux
|
||||
flottaient à des échelles différentes.
|
||||
- **Fix** : le nuage reçoit le MÊME `scalePerEster` que la courbe ESE
|
||||
(autoByModel ESE) — il entoure désormais la courbe calibrée (Android +
|
||||
web en miroir).
|
||||
- 1 nouveau test : nuage calibré ×2 = nuage brut ×2 (le nuage suit la
|
||||
calibration).
|
||||
|
||||
### Note — pourquoi le taux affiché a bougé depuis v1.8.2 (+4 pg/mL)
|
||||
|
||||
- v1.8.2 a corrigé l'**arrondi de la médiane de calibration** (troncature
|
||||
flottante → arrondi au plus proche) : une échelle dont la médiane brute
|
||||
tombait à 0,8999… était tronquée en 0,89 au lieu de 0,90. Avec le modèle
|
||||
ESE analytique (v1.9.0) déplaçant ce dernier bit, le piège est devenu
|
||||
visible : les échelles auto-calibrées ont bougé d'un cran d'affichage →
|
||||
le niveau estimé a monté d'environ 1 % (+4 pg/mL sur ~320).
|
||||
- **Le niveau actuel est le CORRIGÉ** (l'arrondi est plus juste que la
|
||||
troncature). Le web applique la même règle depuis v1.9.1 (harmonisation
|
||||
des niveaux entre plateformes).
|
||||
|
||||
## [1.9.0] — 2026-09-18 (versionCode 38)
|
||||
|
||||
### Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
|
||||
|
||||
- **Le modèle ESE n'est plus les tables horaires ODS** : il est désormais la
|
||||
**forme close 3C publiée par estrannaise.js** (les sources originelles
|
||||
dont l'ODS n'était que l'échantillonnage horaire) — fidélité épinglée :
|
||||
**RMS 0,00** vs les tables sur 0→200 j, écart de pic ≤ 0,1 %.
|
||||
- **Décision** (utilisatrice) : « tous les modèles ont maintenant leurs
|
||||
sources originelles — on abandonne complètement les liens avec le fichier
|
||||
ODS ». Concrètement : plus aucune lecture des tables au runtime
|
||||
(`concentrationOfDose` dispatche ESE → forme close ; `cutoffHours` ESE →
|
||||
10 × t½ terminale analytique ; l'asset `pk_profiles.json` quitte l'APK,
|
||||
−550 Ko, et ne sert plus qu'aux tests de fidélité).
|
||||
- **6 esters injectables couverts** (EV, EU, EEn, EC, EB + **EUCS**,
|
||||
undécylate suspension cristalline, exclusif à ce fit) — l'ancien ESE
|
||||
n'en couvrait que 3. TFS reste à 7 (liste explicite : EUCS n'a pas de
|
||||
V3C TFS), WHSAH à 6.
|
||||
- **NUAGE D'INCERTITUDE (exclusif ESE)** : Estrannaise publie le posterior
|
||||
MCMC de ses paramètres (313 échantillons/ester, asset ≈ 48 Ko) — chip
|
||||
`Nuage` (off par défaut, activable à volonté, actif SEULEMENT si ESE est
|
||||
affiché, coupure automatique si ESE est éteint) → nuage diffus de 32
|
||||
courbes du posterior montrant la plage d'imprécision du modèle, comme
|
||||
sur le site estrannaise. Dessiné en alpha faible sous les courbes, hors
|
||||
échelles/labels/extrema.
|
||||
- t½ terminale ESE désormais **analytique** (ln2/min(k1,k2,k3)) — la
|
||||
recommandation de prise de sang n'estime plus la t½ par pente de table.
|
||||
- **11 nouveaux tests** (234 au total / 204 sans les données locales) :
|
||||
fidélité (RMS, pics ±0,2 %), cas dégénérés (taux égaux → limites sans
|
||||
NaN), MCMC 313 × 6, nuage (32 courbes à dispersion réelle, fenêtre,
|
||||
exclusivité ESE, gardes), choicesForModel ESE=6, t½ analytique.
|
||||
- **Porté web** (web v1.9.0, versions sync) : `estrannaise-models.js` +
|
||||
`estrannaise-cloud.js` miroirs, nuage canvas, fetch MCMC au démarrage
|
||||
(l'ODS web quitte aussi le runtime), 11 tests miroirs + E2E nuage.
|
||||
- Validé émulateur §16.ter (APK release, seed réel v1.7.0) : courbe ESE
|
||||
analytique affichée, chips Extend/Cloud présents, recommandation
|
||||
de prise de sang fonctionnelle, 0 crash.
|
||||
|
||||
## [1.8.2] — 2026-09-17 (versionCode 37)
|
||||
|
||||
### Maintenance — performance du moteur + fraîcheur de l'interface
|
||||
|
||||
Aucune nouvelle fonctionnalité : session d'audit complet (code + doc), avec
|
||||
les optimisations et corrections sûres qui en sont sorties.
|
||||
|
||||
### Performance du moteur PK
|
||||
- `e2At`/`computeCurve` : `cutoffHours` et les paramètres Bateman
|
||||
(bisection 50 itérations) ne sont plus recalculés à chaque (point ×
|
||||
dose × traitement) — pré-calculés par traitement, et Bateman devient
|
||||
**paresseux** (inutile pour les traitements à profil). Le chemin le plus
|
||||
chaud (tracé labs : ~720 points × N × D appels de cutoff par refresh)
|
||||
s'allège d'un facteur P × T. Doses pré-groupées par traitement (fin des
|
||||
scans complets). **Résultat identique** (ordre de sommation préservé —
|
||||
223 tests verts inchangés).
|
||||
- `AppLog` : purge du buffer sans recopie à chaque ligne loggée.
|
||||
- `ChangelogHelper` : Regex précompilées (dialog « Nouveautés »).
|
||||
- `CurveChart` : SimpleDateFormat créé hors boucle de dessin.
|
||||
|
||||
### Corrigé — fraîcheur de l'interface
|
||||
- **Accueil** : éditer un scaleFactor ou le modèle T rafraîchit la courbe
|
||||
immédiatement (clés du producteur incomplètes — l'ancien trio attendait
|
||||
le tick de 60 s).
|
||||
- **Graphiques** : éditer la VALEUR d'une analyse rafraîchit les courbes
|
||||
(la clé n'était que le NOMBRE d'analyses).
|
||||
|
||||
### Maintenance — code & documentation
|
||||
- Code mort supprimé (Repository/DAO sans appelants, `currentLevel`,
|
||||
variable et scheduler dupliqués), imports morts/redondants nettoyés,
|
||||
médiane de calibration factorisée (4 copies → 1 helper),
|
||||
qualifications raccourcies, en-têtes/KDoc obsolètes mis à jour.
|
||||
- Documentation : ~35 corrections (compteurs de tests obsolètes, TOC et
|
||||
ancres réparées, inventaire des suites de tests à jour et fusionné,
|
||||
titres CHANGELOG doublés supprimés, versions « à jour v1.2.0 » /
|
||||
« Gradle 8.9 » / « API 34 » corrigées, fautes).
|
||||
|
||||
## [1.8.1] — 2026-09-17 (versionCode 36)
|
||||
|
||||
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
|
||||
|
||||
- **Bug remonté** : la recommandation disait « stabilisée depuis février »
|
||||
alors que l'ester, le dosage ET la posologie venaient de changer —
|
||||
l'ancien calcul partait de la **1ʳᵉ dose du traitement** (proxy aveugle
|
||||
aux changements récents).
|
||||
- **Fix** : le régime courant = la **séquence terminale de doses** à
|
||||
(ester effectif, dose mg, **écart inter-doses**) constants — tout
|
||||
changement récent réinitialise la stabilisation : la recommandation saute
|
||||
au premier creux **après** la stabilisation du nouveau régime (règle des
|
||||
~5 demi-vies conservée, intervalle comparé exactement). La carte porte
|
||||
désormais l'**ester effectif** de la dernière dose (override compris).
|
||||
- **Nouveau : suggestion aussi sur l'Accueil** (demande) — carte compacte
|
||||
(creux daté + créneau associé), avec la mention de stabilisation
|
||||
seulement si le régime n'était pas déjà stable.
|
||||
- Validé émulateur sur l'export réel : la carte passe de « stabilisé depuis
|
||||
le 06/02 » (v1.8.0) à « **EEN pas stabilisé avant le 29/09 — premier
|
||||
creux fiable après ton dernier changement** », creux recommandé 27/09
|
||||
22:43, 0 crash.
|
||||
- 3 nouveaux tests (223 au total / 193 sans les données locales) :
|
||||
changement de dose / d'intervalle / d'ester → réinitialisation épinglée.
|
||||
- **Web** : même fix en miroir + encart Accueil (web v1.8.1, versions sync).
|
||||
|
||||
## [1.8.0] — 2026-09-17 (versionCode 35)
|
||||
|
||||
### Prochaine prise de sang recommandée (page Analyses)
|
||||
|
||||
- **Nouvelle carte sur la page Analyses** : « Prochaine prise de sang
|
||||
(suggestion) » — l'app déduit de ses courbes quand refaire une analyse :
|
||||
**au creux estimé, juste avant l'injection suivante** (le creux est le
|
||||
moment le plus informatif et comparable ; l'heure exacte du creux calculé
|
||||
est affichée, avec la mention « ou simplement la veille du créneau »).
|
||||
- **Condition de stabilité** (choix v1.8.0) : un creux n'est interprétable
|
||||
que si l'ester est stabilisé — règle des **~5 demi-vies terminales**
|
||||
(≈ 97 % de l'équilibre). L'app **saute donc au premier creux stabilisé**
|
||||
après un changement d'ester/dose/rythme, et la carte l'explique
|
||||
(« stabilisé depuis le … (~5 demi-vies) » / « c'est le premier creux fiable
|
||||
depuis ton dernier changement »). Le modèle Estrannaise lit sa demi-vie
|
||||
dans les tables (nouveau `PKProfileStore.terminalHalfLifeDays`), TFS/WHSAH
|
||||
la calculent analytiquement.
|
||||
- **Périmètre honnête** : la carte n'apparaît que s'il existe un traitement
|
||||
E2 **injectable actif avec une Posologie** (sinon pas de creux prévisible) ;
|
||||
**et si un injectable actif est SANS Posologie, la page affiche une invite**
|
||||
« renseigne une Posologie pour recevoir des recommandations » (demande
|
||||
v1.8.0). Créneaux déjà mesurés jamais re-recommandés (prise en compte des
|
||||
analyses existantes). Calcul hors UI thread + rafraîchi chaque minute ;
|
||||
disclaimer estimation visible sur la carte.
|
||||
- **11 nouveaux tests** (220 au total / 190 sans les données locales) :
|
||||
LabTimingTest (8 : creux avant créneau, saut de stabilisation, stabilisé,
|
||||
prise récente → creux suivant, sans Posologie → null, oral → null,
|
||||
invite, Estrannaise lit sa t½ dans la table) + régression n°5 étendue
|
||||
(recommandation cohérente sur l'export réel).
|
||||
- **Porté web** (web v1.8.0, versions sync) : `js/pk/lab-timing.js` miroir +
|
||||
encart + scénario E2E.
|
||||
- Validé émulateur §16.ter (APK release, seed réel v1.7.0) : carte rendue —
|
||||
« creux 20/09 10:37, juste avant l'injection EEN du 20/09 11:20, EEN
|
||||
stabilisé depuis le 06/02 » — 0 crash.
|
||||
|
||||
## [1.7.1] — 2026-09-16 (versionCode 34)
|
||||
|
||||
### Corrigé — une seule note sur deux s'affichait dans les Analyses
|
||||
|
||||
- **Bug remonté** (identique côté web) : dans une prise de sang E2+T, quand
|
||||
les deux entrées portent des notes **distinctes**, une seule s'affichait
|
||||
dans l'écran Analyses — l'autre était bien **conservée** (export/import
|
||||
l'avaient) mais perdue à l'affichage.
|
||||
- **Cause** : l'affichage ne prenait que la première note non vide du groupe
|
||||
(hypothèse historique « toutes identiques en pratique » — vraie à l'époque
|
||||
du dialog de création, fausse depuis l'édition unitaire E2/T).
|
||||
- **Fix** : `labNotesForDisplay` (helper pur, testé) — notes distinctes →
|
||||
une ligne par note **préfixée du marqueur** (« E2 : … » / « T : … ») ;
|
||||
note identique sur les deux entrées → une seule ligne (pas de doublon) ;
|
||||
notes vides ignorées ; une seule note → brute (comportement v1.0 gardé).
|
||||
- **Épinglé par** : 5 nouveaux tests LabsGroupingTest + **régression n°5
|
||||
data-driven sur le nouvel export réel** (v1.7.0 : la dernière prise
|
||||
porte exactement ce cas — deux notes distinctes E2/T, les deux
|
||||
affichées). Validé émulateur sur l'APK release seedé avec cet export.
|
||||
- **Web** : même fix en miroir (web v1.7.1, versions sync).
|
||||
|
||||
## [1.7.0] — 2026-09-13 (versionCode 33)
|
||||
|
||||
### Sauvegarde automatique quotidienne (opt-in)
|
||||
|
||||
- **Nouvelle option Paramètres → « Sauvegarde automatique quotidienne »**
|
||||
(désactivée par défaut, en PLUS de l'export manuel qui reste inchangé) :
|
||||
chaque jour, l'app écrit une sauvegarde JSON complète (MÊME format que
|
||||
l'export manuel — importable tel quel) dans le **dossier que tu choisis**
|
||||
(ex. un dossier Owncloud synchronisé → le backup atterrit dans ton cloud).
|
||||
- **Aucune permission de stockage** : le dossier est choisi UNE FOIS via le
|
||||
sélecteur système (SAF) — la permission persiste (survit au reboot), tu
|
||||
peux la révoquer à tout moment dans les réglages système. 100 % local :
|
||||
l'écriture va uniquement dans CE dossier.
|
||||
- **Rétention configurable** (1–30 copies, défaut 7) : chaque run crée un
|
||||
NOUVEAU fichier horodaté `hormonetrack-auto-20260913-1830.json` (jamais
|
||||
d'écrasement — les N dernières copies saines restent disponibles) et les
|
||||
plus vieilles sont supprimées. **Tes exports manuels, tes logs et tout
|
||||
fichier étranger du dossier ne sont JAMAIS touchés** (rétention pure,
|
||||
épinglée par tests).
|
||||
- **Statut visible** : « Dernière sauvegarde : OK/ÉCHEC (date) » dans
|
||||
Paramètres + journalisation AppLog complète (diagnostic à distance).
|
||||
À l'activation, un **premier backup est écrit immédiatement** (pas
|
||||
d'attente de 24 h) ; ensuite WorkManager (24 h, survit au reboot, gère
|
||||
le Doze).
|
||||
- 9 nouveaux tests (200 au total / 177 sans les données locales) : noms
|
||||
horodatés (règle anti-#48 : signature LocalDateTime obligatoire), garde
|
||||
de rétention (jamais de suppression hors auto-backups reconnaissables,
|
||||
keep clampé ≥ 1 — `keep=0` ne supprime jamais la dernière copie).
|
||||
- Validé sur émulateur (§16.ter, APK release + recette standard) : dossier
|
||||
choisi via le sélecteur SAF, fichier `hormonetrack-auto-…json` écrit,
|
||||
rétention appliquée, 0 crash.
|
||||
|
||||
### Unités des axes du graphique
|
||||
|
||||
- **pg/mL** (E2, axe gauche) et **ng/mL** (T, axe droit) affichées au sommet
|
||||
des colonnes de labels du graphique de simulation — remontée : « on n'a
|
||||
jamais ajouté les unités des mesures dans les axes » (des nombres nus
|
||||
depuis la v1.0). Portées aussi côté web (v1.7.0 web, versions sync).
|
||||
|
||||
## [1.6.0] — 2026-09-12 (versionCode 32)
|
||||
|
||||
### « Tracé labs » prolongé au-delà du dernier lab
|
||||
|
||||
- **Nouveau chip `Prolonger`** (à côté de `Tracé labs`, **off par défaut**,
|
||||
sans effet — donc désactivé — tant que Tracé labs est off) : la courbe
|
||||
ancrée sur tes prises de sang se PROLONGE désormais au-delà du dernier lab,
|
||||
au lieu de s'arrêter là (limite v1.5.0 : justement la période la plus
|
||||
récente était invisible).
|
||||
- **Recette de la prolongation** : `courbe(t) = M(t) × ρ_dernier lab` où
|
||||
M(t) = le modèle PK brut (porte TOUTE la dynamique : Tmax, queues,
|
||||
accumulation **et les doses loguées après le dernier lab** — une nouvelle
|
||||
injection, même à un autre ester, fait repartir la courbe en pic) et
|
||||
ρ = le ratio corps-réel ÷ modèle observé au dernier lab, supposé constant.
|
||||
Demande couverte : simuler à partir des labs précédents, du dosage et du
|
||||
type d'ester injecté, et de l'évolution classique de l'ester.
|
||||
- **Horizon honnête** : la prolongation s'arrête quand le MODÈLE lui-même
|
||||
s'éteint (dernière dose + coupure moteur = 10 t½ terminales V3C/WHS, fin
|
||||
de table ODS, 30 t½ Bateman) — jamais de ligne à zéro inventée au-delà.
|
||||
- **Lisibilité** : la partie prolongée est dessinée SÉPARÉMENT (même rose,
|
||||
atténué) avec sa propre légende — « estimation, plus ancrée » : la partie
|
||||
mesurée (qui passe par les labs) ne peut pas se confondre avec la partie
|
||||
simulée.
|
||||
- **Avertissement visible** (demande) : tant que la partie prolongée est
|
||||
affichée, un texte sous la légende rappelle que c'est une **simple
|
||||
simulation sans garantie de correspondre au réel**, extrapolée à partir
|
||||
de tes résultats de laboratoire — qui peuvent **eux-mêmes être erronés**
|
||||
— et qu'il faut se fier à la prochaine prise de sang, pas à cette courbe.
|
||||
- **Garde anti-ratés inchangée** : seuls les labs SIGNIFICATIFS ancrent
|
||||
(leçon #61) — un lab tardif hors fenêtre d'action ne prolonge pas la
|
||||
courbe et n'est jamais traversé.
|
||||
- **Limites documentées** (cf doc de développement §7.10.bis) : ρ est
|
||||
constant (pas d'extrapolation de pente — elle divergerait sans base
|
||||
physiologique) ; après un changement d'ester, l'amplitude reste calibrée
|
||||
par le ρ de l'ancien ester (même limite que la calibration classique) ;
|
||||
les doses PRÉVISIONNELLES ne participent pas (la projection reste la
|
||||
série Prévision).
|
||||
- **8 nouveaux tests** (191 au total / 168 sans les données locales) :
|
||||
identité ρ constant + continuité de suture, horizon = cutoff du
|
||||
traitement, montée après une dose EV loguée après le dernier lab,
|
||||
flag off = comportement v1.5.0 bit-compatible, garde #61 sur la
|
||||
prolongation, gardes du helper d'horizon, `lastAnchorMs`.
|
||||
- **Validé sur émulateur** (recette §16.ter, APK release + données
|
||||
réelles seedées) — et heureusement : la validation a TROUVÉ le bug
|
||||
#64 (courbe vide en release seule, inlining destructeur de R8 full
|
||||
mode sur `LabTrajectoryModel`, cf doc §14). FIX : garde R8 explicite ;
|
||||
re-validé en release : ancrée, prolongée, avertissement, 0 crash.
|
||||
|
||||
## [1.5.0] — 2026-09-11 (versionCode 31)
|
||||
|
||||
### « Tracé labs » — courbe ancrée sur tes prises de sang
|
||||
|
||||
- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**)
|
||||
: `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique
|
||||
(Tmax, queues, accumulation) et l'amplitude est recalée sur CHAQUE prise
|
||||
de sang : la trajectoire réelle que tes labs tracent, avec la dynamique
|
||||
du modèle entre les points.
|
||||
- **Passage exact garanti** sur chaque lab ; transition continue
|
||||
(log-linéaire) d'un lab au suivant — jamais de saut vertical.
|
||||
- Garde anti-ratés : un lab qui ne « gouverne » plus la prédiction du
|
||||
modèle (< 15 % du max, cf leçon #61) n'est PAS forcé-traversé — une
|
||||
dose de test de janvier ne crée pas de zigzag.
|
||||
- **E2 uniquement** (unité pg/mL) ; fenêtre = entre le 1er et le dernier
|
||||
lab significatif — rien avant/après : la courbe s'arrête à ta dernière
|
||||
prise de sang (honnête, pas d'extrapolation).
|
||||
- Indépendante de la calibration (elle est SA propre référence) et
|
||||
n'entre JAMAIS dans l'accueil/les seuils d'alerte/les rappels — une
|
||||
courbe de comparaison, pas une estimation d'action.
|
||||
- Couleur dédiée rose foncé pointillé, hors pics/creux.
|
||||
|
||||
### Améliorations UI
|
||||
|
||||
- La rangée de chips du graphique défile horizontalement (le 4ᵉ chip
|
||||
était coupé hors fenêtre sur écrans étroits — constat émulateur v1.5.0).
|
||||
|
||||
## [1.4.10] — 2026-09-07 (versionCode 30)
|
||||
|
||||
### Corrigé
|
||||
- **Le pan du graphique ne fonctionnait pas sur la vue 24 h** (#62, remontée
|
||||
par test utilisateur sur Pixel 9 : « le glisser horizontal ne marche pas
|
||||
sur une échelle de 24 h, uniquement au-dessus ») : les deltas de mouvement
|
||||
du doigt (~30 px ≈ 0,67 h à 24 h) étaient **tronqués à 0** par le cast
|
||||
en heures entières à CHAQUE événement de mouvement → le pan ne bougeait
|
||||
jamais sur 24 h (alors que sur 7 j le même delta ≈ 4,7 h). FIX : **cumul
|
||||
fractionnaire** — le résiduel < 1 h est conservé d'un événement au
|
||||
suivant (`panDeltaHours`, pur et testé) : le pan se déclenche dès que le
|
||||
cumul franchit une heure entière. Aucun impact sur les vues larges.
|
||||
- **Nettoyage** : instrumentation de diagnostic retirée ; 2 imports/
|
||||
constantes morts supprimés.
|
||||
|
||||
### Ajouté
|
||||
- **4 nouveaux tests** (172 au total / 150 sans les données locales) :
|
||||
cumul fractionnaire à 24 h (2 deltas → 1 h), 7 j (delta immédiat),
|
||||
deltas négatifs (troncature vers zéro), cas dégénérés.
|
||||
- **Limitation de test documentée** (§16.ter) : l'émulateur headless ne
|
||||
délivre pas les mouvements intermédiaires au graphique via `adb input
|
||||
swipe` → le pan n'est PAS testable par adb sur cet environnement ; le
|
||||
test humain sur Pixel 9 reste la référence pour les gestes.
|
||||
|
||||
## [1.4.9] — 2026-09-07 (versionCode 29)
|
||||
|
||||
@ -12,7 +682,7 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.9
|
||||
diagnostic par reproduction sur données réelles — l'export contient une
|
||||
dose EEn de test logguée en **janvier** ; les labs de janvier-mars
|
||||
tombaient 15–76 j après cette dose, quand la prédiction WHSAH est
|
||||
RÉSIDUELLE (184 → 1 pg/mL) → ratios aberrants (3,3 / 15,9 / 89,6 / 383)
|
||||
RÉSIDUELLE (184 → 1 pg/mL) → ratios aberrants (3,3 / 15,9 / 89,6 / 383 / 391)
|
||||
→ la MÉDIANE tombait à ×2,21, **dans les deux pipelines** (manuel ET
|
||||
auto — même défaut de frontière). FIX : **garde de significativité**
|
||||
(`labIsSignificant`) — un lab ne calibre que si la prédiction reste
|
||||
@ -32,8 +702,6 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.9
|
||||
|
||||
## [1.4.8] — 2026-09-07 (versionCode 28)
|
||||
|
||||
## [1.4.8] — 2026-09-07 (versionCode 28)
|
||||
|
||||
### Corrigé
|
||||
- **La calibration automatique était fausse pour les modèles autres que le
|
||||
modèle stocké du traitement** (#60, remontée : « la calibration auto sur
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -45,8 +45,9 @@ installer — pas besoin d'Android Studio ni de faire un build :
|
||||
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
|
||||
2. Laisse le **Gradle Sync** se terminer (première fois : téléchargements, 5–15 min)
|
||||
- La barre du bas affiche la progression ; attends « Gradle sync finished ».
|
||||
3. Vérifie que l'asset est bien présent :
|
||||
`app/src/main/assets/pk_profiles.json` (profils Estrannaise / Transfem Science extraits de ton `Estrogen.ods`).
|
||||
3. (v1.9.0) Le modèle Estrannaise est **analytique** : l'ancienne table
|
||||
`pk_profiles.json` n'est plus embarquée dans l'APK (elle ne sert qu'aux
|
||||
tests de fidélité, dans `app/src/test/assets/`).
|
||||
|
||||
## 3. Préparer ton téléphone
|
||||
|
||||
@ -87,7 +88,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
||||
|
||||
## 6. Premiers pas dans l'app
|
||||
|
||||
1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*)
|
||||
1. **Traitements → +** → choisis un preset (ex : *Injection EV —
|
||||
Estrannaise*). Le modèle Estrannaise propose ses 6 esters injectables
|
||||
(EV, EU, EEn, EC huile, EB, EUCS suspension cristalline) ; le menu
|
||||
déroulant Ester liste toujours les esters couverts par le modèle choisi.
|
||||
- Tu as changé d'ester ? Crée (ou garde) le traitement de l'ancien ester et
|
||||
passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes
|
||||
rappels, mais **son historique reste simulé et calibré** sur les graphiques —
|
||||
@ -101,7 +105,11 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
||||
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
|
||||
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
|
||||
2. **Doses → +** → logue tes injections passées
|
||||
- L'accueil affiche la **prochaine dose en jours** quand elle est à plus de 24 h
|
||||
- L'accueil liste **toutes les prochaines doses** (v1.9.6) : une ligne
|
||||
par traitement, triées par prochaine prise (la plus proche en premier,
|
||||
en gras) — une prise quotidienne ne masque plus le rappel de
|
||||
l'injection hebdomadaire. Affichage en **jours** quand la prochaine
|
||||
prise est à plus de 24 h
|
||||
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
|
||||
- La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h**
|
||||
(flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation
|
||||
@ -122,6 +130,13 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
||||
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
|
||||
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
|
||||
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
|
||||
- **Prochaine prise de sang (suggestion, v1.8.0 ; v1.8.1 : aussi sur l'Accueil)** : si ton traitement E2
|
||||
injectable actif a une **Posologie**, la page te suggère quand faire la
|
||||
prochaine analyse — **au creux estimé, juste avant l'injection suivante**
|
||||
(moment le plus comparable), au premier creux où ton ester est stabilisé
|
||||
(~5 demi-vies après le dernier changement). Sans Posologie : la page
|
||||
t'invite à en renseigner une (Traitements → éditer) pour activer la
|
||||
suggestion. C'est une estimation du modèle, pas un avis médical
|
||||
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
|
||||
- Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de
|
||||
fichiers → choisis où enregistrer le .txt → un message de confirmation
|
||||
@ -141,10 +156,19 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
||||
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
|
||||
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
|
||||
v1.4.2)
|
||||
- **Sauvegarde automatique quotidienne** (v1.7.0, opt-in) : active-la,
|
||||
choisis le dossier une seule fois (ex. un dossier Owncloud synchronisé)
|
||||
→ chaque jour un backup JSON complet y est écrit, avec les copies les
|
||||
plus récentes conservées (réglable, défaut 7) — tes exports manuels et
|
||||
tes autres fichiers ne sont jamais touchés. Un premier backup est écrit
|
||||
dès l'activation ; le statut (OK/ÉCHEC + date) s'affiche dans la carte
|
||||
|
||||
## 7. Les modèles mathématiques
|
||||
|
||||
- **Estrannaise** : tables horaires issues de ta feuille `Estrogen.ods` (table « Models »)
|
||||
- **Estrannaise (v1.9.0 : analytique)** : forme close 3C publiée par
|
||||
estrannaise.js — les anciennes tables du tableur en étaient
|
||||
l'échantillonnage (fidélité vérifiée à l'identique) ; 6 esters injectables
|
||||
couverts (EV, EU, EEn, EC, EB + EUCS en suspension cristalline)
|
||||
- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments
|
||||
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
|
||||
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
|
||||
@ -191,6 +215,9 @@ Pics de référence (dose unique de 5 mg, IM) :
|
||||
graphique, seuls les modèles utilisés par tes traitements (injections, en
|
||||
cours ou passés) sont affichés — les autres s'activent au tap ; et TOUTES
|
||||
les courbes affichées suivent ta calibration (labs), quel que soit le modèle
|
||||
- **Pan fluide sur la vue 24 h** (v1.4.10) : le glisser horizontal fonctionne
|
||||
sur toutes les échelles, y compris 24 h (les petits mouvements de doigt
|
||||
s'accumulent jusqu'à franchir une heure entière)
|
||||
- **Marqueurs de doses** (v1.4.5) : les doses projetées sont marquées par une
|
||||
ligne verticale pointillée avec leur heure ; tes prises enregistrées par un
|
||||
petit triangle discret au bas du graphique — plus d'ambiguïté sur « où » la
|
||||
@ -198,9 +225,32 @@ Pics de référence (dose unique de 5 mg, IM) :
|
||||
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
|
||||
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
|
||||
du téléphone)
|
||||
- **Nuage d'incertitude (v1.9.0, exclusif ESE)** : chip `Nuage` (actif
|
||||
seulement quand Estrannaise est affiché) → nuage diffus de courbes du
|
||||
posterior MCMC d'Estrannaise montrant la plage d'imprécision du modèle
|
||||
(comme sur le site estrannaise). v1.9.2 : il couvre toutes les doses E2
|
||||
injectables quand ESE est affiché — aucun traitement n'a besoin d'être
|
||||
stocké en ESE
|
||||
- **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et
|
||||
l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de
|
||||
labels — plus besoin de deviner l'unité des nombres
|
||||
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
|
||||
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
|
||||
d'œil les hauts et les bas entre deux injections
|
||||
- **Tracé labs** (chip `Tracé labs`, off par défaut) : une courbe E2 qui suit la
|
||||
forme du modèle PK MAIS passe EXACTEMENT par chacune de tes prises de sang —
|
||||
la trajectoire que tes labs tracent, avec la dynamique du modèle entre deux
|
||||
analyses (rose pointillé). Entre le 1er et le dernier lab seulement : la
|
||||
courbe ne s'invente pas de niveau.
|
||||
**Prolonger** (chip, off par défaut — actif seulement quand Tracé labs est on) :
|
||||
la courbe se PROLONGE après ton dernier lab : le modèle (dosages, esters
|
||||
injectés depuis, pics et descentes classiques) × le ratio mesuré à ton dernier
|
||||
lab. La partie prolongée est dessinée en rose ATTÉNUÉ avec sa légende
|
||||
(« estimation, plus ancrée ») et un AVERTISSEMENT : c'est une simple
|
||||
simulation sans garantie de correspondre au réel, extrapolée à partir de tes
|
||||
résultats de laboratoire — qui peuvent eux-mêmes être erronés. Elle s'arrête
|
||||
quand le modèle lui-même s'éteint. Compare-la à ta prochaine prise de sang,
|
||||
et fie-toi à elle plutôt qu'à cette courbe
|
||||
- **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les
|
||||
facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour
|
||||
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
|
||||
@ -215,7 +265,7 @@ Pics de référence (dose unique de 5 mg, IM) :
|
||||
|
||||
| Problème | Solution |
|
||||
|----------|----------|
|
||||
| « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → installer API 34 |
|
||||
| « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → vérifier/installer la plateforme 37 (le build AGP peut la télécharger automatiquement) |
|
||||
| Pas de téléphone détecté | Réactive le Débogage USB, change de câble (données, pas charge seule) |
|
||||
| Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre |
|
||||
| Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app |
|
||||
@ -226,4 +276,7 @@ Pics de référence (dose unique de 5 mg, IM) :
|
||||
|
||||
- **Tout est local** : base Room sur le téléphone, aucun serveur, aucun compte
|
||||
- Sauvegarde = fichier JSON que tu choisis où stocker (Owncloud, etc.)
|
||||
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
|
||||
choisi** (sélecteur système, permission révocable dans les réglages) —
|
||||
rien ne part ailleurs
|
||||
- La désinstallation supprime les données → pense à exporter régulièrement
|
||||
|
||||
68
scripts/make-release-keystore.sh
Executable file
68
scripts/make-release-keystore.sh
Executable file
@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Génération du KEYSTORE de signature RELEASE + keystore.properties (v1.9.8).
|
||||
#
|
||||
# Les deux fichiers sont écrits à la RACINE du dépôt et sont GITIGNORÉS
|
||||
# (.gitignore) : ne JAMAIS les committer — le keystore et ses mots de passe
|
||||
# sont l'identité de l'app (une fuite = n'importe qui peut publier un APK qui
|
||||
# se met à jour par-dessus le tien).
|
||||
#
|
||||
# Usage :
|
||||
# scripts/make-release-keystore.sh
|
||||
#
|
||||
# Après génération, `./gradlew assembleRelease` signe automatiquement avec ce
|
||||
# keystore (app/build.gradle.kts détecte keystore.properties).
|
||||
#
|
||||
# ⚠️ MIGRATION sur le téléphone (signature différente de la clé debug) :
|
||||
# 1. Paramètres → Sauvegarde → Exporter (JSON) — OU auto-backup existant ;
|
||||
# 2. désinstaller l'app (debug-signée) ;
|
||||
# 3. installer l'APK release signé par le nouveau keystore ;
|
||||
# 4. Paramètres → Sauvegarde → Importer.
|
||||
# La base Room n'est PAS convertible entre signatures : seul le JSON voyage.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
KEYSTORE_FILE="release.keystore"
|
||||
PROPERTIES_FILE="keystore.properties"
|
||||
|
||||
if [[ -f "$PROPERTIES_FILE" || -f "$KEYSTORE_FILE" ]]; then
|
||||
echo "ERREUR : $PROPERTIES_FILE et/ou $KEYSTORE_FILE existent déjà." >&2
|
||||
echo "Supprime-les d'abord si tu veux vraiment les régénérer (une régénération" >&2
|
||||
echo "= perte de la capacité de mettre à jour les installs existantes)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Mot de passe du keystore (>= 6 caractères) : " -s STORE_PASS
|
||||
echo
|
||||
read -r -p "Confirmation : " -s STORE_PASS_CONFIRM
|
||||
echo
|
||||
if [[ "$STORE_PASS" != "$STORE_PASS_CONFIRM" || ${#STORE_PASS} -lt 6 ]]; then
|
||||
echo "ERREUR : les mots de passe ne correspondent pas ou font moins de 6 caractères." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# keyPassword = storePassword ici (un seul secret à retenir) ; keytool exige >= 6 car.
|
||||
keytool -genkeypair \
|
||||
-keystore "$KEYSTORE_FILE" \
|
||||
-alias hormonetrack \
|
||||
-keyalg RSA -keysize 4096 -validity 10000 \
|
||||
-storepass "$STORE_PASS" \
|
||||
-keypass "$STORE_PASS" \
|
||||
-dname "CN=HormoneTrack, OU=HormoneTrack, O=HormoneTrack"
|
||||
|
||||
chmod 600 "$KEYSTORE_FILE"
|
||||
|
||||
cat >"$PROPERTIES_FILE" <<EOF
|
||||
# Signature release de HormoneTrack — GITIGNORÉ, ne jamais committer.
|
||||
storeFile=$KEYSTORE_FILE
|
||||
storePassword=$STORE_PASS
|
||||
keyAlias=hormonetrack
|
||||
keyPassword=$STORE_PASS
|
||||
EOF
|
||||
chmod 600 "$PROPERTIES_FILE"
|
||||
|
||||
echo
|
||||
echo "OK : $KEYSTORE_FILE + $PROPERTIES_FILE créés (gitignorés)."
|
||||
echo "Prochaine build : ./gradlew assembleRelease (signée avec ce keystore)"
|
||||
echo "⚠️ Sur le téléphone : exporter le JSON AVANT d'installer (signature changée)"
|
||||
echo " → cf docs/DEVELOPPEMENT.md §16.quater pour la migration pas à pas."
|
||||
Loading…
x
Reference in New Issue
Block a user