Compare commits
41 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 | |||
| 9b83533d72 | |||
| 22d4c66d81 | |||
| 1c9d2e4e6a | |||
| ca917ba772 |
7
.gitignore
vendored
7
.gitignore
vendored
@ -5,6 +5,13 @@ build/
|
|||||||
# Local configuration (machine-specific, never commit)
|
# Local configuration (machine-specific, never commit)
|
||||||
local.properties
|
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
|
# IntelliJ / Android Studio
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.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)).
|
||||||
151
README.md
151
README.md
@ -1,5 +1,7 @@
|
|||||||
# HormoneTrack
|
# 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.
|
> **🤖 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
|
> La contribution humaine a été **essentielle** : feedback continu, retours
|
||||||
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
> 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
|
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||||
> sang et aux consignes de ton endocrinologue.
|
> sang et aux consignes de ton endocrinologue.
|
||||||
|
|
||||||
- **Statut** : v1.4.5 — build Android ✅, **lint vert** ✅, **145 tests unitaires** ✅ (123 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)
|
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.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)
|
- **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)
|
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
||||||
|
- **README anglais** : [README.en.md](README.en.md)
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
@ -35,8 +38,26 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
|
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
|
||||||
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
|
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
avec les **7 esters** : EV, EU, EEn, EB, EC (huile), EC (suspension), PEP,
|
avec les **7 esters** (EV, EU, EEn, EB, EC huile, EC suspension, PEP) — et WHSAH
|
||||||
affichés côte à côte avec toggles indépendants
|
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,
|
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
|
||||||
patch et voie orale
|
patch et voie orale
|
||||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
||||||
@ -46,13 +67,25 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
vers la gauche, jusqu'à **1 an**
|
vers la gauche, jusqu'à **1 an**
|
||||||
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
|
- **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),
|
(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
|
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
|
||||||
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
|
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
|
- **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
|
**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
|
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),
|
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
||||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||||
@ -64,13 +97,23 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
||||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
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
|
**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)
|
- **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 +
|
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,
|
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
|
||||||
canal dédié) — évaluées sur le taux **estimé**, opt-in
|
canal dédié) — évaluées sur le taux **estimé**, opt-in
|
||||||
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
|
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
|
||||||
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2)
|
**+ 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
|
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
|
||||||
(fermé = ne réapparaît pas avant la prochaine version)
|
(fermé = ne réapparaît pas avant la prochaine version)
|
||||||
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
|
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
|
||||||
@ -91,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
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 145 tests (123 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
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -103,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) —
|
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
||||||
**releases taguées** (`v1.1.0` → `v1.4.5`) 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)) :
|
(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
|
```bash
|
||||||
git tag # lister les releases
|
git tag # lister les releases
|
||||||
@ -113,31 +157,62 @@ git log --oneline # historique par couches (toolchain / moteur / UI / docs)
|
|||||||
git push -u origin main --tags
|
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
|
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
||||||
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
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
|
## Les modèles en bref
|
||||||
|
|
||||||
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
||||||
(pg/mL par mg) ; les contributions se superposent. **Estrannaise** = tables horaires
|
(pg/mL par mg) ; les contributions se superposent. **Trois modèles superposables** :
|
||||||
du `.ods` (8001 h) ; **Transfem Science** = méta-analyse à 3 compartiments (V3C,
|
|
||||||
forme close, [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)).
|
- **Estrannaise** = tables horaires du `.ods` (8001 h), EV/EU/EEn
|
||||||
|
- **Transfem Science** = méta-analyse à 3 compartiments (V3C, forme close,
|
||||||
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
|
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
|
||||||
|
- **WHSAH** (v1.4.6) = fit « license-free » du [WHSAH Collective via
|
||||||
|
Mona](https://github.com/mona-hrt/mona) — même famille mathématique mais
|
||||||
|
paramètres indépendants avec biodisponibilité explicite F < 1 : montée
|
||||||
|
plus rapide à J+1 (EEn ~70 pg/mL à J+1 pour 5 mg vs ~22 chez TFS) et
|
||||||
|
décroissance plus longue (t½ EEn 7,3 j vs 4,5 j). 6 esters (sans PEP)
|
||||||
|
|
||||||
Pics de référence (pg/mL par mg) :
|
Pics de référence (pg/mL par mg) :
|
||||||
|
|
||||||
| Profil | Modèle | Pic (pg/mL/mg) | Tmax |
|
| Profil | Modèle | Pic (pg/mL/mg) | Tmax | t½ term. |
|
||||||
|----------|------------------|----------------|--------|
|
|----------|------------------|----------------|--------|----------|
|
||||||
| EV | Estrannaise | 61,1 | ~45 h |
|
| EV | Estrannaise | 61,1 | ~45 h | — |
|
||||||
| EU | Estrannaise | 3,4 | ~55 h (plateau long) |
|
| EU | Estrannaise | 3,4 | ~55 h (plateau long) | — |
|
||||||
| EEn | Estrannaise | 31,4 | ~152 h |
|
| EEn | Estrannaise | 31,4 | ~152 h | — |
|
||||||
| EV | Transfem Science | 59,0 | ~51 h |
|
| EV | Transfem Science | 59,0 | ~51 h | 3,0 j |
|
||||||
| EU | Transfem Science | 10,1 | ~198 h |
|
| EU | Transfem Science | 10,1 | ~198 h | — |
|
||||||
| EEn | Transfem Science | 32,0 | ~156 h |
|
| EEn | Transfem Science | 32,0 | ~156 h | 4,5 j |
|
||||||
| EB | Transfem Science | 194,2 | ~16 h |
|
| EB | Transfem Science | 194,2 | ~16 h | 1,2 j |
|
||||||
| EC (huile) | Transfem Science | 31,1 | ~103 h |
|
| EC (huile) | Transfem Science | 31,1 | ~103 h | 6,7 j |
|
||||||
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j |
|
| EC (susp.) | Transfem Science | 48,2 | ~29 h | 5,1 j |
|
||||||
|
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | 28,4 j |
|
||||||
|
| EV | WHSAH | 73,5 | ~41 h | 3,1 j |
|
||||||
|
| EEn | WHSAH | 37,6 | ~120 h | 7,3 j |
|
||||||
|
| EB | WHSAH | 260,1 | ~12 h | 1,3 j |
|
||||||
|
| EC (huile) | WHSAH | 25,0 | ~81 h | 7,9 j |
|
||||||
|
| EC (susp.) | WHSAH | 53,5 | ~16 h | 7,1 j |
|
||||||
|
| EU | WHSAH | 4,9 | ~67 h | 31,7 j |
|
||||||
|
|
||||||
La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle
|
**Calibration (v1.4.8)** : le facteur d'échelle s'applique par ESTER et par
|
||||||
à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine.
|
PÉRIODE d'injection (médiane des ratios lab ÷ prédiction, comme la colonne
|
||||||
|
« Scale factor » de la feuille d'origine) — et **par MODÈLE** : chaque courbe
|
||||||
|
affichée (Estrannaise / Transfem Science / WHSAH) est calibrée avec la
|
||||||
|
prédiction de SON modèle → toutes collent à tes labs, quelle que soit leur
|
||||||
|
forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
||||||
|
|
||||||
## Vie privée
|
## Vie privée
|
||||||
|
|
||||||
@ -148,6 +223,9 @@ La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste
|
|||||||
depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été
|
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))
|
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.)
|
- 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
|
- `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é ;
|
- 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
|
passer le dépôt en public rend les APK téléchargeables sans compte (sans risque
|
||||||
@ -157,7 +235,7 @@ La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste
|
|||||||
|
|
||||||
```
|
```
|
||||||
HormoneTrack/
|
HormoneTrack/
|
||||||
├── README.md ← ce fichier
|
├── README.md ← ce fichier (README.en.md = version anglaise)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
||||||
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
||||||
@ -166,9 +244,11 @@ HormoneTrack/
|
|||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
│ ├── 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)
|
│ ├── 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
|
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
||||||
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
│ 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)
|
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
||||||
├── settings.gradle.kts
|
├── settings.gradle.kts
|
||||||
├── gradle.properties
|
├── gradle.properties
|
||||||
@ -183,22 +263,29 @@ HormoneTrack/
|
|||||||
│ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS)
|
│ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS)
|
||||||
│ ├── java/com/hormonetrack/
|
│ ├── java/com/hormonetrack/
|
||||||
│ │ ├── data/ (Room : models, DAOs, repository, backup)
|
│ │ ├── data/ (Room : models, DAOs, repository, backup)
|
||||||
│ │ ├── pk/ (moteur pharmacocinétique + profils)
|
│ │ ├── pk/ (moteur PK + profils : Estrannaise/TFS/WHSAH, alertes, export)
|
||||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||||
|
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
|
||||||
│ │ ├── HormoneTrackApp.kt
|
│ │ ├── HormoneTrackApp.kt
|
||||||
│ │ └── MainActivity.kt
|
│ │ └── MainActivity.kt
|
||||||
│ └── res/ (strings FR/EN, thème, icônes)
|
│ └── res/ (strings FR/EN, thème, icônes)
|
||||||
└── test/java/com/hormonetrack/ ← tests unitaires JVM
|
├── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||||
├── pk/ (moteur + profils)
|
│ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
||||||
└── data/backup/ (round-trip Gson)
|
│ │ 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
|
## Feuille de route
|
||||||
|
|
||||||
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
- [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
|
- [ ] 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))
|
- [ ] 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 {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("com.google.devtools.ksp")
|
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 {
|
android {
|
||||||
namespace = "com.hormonetrack"
|
namespace = "com.hormonetrack"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@ -17,8 +33,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 25
|
versionCode = 50
|
||||||
versionName = "1.4.5"
|
versionName = "1.13.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -35,12 +51,27 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
// ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste
|
// Signature : VRAI keystore si keystore.properties existe (racine du
|
||||||
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
|
// dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ;
|
||||||
// sans désinstallation ni perte de données, tout en bénéficiant de
|
// sinon CLÉ DEBUG volontairement : l'APK release reste installable
|
||||||
// R8. Si un jour on distribue largement : générer un vrai keystore
|
// PAR-DESSUS l'APK debug déjà installé (même signature), sans
|
||||||
// (signature différente → migration backup→désinstall→réimport).
|
// désinstallation ni perte de données, tout en bénéficiant de R8.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// ⚠️ 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
|
// Tests unitaires JVM
|
||||||
testImplementation("junit:junit:4.13.2")
|
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)
|
# Énumérations : converties en String par Room et Gson (par nom)
|
||||||
-keepclassmembers enum com.hormonetrack.data.model.** { *; }
|
-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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<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 android.content.Context
|
||||||
import com.hormonetrack.data.AppDatabase
|
import com.hormonetrack.data.AppDatabase
|
||||||
import com.hormonetrack.data.repository.HormoneRepository
|
import com.hormonetrack.data.repository.HormoneRepository
|
||||||
import com.hormonetrack.pk.PKProfileStore
|
import com.hormonetrack.pk.EstrannaiseModels
|
||||||
import com.hormonetrack.reminder.createNotificationChannel
|
import com.hormonetrack.reminder.createNotificationChannel
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.util.AppLog
|
||||||
import com.hormonetrack.reminder.AlertNotifier
|
import com.hormonetrack.reminder.AlertNotifier
|
||||||
import com.hormonetrack.settings.AppSettings
|
import com.hormonetrack.settings.AppSettings
|
||||||
|
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||||
|
|
||||||
class HormoneTrackApp : Application() {
|
class HormoneTrackApp : Application() {
|
||||||
|
|
||||||
@ -18,7 +19,10 @@ class HormoneTrackApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = AppContainer(this)
|
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)
|
createNotificationChannel(this)
|
||||||
AppLog.init(this)
|
AppLog.init(this)
|
||||||
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
|
// 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.
|
// démarrage ne change rien ; WorkManager survit aux reboots.
|
||||||
AlertNotifier.createChannel(this)
|
AlertNotifier.createChannel(this)
|
||||||
AlertNotifier.schedulePeriodic(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.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.hormonetrack.ui.HormoneTrackRoot
|
import com.hormonetrack.ui.HormoneTrackRoot
|
||||||
@ -22,6 +23,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val notificationPermissionLauncher =
|
private val notificationPermissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val container = (application as HormoneTrackApp).container
|
val container = (application as HormoneTrackApp).container
|
||||||
@ -53,15 +65,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
logDoseRequest.value = consumeLogDoseExtras(intent)
|
||||||
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
HormoneTrackTheme {
|
HormoneTrackTheme {
|
||||||
HormoneTrackRoot(
|
HormoneTrackRoot(logDoseRequest = logDoseRequest)
|
||||||
initialOpenLogDose = openLogDose,
|
|
||||||
initialTreatmentId = treatmentId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,5 +77,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(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
|
package com.hormonetrack.data.backup
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.hormonetrack.data.model.DoseLog
|
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.model.Treatment
|
||||||
import com.hormonetrack.data.repository.HormoneRepository
|
import com.hormonetrack.data.repository.HormoneRepository
|
||||||
import com.hormonetrack.pk.TConfig
|
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
|
* 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 alertE2High: Double? = null,
|
||||||
val alertE2Low: Double? = null,
|
val alertE2Low: Double? = null,
|
||||||
val alertTHigh: 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(
|
data class BackupData(
|
||||||
@ -133,4 +142,98 @@ object BackupManager {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
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,18 +9,10 @@ interface DoseLogDao {
|
|||||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
|
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
|
||||||
fun getAll(): Flow<List<DoseLog>>
|
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")
|
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
|
||||||
suspend fun getAllOnce(): List<DoseLog>
|
suspend fun getAllOnce(): List<DoseLog>
|
||||||
|
|
||||||
|
|||||||
@ -9,12 +9,8 @@ interface LabResultDao {
|
|||||||
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
|
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
|
||||||
fun getAll(): Flow<List<LabResult>>
|
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")
|
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
|
||||||
suspend fun getAllOnce(): List<LabResult>
|
suspend fun getAllOnce(): List<LabResult>
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,6 @@ interface TreatmentDao {
|
|||||||
@Query("SELECT * FROM treatments WHERE id = :id")
|
@Query("SELECT * FROM treatments WHERE id = :id")
|
||||||
suspend fun getById(id: Long): Treatment?
|
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")
|
@Query("SELECT * FROM treatments ORDER BY createdAt DESC")
|
||||||
suspend fun getAllOnce(): List<Treatment>
|
suspend fun getAllOnce(): List<Treatment>
|
||||||
|
|
||||||
@ -35,6 +32,4 @@ interface TreatmentDao {
|
|||||||
@Delete
|
@Delete
|
||||||
suspend fun delete(treatment: Treatment)
|
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 {
|
object PKPresets {
|
||||||
|
|
||||||
val all: List<PKPreset> = listOf(
|
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(
|
PKPreset(
|
||||||
nameRes = R.string.preset_ev_ese,
|
nameRes = R.string.preset_ev_ese,
|
||||||
type = TreatmentType.ESTRADIOL,
|
type = TreatmentType.ESTRADIOL,
|
||||||
@ -46,6 +47,39 @@ object PKPresets {
|
|||||||
absorptionHours = 152f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
|
absorptionHours = 152f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
|
||||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
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 ---
|
// --- Injections: Transfem Science models ---
|
||||||
PKPreset(
|
PKPreset(
|
||||||
nameRes = R.string.preset_ev_tfs,
|
nameRes = R.string.preset_ev_tfs,
|
||||||
@ -113,6 +147,64 @@ object PKPresets {
|
|||||||
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
|
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
|
||||||
defaultDoseUnit = "mg", defaultDoseAmount = 32.5
|
defaultDoseUnit = "mg", defaultDoseAmount = 32.5
|
||||||
),
|
),
|
||||||
|
// --- Nouveaux esters : modèle WHSAH (v1.4.6, fit license-free de
|
||||||
|
// Mona — cf pk/WhsahModels.kt) : 6 esters, PEP non couvert. Les
|
||||||
|
// champs Bateman reflètent les t½ WHSAH (pas utilisés au calcul,
|
||||||
|
// affichés dans l'éditeur).
|
||||||
|
PKPreset(
|
||||||
|
nameRes = R.string.preset_ev_whs,
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
esterType = Esters.EV,
|
||||||
|
pkModel = PKModels.WHSAH,
|
||||||
|
absorptionHours = 41f, eliminationHalfLifeHours = 75f, bioavailabilityFraction = 1f,
|
||||||
|
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
|
||||||
|
),
|
||||||
|
PKPreset(
|
||||||
|
nameRes = R.string.preset_eu_whs,
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
esterType = Esters.EU,
|
||||||
|
pkModel = PKModels.WHSAH,
|
||||||
|
absorptionHours = 80f, eliminationHalfLifeHours = 722f, bioavailabilityFraction = 1f,
|
||||||
|
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||||
|
),
|
||||||
|
PKPreset(
|
||||||
|
nameRes = R.string.preset_een_whs,
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_SUBCUT,
|
||||||
|
esterType = Esters.EEN,
|
||||||
|
pkModel = PKModels.WHSAH,
|
||||||
|
absorptionHours = 120f, eliminationHalfLifeHours = 176f, bioavailabilityFraction = 1f,
|
||||||
|
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||||
|
),
|
||||||
|
PKPreset(
|
||||||
|
nameRes = R.string.preset_eb_whs,
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
esterType = Esters.EB,
|
||||||
|
pkModel = PKModels.WHSAH,
|
||||||
|
absorptionHours = 13f, eliminationHalfLifeHours = 25f, bioavailabilityFraction = 1f,
|
||||||
|
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
|
||||||
|
),
|
||||||
|
PKPreset(
|
||||||
|
nameRes = R.string.preset_ec_whs,
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
esterType = Esters.EC,
|
||||||
|
pkModel = PKModels.WHSAH,
|
||||||
|
absorptionHours = 91f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f,
|
||||||
|
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||||
|
),
|
||||||
|
PKPreset(
|
||||||
|
nameRes = R.string.preset_ecs_whs,
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
esterType = Esters.ECS,
|
||||||
|
pkModel = PKModels.WHSAH,
|
||||||
|
absorptionHours = 60f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
|
||||||
|
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||||
|
),
|
||||||
// --- Transdermal / oral (Bateman fallback) ---
|
// --- Transdermal / oral (Bateman fallback) ---
|
||||||
PKPreset(
|
PKPreset(
|
||||||
nameRes = R.string.preset_e2_gel,
|
nameRes = R.string.preset_e2_gel,
|
||||||
|
|||||||
@ -16,14 +16,55 @@ object Esters {
|
|||||||
const val EC = "EC" // cypionate, solution huileuse
|
const val EC = "EC" // cypionate, solution huileuse
|
||||||
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
|
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
|
||||||
const val PEP = "PEP" // polyestradiol phosphate
|
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). */
|
/** 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) ; 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) {
|
||||||
|
// ⚠️ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object PKModels {
|
object PKModels {
|
||||||
const val ESTRANNAISE = "ESE"
|
const val ESTRANNAISE = "ESE"
|
||||||
const val TRANSFEM_SCIENCE = "TFS"
|
const val TRANSFEM_SCIENCE = "TFS"
|
||||||
|
// v1.4.6 : 3ᵉ modèle PK — le fit « license-free » du WHSAH Collective
|
||||||
|
// (source : app Mona, cf pk/WhsahModels.kt). Champ texte → aucune
|
||||||
|
// migration Room ni changement de backup nécessaires.
|
||||||
|
const val WHSAH = "WHS"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ressource de LABEL d'un modèle PK (v1.4.6/1.4.7, fix #59) — UNIQUE source
|
||||||
|
* de vérité pour les affichages : chips de `TreatmentsScreen`, dropdown de
|
||||||
|
* `TreatmentEditorScreen`, chip du graphique. ⚠️ L'ancien ternaire à 2
|
||||||
|
* branches affichait « Estrannaise » pour WHSAH (WHSAH tombait dans le
|
||||||
|
* `else`), remonté par l'utilisatrice — TOUTE nouvelle utilisation doit
|
||||||
|
* passer par ce helper, pas par un ternaire (bug #59).
|
||||||
|
*/
|
||||||
|
fun modelLabelRes(pkModel: String): Int = when (pkModel) {
|
||||||
|
PKModels.TRANSFEM_SCIENCE -> com.hormonetrack.R.string.model_tfs
|
||||||
|
PKModels.WHSAH -> com.hormonetrack.R.string.model_whs
|
||||||
|
else -> com.hormonetrack.R.string.model_ese
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TreatmentType {
|
enum class TreatmentType {
|
||||||
@ -53,7 +94,8 @@ data class Treatment(
|
|||||||
val isActive: Boolean = true,
|
val isActive: Boolean = true,
|
||||||
val notes: String? = null,
|
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 esterType: String = Esters.NONE,
|
||||||
val pkModel: String = PKModels.ESTRANNAISE,
|
val pkModel: String = PKModels.ESTRANNAISE,
|
||||||
|
|
||||||
@ -84,6 +126,9 @@ data class Treatment(
|
|||||||
) {
|
) {
|
||||||
val isInjection: Boolean
|
val isInjection: Boolean
|
||||||
get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
|
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
|
val usesProfileModel: Boolean
|
||||||
get() = isInjection && esterType != Esters.NONE
|
get() = isInjection && esterType != Esters.NONE
|
||||||
|
|||||||
@ -18,15 +18,6 @@ class HormoneRepository(
|
|||||||
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
|
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
|
||||||
val allLabResults: Flow<List<LabResult>> = labResultDao.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 getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id)
|
||||||
|
|
||||||
suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce()
|
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
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.gson.JsonParser
|
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 {
|
object PKProfileStore {
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@ -10,15 +24,6 @@ object PKProfileStore {
|
|||||||
|
|
||||||
private lateinit var profiles: Map<String, FloatArray>
|
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). */
|
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
|
||||||
fun initWithJson(json: String) {
|
fun initWithJson(json: String) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
@ -36,7 +41,7 @@ object PKProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun profileKey(ester: String, model: String): String =
|
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
|
* Case-insensitive lookup. The ODS asset keys use the biological casing
|
||||||
@ -56,6 +61,62 @@ object PKProfileStore {
|
|||||||
fun profileLength(ester: String, model: String): Int =
|
fun profileLength(ester: String, model: String): Int =
|
||||||
lookup(profileKey(ester, model))?.size ?: 0
|
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,
|
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
|
||||||
* linearly interpolated between hourly points; beyond the table, extrapolated
|
* linearly interpolated between hourly points; beyond the table, extrapolated
|
||||||
@ -64,8 +125,8 @@ object PKProfileStore {
|
|||||||
fun sample(ester: String, model: String, dtHours: Double): Double {
|
fun sample(ester: String, model: String, dtHours: Double): Double {
|
||||||
if (!initialized || dtHours <= 0.0) return 0.0
|
if (!initialized || dtHours <= 0.0) return 0.0
|
||||||
val suffix = when (model) {
|
val suffix = when (model) {
|
||||||
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs"
|
PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||||
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese"
|
PKModels.ESTRANNAISE -> "ese"
|
||||||
else -> return 0.0
|
else -> return 0.0
|
||||||
}
|
}
|
||||||
val arr = lookup("${ester}_$suffix") ?: 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
|
// display floor long before the true value vanishes. Extrapolate from the
|
||||||
// last point still >= 1% of the peak, using the average hourly decay of
|
// last point still >= 1% of the peak, using the average hourly decay of
|
||||||
// the previous 48 h (never sampling before the peak).
|
// the previous 48 h (never sampling before the peak).
|
||||||
var peakIdx = 0
|
val (j, rate) = terminalDecayParameters(arr) ?: return 0.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
|
|
||||||
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
|
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,12 +88,23 @@ object PharmacokineticEngine {
|
|||||||
val model = modelOverride ?: treatment.pkModel
|
val model = modelOverride ?: treatment.pkModel
|
||||||
// v1.4.0 : le modèle Transfem Science utilise les paramètres
|
// v1.4.0 : le modèle Transfem Science utilise les paramètres
|
||||||
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
||||||
// forme close — les tables ODS restent le fallback (esters
|
// forme close.
|
||||||
// sans modèle V3C, ou modèle Estrannaise).
|
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
|
||||||
if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) {
|
// 3ᵉ voie de dispatch, au même niveau que TFS.
|
||||||
return TransfemScienceModels.sample(ester, dtH) * mg
|
// 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
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return PKProfileStore.sample(ester, model, dtH) * mg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,17 +120,39 @@ object PharmacokineticEngine {
|
|||||||
return max(0.0, c)
|
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 profileH = if (treatment.usesProfileModel) {
|
||||||
val model = treatment.pkModel
|
val model = treatment.pkModel
|
||||||
if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
|
when {
|
||||||
// v1.4.0 : modèle V3C (forme close, pas de table bornée) →
|
// v1.4.6 : modèle WHSAH → coupure à 10 demi-vies terminales
|
||||||
// coupure à 10 demi-vies TERMINALES (~0,1 % de résidu), la
|
// du fit (ex. EEn t½ 7,3 j → 73 j), comme pour le V3C TFS.
|
||||||
// t½ venant des paramètres de l'article (ex. EV 3 j → 30 j).
|
model == PKModels.WHSAH && WhsahModels.hasModel(treatment.esterType) ->
|
||||||
TransfemScienceModels.model(treatment.esterType)!!
|
WhsahModels.model(treatment.esterType)!!
|
||||||
.terminalHalfLifeDays * 24.0 * 10.0
|
.terminalHalfLifeDays * 24.0 * 10.0
|
||||||
} else {
|
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
|
||||||
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
|
TransfemScienceModels.model(treatment.esterType)!!
|
||||||
|
.terminalHalfLifeDays * 24.0 * 10.0
|
||||||
|
// 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
|
} else 0.0
|
||||||
val batemanH = if (treatment.usesProfileModel) 0.0
|
val batemanH = if (treatment.usesProfileModel) 0.0
|
||||||
@ -131,6 +164,65 @@ object PharmacokineticEngine {
|
|||||||
// Aggregated levels
|
// 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(
|
fun e2At(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
@ -144,28 +236,7 @@ object PharmacokineticEngine {
|
|||||||
*/
|
*/
|
||||||
scalePerEster: Map<String, Double>? = null
|
scalePerEster: Map<String, Double>? = null
|
||||||
): Double {
|
): Double {
|
||||||
val batemanCache = HashMap<Long, BatemanParams>()
|
return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
||||||
@ -271,7 +342,15 @@ object PharmacokineticEngine {
|
|||||||
val searchStart = maxOf(startMs, earliestDose)
|
val searchStart = maxOf(startMs, earliestDose)
|
||||||
|
|
||||||
val batemanCache = HashMap<Long, BatemanParams>()
|
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)
|
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
|
||||||
val sortedEstrogenDoses = doseLogs
|
val sortedEstrogenDoses = doseLogs
|
||||||
@ -293,10 +372,10 @@ object PharmacokineticEngine {
|
|||||||
}
|
}
|
||||||
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
|
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
|
||||||
for (treatment in relevantTreatments) {
|
for (treatment in relevantTreatments) {
|
||||||
val cutoff = cutoffHours(treatment)
|
val cutoff = cutoffByTreatment.getValue(treatment.id)
|
||||||
val p = batemanCache[treatment.id]
|
val p = batemanCache[treatment.id]
|
||||||
for (dose in doseLogs) {
|
for (dose in dosesByTreatment[treatment.id].orEmpty()) {
|
||||||
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue
|
if (dose.timestamp > t) continue
|
||||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||||
if (dtH > cutoff) continue
|
if (dtH > cutoff) continue
|
||||||
val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
|
val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
|
||||||
@ -337,6 +416,14 @@ object PharmacokineticEngine {
|
|||||||
): List<DoseLog> {
|
): List<DoseLog> {
|
||||||
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
|
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
|
||||||
if (intervalDays <= 0.0) 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()
|
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
|
||||||
if (intervalMs <= 0L) return emptyList()
|
if (intervalMs <= 0L) return emptyList()
|
||||||
|
|
||||||
@ -389,7 +476,8 @@ object PharmacokineticEngine {
|
|||||||
fun computeEsterScaleFactors(
|
fun computeEsterScaleFactors(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
e2Labs: List<LabResult>
|
e2Labs: List<LabResult>,
|
||||||
|
modelOverride: String? = null
|
||||||
): Map<String, Double> {
|
): Map<String, Double> {
|
||||||
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
|
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
|
||||||
val estrogenDoses = doseLogs.filter { d ->
|
val estrogenDoses = doseLogs.filter { d ->
|
||||||
@ -397,12 +485,23 @@ object PharmacokineticEngine {
|
|||||||
}
|
}
|
||||||
if (estrogenDoses.isEmpty()) return emptyMap()
|
if (estrogenDoses.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab
|
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab,
|
||||||
|
// avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec
|
||||||
|
// SA propre prédiction — v1.4.7 calculait tout depuis le modèle
|
||||||
|
// 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) }
|
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>>()
|
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
||||||
for (lab in e2Labs) {
|
var maxPredictionSoFar = 0.0
|
||||||
val predicted = e2At(unscaled, doseLogs, lab.timestamp)
|
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
||||||
if (predicted <= 0.5) continue
|
// les labs hors de la fenêtre d'action du modèle (prédiction
|
||||||
|
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
||||||
|
// tombait à ×2,21
|
||||||
|
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||||
|
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
|
||||||
|
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||||
|
|
||||||
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
||||||
val attributedDose = estrogenDoses
|
val attributedDose = estrogenDoses
|
||||||
@ -415,12 +514,10 @@ object PharmacokineticEngine {
|
|||||||
|
|
||||||
ratiosByEster.getOrPut(ester) { mutableListOf() }
|
ratiosByEster.getOrPut(ester) { mutableListOf() }
|
||||||
.add(lab.value / predicted)
|
.add(lab.value / predicted)
|
||||||
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||||
}
|
}
|
||||||
return ratiosByEster.mapValues { (_, ratios) ->
|
return ratiosByEster.mapValues { (_, ratios) ->
|
||||||
val sorted = ratios.sorted()
|
medianRounded(ratios, 2)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,7 +540,8 @@ object PharmacokineticEngine {
|
|||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
tLabs: List<LabResult>,
|
tLabs: List<LabResult>,
|
||||||
current: TConfig,
|
current: TConfig,
|
||||||
scalePerEster: Map<String, Double>? = null
|
scalePerEster: Map<String, Double>? = null,
|
||||||
|
modelOverride: String? = null
|
||||||
): Map<String, Double> {
|
): Map<String, Double> {
|
||||||
val estrogenDoses = doseLogs.filter { d ->
|
val estrogenDoses = doseLogs.filter { d ->
|
||||||
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
|
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
|
||||||
@ -451,7 +549,10 @@ object PharmacokineticEngine {
|
|||||||
if (estrogenDoses.isEmpty()) return emptyMap()
|
if (estrogenDoses.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
val ksByEster = HashMap<String, MutableList<Double>>()
|
val ksByEster = HashMap<String, MutableList<Double>>()
|
||||||
for (lab in tLabs) {
|
var maxE2SoFar = 0.0
|
||||||
|
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant
|
||||||
|
// (l'E2 prédite doit rester ≥ 15 % du max observé — cf labIsSignificant)
|
||||||
|
for (lab in tLabs.sortedBy { it.timestamp }) {
|
||||||
val tNgMl = convertTToNgMl(lab.value, lab.unit)
|
val tNgMl = convertTToNgMl(lab.value, lab.unit)
|
||||||
if (tNgMl <= current.floor + 0.02) continue
|
if (tNgMl <= current.floor + 0.02) continue
|
||||||
val attributed = estrogenDoses
|
val attributed = estrogenDoses
|
||||||
@ -461,19 +562,18 @@ object PharmacokineticEngine {
|
|||||||
val ester = doseEster(tr, attributed)
|
val ester = doseEster(tr, attributed)
|
||||||
if (ester == Esters.NONE) continue
|
if (ester == Esters.NONE) continue
|
||||||
|
|
||||||
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster)
|
// v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60)
|
||||||
if (e2 <= 1.0) continue
|
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster,
|
||||||
|
modelOverride = modelOverride)
|
||||||
|
if (!labIsSignificant(e2, maxE2SoFar)) continue
|
||||||
|
|
||||||
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
|
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
|
||||||
if (k > 1e-4 && k < 10.0) {
|
if (k > 1e-4 && k < 10.0) {
|
||||||
ksByEster.getOrPut(ester) { mutableListOf() }.add(k)
|
ksByEster.getOrPut(ester) { mutableListOf() }.add(k)
|
||||||
}
|
}
|
||||||
|
if (e2 > maxE2SoFar) maxE2SoFar = e2
|
||||||
}
|
}
|
||||||
return ksByEster.mapValues { (_, ks) ->
|
return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -503,14 +603,23 @@ object PharmacokineticEngine {
|
|||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
labs: List<LabResult>,
|
labs: List<LabResult>,
|
||||||
tConfig: TConfig
|
tConfig: TConfig,
|
||||||
|
/**
|
||||||
|
* Modèle PK pour les PRÉDICTIONS de calibration (v1.4.8, fix #60) :
|
||||||
|
* null = modèle stocké de chaque traitement (Home). Le graphique
|
||||||
|
* appelle une fois PAR MODÈLE affiché (ESE/TFS/WHS) — chaque courbe
|
||||||
|
* reçoit ainsi SES échelles et SON k.
|
||||||
|
*/
|
||||||
|
modelOverride: String? = null
|
||||||
): AutoCalibrated {
|
): AutoCalibrated {
|
||||||
val e2Labs = labs.filter { it.marker.equals("E2", true) }
|
val e2Labs = labs.filter { it.marker.equals("E2", true) }
|
||||||
val tLabs = labs.filter { it.marker.equals("T", true) }
|
val tLabs = labs.filter { it.marker.equals("T", true) }
|
||||||
|
|
||||||
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
|
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs,
|
||||||
|
modelOverride = modelOverride)
|
||||||
val tKPerEster = computeTKPerEster(
|
val tKPerEster = computeTKPerEster(
|
||||||
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales
|
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales,
|
||||||
|
modelOverride = modelOverride
|
||||||
)
|
)
|
||||||
return AutoCalibrated(
|
return AutoCalibrated(
|
||||||
treatments = treatments,
|
treatments = treatments,
|
||||||
@ -526,6 +635,57 @@ object PharmacokineticEngine {
|
|||||||
// Calibration from lab results
|
// Calibration from lab results
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de SIGNIFICATIVITÉ d'un lab (v1.4.9, fix #61) : le lab ne
|
||||||
|
* calibre que si la prédiction du modèle à son instant reste ≥ 15 %
|
||||||
|
* du maximum de prédiction déjà observé — c'est-à-dire tant que
|
||||||
|
* l'injection « gouverne » encore le taux. Au-delà, le lab reflète
|
||||||
|
* AUTRE CHOSE (temps mort, début de traitement, autre ester) et son
|
||||||
|
* ratio lab ÷ prédiction devient aberrant (ex. lab 373 pg/mL contre
|
||||||
|
* une prédiction résiduelle de 23 pg/mL → ratio 15,9). Remontée
|
||||||
|
* v1.4.8 : une dose EEn de test logguée en janvier, des labs de
|
||||||
|
* janvier-mars à 15–76 j → ratios aberrants dont la MÉDIANE tombait
|
||||||
|
* à ×2,21 — dans les DEUX pipelines (manuel et auto, même défaut).
|
||||||
|
*
|
||||||
|
* Pourquoi une garde RELATIVE (et pas temporelle en jours) : la
|
||||||
|
* fenêtre d'action dépend du profil (l'injection gouverne ~2 j pour
|
||||||
|
* le benzoate, des mois pour l'undécylate) — une fenêtre fixe
|
||||||
|
* casserait autant les labs réels que les tests. La PRÉDICTION,
|
||||||
|
* elle, contient déjà toute la physiologie du modèle.
|
||||||
|
*/
|
||||||
|
const val LAB_MIN_PREDICTION_FRACTION = 0.15
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le lab est SIGNIFICATIF pour la calibration : prédiction > 0,5 pg/mL
|
||||||
|
* (pas du bruit) ET ≥ [LAB_MIN_PREDICTION_FRACTION] du maximum de
|
||||||
|
* prédiction déjà observé. [maxSoFar] = max des prédictions calculées
|
||||||
|
* 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)
|
||||||
|
|
||||||
fun computeScaleFactor(
|
fun computeScaleFactor(
|
||||||
treatment: Treatment,
|
treatment: Treatment,
|
||||||
allDoseLogs: List<DoseLog>,
|
allDoseLogs: List<DoseLog>,
|
||||||
@ -537,7 +697,12 @@ object PharmacokineticEngine {
|
|||||||
|
|
||||||
val p = batemanParams(treatment)
|
val p = batemanParams(treatment)
|
||||||
val ratios = mutableListOf<Double>()
|
val ratios = mutableListOf<Double>()
|
||||||
for (lab in e2Labs) {
|
var maxPredictionSoFar = 0.0
|
||||||
|
// ⚠️ fix #61 : labs par timestamp CROISSANT — le max de prédiction se
|
||||||
|
// construit au fil de l'eau et la garde labIsSignificant exclut les
|
||||||
|
// labs qui n'atteignent plus 15 % du max (l'injection ne gouverne
|
||||||
|
// plus : le lab reflète autre chose — ratios aberrants → ×2,21)
|
||||||
|
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||||
var predicted = 0.0
|
var predicted = 0.0
|
||||||
for (dose in myDoses) {
|
for (dose in myDoses) {
|
||||||
if (dose.timestamp > lab.timestamp) continue
|
if (dose.timestamp > lab.timestamp) continue
|
||||||
@ -545,18 +710,13 @@ object PharmacokineticEngine {
|
|||||||
if (dtH > cutoffHours(treatment)) continue
|
if (dtH > cutoffHours(treatment)) continue
|
||||||
predicted += concentrationOfDose(treatment, dose, lab.timestamp, p)
|
predicted += concentrationOfDose(treatment, dose, lab.timestamp, p)
|
||||||
}
|
}
|
||||||
if (predicted > 0.5) {
|
if (labIsSignificant(predicted, maxPredictionSoFar)) {
|
||||||
ratios.add(lab.value / predicted)
|
ratios.add(lab.value / predicted)
|
||||||
}
|
}
|
||||||
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||||
}
|
}
|
||||||
if (ratios.isEmpty()) return null
|
if (ratios.isEmpty()) return null
|
||||||
ratios.sort()
|
return medianRounded(ratios, 2)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun computeTConfigCalibration(
|
fun computeTConfigCalibration(
|
||||||
@ -565,8 +725,10 @@ object PharmacokineticEngine {
|
|||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
current: TConfig
|
current: TConfig
|
||||||
): 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
|
val usable = tLabs
|
||||||
.filter { it.value > current.floor + 0.02 }
|
|
||||||
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
|
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
|
||||||
.filter { it.value > current.floor + 0.02 }
|
.filter { it.value > current.floor + 0.02 }
|
||||||
if (usable.isEmpty()) return null
|
if (usable.isEmpty()) return null
|
||||||
@ -579,13 +741,33 @@ object PharmacokineticEngine {
|
|||||||
if (k > 1e-4 && k < 10.0) ks.add(k)
|
if (k > 1e-4 && k < 10.0) ks.add(k)
|
||||||
}
|
}
|
||||||
if (ks.isEmpty()) return null
|
if (ks.isEmpty()) return null
|
||||||
ks.sort()
|
return current.copy(k = medianRounded(ks, 3))
|
||||||
val median = if (ks.size % 2 == 1) {
|
}
|
||||||
ks[ks.size / 2]
|
|
||||||
} else {
|
/**
|
||||||
(ks[ks.size / 2 - 1] + ks[ks.size / 2]) / 2.0
|
* 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
102
app/src/main/java/com/hormonetrack/pk/WhsahModels.kt
Normal file
102
app/src/main/java/com/hormonetrack/pk/WhsahModels.kt
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modèle PK « WHSAH » (v1.4.6) — 3ᵉ modèle au niveau d'Estrannaise et
|
||||||
|
* Transfem Science, superposable sur le graphique.
|
||||||
|
*
|
||||||
|
* SOURCE : le fit « license-free » publié par le WHSAH Collective dans
|
||||||
|
* l'app open-source Mona (projet Flutter — lib/data/model/graph_calculator.dart,
|
||||||
|
* commit « new license-free pk params » ; leurs auteurs déclarent explicitement
|
||||||
|
* les coefficients libres d'usage, « Inferred with love exclusively for Mona »).
|
||||||
|
* Crédit : alix / WHSAH Collective via Mona.
|
||||||
|
*
|
||||||
|
* POURQUOI CE MODÈLE EXISTE (cf diagnostic comparatif du 7 sept. 2026) :
|
||||||
|
* Mona a d'abord utilisé les paramètres Transfem Science (identiques aux
|
||||||
|
* nôtres, commit du 6 mars 2026) PUIS les a remplacés par ce fit indépendant.
|
||||||
|
* Les deux sont des tri-exponentielles en forme close ajustées sur les mêmes
|
||||||
|
* études, avec des conventions différentes :
|
||||||
|
*
|
||||||
|
* - WHSAH paramètre une BIODISPONIBILITÉ explicite F < 1 (0,62–0,76) et
|
||||||
|
* un facteur d'échelle `auc` intégré — les courbes « brutes » sont ~24 %
|
||||||
|
* plus basses que TFS non calibrée (la calibration de l'app absorbe ça) ;
|
||||||
|
* - la MONTÉE est plus rapide (compartiment rapide plus vif) et la t½
|
||||||
|
* terminale plus longue sur certains esters : EV Cmax 367 pg/mL @ 1,7 j
|
||||||
|
* (TFS : 295 @ 2,1 j), EEn Cmax 188 @ 5,0 j (TFS : 160 @ 6,5 j) et
|
||||||
|
* t½ EEn 7,3 j (TFS : 4,5 j) — à J+1 l'EEn WHSAH est ~3× plus haut
|
||||||
|
* (70 vs 22 pg/mL) ;
|
||||||
|
* - PEP NON COUVERT (retiré du jeu WHSAH) → `sample("PEP", …) = 0`.
|
||||||
|
*
|
||||||
|
* FORMULE (telle qu'implémentée dans Mona, normalisée PAR MG pour le moteur
|
||||||
|
* — le multiplicateur de dose est appliqué par `PharmacokineticEngine`) :
|
||||||
|
*
|
||||||
|
* sample = F · auc · k1·k2·k3 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||||
|
* − e^(−k2·t)/((k1−k2)(k2−k3))
|
||||||
|
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
|
||||||
|
*
|
||||||
|
* avec t en JOURS, résultat en pg/mL pour 1 mg. Sanity checks épinglés par
|
||||||
|
* `WhsahModelsTest` (fidélité au comportement de Mona, ±2 %) :
|
||||||
|
* EV Cmax 367,5 @ 1,69 j · EEn 187,9 @ 4,99 j · t½ EEn 7,34 j.
|
||||||
|
*/
|
||||||
|
object WhsahModels {
|
||||||
|
|
||||||
|
/** Modèle WHSAH d'un ester (fit F/auc/k1/k2/k3). */
|
||||||
|
data class WHS(
|
||||||
|
/** Clé ester de l'app ("EV", "EB", …). */
|
||||||
|
val ester: String,
|
||||||
|
/** Biodisponibilité explicite (< 1 dans le fit WHSAH). */
|
||||||
|
val F: Double,
|
||||||
|
/** Constante d'échelle du fit (pg/mL intégrés). */
|
||||||
|
val auc: Double,
|
||||||
|
/** Constantes de vitesse en j⁻¹ (k1 = absorption, k2/k3 phases). */
|
||||||
|
val k1: Double,
|
||||||
|
val k2: Double,
|
||||||
|
val k3: Double
|
||||||
|
) {
|
||||||
|
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
|
||||||
|
val terminalHalfLifeDays: Double
|
||||||
|
get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MODELS: Map<String, WHS> = mapOf(
|
||||||
|
// estradiol enanthate (solution huileuse)
|
||||||
|
"EEN" to WHS("EEN", F = 0.708, auc = 875.4, k1 = 0.09441, k2 = 3.354, k3 = 0.4078),
|
||||||
|
// estradiol valerate (solution huileuse)
|
||||||
|
"EV" to WHS("EV", F = 0.764, auc = 621.3, k1 = 0.2230, k2 = 17.62, k3 = 1.305),
|
||||||
|
// estradiol benzoate (solution huileuse)
|
||||||
|
"EB" to WHS("EB", F = 0.723, auc = 889.9, k1 = 0.5220, k2 = 521.9, k3 = 5.223),
|
||||||
|
// estradiol cypionate — SOLUTION HUILEUSE
|
||||||
|
"EC" to WHS("EC", F = 0.687, auc = 554.5, k1 = 0.0880, k2 = 17.95, k3 = 0.7177),
|
||||||
|
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux)
|
||||||
|
"ECS" to WHS("ECS", F = 0.687, auc = 852.6, k1 = 0.0973, k2 = 218.67, k3 = 6.624),
|
||||||
|
// estradiol undecylate — données d'étude limitées (comme chez TFS)
|
||||||
|
"EU" to WHS("EU", F = 0.618, auc = 385.8, k1 = 0.02189, k2 = 183.4, k3 = 1.564)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase())
|
||||||
|
|
||||||
|
/** Le modèle WHSAH de l'ester, ou null (lookup insensible à la casse). */
|
||||||
|
fun model(ester: String): WHS? = MODELS[ester.uppercase()]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||||
|
* modèle WHSAH. dtHours ≤ 0 → 0 (rien avant la dose).
|
||||||
|
*/
|
||||||
|
fun sample(ester: String, dtHours: Double): Double {
|
||||||
|
if (dtHours <= 0.0) return 0.0
|
||||||
|
val m = model(ester) ?: return 0.0
|
||||||
|
return rawConcentrationDays(m, dtHours / 24.0) // t en jours
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cp(t) en pg/mL pour 1 mg (forme Mona exacte — voir KDoc de l'objet).
|
||||||
|
* Les compartiments « ultra-rapides » (k2 jusqu'à ~520 j⁻¹ : EB) décroissent
|
||||||
|
* instantanément et n'affectent que t≈0 — double précision suffisante
|
||||||
|
* (vérifié par les tests de pics).
|
||||||
|
*/
|
||||||
|
private fun rawConcentrationDays(m: WHS, tDays: Double): Double {
|
||||||
|
val part1 = kotlin.math.exp(-m.k1 * tDays) / ((m.k1 - m.k2) * (m.k1 - m.k3))
|
||||||
|
val part2 = kotlin.math.exp(-m.k2 * tDays) / ((m.k1 - m.k2) * (m.k2 - m.k3))
|
||||||
|
val part3 = kotlin.math.exp(-m.k3 * tDays) / ((m.k1 - m.k3) * (m.k2 - m.k3))
|
||||||
|
return m.F * m.auc * m.k1 * m.k2 * m.k3 * (part1 - part2 + part3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,7 +20,6 @@ import com.hormonetrack.R
|
|||||||
import com.hormonetrack.data.AppDatabase
|
import com.hormonetrack.data.AppDatabase
|
||||||
import com.hormonetrack.pk.Alerts
|
import com.hormonetrack.pk.Alerts
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.pk.TConfig
|
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.util.AppLog
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@ -33,7 +32,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
* Fonctionnement :
|
* Fonctionnement :
|
||||||
* - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le
|
* - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le
|
||||||
* niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil :
|
* niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil :
|
||||||
* calibration par période d'ester si l'option est active) contre les
|
* calibration par période d'ester si l'option est active) contre les
|
||||||
* seuils configurés ;
|
* seuils configurés ;
|
||||||
* - **anti-spam** : l'état des alertes déjà notifiées est persisté en
|
* - **anti-spam** : l'état des alertes déjà notifiées est persisté en
|
||||||
* DataStore (`Alerts.encodeState`) → pas de re-notification du même
|
* DataStore (`Alerts.encodeState`) → pas de re-notification du même
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.ui.components.formatDose
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@ -188,6 +189,6 @@ object CalendarEvents {
|
|||||||
private fun formatInterval(treatment: Treatment): String =
|
private fun formatInterval(treatment: Treatment): String =
|
||||||
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
|
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
|
||||||
|
|
||||||
private fun formatDose(d: Double): String =
|
// formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt
|
||||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d)
|
// (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 -> {
|
ReminderContract.ACTION_SNOOZE -> {
|
||||||
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
|
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
|
||||||
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
|
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.DoseLog
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Calendar
|
|
||||||
|
|
||||||
object ReminderContract {
|
object ReminderContract {
|
||||||
const val CHANNEL_ID = "hormonetrack_reminders"
|
const val CHANNEL_ID = "hormonetrack_reminders"
|
||||||
@ -30,6 +30,17 @@ object ReminderContract {
|
|||||||
const val EXTRA_TREATMENT_NAME = "treatment_name"
|
const val EXTRA_TREATMENT_NAME = "treatment_name"
|
||||||
const val EXTRA_DOSE_AMOUNT = "dose_amount"
|
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 =
|
fun reminderIntent(context: Context, treatmentId: Long): Intent =
|
||||||
Intent(context, ReminderReceiver::class.java).apply {
|
Intent(context, ReminderReceiver::class.java).apply {
|
||||||
action = ACTION_REMINDER
|
action = ACTION_REMINDER
|
||||||
@ -53,10 +64,16 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
|
|
||||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
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)
|
val intent = ReminderContract.reminderIntent(context, treatmentId)
|
||||||
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
|
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
|
||||||
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
|
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
|
||||||
|
plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) }
|
||||||
return PendingIntent.getBroadcast(
|
return PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
treatmentId.toInt(),
|
treatmentId.toInt(),
|
||||||
@ -80,12 +97,27 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
val fireAt = PharmacokineticEngine.nextReminderFireFor(
|
val fireAt = PharmacokineticEngine.nextReminderFireFor(
|
||||||
treatment, doseLogs, System.currentTimeMillis()
|
treatment, doseLogs, System.currentTimeMillis()
|
||||||
) ?: return false
|
) ?: 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)
|
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)
|
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,19 +240,41 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
manager.notify(treatmentId.toInt(), notification)
|
|
||||||
|
|
||||||
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
||||||
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
|
// 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
|
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
|
||||||
// (grille Posologie si elle existe, sinon quotidien).
|
// (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()
|
val pending = goAsync()
|
||||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val db = AppDatabase.getInstance(context)
|
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 ->
|
db.treatmentDao().getById(treatmentId)?.let { tr ->
|
||||||
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce())
|
AlarmScheduler(context).scheduleFor(tr, doses)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
@ -232,23 +286,23 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
val result = goAsync()
|
// v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver —
|
||||||
Thread {
|
// 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 {
|
try {
|
||||||
val db = AppDatabase.getInstance(context)
|
val db = AppDatabase.getInstance(context)
|
||||||
// TOUS les traitements : les inactifs doivent voir leur alarme
|
// TOUS les traitements : les inactifs doivent voir leur alarme
|
||||||
// annulée au boot (sinon une alarme périmée continue de sonner) ;
|
// annulée au boot (sinon une alarme périmée continue de sonner) ;
|
||||||
// les doses alimentent la grille Posologie (fix #52)
|
// les doses alimentent la grille Posologie (fix #52)
|
||||||
val treatments = kotlinx.coroutines.runBlocking {
|
val treatments = db.treatmentDao().getAllOnce()
|
||||||
db.treatmentDao().getAllOnce()
|
val doses = db.doseLogDao().getAllOnce()
|
||||||
}
|
|
||||||
val doses = kotlinx.coroutines.runBlocking {
|
|
||||||
db.doseLogDao().getAllOnce()
|
|
||||||
}
|
|
||||||
AlarmScheduler(context).rescheduleAll(treatments, doses)
|
AlarmScheduler(context).rescheduleAll(treatments, doses)
|
||||||
} finally {
|
} 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.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
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.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.hormonetrack.pk.Alerts
|
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).
|
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
|
||||||
*/
|
*/
|
||||||
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
|
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 ->
|
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?) {
|
suspend fun setAlertNotifiedState(encoded: String?) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)
|
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é
|
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
|
||||||
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
package com.hormonetrack.settings
|
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
|
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
|
||||||
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
|
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
|
||||||
@ -28,7 +32,7 @@ object ChangelogHelper {
|
|||||||
|
|
||||||
private fun parse(version: String): List<Int>? {
|
private fun parse(version: String): List<Int>? {
|
||||||
val cleaned = version.trim().removePrefix("v")
|
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() }
|
return cleaned.split(".").map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +47,7 @@ object ChangelogHelper {
|
|||||||
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
|
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
|
||||||
*/
|
*/
|
||||||
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
|
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()
|
val out = StringBuilder()
|
||||||
for (match in pattern.findAll(markdown)) {
|
for (match in pattern.findAll(markdown)) {
|
||||||
val version = match.groupValues[1]
|
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)
|
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
||||||
|
|
||||||
@Composable
|
@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 app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
|
||||||
@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
|||||||
) {
|
) {
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
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") },
|
onOpenSettings = { navController.navigate("settings") },
|
||||||
onOpenCharts = {
|
onOpenCharts = {
|
||||||
// Tap sur le mini-graphique de l'accueil → écran Graphiques
|
// 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.foundation.Canvas
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
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.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.data.model.DoseLog
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
import com.hormonetrack.data.model.LabResult
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.ui.theme.ChartE2
|
|
||||||
import com.hormonetrack.ui.theme.ChartT
|
|
||||||
import com.hormonetrack.ui.theme.LabDot
|
import com.hormonetrack.ui.theme.LabDot
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
@ -41,7 +41,22 @@ data class ChartSeries(
|
|||||||
val points: List<LevelPoint>,
|
val points: List<LevelPoint>,
|
||||||
val e2Style: CurveStyle,
|
val e2Style: CurveStyle,
|
||||||
/** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */
|
/** 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(
|
data class ChartOptions(
|
||||||
@ -88,16 +103,56 @@ fun xLabelTicks(
|
|||||||
cal.set(Calendar.MILLISECOND, 0)
|
cal.set(Calendar.MILLISECOND, 0)
|
||||||
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
||||||
}
|
}
|
||||||
val stepMs = stepHours * 3_600_000L
|
|
||||||
val out = mutableListOf<Long>()
|
val out = mutableListOf<Long>()
|
||||||
var t = cal.timeInMillis
|
var t = cal.timeInMillis
|
||||||
while (t <= t1) {
|
while (t <= t1) {
|
||||||
out.add(t)
|
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
|
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. */
|
/** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */
|
||||||
fun niceCeil(value: Double): Double {
|
fun niceCeil(value: Double): Double {
|
||||||
if (value <= 0) return 100.0
|
if (value <= 0) return 100.0
|
||||||
@ -113,6 +168,30 @@ fun niceCeil(value: Double): Double {
|
|||||||
return nice * exp
|
return nice * exp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles de MODÈLES PK par défaut (v1.4.7, fix #58) : au chargement du
|
||||||
|
* graphique, seuls les modèles RÉELLEMENT utilisés par les traitements (en
|
||||||
|
* cours ou passés — `isActive` n'est pas un filtre de simulation, cf §6.bis)
|
||||||
|
* sont affichés ; les autres restent disponibles (chip off, activables au
|
||||||
|
* tap). Ex. EV historique sous TFS + EEn actuel sous WHSAH → TFS ✓ WHSAH ✓
|
||||||
|
* Estrannaise ✗.
|
||||||
|
*
|
||||||
|
* @param pkModelsUsed ensemble des `pkModel` des traitements de l'utilisatrice.
|
||||||
|
* @return map modèle → toggle initial. GARDE-FOU : si AUCUN modèle profil
|
||||||
|
* n'est utilisé (traitements gel/oral uniquement), TFS est forcé à ON —
|
||||||
|
* les traitements Bateman sont tracés IDENTIQUEMENT dans chaque série
|
||||||
|
* (cf §7.3b) : sans garde-fou, le graphique serait vide.
|
||||||
|
*/
|
||||||
|
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
||||||
|
val toggles = mapOf(
|
||||||
|
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 + (PKModels.TRANSFEM_SCIENCE to true) else toggles
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
|
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
|
||||||
* plus on zoome, plus le pas finit pour des courbes lisses
|
* plus on zoome, plus le pas finit pour des courbes lisses
|
||||||
@ -241,12 +320,44 @@ fun CurveChart(
|
|||||||
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
||||||
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
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) {
|
Canvas(modifier = modifier) {
|
||||||
val allPoints = series.flatMap { it.points }
|
val allPoints = series.flatMap { it.points }
|
||||||
if (allPoints.size < 2) return@Canvas
|
if (allPoints.size < 2) return@Canvas
|
||||||
val padLeft = 42.dp.toPx()
|
val padLeft = 42.dp.toPx()
|
||||||
val padRight = 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 padBottom = 26.dp.toPx()
|
||||||
|
|
||||||
val w = size.width - padLeft - padRight
|
val w = size.width - padLeft - padRight
|
||||||
@ -279,24 +390,32 @@ fun CurveChart(
|
|||||||
|
|
||||||
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
|
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
|
||||||
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
|
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 }) {
|
if (options.showT && series.any { it.tStyle != null }) {
|
||||||
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
|
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
|
// 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
|
// 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).
|
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
||||||
if (forecastDoses.isNotEmpty()) {
|
if (forecastDoses.isNotEmpty()) {
|
||||||
val markerColor = forecastMarkerColor
|
val markerColor = forecastMarkerColor
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
|
||||||
color = android.graphics.Color.argb(
|
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
||||||
(markerColor.alpha * 255).toInt(),
|
// redessine à chaque pan/zoom, une allocation par dose × frame
|
||||||
(markerColor.red * 255).toInt(),
|
// était du gaspillage (la classe n'est de toute façon pas
|
||||||
(markerColor.green * 255).toInt(),
|
// thread-safe — ici on reste dans le DrawScope UI)
|
||||||
(markerColor.blue * 255).toInt()
|
val hourFmt = markerTimeFmt
|
||||||
)
|
|
||||||
}
|
|
||||||
for (dose in forecastDoses) {
|
for (dose in forecastDoses) {
|
||||||
if (dose.timestamp !in t0..t1) continue
|
if (dose.timestamp !in t0..t1) continue
|
||||||
val x = xOf(dose.timestamp)
|
val x = xOf(dose.timestamp)
|
||||||
@ -316,16 +435,42 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
drawPath(path, markerColor)
|
drawPath(path, markerColor)
|
||||||
// heure de la dose simulée DANS le fuseau de lecture
|
// 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(
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint
|
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) {
|
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
|
if (s.points.size < 2) continue
|
||||||
val e2Path = Path()
|
val e2Path = Path()
|
||||||
s.points.forEachIndexed { i, p ->
|
s.points.forEachIndexed { i, p ->
|
||||||
@ -363,18 +508,22 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
// 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)
|
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
||||||
if (options.showExtrema) {
|
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
|
||||||
.forEach { ex ->
|
// 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)
|
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||||
}
|
}
|
||||||
if (options.showT) {
|
if (options.showT) {
|
||||||
s.tStyle?.let { tStyle ->
|
s.tStyle?.let { tStyle ->
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
|
tExtrema.orEmpty().forEach { ex ->
|
||||||
.forEach { ex ->
|
|
||||||
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -439,7 +588,7 @@ fun CurveChart(
|
|||||||
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
|
* 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).
|
* 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 r = 5.dp.toPx()
|
||||||
val offset = 6.dp.toPx()
|
val offset = 6.dp.toPx()
|
||||||
val path = Path()
|
val path = Path()
|
||||||
@ -459,46 +608,77 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetr
|
|||||||
}
|
}
|
||||||
path.close()
|
path.close()
|
||||||
drawPath(path, color)
|
drawPath(path, color)
|
||||||
// Valeur estimée du pic/creux, dans la couleur de la courbe
|
// Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
|
||||||
val paint = android.graphics.Paint().apply {
|
// en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
|
||||||
this.color = android.graphics.Color.argb(
|
val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||||
(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(
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
formatValue(extremum.value), cx, valueY, paint
|
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) {
|
/** 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
|
val y = padTop + h * i / rows
|
||||||
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
|
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 {
|
* Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
|
||||||
val c = Color(0xFF6B7280)
|
* (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
|
||||||
return android.graphics.Paint().apply {
|
* label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
|
||||||
color = android.graphics.Color.argb(
|
* dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
|
||||||
(c.alpha * 255).toInt(),
|
* Les clés sont des value classes (Color) → pas de fuite.
|
||||||
(c.red * 255).toInt(),
|
*/
|
||||||
(c.green * 255).toInt(),
|
private val textPaintCache = HashMap<String, android.graphics.Paint>()
|
||||||
(c.blue * 255).toInt()
|
|
||||||
)
|
private fun DrawScope.textPaint(
|
||||||
textSize = 10.dp.toPx()
|
color: Color,
|
||||||
textAlign = align
|
textSizeDp: Float,
|
||||||
isAntiAlias = true
|
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 = 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) {
|
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
||||||
for (i in 0..4) {
|
for (i in 0..4) {
|
||||||
@ -520,7 +700,11 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
|
|||||||
private fun DrawScope.drawXLabels(
|
private fun DrawScope.drawXLabels(
|
||||||
color: Color, t0: Long, t1: Long,
|
color: Color, t0: Long, t1: Long,
|
||||||
padLeft: Float, yBottom: Float, w: Float,
|
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 paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
||||||
val spanH = (t1 - t0) / 3_600_000.0
|
val spanH = (t1 - t0) / 3_600_000.0
|
||||||
@ -534,28 +718,25 @@ private fun DrawScope.drawXLabels(
|
|||||||
}
|
}
|
||||||
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
||||||
// tick « minuit local » serait étiqueté avec l'heure système.
|
// tick « minuit local » serait étiqueté avec l'heure système.
|
||||||
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
|
// v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
|
||||||
else SimpleDateFormat("dd/MM", Locale.getDefault())
|
val fmt = if (spanH <= 25) hourFmt else dayFmt
|
||||||
fmt.timeZone = zone
|
|
||||||
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
// 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
|
// (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.
|
// 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())
|
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
||||||
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
// Paint en cache (v1.9.5 : une allocation par lab × frame → une par
|
||||||
color = android.graphics.Color.rgb(
|
// couleur, réutilisée entre frames)
|
||||||
(LabDot.red * 255).toInt(),
|
val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||||
(LabDot.green * 255).toInt(),
|
|
||||||
(LabDot.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
textSize = 9.dp.toPx()
|
|
||||||
isFakeBoldText = true
|
|
||||||
}
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,16 +140,11 @@ fun DoseDialog(
|
|||||||
text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
|
text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
|
||||||
onClick = { esterOverride = null; esterExpanded = false }
|
onClick = { esterOverride = null; esterExpanded = false }
|
||||||
)
|
)
|
||||||
// v1.4.0 : la liste d'esters suit le MODÈLE du
|
// v1.4.0/v1.4.6 : la liste d'esters suit le MODÈLE
|
||||||
// traitement — TFS = tous les esters de la
|
// du traitement (helper pur Esters.choicesForModel,
|
||||||
// méta-analyse (V3C, cf TransfemScienceModels),
|
// testé) — TFS = 7 esters (V3C), WHSAH = 6 (PEP non
|
||||||
// Estrannaise (ODS) = EV/EU/EEn seulement (les
|
// couvert), Estrannaise (ODS) = EV/EU/EEn seulement.
|
||||||
// tables ne couvrent pas les autres esters).
|
val choices = Esters.choicesForModel(selected.pkModel)
|
||||||
val choices = if (selected.pkModel == PKModels.TRANSFEM_SCIENCE) {
|
|
||||||
Esters.INJECTABLE
|
|
||||||
} else {
|
|
||||||
listOf(Esters.EV, Esters.EU, Esters.EEN)
|
|
||||||
}
|
|
||||||
choices.forEach { e ->
|
choices.forEach { e ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(esterLabel(e)) },
|
text = { Text(esterLabel(e)) },
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
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
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -39,8 +42,10 @@ import androidx.compose.ui.layout.onSizeChanged
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
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.LevelPoint
|
||||||
|
import com.hormonetrack.pk.EstrannaiseCloud
|
||||||
|
import com.hormonetrack.pk.LabTrajectoryModel
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
@ -49,15 +54,23 @@ import com.hormonetrack.ui.components.ChartSeries
|
|||||||
import com.hormonetrack.ui.components.CurveChart
|
import com.hormonetrack.ui.components.CurveChart
|
||||||
import com.hormonetrack.ui.components.CurveStyle
|
import com.hormonetrack.ui.components.CurveStyle
|
||||||
import com.hormonetrack.ui.theme.ChartE2
|
import com.hormonetrack.ui.theme.ChartE2
|
||||||
|
import com.hormonetrack.ui.theme.ChartLabTrajectory
|
||||||
import com.hormonetrack.ui.theme.ChartT
|
import com.hormonetrack.ui.theme.ChartT
|
||||||
|
import com.hormonetrack.ui.theme.ChartTWhs
|
||||||
|
import com.hormonetrack.ui.theme.ChartWhs
|
||||||
import com.hormonetrack.ui.theme.LabDot
|
import com.hormonetrack.ui.theme.LabDot
|
||||||
import com.hormonetrack.ui.theme.TealTertiary
|
import com.hormonetrack.ui.theme.TealTertiary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
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 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
|
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
|
||||||
private const val MIN_RANGE_H = 6L
|
private const val MIN_RANGE_H = 6L
|
||||||
private const val MAX_RANGE_H = 24L * 300
|
private const val MAX_RANGE_H = 24L * 300
|
||||||
@ -66,10 +79,12 @@ private const val MAX_RANGE_H = 24L * 300
|
|||||||
* Écran Graphiques.
|
* Écran Graphiques.
|
||||||
*
|
*
|
||||||
* Fonctions :
|
* Fonctions :
|
||||||
* - plages 24 h / 7 j / 30 j ;
|
* - 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é ;
|
* - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé
|
||||||
* - **les deux modèles PK** (Estrannaise / Transfem Science) peuvent être affichés
|
* (le futur avec la Prévision active) ;
|
||||||
* simultanément (override de modèle dans le moteur), avec toggle indépendant ;
|
* - **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
|
* - **prévision** : prolonge la courbe avec des doses projetées à partir de la
|
||||||
* « fréquence » configurée sur chaque traitement ;
|
* « fréquence » configurée sur chaque traitement ;
|
||||||
* - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T
|
* - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T
|
||||||
@ -105,6 +120,46 @@ fun ChartScreen() {
|
|||||||
var showLabs by remember { mutableStateOf(true) }
|
var showLabs by remember { mutableStateOf(true) }
|
||||||
var showEse by remember { mutableStateOf(true) }
|
var showEse by remember { mutableStateOf(true) }
|
||||||
var showTfs by remember { mutableStateOf(true) }
|
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
|
||||||
|
// (cf §6.bis : EV inactif reste simulé) ; les autres modèles restent
|
||||||
|
// activables au tap. ⚠️ FILTRER par usesProfileModel : sinon un
|
||||||
|
// traitement oral/anti-androgène (pkModel stocké = ESE par défaut, sans
|
||||||
|
// courbe profil) activerait Estrannaise à tort (vu sur l'émulateur avec
|
||||||
|
// le CPA seedé). Une seule initialisation.
|
||||||
|
var modelTogglesInitialized by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(treatments) {
|
||||||
|
if (!modelTogglesInitialized && treatments.isNotEmpty()) {
|
||||||
|
val toggles = defaultModelToggles(
|
||||||
|
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
||||||
|
)
|
||||||
|
showEse = toggles.getValue(PKModels.ESTRANNAISE)
|
||||||
|
showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
|
||||||
|
showWhs = toggles.getValue(PKModels.WHSAH)
|
||||||
|
modelTogglesInitialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
var showForecast by remember { mutableStateOf(false) }
|
var showForecast by remember { mutableStateOf(false) }
|
||||||
var showExtrema by remember { mutableStateOf(false) }
|
var showExtrema by remember { mutableStateOf(false) }
|
||||||
var chartWidthPx by remember { mutableIntStateOf(0) }
|
var chartWidthPx by remember { mutableIntStateOf(0) }
|
||||||
@ -120,22 +175,49 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
|
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
|
||||||
// recalculés à la volée depuis les labs. null = option désactivée.
|
// recalculés à la volée depuis les labs. null = option désactivée.
|
||||||
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
|
// ── Calibration PAR MODÈLE (v1.4.8, fix #60) ──────────────────────────────
|
||||||
null, treatments, doseLogs, labResults, tConfig, autoCalibrate
|
// Demande : « la calibration doit être active sur tous les modèles si elle
|
||||||
|
// est activée, mais les facteurs ne doivent pas être identiques entre les
|
||||||
|
// profils PK ». AVANT : un SEUL jeu de facteurs calculé depuis le modèle
|
||||||
|
// STOCKÉ des traitements puis partagé par toutes les courbes → en
|
||||||
|
// basculant un traitement (ex. EEn TFS → WHSAH), le facteur devenait
|
||||||
|
// absurde (×2,21, remontée). DÉSORMAIS : une AutoCalibrated PAR modèle
|
||||||
|
// (ESE/TFS/WHS), chacune calibrée avec la prédiction DE SON modèle →
|
||||||
|
// chaque courbe affichée colle à ses labs. Home continue d'utiliser le
|
||||||
|
// modèle stocké (calcul sans override).
|
||||||
|
val autoByModel by produceState<Map<String, PharmacokineticEngine.AutoCalibrated>>(
|
||||||
|
emptyMap(), treatments, doseLogs, labResults, tConfig, autoCalibrate
|
||||||
) {
|
) {
|
||||||
value = if (!autoCalibrate) {
|
value = if (!autoCalibrate) {
|
||||||
null
|
emptyMap()
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
|
mapOf(
|
||||||
|
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
|
||||||
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
|
||||||
|
),
|
||||||
|
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
|
||||||
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
|
||||||
|
),
|
||||||
|
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
|
||||||
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
|
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
|
||||||
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
|
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
|
||||||
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
|
// ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
|
||||||
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays)
|
// 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) ──
|
// ── 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
|
// ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
|
||||||
@ -161,7 +243,7 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
|
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
|
||||||
com.hormonetrack.ui.components.forecastExtensionHours(
|
forecastExtensionHours(
|
||||||
rangeHours, forecastHorizonHours, firstSlotGapH
|
rangeHours, forecastHorizonHours, firstSlotGapH
|
||||||
)
|
)
|
||||||
} else 0L
|
} else 0L
|
||||||
@ -180,11 +262,20 @@ fun ChartScreen() {
|
|||||||
val gestureFutureHorizon by rememberUpdatedState(futurePanHorizon)
|
val gestureFutureHorizon by rememberUpdatedState(futurePanHorizon)
|
||||||
val gestureMaxPan by rememberUpdatedState(maxPanHours)
|
val gestureMaxPan by rememberUpdatedState(maxPanHours)
|
||||||
val gestureExtension by rememberUpdatedState(forecastExtensionH)
|
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 —
|
// 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
|
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
||||||
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
||||||
// décalée d'un jour).
|
// 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(
|
val forecastDoses by produceState(
|
||||||
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
||||||
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
||||||
@ -200,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(
|
val curves by produceState(
|
||||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
emptyList<ChartCurve>(),
|
||||||
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast
|
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) {
|
withContext(Dispatchers.Default) {
|
||||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
val list = mutableListOf<ChartCurve>()
|
||||||
if (showEse || showTfs) {
|
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
|
||||||
// Valeurs actuelles : échelles par ester si auto-calibration active,
|
// Totalement indépendante des toggles de modèles (elle utilise le
|
||||||
// sinon le scaleFactor stocké de chaque traitement
|
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
|
||||||
val effTreatments = effectiveAuto?.treatments ?: treatments
|
// et de la calibration (elle EST sa propre calibration continue —
|
||||||
val effTConfig = effectiveAuto?.tConfig ?: tConfig
|
// scalePerEster ignoré, épinglé par LabTrajectoryModelTest).
|
||||||
val effScales = effectiveAuto?.esterScales
|
// 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[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
|
||||||
|
val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
|
||||||
// Doses réelles + doses prévisionnelles éventuelles
|
// Doses réelles + doses prévisionnelles éventuelles
|
||||||
val forecastDoses = if (showForecast) {
|
val forecastDoses = if (showForecast) {
|
||||||
effTreatments.flatMap {
|
effTreatments.flatMap {
|
||||||
@ -221,35 +361,39 @@ fun ChartScreen() {
|
|||||||
} else emptyList()
|
} else emptyList()
|
||||||
val allDoses = doseLogs + forecastDoses
|
val allDoses = doseLogs + forecastDoses
|
||||||
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||||
val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
|
val step = stepForRange(rangeHours)
|
||||||
if (showEse) {
|
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
||||||
list.add(
|
// le modèle est la seule variable (même calcul, même calibration
|
||||||
"ESE" to PharmacokineticEngine.computeCurve(
|
// PAR MODÈLE via autoByModel[model], fix #60)
|
||||||
effTreatments, allDoses, startMs, endMs,
|
for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
|
||||||
stepMs = step,
|
if (!enabled) continue
|
||||||
tConfig = effTConfig, modelOverride = "ESE",
|
val autoM = autoByModel[model]
|
||||||
scalePerEster = effScales,
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
tKPerEster = effectiveAuto?.tKPerEster
|
effTreatments, allDoses, startMs, endMs,
|
||||||
)
|
stepMs = step,
|
||||||
|
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
|
||||||
|
scalePerEster = autoM?.esterScales,
|
||||||
|
tKPerEster = autoM?.tKPerEster
|
||||||
)
|
)
|
||||||
}
|
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
||||||
if (showTfs) {
|
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
||||||
list.add(
|
// publient pas de posterior MCMC)
|
||||||
"TFS" to PharmacokineticEngine.computeCurve(
|
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
|
||||||
effTreatments, allDoses, startMs, endMs,
|
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
||||||
stepMs = step,
|
// nuage brut ≠ échelle de la courbe calibrée)
|
||||||
tConfig = effTConfig, modelOverride = "TFS",
|
EstrannaiseCloud.compute(
|
||||||
scalePerEster = effScales,
|
treatments, doseLogs, startMs, endMs, step,
|
||||||
tKPerEster = effectiveAuto?.tKPerEster
|
scalePerEster = autoM?.esterScales
|
||||||
)
|
)
|
||||||
)
|
} else emptyList()
|
||||||
|
list.add(ChartCurve(model, curve, cloud))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value = list
|
value = list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val allPoints = curves.flatMap { it.second }
|
val allPoints = curves.flatMap { it.points }
|
||||||
val (a, b) = if (allPoints.size >= 2) {
|
val (a, b) = if (allPoints.size >= 2) {
|
||||||
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
||||||
} else 0L to 0L
|
} else 0L to 0L
|
||||||
@ -261,14 +405,37 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
||||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||||
val series = curves.map { (model, points) ->
|
val series = curves.map { (model, points, cloud) ->
|
||||||
if (model == "ESE") {
|
when (model) {
|
||||||
ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
|
||||||
} else {
|
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||||
ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ 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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -351,11 +518,26 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
// Modèles PK superposables
|
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) + v1.5.0 : le chip
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
// « 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(
|
FilterChip(
|
||||||
selected = showEse,
|
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)) }
|
label = { Text(stringResource(R.string.model_ese)) }
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@ -363,6 +545,37 @@ fun ChartScreen() {
|
|||||||
onClick = { showTfs = !showTfs },
|
onClick = { showTfs = !showTfs },
|
||||||
label = { Text(stringResource(R.string.model_tfs)) }
|
label = { Text(stringResource(R.string.model_tfs)) }
|
||||||
)
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = showWhs,
|
||||||
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -380,9 +593,15 @@ fun ChartScreen() {
|
|||||||
// temps (panHours croît) ; glisser à GAUCHE =
|
// temps (panHours croît) ; glisser à GAUCHE =
|
||||||
// avancer dans le FUTUR (panHours décroît, borné
|
// avancer dans le FUTUR (panHours décroît, borné
|
||||||
// par l'horizon de prévision — v1.4.1)
|
// 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) {
|
if (hoursDragged != 0L) {
|
||||||
panHours = com.hormonetrack.ui.components.clampPanHours(
|
panHours = clampPanHours(
|
||||||
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
|
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -400,7 +619,7 @@ fun ChartScreen() {
|
|||||||
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
|
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
|
||||||
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
|
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
|
||||||
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
|
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
|
||||||
panHours = com.hormonetrack.ui.components.clampPanHours(
|
panHours = clampPanHours(
|
||||||
newPan, gestureFutureHorizon, gestureMaxPan
|
newPan, gestureFutureHorizon, gestureMaxPan
|
||||||
)
|
)
|
||||||
rangeHours = newRange
|
rangeHours = newRange
|
||||||
@ -446,33 +665,60 @@ 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, _) ->
|
curves.forEach { (model, _) ->
|
||||||
if (model == "ESE") {
|
when (model) {
|
||||||
Text(
|
PKModels.ESTRANNAISE -> {
|
||||||
stringResource(R.string.legend_ese_e2),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = ChartE2
|
|
||||||
)
|
|
||||||
if (showT) {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_ese_t),
|
stringResource(R.string.legend_ese_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = ChartT
|
color = ChartE2
|
||||||
)
|
)
|
||||||
|
if (showT) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.legend_ese_t),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = ChartT
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
||||||
Text(
|
PKModels.WHSAH -> {
|
||||||
stringResource(R.string.legend_tfs_e2),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = TealTertiary
|
|
||||||
)
|
|
||||||
if (showT) {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_tfs_t),
|
stringResource(R.string.legend_whs_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = ChartT
|
color = ChartWhs
|
||||||
)
|
)
|
||||||
|
if (showT) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.legend_whs_t),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = ChartTWhs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"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),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = TealTertiary
|
||||||
|
)
|
||||||
|
if (showT) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.legend_tfs_t),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = ChartT
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -497,6 +743,48 @@ fun ChartScreen() {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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(
|
Text(
|
||||||
stringResource(R.string.drag_hint),
|
stringResource(R.string.drag_hint),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@ -2,19 +2,24 @@ package com.hormonetrack.ui.screens
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@ -24,6 +29,7 @@ import androidx.compose.material3.FloatingActionButton
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -37,13 +43,18 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
import com.hormonetrack.data.model.DoseLog
|
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.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.DoseDialog
|
import com.hormonetrack.ui.components.DoseDialog
|
||||||
|
import com.hormonetrack.ui.theme.LabDot
|
||||||
import com.hormonetrack.util.HrtDuration
|
import com.hormonetrack.util.HrtDuration
|
||||||
import com.hormonetrack.ui.components.formatDose
|
import com.hormonetrack.ui.components.formatDose
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -53,6 +64,54 @@ import java.time.Instant
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DosesScreen() {
|
fun DosesScreen() {
|
||||||
@ -60,6 +119,8 @@ fun DosesScreen() {
|
|||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||||
val treatments by repo.allTreatments.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 } }
|
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
|
||||||
|
|
||||||
var showLogDialog by remember { mutableStateOf(false) }
|
var showLogDialog by remember { mutableStateOf(false) }
|
||||||
@ -158,6 +219,24 @@ fun DosesScreen() {
|
|||||||
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
|
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable { toEdit = log },
|
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 = {
|
headlineContent = {
|
||||||
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
|
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
|
||||||
},
|
},
|
||||||
@ -182,8 +261,16 @@ fun DosesScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = { toDelete = log }) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
// 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) }
|
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
|
||||||
}) { Text(stringResource(R.string.delete)) }
|
}) { Text(stringResource(R.string.delete)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = { TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } },
|
||||||
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
|
|
||||||
},
|
|
||||||
title = { Text(stringResource(R.string.delete)) },
|
title = { Text(stringResource(R.string.delete)) },
|
||||||
text = { Text(stringResource(R.string.confirm_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.data.model.Treatment
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.pk.LabTiming
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.ChartOptions
|
import com.hormonetrack.ui.components.ChartOptions
|
||||||
@ -71,12 +72,13 @@ import java.time.format.DateTimeFormatter
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
private val HOUR_MS = 3_600_000L
|
private const val HOUR_MS = 3_600_000L
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
openLogDoseForTreatmentId: Long?,
|
openLogDoseForTreatmentId: Long?,
|
||||||
|
onLogDoseConsumed: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCharts: () -> Unit,
|
onOpenCharts: () -> Unit,
|
||||||
onOpenTreatment: (Long) -> Unit
|
onOpenTreatment: (Long) -> Unit
|
||||||
@ -112,10 +114,16 @@ fun HomeScreen(
|
|||||||
var showLogDialog by remember { mutableStateOf(false) }
|
var showLogDialog by remember { mutableStateOf(false) }
|
||||||
var preselectId by remember { mutableStateOf<Long?>(null) }
|
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) {
|
LaunchedEffect(openLogDoseForTreatmentId) {
|
||||||
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
||||||
showLogDialog = true
|
showLogDialog = true
|
||||||
preselectId = it
|
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) {
|
withContext(Dispatchers.Default) {
|
||||||
val end = System.currentTimeMillis()
|
val end = System.currentTimeMillis()
|
||||||
value = PharmacokineticEngine.computeCurve(
|
value = PharmacokineticEngine.computeCurve(
|
||||||
@ -222,6 +233,106 @@ fun HomeScreen(
|
|||||||
|
|
||||||
NextDoseCard(activeTreatments, doseLogs)
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
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
|
@Composable
|
||||||
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
|
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
|
||||||
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
|
// 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.
|
// 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 nowMs = System.currentTimeMillis()
|
||||||
val fires = treatments
|
val fires = treatments
|
||||||
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
|
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
|
||||||
|
.sortedBy { it.second }
|
||||||
if (fires.isEmpty()) return
|
if (fires.isEmpty()) return
|
||||||
val next = fires.minByOrNull { it.second }!!.second
|
|
||||||
val nextTreatment = fires.minByOrNull { it.second }!!.first
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.next_dose),
|
stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
|
||||||
style = MaterialTheme.typography.labelMedium
|
style = MaterialTheme.typography.labelMedium
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
val deltaMs = next - System.currentTimeMillis()
|
// Première ligne (la plus proche) mise en avant, les suivantes en
|
||||||
val h = deltaMs / HOUR_MS
|
// style secondaire — même hiérarchie visuelle que v1.4.x.
|
||||||
val m = (deltaMs % HOUR_MS) / 60_000L
|
fires.forEachIndexed { idx, (treatment, next) ->
|
||||||
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))
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.next_dose_days, days, remH, dayTimeStr, name),
|
nextDoseLine(
|
||||||
style = MaterialTheme.typography.titleMedium
|
nowMs, next, treatment?.name ?: "",
|
||||||
)
|
stringResource(R.string.next_dose_days)
|
||||||
} else {
|
),
|
||||||
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
style = if (idx == 0) MaterialTheme.typography.titleMedium
|
||||||
.withZone(ZoneId.systemDefault())
|
else MaterialTheme.typography.bodyMedium
|
||||||
.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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@ -24,9 +26,12 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -34,12 +39,17 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.model.LabResult
|
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.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.LabDialog
|
import com.hormonetrack.ui.components.LabDialog
|
||||||
import com.hormonetrack.ui.components.formatLabValue
|
import com.hormonetrack.ui.components.formatLabValue
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@ -62,11 +72,54 @@ fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>
|
|||||||
}
|
}
|
||||||
.sortedByDescending { it.first }
|
.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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LabsScreen() {
|
fun LabsScreen() {
|
||||||
val container = LocalAppContainer.current
|
val container = LocalAppContainer.current
|
||||||
val repo = container.repository
|
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())
|
val labs by repo.allLabResults.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
var showLabDialog by remember { mutableStateOf(false) }
|
var showLabDialog by remember { mutableStateOf(false) }
|
||||||
@ -81,6 +134,50 @@ fun LabsScreen() {
|
|||||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
|
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(
|
Scaffold(
|
||||||
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
|
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
@ -94,6 +191,104 @@ fun LabsScreen() {
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.nav_labs)) }
|
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()) {
|
if (grouped.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.no_data),
|
stringResource(R.string.no_data),
|
||||||
@ -114,9 +309,12 @@ fun LabsScreen() {
|
|||||||
supportingContent = {
|
supportingContent = {
|
||||||
Column {
|
Column {
|
||||||
Text(fmt.format(Instant.ofEpochMilli(ts)))
|
Text(fmt.format(Instant.ofEpochMilli(ts)))
|
||||||
// Notes du groupe (toutes identiques en pratique)
|
// Notes du groupe (v1.7.1) : TOUTES les notes
|
||||||
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
|
// distinctes, préfixées du marqueur quand elles
|
||||||
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
// 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
|
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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@ -19,16 +12,10 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -47,49 +34,84 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.backup.BackupManager
|
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.Alerts
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.reminder.AlarmScheduler
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
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 com.hormonetrack.util.ExportFileNames
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
val container = LocalAppContainer.current
|
val container = LocalAppContainer.current
|
||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scheduler = remember { AlarmScheduler(context) }
|
|
||||||
|
|
||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
||||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||||
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
||||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
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
|
// 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?)
|
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||||
|
|
||||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
// ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ──
|
||||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
val autoBackupEnabled by container.settings.autoBackupEnabled
|
||||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
.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 showImportConfirm by remember { mutableStateOf(false) }
|
||||||
|
var importJson by remember { mutableStateOf<String?>(null) }
|
||||||
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
||||||
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
// 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
|
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
||||||
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
||||||
// lambda qui le capture).
|
// lambda qui le capture).
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
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(
|
val logExportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("text/plain")
|
ActivityResultContracts.CreateDocument("text/plain")
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@ -134,7 +156,6 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var importJson by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val exportLauncher = rememberLauncherForActivityResult(
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("application/json")
|
ActivityResultContracts.CreateDocument("application/json")
|
||||||
@ -144,13 +165,20 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
|
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
|
||||||
// (langue, auto-calibration, seuils d'alerte) — collect des
|
// (langue, auto-calibration, seuils d'alerte) — collect des
|
||||||
// DataStore Flows (first()) avant sérialisation.
|
// 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(),
|
language = container.settings.language.first(),
|
||||||
autoCalibrate = container.settings.autoCalibrate.first(),
|
autoCalibrate = container.settings.autoCalibrate.first(),
|
||||||
alertE2High = container.settings.alertThresholds.first().e2High,
|
alertE2High = thresholds.e2High,
|
||||||
alertE2Low = container.settings.alertThresholds.first().e2Low,
|
alertE2Low = thresholds.e2Low,
|
||||||
alertTHigh = container.settings.alertThresholds.first().tHigh,
|
alertTHigh = thresholds.tHigh,
|
||||||
alertTLow = container.settings.alertThresholds.first().tLow
|
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 json = BackupManager.exportJson(repo, tConfig, settings)
|
||||||
val ok = BackupManager.writeBackup(context, uri, json)
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -200,405 +268,56 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Language ---
|
LanguageCard(currentLanguage)
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("Français") }
|
|
||||||
)
|
|
||||||
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AutoCalibrationCard(autoCalibrate)
|
||||||
// --- 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TModelCard(tConfig, showMessage)
|
||||||
// --- 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AlertThresholdsCard(alertThresholds, showMessage)
|
||||||
// --- 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TroughTargetCard(troughTarget, showMessage)
|
||||||
// --- 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ChartTimezoneCard(chartTimezone, showMessage)
|
||||||
// --- 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
RemindersCard()
|
||||||
// --- 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
BackupCard(
|
||||||
// --- Logs de diagnostic (v1.3.1) ---
|
onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
|
||||||
Card(Modifier.fillMaxWidth()) {
|
onImport = { importLauncher.launch(arrayOf("application/json")) }
|
||||||
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).
|
|
||||||
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
|
||||||
}) { Text(stringResource(R.string.logs_export)) }
|
|
||||||
TextButton(onClick = {
|
|
||||||
AppLog.clear()
|
|
||||||
logLineCount = AppLog.count()
|
|
||||||
}) { Text(stringResource(R.string.logs_clear)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AutoBackupCard(
|
||||||
// --- About : version installée + lien releases Gitea ---
|
enabled = autoBackupEnabled,
|
||||||
Card(Modifier.fillMaxWidth()) {
|
treeUri = autoBackupTreeUri,
|
||||||
Column(Modifier.padding(12.dp)) {
|
keep = autoBackupKeep,
|
||||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
lastRun = autoBackupLastRun,
|
||||||
Text(
|
onPickFolderToEnable = {
|
||||||
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
|
pickFolderToEnable = true
|
||||||
style = MaterialTheme.typography.bodyMedium
|
folderPickerLauncher.launch(null)
|
||||||
)
|
},
|
||||||
TextButton(onClick = {
|
onChooseFolder = {
|
||||||
context.startActivity(
|
pickFolderToEnable = false
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
folderPickerLauncher.launch(null)
|
||||||
)
|
},
|
||||||
}) { Text(stringResource(R.string.releases_link)) }
|
onMessage = showMessage
|
||||||
Text(
|
)
|
||||||
stringResource(R.string.disclaimer),
|
Spacer(Modifier.height(12.dp))
|
||||||
style = MaterialTheme.typography.labelMedium,
|
LogsCard(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
logLineCount = logLineCount,
|
||||||
)
|
onExport = {
|
||||||
Text(
|
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
|
||||||
stringResource(R.string.models_credit),
|
// et testé — passer un LocalDate au pattern horaire crashait
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// l'app au tap (bug v1.3.1→v1.3.3).
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
||||||
)
|
},
|
||||||
|
onClear = {
|
||||||
|
AppLog.clear()
|
||||||
|
logLineCount = AppLog.count()
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AboutCard()
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -628,11 +347,23 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
it.settings?.let { us ->
|
it.settings?.let { us ->
|
||||||
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
||||||
container.settings.setAlertThresholds(
|
container.settings.setAlertThresholds(
|
||||||
com.hormonetrack.pk.Alerts.Thresholds(
|
Alerts.Thresholds(
|
||||||
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
||||||
tHigh = us.alertTHigh, tLow = us.alertTLow
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
us.language?.let { lang ->
|
us.language?.let { lang ->
|
||||||
container.settings.setLanguage(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)
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import androidx.compose.material3.TimePicker
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -47,11 +48,11 @@ import com.hormonetrack.R
|
|||||||
import com.hormonetrack.data.model.AdministrationRoute
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
import com.hormonetrack.data.model.Esters
|
import com.hormonetrack.data.model.Esters
|
||||||
import com.hormonetrack.data.model.PKModels
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
import com.hormonetrack.data.model.modelLabelRes
|
||||||
import com.hormonetrack.data.model.PKPresets
|
import com.hormonetrack.data.model.PKPresets
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.data.model.TreatmentType
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import android.app.Activity
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import com.hormonetrack.reminder.AlarmScheduler
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
@ -79,6 +80,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
var route by remember { mutableStateOf(AdministrationRoute.INJECTION_IM) }
|
var route by remember { mutableStateOf(AdministrationRoute.INJECTION_IM) }
|
||||||
var ester by remember { mutableStateOf(Esters.EV) }
|
var ester by remember { mutableStateOf(Esters.EV) }
|
||||||
var model by remember { mutableStateOf(PKModels.ESTRANNAISE) }
|
var model by remember { mutableStateOf(PKModels.ESTRANNAISE) }
|
||||||
|
// v1.4.9 : état de l'auto-calibration — sert au hint de la carte
|
||||||
|
// Calibration (l'auto écrase le facteur manuel à l'AFFICHAGE des courbes)
|
||||||
|
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||||
var doseText by remember { mutableStateOf("4") }
|
var doseText by remember { mutableStateOf("4") }
|
||||||
var unit by remember { mutableStateOf("mg") }
|
var unit by remember { mutableStateOf("mg") }
|
||||||
var tmaxText by remember { mutableStateOf("46") }
|
var tmaxText by remember { mutableStateOf("46") }
|
||||||
@ -268,23 +272,44 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
if (PharmacokineticEngine.isInjectionRoute(route)) {
|
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(
|
DropdownField(
|
||||||
label = stringResource(R.string.ester),
|
label = stringResource(R.string.ester),
|
||||||
selectedLabel = 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 }
|
onSelect = { ester = it }
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
DropdownField(
|
DropdownField(
|
||||||
label = stringResource(R.string.pk_model),
|
label = stringResource(R.string.pk_model),
|
||||||
selectedLabel = stringResource(
|
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
||||||
if (model == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs else R.string.model_ese
|
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
||||||
),
|
// 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(
|
options = listOf(
|
||||||
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
||||||
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs)
|
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 {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@ -343,6 +368,19 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
if (autoCalibrate) {
|
||||||
|
// v1.4.9 (demande utilisateur) : quand l'auto-
|
||||||
|
// calibration est ON, le facteur manuel (champ +
|
||||||
|
// bouton) est IGNORÉ à l'affichage des courbes —
|
||||||
|
// l'expliciter pour éviter la confusion « j'ai
|
||||||
|
// calibré mais rien ne change ».
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.calibration_auto_override),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
FilledTonalButton(onClick = {
|
FilledTonalButton(onClick = {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
@ -412,8 +450,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val scheduler2 = AlarmScheduler(context)
|
// Le scheduler est déjà créé au niveau de l'écran
|
||||||
if (!scheduler2.canScheduleExact()) {
|
// (var `scheduler`) — pas de recréation par recompose.
|
||||||
|
if (!scheduler.canScheduleExact()) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.exact_alarm_needed),
|
stringResource(R.string.exact_alarm_needed),
|
||||||
|
|||||||
@ -27,14 +27,31 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.model.PKModels
|
import com.hormonetrack.data.model.PKModels
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.modelLabelRes
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.formatDose
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TreatmentsScreen(
|
fun TreatmentsScreen(
|
||||||
@ -44,6 +61,11 @@ fun TreatmentsScreen(
|
|||||||
val container = LocalAppContainer.current
|
val container = LocalAppContainer.current
|
||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
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(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@ -57,22 +79,44 @@ fun TreatmentsScreen(
|
|||||||
title = { Text(stringResource(R.string.nav_treatments)) }
|
title = { Text(stringResource(R.string.nav_treatments)) }
|
||||||
)
|
)
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
items(treatments, key = { it.id }) { tr ->
|
items(activeTreatments, key = { it.id }) { tr ->
|
||||||
TreatmentCard(tr, onClick = { onEdit(tr.id) })
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
|
private fun TreatmentCard(tr: Treatment, dimmed: Boolean = false, onClick: () -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.alpha(if (dimmed) 0.55f else 1f)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@ -99,10 +143,12 @@ private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
|
|||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = {
|
label = {
|
||||||
|
// v1.4.7 (fix #59) : label via modelLabelRes —
|
||||||
|
// l'ancien ternaire à 2 branches affichait
|
||||||
|
// « Estrannaise » pour le modèle WHSAH.
|
||||||
Text(
|
Text(
|
||||||
tr.esterType + " · " + stringResource(
|
tr.esterType + " · " + stringResource(
|
||||||
if (tr.pkModel == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs
|
modelLabelRes(tr.pkModel)
|
||||||
else R.string.model_ese
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,13 @@ val TransPink = Color(0xFFF5A9B8)
|
|||||||
val TealTertiary = Color(0xFF2FA48D)
|
val TealTertiary = Color(0xFF2FA48D)
|
||||||
val TealContainer = Color(0xFFBFF0E4)
|
val TealContainer = Color(0xFFBFF0E4)
|
||||||
val ChartE2 = Color(0xFF4F5BD5)
|
val ChartE2 = Color(0xFF4F5BD5)
|
||||||
|
// v1.4.6 : modèle WHSAH (3ᵉ PK) — E2 vert profond, T violet (pointillé)
|
||||||
|
val ChartWhs = Color(0xFF2E7D32)
|
||||||
|
val ChartTWhs = Color(0xFF9C27B0)
|
||||||
val ChartT = Color(0xFFD6589E)
|
val ChartT = Color(0xFFD6589E)
|
||||||
val LabDot = Color(0xFFE67E22)
|
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 GoodGreen = Color(0xFF2E7D32)
|
||||||
val WarnOrange = Color(0xFFB26A00)
|
val WarnOrange = Color(0xFFB26A00)
|
||||||
|
|||||||
@ -60,9 +60,9 @@ object AppLog {
|
|||||||
fun log(tag: String, message: String) = synchronized(lock) {
|
fun log(tag: String, message: String) = synchronized(lock) {
|
||||||
val line = formatLine(System.currentTimeMillis(), tag, message)
|
val line = formatLine(System.currentTimeMillis(), tag, message)
|
||||||
buffer.addLast(line)
|
buffer.addLast(line)
|
||||||
val trimmed = trimLines(buffer.toList(), MAX_LINES)
|
// Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le
|
||||||
buffer.clear()
|
// buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières)
|
||||||
buffer.addAll(trimmed)
|
while (buffer.size > MAX_LINES) buffer.removeFirst()
|
||||||
try {
|
try {
|
||||||
file?.appendText(line + "\n")
|
file?.appendText(line + "\n")
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@ -26,6 +26,21 @@ object ExportFileNames {
|
|||||||
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||||
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd")
|
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 :
|
* Nom du fichier d'export des logs de diagnostic :
|
||||||
* `hormonetrack-logs-20260906-1234.txt`.
|
* `hormonetrack-logs-20260906-1234.txt`.
|
||||||
@ -43,4 +58,34 @@ object ExportFileNames {
|
|||||||
*/
|
*/
|
||||||
fun backupFileName(today: LocalDate = LocalDate.now()): String =
|
fun backupFileName(today: LocalDate = LocalDate.now()): String =
|
||||||
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json"
|
"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="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="logged_today">Log rapide</string>
|
||||||
<string name="next_dose">Prochaine dose</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="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="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>
|
<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_24h">24 h</string>
|
||||||
<string name="chart_7j">7 jours</string>
|
<string name="chart_7j">7 jours</string>
|
||||||
<string name="chart_30j">30 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_labs">Analyses</string>
|
||||||
<string name="show_forecast">Prévision</string>
|
<string name="show_forecast">Prévision</string>
|
||||||
<string name="show_extrema">Pics / creux</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_in">Zoom avant (fenêtre plus courte)</string>
|
||||||
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
||||||
<string name="hrt_since">Sous THS depuis le %1$s</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_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="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_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="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
|
||||||
<string name="back_to_present">Revenir à maintenant</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>
|
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
|
||||||
@ -79,6 +103,8 @@
|
|||||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
|
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
|
||||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||||
|
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, gauche)</string>
|
||||||
|
<string name="legend_whs_t">-- T WHSAH (ng/mL, droite)</string>
|
||||||
<string name="legend_labs">● Résultats de prise de sang</string>
|
<string name="legend_labs">● Résultats de prise de sang</string>
|
||||||
<string name="interval_value">Δ %1$s j</string>
|
<string name="interval_value">Δ %1$s j</string>
|
||||||
|
|
||||||
@ -118,6 +144,26 @@
|
|||||||
<string name="dose_unit">Unité</string>
|
<string name="dose_unit">Unité</string>
|
||||||
<string name="active">Actif</string>
|
<string name="active">Actif</string>
|
||||||
<string name="inactive">inactif</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="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
||||||
|
|
||||||
<string name="type_estradiol">Œstradiol</string>
|
<string name="type_estradiol">Œstradiol</string>
|
||||||
@ -136,6 +182,7 @@
|
|||||||
<string name="pk_model">Modèle PK</string>
|
<string name="pk_model">Modèle PK</string>
|
||||||
<string name="model_ese">Estrannaise</string>
|
<string name="model_ese">Estrannaise</string>
|
||||||
<string name="model_tfs">Transfem Science</string>
|
<string name="model_tfs">Transfem Science</string>
|
||||||
|
<string name="model_whs">WHSAH</string>
|
||||||
<string name="select_preset">Choisir un modèle (optionnel)</string>
|
<string name="select_preset">Choisir un modèle (optionnel)</string>
|
||||||
|
|
||||||
<string name="pk_absorption">Temps jusqu\'au pic (h)</string>
|
<string name="pk_absorption">Temps jusqu\'au pic (h)</string>
|
||||||
@ -145,6 +192,7 @@
|
|||||||
<string name="calibration_title">Calibration</string>
|
<string name="calibration_title">Calibration</string>
|
||||||
<string name="scale_factor">Facteur d\'échelle</string>
|
<string name="scale_factor">Facteur d\'échelle</string>
|
||||||
<string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string>
|
<string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string>
|
||||||
|
<string name="calibration_auto_override">Auto-calibration active : les courbes utilisent les facteurs automatiques — cette valeur manuelle est ignorée à l\'affichage (désactive-la dans Paramètres pour la prendre en compte).</string>
|
||||||
<string name="calibrate_from_labs">Calibrer avec les analyses</string>
|
<string name="calibrate_from_labs">Calibrer avec les analyses</string>
|
||||||
<string name="auto_calibrate">Calibration automatique (E2 + T)</string>
|
<string name="auto_calibrate">Calibration automatique (E2 + T)</string>
|
||||||
<string name="auto_calibrate_hint">Ajuste automatiquement le facteur d\'échelle de CHAQUE ester à partir des analyses faites pendant sa période (ex. : labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T — affichage uniquement (les valeurs stockées ne changent pas). Les réglages manuels ci-dessous et dans Traitements restent effectifs quand c\'est désactivé.</string>
|
<string name="auto_calibrate_hint">Ajuste automatiquement le facteur d\'échelle de CHAQUE ester à partir des analyses faites pendant sa période (ex. : labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T — affichage uniquement (les valeurs stockées ne changent pas). Les réglages manuels ci-dessous et dans Traitements restent effectifs quand c\'est désactivé.</string>
|
||||||
@ -189,6 +237,17 @@
|
|||||||
<string name="import_json">Importer</string>
|
<string name="import_json">Importer</string>
|
||||||
<string name="export_ok">Sauvegarde exportée.</string>
|
<string name="export_ok">Sauvegarde exportée.</string>
|
||||||
<string name="export_fail">L\'export a échoué.</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_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_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>
|
<string name="import_confirm">Effacer & restaurer</string>
|
||||||
@ -200,6 +259,9 @@
|
|||||||
|
|
||||||
<!-- Presets -->
|
<!-- Presets -->
|
||||||
<string name="preset_ev_ese">Injection EV — Estrannaise</string>
|
<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_eu_ese">Injection EU — Estrannaise</string>
|
||||||
<string name="preset_een_ese">Injection EEn — Estrannaise</string>
|
<string name="preset_een_ese">Injection EEn — Estrannaise</string>
|
||||||
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>
|
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>
|
||||||
@ -208,6 +270,12 @@
|
|||||||
<string name="preset_eb_tfs">Injection EB — Transfem Science</string>
|
<string name="preset_eb_tfs">Injection EB — Transfem Science</string>
|
||||||
<string name="preset_ec_tfs">Injection EC (huile) — Transfem Science</string>
|
<string name="preset_ec_tfs">Injection EC (huile) — Transfem Science</string>
|
||||||
<string name="preset_ecs_tfs">Injection EC (suspension aqueuse) — Transfem Science</string>
|
<string name="preset_ecs_tfs">Injection EC (suspension aqueuse) — Transfem Science</string>
|
||||||
|
<string name="preset_ev_whs">Injection EV — WHSAH</string>
|
||||||
|
<string name="preset_eu_whs">Injection EU — WHSAH</string>
|
||||||
|
<string name="preset_een_whs">Injection EEn — WHSAH</string>
|
||||||
|
<string name="preset_eb_whs">Injection EB — WHSAH</string>
|
||||||
|
<string name="preset_ec_whs">Injection EC (huile) — WHSAH</string>
|
||||||
|
<string name="preset_ecs_whs">Injection EC (suspension aqueuse) — WHSAH</string>
|
||||||
<string name="preset_pep_tfs">Injection PEP — Transfem Science</string>
|
<string name="preset_pep_tfs">Injection PEP — Transfem Science</string>
|
||||||
<string name="preset_e2_gel">E2 gel transdermique</string>
|
<string name="preset_e2_gel">E2 gel transdermique</string>
|
||||||
<string name="preset_e2_patch">E2 patch</string>
|
<string name="preset_e2_patch">E2 patch</string>
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<string name="no_data">No data yet — add a treatment and log a dose.</string>
|
<string name="no_data">No data yet — add a treatment and log a dose.</string>
|
||||||
<string name="logged_today">Quick log</string>
|
<string name="logged_today">Quick log</string>
|
||||||
<string name="next_dose">Next dose</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="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="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
|
||||||
<string name="home_chart_title">Last 24 hours (estimate)</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_24h">24 h</string>
|
||||||
<string name="chart_7j">7 days</string>
|
<string name="chart_7j">7 days</string>
|
||||||
<string name="chart_30j">30 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_labs">Labs</string>
|
||||||
<string name="show_forecast">Forecast</string>
|
<string name="show_forecast">Forecast</string>
|
||||||
<string name="show_extrema">Peaks / troughs</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_in">Zoom in (shorter window)</string>
|
||||||
<string name="zoom_out">Zoom out (longer window)</string>
|
<string name="zoom_out">Zoom out (longer window)</string>
|
||||||
<string name="hrt_since">Under HRT since %1$s</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_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="agenda_permission">Calendar permission is requested when you enable this.</string>
|
||||||
<string name="legend_extrema">▲▼ estimated peaks & troughs (per curve)</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="legend_forecast_dose">— next projected dose (vertical marker)</string>
|
||||||
<string name="back_to_present">Back to now</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>
|
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>
|
||||||
@ -79,6 +103,8 @@
|
|||||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
|
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
|
||||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||||
|
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, left)</string>
|
||||||
|
<string name="legend_whs_t">-- T WHSAH (ng/mL, right)</string>
|
||||||
<string name="legend_labs">● Lab results</string>
|
<string name="legend_labs">● Lab results</string>
|
||||||
<string name="interval_value">Δ %1$s d</string>
|
<string name="interval_value">Δ %1$s d</string>
|
||||||
|
|
||||||
@ -118,6 +144,26 @@
|
|||||||
<string name="dose_unit">Unit</string>
|
<string name="dose_unit">Unit</string>
|
||||||
<string name="active">Active</string>
|
<string name="active">Active</string>
|
||||||
<string name="inactive">inactive</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="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
||||||
|
|
||||||
<string name="type_estradiol">Estradiol</string>
|
<string name="type_estradiol">Estradiol</string>
|
||||||
@ -136,6 +182,7 @@
|
|||||||
<string name="pk_model">PK model</string>
|
<string name="pk_model">PK model</string>
|
||||||
<string name="model_ese">Estrannaise</string>
|
<string name="model_ese">Estrannaise</string>
|
||||||
<string name="model_tfs">Transfem Science</string>
|
<string name="model_tfs">Transfem Science</string>
|
||||||
|
<string name="model_whs">WHSAH</string>
|
||||||
<string name="select_preset">Choose a preset (optional)</string>
|
<string name="select_preset">Choose a preset (optional)</string>
|
||||||
|
|
||||||
<string name="pk_absorption">Time to peak (h)</string>
|
<string name="pk_absorption">Time to peak (h)</string>
|
||||||
@ -145,6 +192,7 @@
|
|||||||
<string name="calibration_title">Calibration</string>
|
<string name="calibration_title">Calibration</string>
|
||||||
<string name="scale_factor">Scale factor</string>
|
<string name="scale_factor">Scale factor</string>
|
||||||
<string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string>
|
<string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string>
|
||||||
|
<string name="calibration_auto_override">Auto-calibration is ON: curves use the automatic factors, this manual value is ignored for display (use Settings to turn it off).</string>
|
||||||
<string name="calibrate_from_labs">Calibrate from labs</string>
|
<string name="calibrate_from_labs">Calibrate from labs</string>
|
||||||
<string name="auto_calibrate">Auto-calibration (E2 + T)</string>
|
<string name="auto_calibrate">Auto-calibration (E2 + T)</string>
|
||||||
<string name="auto_calibrate_hint">Automatically adjusts the scale factor of EACH ester from the labs drawn during that ester\'s period (e.g. valerate labs calibrate the valerate doses, enanthate labs the enanthate doses) and recalibrates the T model — display only (stored values unchanged). Manual values below and in Treatments stay in effect when this is off.</string>
|
<string name="auto_calibrate_hint">Automatically adjusts the scale factor of EACH ester from the labs drawn during that ester\'s period (e.g. valerate labs calibrate the valerate doses, enanthate labs the enanthate doses) and recalibrates the T model — display only (stored values unchanged). Manual values below and in Treatments stay in effect when this is off.</string>
|
||||||
@ -189,6 +237,17 @@
|
|||||||
<string name="import_json">Import</string>
|
<string name="import_json">Import</string>
|
||||||
<string name="export_ok">Backup exported.</string>
|
<string name="export_ok">Backup exported.</string>
|
||||||
<string name="export_fail">Export failed.</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_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_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>
|
<string name="import_confirm">Erase & restore</string>
|
||||||
@ -200,6 +259,9 @@
|
|||||||
|
|
||||||
<!-- Presets -->
|
<!-- Presets -->
|
||||||
<string name="preset_ev_ese">EV injection — Estrannaise</string>
|
<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_eu_ese">EU injection — Estrannaise</string>
|
||||||
<string name="preset_een_ese">EEn injection — Estrannaise</string>
|
<string name="preset_een_ese">EEn injection — Estrannaise</string>
|
||||||
<string name="preset_ev_tfs">EV injection — Transfem Science</string>
|
<string name="preset_ev_tfs">EV injection — Transfem Science</string>
|
||||||
@ -208,6 +270,12 @@
|
|||||||
<string name="preset_eb_tfs">EB injection — Transfem Science</string>
|
<string name="preset_eb_tfs">EB injection — Transfem Science</string>
|
||||||
<string name="preset_ec_tfs">EC injection (oil) — Transfem Science</string>
|
<string name="preset_ec_tfs">EC injection (oil) — Transfem Science</string>
|
||||||
<string name="preset_ecs_tfs">EC injection (aqueous suspension) — Transfem Science</string>
|
<string name="preset_ecs_tfs">EC injection (aqueous suspension) — Transfem Science</string>
|
||||||
|
<string name="preset_ev_whs">EV injection — WHSAH</string>
|
||||||
|
<string name="preset_eu_whs">EU injection — WHSAH</string>
|
||||||
|
<string name="preset_een_whs">EEn injection — WHSAH</string>
|
||||||
|
<string name="preset_eb_whs">EB injection — WHSAH</string>
|
||||||
|
<string name="preset_ec_whs">EC injection (oil) — WHSAH</string>
|
||||||
|
<string name="preset_ecs_whs">EC injection (aqueous suspension) — WHSAH</string>
|
||||||
<string name="preset_pep_tfs">PEP injection — Transfem Science</string>
|
<string name="preset_pep_tfs">PEP injection — Transfem Science</string>
|
||||||
<string name="preset_e2_gel">E2 transdermal gel</string>
|
<string name="preset_e2_gel">E2 transdermal gel</string>
|
||||||
<string name="preset_e2_patch">E2 patch</string>
|
<string name="preset_e2_patch">E2 patch</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)
|
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
|
@Test
|
||||||
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
|
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 :
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt
Normal file
192
app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.Esters
|
||||||
|
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 org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process de tests **CALIBRATION** (v1.4.8, fix #60) — sémantique
|
||||||
|
* « calibration PAR MODÈLE PK » : chaque courbe affichée (ESE/TFS/WHS) est
|
||||||
|
* calibrée avec la prédiction de SON modèle, donc chaque courbe calibrée
|
||||||
|
* passe par les labs.
|
||||||
|
*
|
||||||
|
* Compléments des tests voisins (pour s'y retrouver) :
|
||||||
|
* - `WhsahEngineTest.calibration is per model` : TFS vs WHS (le cas de la
|
||||||
|
* remontée ×2,21) ;
|
||||||
|
* - `WhsahEngineTest.calibration applies to ALL model curves` : le facteur
|
||||||
|
* fourni est appliqué à toutes les courbes (application uniforme) ;
|
||||||
|
* - `PharmacokineticEngineTest` : calibration T manuelle, SF médiane.
|
||||||
|
*
|
||||||
|
* Ce fichier couvre ce qui restait sans filet :
|
||||||
|
* 1. la couverture des **3 modèles** (ESE inclus) — chaque courbe calibrée
|
||||||
|
* atterrit sur le lab ;
|
||||||
|
* 2. la **délégation** `autoCalibrated(modelOverride)` →
|
||||||
|
* `computeEsterScaleFactors(modelOverride)` (le graphique et Home
|
||||||
|
* utilisent la même fonction, pas deux implémentations) ;
|
||||||
|
* 3. le **k T par modèle** (calibré contre l'E2 calibrée de SON modèle) ;
|
||||||
|
* 4. les cas **dégénérés** (labs antérieurs à la 1ʳᵉ dose → échelles vides ;
|
||||||
|
* prédiction trop faible → lab ignoré).
|
||||||
|
*/
|
||||||
|
class CalibrationPerModelTest {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val BASE = System.currentTimeMillis()
|
||||||
|
const val DAY_MS = 86_400_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.Before
|
||||||
|
fun loadProfiles() {
|
||||||
|
// ⚠️ le chemin ESE lit les tables ODS (asset) — sans init,
|
||||||
|
// PKProfileStore.sample retourne 0 et les labs seraient ignorés
|
||||||
|
// (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/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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traitement EEn (modèle TFS stocké), 5 mg à BASE. */
|
||||||
|
private fun treatment(): Treatment = Treatment(
|
||||||
|
id = 1L,
|
||||||
|
name = "EEn",
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = 5.0,
|
||||||
|
doseUnit = "mg",
|
||||||
|
isActive = true,
|
||||||
|
esterType = Esters.EEN,
|
||||||
|
pkModel = PKModels.TRANSFEM_SCIENCE
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun doses(): List<DoseLog> = listOf(
|
||||||
|
DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Lab E2 de 250 pg/mL 7 jours après l'injection (au creux TFS). */
|
||||||
|
private fun labE2(): LabResult = LabResult(
|
||||||
|
marker = "E2", value = 250.0, unit = "pg/mL",
|
||||||
|
timestamp = BASE + 7 * DAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all three models calibrated land on the lab (ESE included)`() {
|
||||||
|
val tr = treatment()
|
||||||
|
val labs = listOf(labE2())
|
||||||
|
for (model in listOf("ESE", "TFS", "WHS")) {
|
||||||
|
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses(), labs, modelOverride = model
|
||||||
|
)
|
||||||
|
assertTrue("échelle $model absente", scales.containsKey("EEN"))
|
||||||
|
val calibrated = PharmacokineticEngine.e2At(
|
||||||
|
listOf(tr), doses(), labE2().timestamp,
|
||||||
|
modelOverride = model, scalePerEster = scales
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"$model : calibrée=$calibrated doit ≈ 250 (échelle ${scales["EEN"]})",
|
||||||
|
calibrated in 250.0 * 0.98..250.0 * 1.02
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three models give three distinct calibration factors`() {
|
||||||
|
val tr = treatment()
|
||||||
|
val labs = listOf(labE2())
|
||||||
|
val ese = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses(), labs, modelOverride = "ESE")["EEN"]!!
|
||||||
|
val tfs = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses(), labs, modelOverride = "TFS")["EEN"]!!
|
||||||
|
val whs = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses(), labs, modelOverride = "WHS")["EEN"]!!
|
||||||
|
// les prédictions des 3 modèles diffèrent au même instant (7 j) :
|
||||||
|
// ESE ≈ 156, TFS ≈ 159, WHS ≈ 178 → facteurs distincts. Nuances :
|
||||||
|
// ESE et TFS sont QUASI IDENTIQUES au creux 7 j (~2 % — la table ODS
|
||||||
|
// dérive du même article) ; WHSAH s'en écarte (~11 % — décroissance
|
||||||
|
// lente, t½ 7,3 j) mais sans excès
|
||||||
|
assertTrue("ESE=$ese vs TFS=$tfs (≥ 1 % : distincts)", kotlin.math.abs(ese - tfs) > 0.01 * ese)
|
||||||
|
assertTrue("TFS=$tfs vs WHS=$whs (≥ 5 %)", kotlin.math.abs(tfs - whs) > 0.05 * tfs)
|
||||||
|
assertTrue("WHS=$whs vs ESE=$ese (≥ 5 %)", kotlin.math.abs(whs - ese) > 0.05 * whs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `autoCalibrated delegates to computeEsterScaleFactors per model (single implementation)`() {
|
||||||
|
val tr = treatment()
|
||||||
|
val labs = listOf(labE2())
|
||||||
|
for (model in listOf(null, "ESE", "TFS", "WHS")) {
|
||||||
|
val viaAuto = PharmacokineticEngine.autoCalibrated(
|
||||||
|
listOf(tr), doses(), labs, TConfig(), modelOverride = model
|
||||||
|
).esterScales
|
||||||
|
val direct = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses(), labs, modelOverride = model
|
||||||
|
)
|
||||||
|
assertEquals("délégation brisée pour modelOverride=$model", direct, viaAuto)
|
||||||
|
}
|
||||||
|
// null = modèle stocké du traitement (TFS) → cohérent avec override TFS
|
||||||
|
assertEquals(
|
||||||
|
PharmacokineticEngine.autoCalibrated(
|
||||||
|
listOf(tr), doses(), labs, TConfig(), modelOverride = "TFS"
|
||||||
|
).esterScales,
|
||||||
|
PharmacokineticEngine.autoCalibrated(
|
||||||
|
listOf(tr), doses(), labs, TConfig()
|
||||||
|
).esterScales
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `testosterone k is calibrated against the E2 of the requested model`() {
|
||||||
|
val tr = treatment()
|
||||||
|
// lab T de 0,35 ng/mL 7 jours après l'injection (même instant que le lab E2)
|
||||||
|
val labs = listOf(
|
||||||
|
labE2(),
|
||||||
|
LabResult(marker = "T", value = 0.35, unit = "ng/mL",
|
||||||
|
timestamp = BASE + 7 * DAY_MS)
|
||||||
|
)
|
||||||
|
val tConfig = TConfig() // base 6.0, floor 0.2, k 0.19
|
||||||
|
|
||||||
|
// Invariant mathématique : k ∝ 1/E2 calibrée — donner une échelle
|
||||||
|
// ×2 doit DIVISER le k par 2 exactement (le k est calculé contre
|
||||||
|
// l'E2 calibrée de la demande modelOverride, pas contre une E2 figée).
|
||||||
|
val tLabs = labs.filter { it.marker.equals("T", true) }
|
||||||
|
fun kFor(scale: Double): Double = PharmacokineticEngine.computeTKPerEster(
|
||||||
|
listOf(tr), doses(), tLabs, tConfig,
|
||||||
|
scalePerEster = mapOf("EEN" to scale),
|
||||||
|
modelOverride = "TFS"
|
||||||
|
)["EEN"]!!
|
||||||
|
val k1 = kFor(1.0)
|
||||||
|
val k2 = kFor(2.0)
|
||||||
|
assertTrue("k(échelle 1)=$k1 doit être dans la garde", k1 > 1e-4 && k1 < 10.0)
|
||||||
|
assertEquals("k ∝ 1/E2 calibrée : échelle ×2 → k ÷2", k1 / 2.0, k2, k1 * 0.01)
|
||||||
|
// et le k calibré ≠ k par défaut (0,19) : il a bien été recalculé
|
||||||
|
// contre l'E2 du modèle (fix #60)
|
||||||
|
assertTrue(
|
||||||
|
"k calibré=$k1 doit différer du k stocké (0,19)",
|
||||||
|
kotlin.math.abs(k1 - 0.19) > 0.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `labs before the first dose yield empty scales (per model too)`() {
|
||||||
|
val tr = treatment()
|
||||||
|
// lab ANTÉRIEUR à la 1ʳᵉ dose → pas de période d'attribution → vide
|
||||||
|
val labs = listOf(LabResult(
|
||||||
|
marker = "E2", value = 250.0, unit = "pg/mL", timestamp = BASE - DAY_MS
|
||||||
|
))
|
||||||
|
for (model in listOf(null, "ESE", "TFS", "WHS")) {
|
||||||
|
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses(), labs, modelOverride = model
|
||||||
|
)
|
||||||
|
assertTrue("échelles vides attendues pour modelOverride=$model", scales.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).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.data.model.LabResult
|
||||||
import com.hormonetrack.ui.screens.groupLabsForDisplay
|
import com.hormonetrack.ui.screens.groupLabsForDisplay
|
||||||
|
import com.hormonetrack.ui.screens.labNotesForDisplay
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
|
* 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.
|
* 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 {
|
class LabsGroupingTest {
|
||||||
|
|
||||||
private fun lab(marker: String, ts: Long, value: Double) =
|
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)
|
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
|
@Test
|
||||||
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
|
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
|
||||||
val ts = 1_788_621_894_346L
|
val ts = 1_788_621_894_346L
|
||||||
@ -53,4 +62,64 @@ class LabsGroupingTest {
|
|||||||
val groups = groupLabsForDisplay(labs)
|
val groups = groupLabsForDisplay(labs)
|
||||||
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
|
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() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val candidates = listOf(
|
val candidates = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class PharmacokineticEngineTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
@ -159,7 +160,11 @@ class PharmacokineticEngineTest {
|
|||||||
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
|
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
|
||||||
}
|
}
|
||||||
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
|
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
|
@Test
|
||||||
|
|||||||
@ -43,6 +43,7 @@ class RegressionUserCase2Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
|
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -42,6 +42,7 @@ class RegressionUserCase3Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -50,6 +50,7 @@ class RegressionUserCase4Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).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")) {
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.hormonetrack.data.backup.BackupData
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Épinglé sur le **bug ×2,21 de la calibration manuelle** (#61, v1.4.9,
|
||||||
|
* remontée : « le bouton Calibrer avec les analyses du traitement EEn WHSAH
|
||||||
|
* me met toujours à ×2,21 »). Données : 4ᵉ export réel (HORS dépôt,
|
||||||
|
* `local-test-data/backup-v1.4.2.json`, gitignoré).
|
||||||
|
*
|
||||||
|
* DIAGNOSTIC (reproduit par ce test à l'origine) : l'export contient une
|
||||||
|
* dose EEn de test logguée en **janvier** (10 mg, bien avant la transition
|
||||||
|
* de juillet) ; les labs de janvier-mars tombaient 15–76 jours 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 / 391) → la MÉDIANE des ratios tombait
|
||||||
|
* à ×2,21, et ce DANS LES DEUX pipelines (manuel ET auto — même défaut de
|
||||||
|
* frontière).
|
||||||
|
*
|
||||||
|
* FIX #61 : **garde de fenêtre d'attribution** (`withinAttributionWindow`) —
|
||||||
|
* un lab ne calibre une dose que s'il est ≤ 3 demi-vies d'élimination du
|
||||||
|
* traitement après elle (au-delà, la contribution est < 12,5 % : le lab
|
||||||
|
* reflète autre chose). Appliqué aux TROIS pipelines de calibration
|
||||||
|
* (manuel, échelles par ester, k T).
|
||||||
|
*
|
||||||
|
* Résultat post-fix (réel, épinglé) : le facteur manuel WHSAH = **0,55**
|
||||||
|
* (médiane des labs dans la fenêtre) au lieu de 2,21.
|
||||||
|
*/
|
||||||
|
class ScaleFactorWhsahReproTest {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATA_FILE: File = listOf(
|
||||||
|
File("../local-test-data/backup-v1.4.2.json"),
|
||||||
|
File("local-test-data/backup-v1.4.2.json"),
|
||||||
|
File("app/local-test-data/backup-v1.4.2.json")
|
||||||
|
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.4.2.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var data: BackupData
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
|
||||||
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le traitement EEn, basculé WHSAH (ce que fait l'utilisatrice). */
|
||||||
|
private fun eenWhs(): com.hormonetrack.data.model.Treatment =
|
||||||
|
data.treatments.single { it.esterType == "EEN" }.copy(pkModel = "WHS")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual calibration for EEn WHSAH lands in the physiological range (not 2-21)`() {
|
||||||
|
val sf = PharmacokineticEngine.computeScaleFactor(
|
||||||
|
eenWhs(), data.doseLogs,
|
||||||
|
data.labResults.filter { it.marker.equals("E2", true) }
|
||||||
|
)
|
||||||
|
assertNotNull(sf)
|
||||||
|
// AVANT le fix : 2,21 (médiane polluée par les labs hors fenêtre).
|
||||||
|
// APRÈS : ~0,55 (les labs dans la fenêtre de 3 × t½).
|
||||||
|
assertTrue("facteur manuel = $sf doit être physiologique (0,3–1,2)", sf!! in 0.3..1.2)
|
||||||
|
// épinglage explicite du bug : plus JAMAIS 2,21
|
||||||
|
assertTrue("régression #61 : facteur=$sf", kotlin.math.abs(sf - 2.21) > 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `auto calibration WHSAH uses the same attribution window (coherent with manual)`() {
|
||||||
|
// Auto (par modèle, fix #60) et manuel (computeScaleFactor) partagent
|
||||||
|
// la même garde → sur les mêmes données, les facteurs convergent
|
||||||
|
// (même set de labs attribués, même prédiction superposée).
|
||||||
|
val auto = PharmacokineticEngine.autoCalibrated(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.tConfig,
|
||||||
|
modelOverride = "WHS"
|
||||||
|
).esterScales["EEN"]
|
||||||
|
val manual = PharmacokineticEngine.computeScaleFactor(
|
||||||
|
eenWhs(), data.doseLogs,
|
||||||
|
data.labResults.filter { it.marker.equals("E2", true) }
|
||||||
|
)
|
||||||
|
assertNotNull(auto)
|
||||||
|
assertTrue(
|
||||||
|
"auto=$auto vs manuel=$manual doivent converger (±15 %)",
|
||||||
|
kotlin.math.abs(auto!! - manual!!) <= 0.15 * maxOf(auto, manual)
|
||||||
|
)
|
||||||
|
// et la valeur est physiologique
|
||||||
|
assertTrue("auto WHSAH=$auto doit être < 1,2", auto in 0.3..1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `significant-lab guard excludes residual predictions (labs at 37-76 days)`() {
|
||||||
|
// La garde v1.4.9 est RELATIVE : un lab ne calibre que si la prédiction
|
||||||
|
// reste ≥ 15 % du maximum déjà observé (l'injection gouverne encore).
|
||||||
|
// Réproduit le scénario réel : prédiction WHSAH montante 184 → 155 →
|
||||||
|
// puis résiduelle 23 → 3,5 → 1,5 → 1,0 (labs de janvier-mars).
|
||||||
|
val seq = listOf(184.0, 155.0, 23.4, 3.5, 1.5, 1.0)
|
||||||
|
var maxSoFar = 0.0
|
||||||
|
val included = mutableListOf<Double>()
|
||||||
|
for (p in seq) {
|
||||||
|
if (PharmacokineticEngine.labIsSignificant(p, maxSoFar)) included.add(p)
|
||||||
|
if (p > maxSoFar) maxSoFar = p
|
||||||
|
}
|
||||||
|
// inclus : 184 (1ᵉʳ), 155 (85 % du max) ; exclus : 23,4 (12,7 % < 15 %),
|
||||||
|
// 3,5, 1,5, 1,0 (résiduels — les ratios 15,9 / 89,6 / 383 / 391 du bug)
|
||||||
|
assertEquals(listOf(184.0, 155.0), included)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `significant-lab guard - first lab and boundary cases`() {
|
||||||
|
// premier lab (max 0) → seule la garde > 0,5 s'applique
|
||||||
|
assertTrue(PharmacokineticEngine.labIsSignificant(0.6, 0.0))
|
||||||
|
assertTrue(!PharmacokineticEngine.labIsSignificant(0.4, 0.0))
|
||||||
|
// exactement à la frontière 15 % → inclus (≥)
|
||||||
|
assertTrue(PharmacokineticEngine.labIsSignificant(15.0, 100.0))
|
||||||
|
// juste en dessous → exclu
|
||||||
|
assertTrue(!PharmacokineticEngine.labIsSignificant(14.9, 100.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,6 +47,7 @@ class TransfemScienceEngineTest {
|
|||||||
// l'asset pour rester au plus près du comportement réel de l'app.
|
// l'asset pour rester au plus près du comportement réel de l'app.
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
@ -79,14 +80,24 @@ class TransfemScienceEngineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 tr = treatment(Esters.EB, doseMg = 5.0)
|
||||||
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
||||||
val curve = PharmacokineticEngine.computeCurve(
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
||||||
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
|
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
|
@Test
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class V120FeaturesTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
@ -263,7 +264,9 @@ class V120FeaturesTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `scalePerEster scales each dose by its own ester factor`() {
|
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 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 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 scales = mapOf("EV" to 0.5, "EEN" to 2.0)
|
||||||
val t = BASE
|
val t = BASE
|
||||||
|
|||||||
198
app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt
Normal file
198
app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
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.Esters
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test d'INTÉGRATION du câblage moteur ↔ modèle WHSAH (v1.4.6) — miroir de
|
||||||
|
* `TransfemScienceEngineTest` : on vérifie le DISPATCH (`concentrationOfDose`/
|
||||||
|
* `e2At`/`computeCurve` avec `pkModel = WHS` ou `modelOverride = "WHS"`) et la
|
||||||
|
* coupure moteur (`cutoffHours` = 10 × t½ terminale WHSAH).
|
||||||
|
*
|
||||||
|
* Scénarios épinglés :
|
||||||
|
* 1. un traitement stocké en ESE mais tracé avec `modelOverride = WHS`
|
||||||
|
* reproduit le comportement Mona (EV : pic 367 pg/mL @ 1,7 j) ;
|
||||||
|
* 2. `pkModel = WHS` sur EEn → courbe WHSAH (t½ 7,3 j → contribution bien
|
||||||
|
* au-delà de 30 j, contrairement au cutoff TFS) ;
|
||||||
|
* 3. WHS + ester sans modèle (PEP) → 0 partout (documenté, PEP non couvert) ;
|
||||||
|
* 4. ESE vs TFS vs WHS donnent 3 courbes DISTINCTES sur EV (les toggles
|
||||||
|
* du graphique ne peuvent pas se recouvrir).
|
||||||
|
*/
|
||||||
|
class WhsahEngineTest {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val BASE = System.currentTimeMillis()
|
||||||
|
const val DAY_MS = 86_400_000L
|
||||||
|
const val HOUR_MS = 3_600_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun loadProfiles() {
|
||||||
|
// 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() }
|
||||||
|
PKProfileStore.initWithJson(file.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun treatment(ester: String, model: String, doseMg: Double = 5.0): Treatment = Treatment(
|
||||||
|
id = 1L,
|
||||||
|
name = "Injection $ester",
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = doseMg,
|
||||||
|
doseUnit = "mg",
|
||||||
|
isActive = true,
|
||||||
|
esterType = ester,
|
||||||
|
pkModel = model
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun dose(treatmentId: Long = 1L, mg: Double = 5.0): DoseLog =
|
||||||
|
DoseLog(treatmentId = treatmentId, timestamp = BASE, doseAmount = mg)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EV stocke en ESE mais override WHS reproduit Mona`() {
|
||||||
|
val tr = treatment(Esters.EV, PKModels.ESTRANNAISE)
|
||||||
|
val d = dose(mg = 5.0)
|
||||||
|
// Override WHS → courbe WHSAH : pic de Mona 367 pg/mL @ 1,69 j
|
||||||
|
val atPeak = PharmacokineticEngine.e2At(
|
||||||
|
listOf(tr), listOf(d), BASE + (1.69 * DAY_MS).toLong(), modelOverride = "WHS"
|
||||||
|
)
|
||||||
|
assertTrue("EV@1,69j WHS = $atPeak (attendu ~367)", atPeak in 355.0..380.0)
|
||||||
|
// sans override → chemin ESE (tables ODS) : pic différent (~61 pg/mL/mg → 305)
|
||||||
|
val ese = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + (0.75 * DAY_MS).toLong())
|
||||||
|
assertTrue("EV ESE (tables ODS) = $ese", ese > 200.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pkModel WHS on EEn contributes far beyond 30 days (10 x t-half cutoff)`() {
|
||||||
|
val tr = treatment(Esters.EEN, PKModels.WHSAH)
|
||||||
|
val d = dose(mg = 5.0)
|
||||||
|
// t½ WHSAH EEn = 7,34 j → coupure à ~73 j : à 40 j la contribution
|
||||||
|
// existe (le cutoff TFS 10×4,5 j = 45 j la laisserait aussi, mais le
|
||||||
|
// cutoff ANCIEN 24 h du fallback tables aurait tout tué)
|
||||||
|
val at30d = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + 30 * DAY_MS)
|
||||||
|
assertTrue("EEn WHS @30 j = $at30d", at30d > 20.0)
|
||||||
|
val at70d = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + 70 * DAY_MS)
|
||||||
|
assertTrue("EEn WHS @70 j = $at70d (10×t½ = 73,4 j → résiduel mais non nul)", at70d > 0.1)
|
||||||
|
// au-delà de la coupure : 0 par design
|
||||||
|
val at80d = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + 80 * DAY_MS)
|
||||||
|
assertEquals(0.0, at80d, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHS model with uncovered ester yields zero (PEP not in the fit)`() {
|
||||||
|
val tr = treatment(Esters.PEP, PKModels.WHSAH, doseMg = 32.5)
|
||||||
|
val d = dose(mg = 32.5)
|
||||||
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(tr), listOf(d), BASE, BASE + 5 * DAY_MS,
|
||||||
|
tConfig = TConfig()
|
||||||
|
)
|
||||||
|
assertTrue(curve.all { it.e2 == 0.0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calibration is per model - each calibrated curve lands on the lab (fix 60)`() {
|
||||||
|
// Remontée v1.4.7 : « la calibration auto sur WHSAH met ×2,21 » —
|
||||||
|
// AVANT : un seul jeu de facteurs calculé depuis le modèle STOCKÉ
|
||||||
|
// puis partagé par toutes les courbes. DÉSORMAIS (fix #60) : chaque
|
||||||
|
// modèle est calibré avec SA prédiction → chaque courbe calibrée
|
||||||
|
// passe PAR le lab, quel que soit le modèle affiché.
|
||||||
|
val tr = treatment(Esters.EEN, PKModels.TRANSFEM_SCIENCE)
|
||||||
|
val doses = listOf(
|
||||||
|
DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
||||||
|
)
|
||||||
|
// lab fictif de 250 pg/mL 7 jours après l'injection (au creux TFS)
|
||||||
|
val lab = LabResult(marker = "E2", value = 250.0, unit = "pg/mL",
|
||||||
|
timestamp = BASE + 7 * DAY_MS)
|
||||||
|
val tConfig = TConfig()
|
||||||
|
|
||||||
|
for ((model, expected) in listOf(
|
||||||
|
"TFS" to null, // attendu = calculé en dessous (auto-cohérence)
|
||||||
|
"WHS" to null
|
||||||
|
)) {
|
||||||
|
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses, listOf(lab), modelOverride = model
|
||||||
|
)
|
||||||
|
assertTrue("échelle $model doit être positive et plausible",
|
||||||
|
(scales["EEN"] ?: 0.0) > 0.0)
|
||||||
|
// la courbe calibrée avec SES échelles passe PAR le lab (±2 %)
|
||||||
|
val calibrated = PharmacokineticEngine.e2At(
|
||||||
|
listOf(tr), doses, lab.timestamp,
|
||||||
|
modelOverride = model, scalePerEster = scales
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"$model : calibrée=$calibrated doit ≈ lab=250 (échelle ${scales["EEN"]})",
|
||||||
|
calibrated in 250.0 * 0.98..250.0 * 1.02
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// et les échelles des DEUX modèles sont DISTINCTES (prédictions
|
||||||
|
// différentes au même instant → facteurs différents)
|
||||||
|
val scalesTfs = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses, listOf(lab), modelOverride = "TFS")
|
||||||
|
val scalesWhs = PharmacokineticEngine.computeEsterScaleFactors(
|
||||||
|
listOf(tr), doses, listOf(lab), modelOverride = "WHS")
|
||||||
|
assertTrue(
|
||||||
|
"TFS=${scalesTfs["EEN"]} vs WHS=${scalesWhs["EEN"]} doivent différer",
|
||||||
|
kotlin.math.abs(scalesTfs["EEN"]!! - scalesWhs["EEN"]!!) > 0.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calibration applies to ALL model curves equally (fix 58)`() {
|
||||||
|
// Demande v1.4.7 : « TOUTES les courbes doivent s'adapter au calibrage »
|
||||||
|
// — épinglé : le facteur par ESTER (scalePerEster) est appliqué par
|
||||||
|
// DOSE (doseEster), indépendamment du modelOverride → les 3 courbes
|
||||||
|
// (ESE/TFS/WHS) d'un même traitement sont scalées IDENTIQUEMENT.
|
||||||
|
val tr = treatment(Esters.EEN, PKModels.WHSAH)
|
||||||
|
val d = dose(mg = 5.0)
|
||||||
|
val t = BASE + 2 * DAY_MS
|
||||||
|
val scales = mapOf("EEN" to 2.0)
|
||||||
|
for (model in listOf("ESE", "TFS", "WHS")) {
|
||||||
|
val calibrated = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(tr), listOf(d), BASE, t, tConfig = TConfig(),
|
||||||
|
modelOverride = model, scalePerEster = scales
|
||||||
|
).last().e2
|
||||||
|
val raw = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(tr), listOf(d), BASE, t, tConfig = TConfig(),
|
||||||
|
modelOverride = model
|
||||||
|
).last().e2
|
||||||
|
assertTrue(
|
||||||
|
"$model : calibrée=$calibrated ≠ 2× brut=$raw",
|
||||||
|
calibrated in raw * 2.0 * 0.99..raw * 2.0 * 1.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three models give three distinct EV curves (no toggle collapse)`() {
|
||||||
|
val tr = treatment(Esters.EV, PKModels.ESTRANNAISE)
|
||||||
|
val d = dose(mg = 5.0)
|
||||||
|
val t = BASE + (2.0 * DAY_MS).toLong()
|
||||||
|
val ese = PharmacokineticEngine.e2At(listOf(tr), listOf(d), t, modelOverride = "ESE")
|
||||||
|
val tfs = PharmacokineticEngine.e2At(listOf(tr), listOf(d), t, modelOverride = "TFS")
|
||||||
|
val whs = PharmacokineticEngine.e2At(listOf(tr), listOf(d), t, modelOverride = "WHS")
|
||||||
|
// 3 interprétations DISTINCTES du même ester — attention : EV ESE et
|
||||||
|
// TFS sont quasi identiques au pic (305 vs 294 = 3,7 %, les tables
|
||||||
|
// ODS dérivaient du même article) ; le seuil de distinction est 2 %.
|
||||||
|
assertTrue("ESE=$ese vs TFS=$tfs", abs(ese - tfs) > 0.02 * ese)
|
||||||
|
assertTrue("TFS=$tfs vs WHS=$whs", abs(tfs - whs) > 0.02 * tfs)
|
||||||
|
assertTrue("WHS=$whs vs ESE=$ese", abs(whs - ese) > 0.02 * whs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abs(x: Double): Double = kotlin.math.abs(x)
|
||||||
|
}
|
||||||
114
app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt
Normal file
114
app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Épingle le modèle **WHSAH** (v1.4.6) sur le comportement de MONA — le fit
|
||||||
|
* « license-free » du WHSAH Collective (app Mona,
|
||||||
|
* lib/data/model/graph_calculator.dart, params F/auc/k1/k2/k3).
|
||||||
|
*
|
||||||
|
* ⚠️ Contrairement à `TransfemScienceModelsTest` (fidélité à l'ARTICLE),
|
||||||
|
* ici la référence est le CODE de Mona : les valeurs de référence ci-dessous
|
||||||
|
* ont été calculées en portant la formule Mona avec SES paramètres. Toute
|
||||||
|
* retouche de `WhsahModels.kt` doit faire correspondre Mona à ±2 %.
|
||||||
|
*/
|
||||||
|
class WhsahModelsTest {
|
||||||
|
|
||||||
|
/** Courbe 5 mg = sample × 5 (pg/mL). */
|
||||||
|
private fun c5mg(ester: String, days: Double): Double =
|
||||||
|
WhsahModels.sample(ester, days * 24.0) * 5.0
|
||||||
|
|
||||||
|
/** Max/t de la courbe 5 mg sur 0..60 j (pas 0,005 j). */
|
||||||
|
private fun peak(ester: String): Pair<Double, Double> {
|
||||||
|
var best = 0.0 to 0.0
|
||||||
|
var t = 0.005
|
||||||
|
while (t < 60.0) {
|
||||||
|
val v = c5mg(ester, t)
|
||||||
|
if (v > best.first) best = v to t
|
||||||
|
t += 0.005
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EV peak matches Mona behavior (367 pg per mL at 1-7 days)`() {
|
||||||
|
val (cmax, tmax) = peak("EV")
|
||||||
|
assertTrue("EV Cmax=$cmax attendu 367,5", cmax in 367.5 * 0.98..367.5 * 1.02)
|
||||||
|
assertTrue("EV Tmax=$tmax attendu 1,69", tmax in 1.69 * 0.95..1.69 * 1.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EEn peak matches Mona behavior (188 pg per mL at 5-0 days)`() {
|
||||||
|
val (cmax, tmax) = peak("EEn")
|
||||||
|
assertTrue("EEn Cmax=$cmax attendu 187,9", cmax in 187.9 * 0.98..187.9 * 1.02)
|
||||||
|
assertTrue("EEn Tmax=$tmax attendu 4,99", tmax in 4.99 * 0.95..4.99 * 1.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal half-lives match the WHSAH fit`() {
|
||||||
|
// t½ terminale (la phase la plus lente) — dérivées des params WHSAH
|
||||||
|
val expected = mapOf(
|
||||||
|
"EV" to 3.11, // ln2 / 0,2230
|
||||||
|
"EEn" to 7.34, // ln2 / 0,09441
|
||||||
|
"EB" to 1.33, // ln2 / 0,522 (min des trois k : k1, pas k2=521,9)
|
||||||
|
"EC" to 7.88, // ln2 / 0,0880
|
||||||
|
"ECS" to 7.12, // ln2 / 0,0973
|
||||||
|
"EU" to 31.7 // ln2 / 0,02189
|
||||||
|
)
|
||||||
|
for ((ester, tHalfDays) in expected) {
|
||||||
|
val m = WhsahModels.model(ester)!!
|
||||||
|
assertTrue(
|
||||||
|
"$ester t½=${m.terminalHalfLifeDays} attendu $tHalfDays",
|
||||||
|
m.terminalHalfLifeDays in tHalfDays * 0.98..tHalfDays * 1.02
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normalization is linear per mg (5 mg = 5x the unit response)`() {
|
||||||
|
// sample retourne pg/mL PAR MG : 5 mg = 5 × sample (cohérence moteur)
|
||||||
|
val t = 2.0
|
||||||
|
assertEquals(
|
||||||
|
WhsahModels.sample("EEn", t * 24.0) * 5.0,
|
||||||
|
c5mg("EEn", t),
|
||||||
|
1e-9
|
||||||
|
)
|
||||||
|
// et la linéarité en dose (le moteur superpose les contributions)
|
||||||
|
val one = WhsahModels.sample("EV", 48.0)
|
||||||
|
val two = WhsahModels.sample("EV", 48.0) * 2.0
|
||||||
|
assertEquals(one * 2.0, two, 1e-12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ester choices follow the pk model (TFS=7, WHS=6, ESE=3)`() {
|
||||||
|
// v1.4.6 : la liste d'override par dose suit la couverture du modèle
|
||||||
|
assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS", "PEP"),
|
||||||
|
com.hormonetrack.data.model.Esters.choicesForModel(
|
||||||
|
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE))
|
||||||
|
assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"),
|
||||||
|
com.hormonetrack.data.model.Esters.choicesForModel(
|
||||||
|
com.hormonetrack.data.model.PKModels.WHSAH))
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PEP is not covered by the WHSAH fit (like Mona)`() {
|
||||||
|
assertTrue(!WhsahModels.hasModel("PEP"))
|
||||||
|
assertEquals(0.0, WhsahModels.sample("PEP", 10.0), 1e-12)
|
||||||
|
// dt ≤ 0 → 0
|
||||||
|
assertEquals(0.0, WhsahModels.sample("EEn", -1.0), 1e-12)
|
||||||
|
assertEquals(0.0, WhsahModels.sample("EEn", 0.0), 1e-12)
|
||||||
|
// esters couverts : les 6 (lookup insensible à la casse)
|
||||||
|
for (ester in listOf("EV", "EEN", "EB", "EC", "ECS", "EU")) {
|
||||||
|
assertTrue(WhsahModels.hasModel(ester))
|
||||||
|
}
|
||||||
|
assertTrue(WhsahModels.hasModel("een")) // casse libre
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -152,6 +152,93 @@ class ChartZoomTest {
|
|||||||
rangeHours = 6L, horizonHours = 24L, firstSlotGapHours = 0L))
|
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
|
||||||
|
fun `default toggles follow the models actually used by treatments`() {
|
||||||
|
// Cas de l'utilisatrice : EV historique sous TFS + EEn actuel sous WHSAH
|
||||||
|
// → Estrannaise OFF (jamais utilisée), les deux autres ON
|
||||||
|
assertEquals(
|
||||||
|
mapOf("ESE" to false, "TFS" to true, "WHS" to true),
|
||||||
|
com.hormonetrack.ui.components.defaultModelToggles(setOf("TFS", "WHS"))
|
||||||
|
)
|
||||||
|
// les 3 modèles utilisés → tous ON
|
||||||
|
assertEquals(
|
||||||
|
mapOf("ESE" to true, "TFS" to true, "WHS" to true),
|
||||||
|
com.hormonetrack.ui.components.defaultModelToggles(setOf("ESE", "TFS", "WHS"))
|
||||||
|
)
|
||||||
|
// un seul modèle → lui seul ON
|
||||||
|
assertEquals(
|
||||||
|
mapOf("ESE" to true, "TFS" to false, "WHS" to false),
|
||||||
|
com.hormonetrack.ui.components.defaultModelToggles(setOf("ESE"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default toggles - no profile model used falls back to TFS (Bateman still drawn)`() {
|
||||||
|
// Traitements gel/oral uniquement (pkModel stocké = ESE mais PAS de
|
||||||
|
// modèle profil au sens usesProfileModel)... le set passé par
|
||||||
|
// ChartScreen contient les pkModel BRUTS : un traitement gel sous
|
||||||
|
// ESE produit setOf("ESE") → ESE ON. Un set vide (aucun traitement)
|
||||||
|
// → garde-fou TFS ON (la courbe ne doit jamais être vide par défaut).
|
||||||
|
assertEquals(
|
||||||
|
mapOf("ESE" to false, "TFS" to true, "WHS" to false),
|
||||||
|
com.hormonetrack.ui.components.defaultModelToggles(emptySet())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── v1.4.5 (fix #55) : labels X alignés sur minuit LOCAL ──
|
// ── v1.4.5 (fix #55) : labels X alignés sur minuit LOCAL ──
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -194,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
|
@Test
|
||||||
fun `xLabelTicks hourly steps land at whole local hours`() {
|
fun `xLabelTicks hourly steps land at whole local hours`() {
|
||||||
val now = System.currentTimeMillis()
|
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"))
|
.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,800 @@
|
|||||||
# Changelog — HormoneTrack
|
# Changelog — HormoneTrack
|
||||||
|
|
||||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.5`, …).
|
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)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **La calibration tombait à ×2,21 (auto ET manuelle) sur un traitement
|
||||||
|
basculé sous un autre modèle** (#61, remontée : « le bouton Calibrer avec
|
||||||
|
les analyses du traitement EEn WHSAH me met toujours à ×2,21 ») :
|
||||||
|
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 / 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
|
||||||
|
≥ 15 % du max déjà observé (l'injection « gouverne » encore le taux) ;
|
||||||
|
appliquée aux 3 pipelines (manuel, échelles par ester, k T), labs
|
||||||
|
traités par timestamp croissant. Résultat : le facteur WHSAH retombe à
|
||||||
|
**0,55** (physiologique).
|
||||||
|
- **Hint UX** (demande) : dans l'éditeur de traitement, quand l'auto-
|
||||||
|
calibration est ON, la carte Calibration affiche que le facteur manuel
|
||||||
|
est ignoré à l'affichage (l'auto recouvre les courbes).
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **4 nouveaux tests** (168 au total / 146 sans les données locales) :
|
||||||
|
reproduction épinglée du ×2,21 (facteur manuel WHSAH 0,55, plus jamais
|
||||||
|
2,21), garde de significativité (séquence réelle + cas aux frontières),
|
||||||
|
cohérence auto↔manuel.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
WHSAH met ×2,21 alors que l'idéal serait < 1 ») : un SEUL jeu de facteurs
|
||||||
|
était calculé depuis la prédiction du modèle STOCKÉ puis partagé par
|
||||||
|
toutes les courbes — en basculant un traitement (ex. EEn TFS → WHSAH),
|
||||||
|
les facteurs devenaient absurdes (les prédictions WHSAH diffèrent de TFS :
|
||||||
|
creux plus profonds, montée plus rapide). FIX (demande : « la calibration
|
||||||
|
doit être active sur tous les modèles mais s'adapter à chaque profil ») :
|
||||||
|
**la calibration est désormais PAR MODÈLE** — le graphique calcule un jeu
|
||||||
|
de facteurs (et de k T) PAR modèle affiché (ESE/TFS/WHS), chacun calibré
|
||||||
|
avec la prédiction DE SON modèle → chaque courbe calibrée passe par tes
|
||||||
|
labs, quel que soit le modèle superposé. Home conserve la calibration du
|
||||||
|
modèle stocké (inchangé). Épinglé par test : chaque courbe calibrée passe
|
||||||
|
par le lab (±2 %) et les échelles TFS/WHS diffèrent.
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **5 nouveaux tests** (164 au total / 142 sans les données locales) :
|
||||||
|
`CalibrationPerModelTest` — les 3 modèles calibrés atterrissent sur le
|
||||||
|
lab (ESE inclus), échelles distinctes (ESE↔TFS ~2 %, WHS ~11 %),
|
||||||
|
délégation `autoCalibrated(modelOverride)` → `computeEsterScaleFactors`
|
||||||
|
(implémentation unique), **k T par modèle** (k ∝ 1/E2 calibrée — et les
|
||||||
|
k post-calibration CONVERGENT entre modèles : même E2 cible, comportement
|
||||||
|
voulu), labs antérieurs à la 1ʳᵉ dose → échelles vides (par modèle).
|
||||||
|
|
||||||
|
## [1.4.7] — 2026-09-07 (versionCode 27)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- **Toggles de modèles pré-cochés selon TES traitements** (#58, demande :
|
||||||
|
« les seules courbes activées par défaut sont celles qui se rapportent au
|
||||||
|
modèle PK des traitements en cours ou passés ») : au chargement du
|
||||||
|
graphique, les chips Estrannaise/Transfem/WHSAH sont alignés sur les
|
||||||
|
modèles RÉELLEMENT utilisés par des traitements à PROFIL PK
|
||||||
|
(`usesProfileModel` — le traitement inactif compte, l'oral/anti-androgène
|
||||||
|
non) ; les autres restent activables au tap. Cas de l'utilisatrice :
|
||||||
|
EV historique TFS + EEn actuel (qu'elle passe sous WHSAH) → TFS + WHSAH
|
||||||
|
ON, Estrannaise OFF. Garde-fou testé : aucun modèle profil utilisé → TFS
|
||||||
|
ON (les traitements Bateman sont tracés identiquement dans chaque série).
|
||||||
|
Une seule initialisation : les toggles ensuite modifiés ne sont pas
|
||||||
|
re-rencontrés. (`defaultModelToggles`, helper pur testé.)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **La carte de traitement affichait « EEN Estrannaise » pour un traitement
|
||||||
|
sous WHSAH** (#59, remontée) : le ternaire à 2 branches du chip
|
||||||
|
`TreatmentsScreen` tombait WHSAH dans le `else` → label « Estrannaise ».
|
||||||
|
FIX : helper centralisé **`modelLabelRes`** (unique source de vérité du
|
||||||
|
label : ESE/TFS/WHS) utilisé par la carte — le dropdown de l'éditeur et
|
||||||
|
le chip du graphique étaient déjà corrects. Règle : TOUT nouvel affichage
|
||||||
|
de modèle passe par le helper, jamais un ternaire.
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Épinglage : la calibration s'applique à TOUTES les courbes** (2ᵉ partie
|
||||||
|
de la demande — vérification) : le facteur par ESTER (`scalePerEster`)
|
||||||
|
est appliqué par DOSE (`doseEster`), indépendamment du modelOverride →
|
||||||
|
les 3 courbes (ESE/TFS/WHS) d'un même traitement sont scalées
|
||||||
|
IDENTIQUEMENT (2× le facteur = 2× la courbe, test `calibration applies
|
||||||
|
to ALL model curves equally`). C'était déjà le comportement ; il est
|
||||||
|
désormais garanti par test. Subtilité documentée (§7.6) : le facteur est
|
||||||
|
calculé depuis la prédiction du modèle STOCKÉ du traitement — les
|
||||||
|
courbes des autres modèles partagent ce facteur (un facteur par
|
||||||
|
ester/période, pas par modèle).
|
||||||
|
- **3 nouveaux tests** (158 au total / 136 sans les données locales) :
|
||||||
|
`defaultModelToggles` (2) + calibration universelle (1).
|
||||||
|
|
||||||
|
## [1.4.6] — 2026-09-07 (versionCode 26)
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **3ᵉ modèle PK superposable : WHSAH** (demande : « ajouter le nouveau
|
||||||
|
modèle à côté de tous les autres comme un 3ᵉ choix possible, au même
|
||||||
|
niveau ») : le fit « license-free » du **WHSAH Collective** publié dans
|
||||||
|
l'app open-source **Mona** (libre d'usage par leurs auteurs — crédit :
|
||||||
|
alix / WHSAH Collective via Mona). Même famille mathématique (tri-
|
||||||
|
exponentielle en forme close) mais paramètres refittés avec une
|
||||||
|
**biodisponibilité explicite F < 1** : montée plus rapide à J+1 (EEn
|
||||||
|
~70 pg/mL vs ~22 chez TFS pour 5 mg) et t½ terminale plus longue
|
||||||
|
(EEn 7,3 j vs 4,5 j). **PEP non couvert** par ce fit (comme chez Mona).
|
||||||
|
- **6 presets WHSAH** (EV/EU/EEn/EB/EC/ECS) + dropdown « PK model » à 3
|
||||||
|
choix dans l'éditeur ; l'override d'ester par dose suit la couverture du
|
||||||
|
modèle (helper `Esters.choicesForModel`, testé : TFS=7 / WHS=6 / ESE=3).
|
||||||
|
- **Écran Graphiques : 3 toggles de modèles indépendants** (Estrannaise /
|
||||||
|
Transfem Science / WHSAH superposables simultanément — légende en 3
|
||||||
|
couleurs : bleu / turquoise / vert) + **l'écran défile verticalement**
|
||||||
|
(la rangée des toggles était coupée sous le pli, #57).
|
||||||
|
- **7 nouveaux tests** (155 au total / 133 sans les données locales) :
|
||||||
|
`WhsahModelsTest` (fidélité au comportement de Mona : EV 367,5 @ 1,69 j,
|
||||||
|
EEn 187,9 @ 4,99 j, t½ 7,34 j), `WhsahEngineTest` (dispatch, coupure
|
||||||
|
10×t½, PEP → 0, 3 courbes distinctes), `choicesForModel`.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **Écran Graphiques : rangée des toggles de modèles coupée sous le pli**
|
||||||
|
(#57, découvert en validant sur émulateur) : le Column ne défilait pas —
|
||||||
|
le chip WHSAH était littéralement hors écran sur un téléphone standard.
|
||||||
|
FIX : écran à défilement vertical.
|
||||||
|
|
||||||
## [1.4.5] — 2026-09-07 (versionCode 25)
|
## [1.4.5] — 2026-09-07 (versionCode 25)
|
||||||
|
|
||||||
@ -39,8 +832,6 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.5
|
|||||||
|
|
||||||
## [1.4.4] — 2026-09-07 (versionCode 24)
|
## [1.4.4] — 2026-09-07 (versionCode 24)
|
||||||
|
|
||||||
## [1.4.4] — 2026-09-07 (versionCode 24)
|
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
- **La prévision était inaccessible depuis la v1.4.1** (#54, remontée : « en
|
- **La prévision était inaccessible depuis la v1.4.1** (#54, remontée : « en
|
||||||
1.4.3 je ne peux même pas drag le graphique vers la gauche pour voir les
|
1.4.3 je ne peux même pas drag le graphique vers la gauche pour voir les
|
||||||
|
|||||||
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`
|
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)
|
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 ».
|
- La barre du bas affiche la progression ; attends « Gradle sync finished ».
|
||||||
3. Vérifie que l'asset est bien présent :
|
3. (v1.9.0) Le modèle Estrannaise est **analytique** : l'ancienne table
|
||||||
`app/src/main/assets/pk_profiles.json` (profils Estrannaise / Transfem Science extraits de ton `Estrogen.ods`).
|
`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
|
## 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
|
## 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
|
- 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
|
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 —
|
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`
|
- 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)
|
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
|
||||||
2. **Doses → +** → logue tes injections passées
|
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
|
(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**
|
- 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
|
(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)
|
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
|
||||||
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
|
- 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)
|
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 »
|
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
|
||||||
- Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de
|
- Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de
|
||||||
fichiers → choisis où enregistrer le .txt → un message de confirmation
|
fichiers → choisis où enregistrer le .txt → un message de confirmation
|
||||||
@ -138,18 +153,31 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
|||||||
bas requis. C'est une estimation du modèle, pas une mesure
|
bas requis. C'est une estimation du modèle, pas une mesure
|
||||||
- **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL),
|
- **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL),
|
||||||
calibrable avec tes résultats T
|
calibrable avec tes résultats T
|
||||||
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
|
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
|
||||||
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
|
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
|
||||||
v1.4.2)
|
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
|
## 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
|
- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments
|
||||||
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
|
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
|
||||||
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
|
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
|
||||||
courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC,
|
courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC,
|
||||||
EC suspension, PEP)
|
EC suspension, PEP)
|
||||||
|
- **WHSAH** (v1.4.6) : un 3ᵉ fit indépendant (« license-free », publié par le
|
||||||
|
WHSAH Collective dans l'app [Mona](https://github.com/mona-hrt/mona)) —
|
||||||
|
montée plus rapide à J+1 et décroissance plus longue sur certains esters ;
|
||||||
|
superposable avec les deux autres dans le graphique (vert). 6 esters (sans PEP)
|
||||||
|
|
||||||
Pics de référence (dose unique de 5 mg, IM) :
|
Pics de référence (dose unique de 5 mg, IM) :
|
||||||
|
|
||||||
@ -183,6 +211,13 @@ Pics de référence (dose unique de 5 mg, IM) :
|
|||||||
pour parcourir la projection dans le futur (jusqu'à 1 an selon la Posologie) ;
|
pour parcourir la projection dans le futur (jusqu'à 1 an selon la Posologie) ;
|
||||||
« Revenir à maintenant » pour revenir. Si tu désactives le chip alors que tu es
|
« Revenir à maintenant » pour revenir. Si tu désactives le chip alors que tu es
|
||||||
dans le futur, l'app revient automatiquement à maintenant
|
dans le futur, l'app revient automatiquement à maintenant
|
||||||
|
- **Toggles pré-cochés selon tes traitements** (v1.4.7) : au chargement du
|
||||||
|
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
|
- **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
|
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
|
petit triangle discret au bas du graphique — plus d'ambiguïté sur « où » la
|
||||||
@ -190,21 +225,47 @@ Pics de référence (dose unique de 5 mg, IM) :
|
|||||||
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
|
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
|
||||||
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
|
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
|
||||||
du téléphone)
|
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
|
- **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
|
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
|
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
|
- **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
|
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
|
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
|
||||||
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
|
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
|
||||||
d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
|
d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
|
||||||
la suppression T), et inversement pour l'enanthate
|
la suppression T), et inversement pour l'enanthate. **Calibration par modèle**
|
||||||
|
(v1.4.8) : chaque modèle affiché dans le graphique (Estrannaise / Transfem
|
||||||
|
Science / WHSAH) reçoit SES propres facteurs calculés sur tes labs — les
|
||||||
|
courbes superposées collent toutes à tes résultats, quelle que soit leur forme
|
||||||
|
|
||||||
## 9. Dépannage
|
## 9. Dépannage
|
||||||
|
|
||||||
| Problème | Solution |
|
| 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) |
|
| 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 |
|
| 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 |
|
| Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app |
|
||||||
@ -215,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
|
- **Tout est local** : base Room sur le téléphone, aucun serveur, aucun compte
|
||||||
- Sauvegarde = fichier JSON que tu choisis où stocker (Owncloud, etc.)
|
- 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
|
- 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