Compare commits

..

No commits in common. "main" and "v1.9.0" have entirely different histories.
main ... v1.9.0

62 changed files with 1024 additions and 5136 deletions

7
.gitignore vendored
View File

@ -5,13 +5,6 @@ build/
# Local configuration (machine-specific, never commit)
local.properties
# Release signing (v1.9.8) — created by scripts/make-release-keystore.sh.
# NEVER commit: the keystore + its passwords are the identity of the app
# (leaking them = anyone can ship an APK that upgrades over yours).
keystore.properties
*.keystore
*.jks
# IntelliJ / Android Studio
.idea/
*.iml

View File

@ -1,305 +0,0 @@
# 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)).

View File

@ -1,7 +1,5 @@
# HormoneTrack
> **Langue** : Français (ce fichier) · [English](README.en.md)
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
> La contribution humaine a été **essentielle** : feedback continu, retours
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
@ -18,12 +16,11 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.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** ✅
- **Statut** : v1.9.0 — build Android ✅, **lint vert** ✅, **234 tests unitaires** ✅ (204 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
- **README anglais** : [README.en.md](README.en.md)
## Fonctionnalités
@ -67,13 +64,10 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
vers la gauche, jusqu'à **1 an**
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché ; **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)
**intervalle en jours entre dosages** affiché
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
utile pour une transition valerate → enanthate ; **regroupés tout en bas** de la
page Traitements sous un en-tête dédié, cartes atténuées (v1.11.0)
utile pour une transition valerate → enanthate
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
@ -82,10 +76,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
**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)
estimations, jamais un avis médical
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
@ -97,9 +88,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà
loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant
repart au créneau suivant)
7 jours ne sonne que le jour d'injection, pas tous les jours
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
@ -134,8 +123,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 284 tests (250 sans les données locales)
./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
./gradlew testDebugUnitTest # 234 tests (204 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```
@ -147,9 +135,9 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.10.0`) et **deux APK par release** (depuis v1.2.5)
**releases taguées** (`v1.1.0` → `v1.8.1`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
```bash
git tag # lister les releases
@ -165,12 +153,6 @@ dépôt `Siphonight/HormoneTrack-web` (mêmes instances) — versions **alignée
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
**Signature release** : l'APK release est signé avec la clé debug tant qu'aucun
`keystore.properties` n'existe à la racine ; dès sa création via
`scripts/make-release-keystore.sh` (fichiers gitignorés), `assembleRelease`
signe avec ce keystore — migration téléphone documentée dans
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
## Les modèles en bref
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
@ -235,7 +217,7 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
```
HormoneTrack/
├── README.md ← ce fichier (README.en.md = version anglaise)
├── README.md ← ce fichier
├── docs/
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
@ -244,11 +226,9 @@ HormoneTrack/
├── scripts/
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
│ ├── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
│ └── make-release-keystore.sh génère le keystore de signature release (v1.9.8, §16.quater)
│ └── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
├── local-test-data/ ← gitignoré : backups réels pour les tests
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
├── keystore.properties ← gitignoré : signature release (optionnel, §16.quater)
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
├── settings.gradle.kts
├── gradle.properties
@ -267,25 +247,22 @@ HormoneTrack/
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
│ │ ├── settings/ (DataStore : TConfig, langue)
│ │ ├── ui/ (Compose : screens, components, theme)
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
│ │ ├── HormoneTrackApp.kt
│ │ └── MainActivity.kt
│ └── res/ (strings FR/EN, thème, icônes)
├── test/java/com/hormonetrack/ ← tests unitaires JVM
│ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
│ │ par modèle, rappels, régressions data-driven)
│ ├── data/backup/ (round-trip Gson + paramètres)
│ ├── ui/ (fenêtre graphique, garde de source)
│ ├── util/ (noms de fichiers d'export, durées)
│ └── reminder/ + settings/ (RRULE agenda, changelog)
└── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8)
└── uitest/ (navigation, dialog changelog, Paramètres)
└── test/java/com/hormonetrack/ ← tests unitaires JVM
├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
│ par modèle, rappels, régressions data-driven)
├── data/backup/ (round-trip Gson + paramètres)
├── ui/ (fenêtre graphique, garde de source)
├── util/ (noms de fichiers d'export, durées)
├── reminder/ + settings/ (RRULE agenda, changelog)
```
## Feuille de route
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
- [x] Tests UI Compose (13) + infrastructure de compilation release signée (v1.9.8)
- [ ] Tests UI Compose + compilation release signée
- [ ] 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))

View File

@ -1,25 +1,9 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
}
// ── Signature RELEASE (v1.9.8) ──────────────────────────────────────────────
// keystore.properties (RACINE du dépôt, GITIGNORÉ) décrit le vrai keystore :
// storeFile=release.keystore (chemin relatif à la racine du dépôt)
// storePassword=…
// keyAlias=…
// keyPassword=…
// Tant que ce fichier est ABSENT, la build release reste signée avec la clé
// DEBUG (comportement historique : installation par-dessus l'APK debug sans
// perte de données, cf §16.quater). Dès qu'il existe (créé par
// scripts/make-release-keystore.sh), la build release l'utilise — ⚠️ signature
// différente ⇒ migration obligatoire : export JSON → désinstallation →
// réinstallation → réimport (documenté §16.quater).
val keystorePropertiesFile = rootProject.file("keystore.properties")
android {
namespace = "com.hormonetrack"
compileSdk = 37
@ -33,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 50
versionName = "1.13.0"
versionCode = 38
versionName = "1.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -51,27 +35,12 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Signature : VRAI keystore si keystore.properties existe (racine du
// dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ;
// sinon CLÉ DEBUG volontairement : l'APK release reste installable
// PAR-DESSUS l'APK debug déjà installé (même signature), sans
// désinstallation ni perte de données, tout en bénéficiant de R8.
// ⚠️ Passer au vrai keystore CHANGE la signature : migration
// backup→désinstall→réimport requise sur le téléphone (§16.quater).
signingConfig = if (keystorePropertiesFile.exists()) {
val props = Properties().apply {
keystorePropertiesFile.inputStream().use { load(it) }
}
signingConfigs.create("release") {
storeFile = rootProject.file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
}
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
// ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
// sans désinstallation ni perte de données, tout en bénéficiant de
// R8. Si un jour on distribue largement : générer un vrai keystore
// (signature différente → migration backup→désinstall→réimport).
signingConfig = signingConfigs.getByName("debug")
}
}
@ -160,16 +129,4 @@ dependencies {
// Tests unitaires JVM
testImplementation("junit:junit:4.13.2")
// ── Tests UI Compose (v1.9.8, androidTest — cf docs/DEVELOPPEMENT.md §8.ter) ──
// Exécution : ./gradlew connectedDebugAndroidTest (émulateur/appareil requis).
// Le BOM est réappliqué sur la configuration androidTest pour épinner les
// mêmes versions que le runtime Compose de l'app.
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
androidTestImplementation("junit:junit:4.13.2")
// UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest)
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
}

View File

@ -1,108 +0,0 @@
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()
}
}
}

View File

@ -1,84 +0,0 @@
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()
}
}

View File

@ -1,104 +0,0 @@
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()
}
}

View File

@ -1,84 +0,0 @@
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()
}
}

View File

@ -1,65 +0,0 @@
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()
}
}

View File

@ -26,13 +26,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<!-- launchMode singleTop (v1.10.1) : un tap sur la notification de
rappel quand l'app est DÉJÀ ouverte délivre onNewIntent (avec les
extras open_log_dose/treatment_id) SANS détruire/recréer
l'activité — l'ancien comportement (standard + CLEAR_TOP)
recréait tout, et onNewIntent était du code mort. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@ -10,7 +10,6 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.mutableStateOf
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope
import com.hormonetrack.ui.HormoneTrackRoot
@ -23,17 +22,6 @@ class MainActivity : AppCompatActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
/**
* Demande « logger une dose » venue d'une NOTIFICATION (extra
* `open_log_dose`/`treatment_id`). Détenu par l'ACTIVITÉ — survit aux
* changements d'onglets — et consommé UNE FOIS par HomeScreen via
* [HormoneTrackRoot] (**fix #69 v1.10.0** : l'ancien code passait le
* paramètre d'intent tel quel à la composition ; quitter l'onglet
* Accueil détruisait les `remember` locaux, le paramètre lui vivait au
* niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil).
*/
private val logDoseRequest = mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val container = (application as HormoneTrackApp).container
@ -65,11 +53,15 @@ class MainActivity : AppCompatActivity() {
}
}
logDoseRequest.value = consumeLogDoseExtras(intent)
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
setContent {
HormoneTrackTheme {
HormoneTrackRoot(logDoseRequest = logDoseRequest)
HormoneTrackRoot(
initialOpenLogDose = openLogDose,
initialTreatmentId = treatmentId
)
}
}
}
@ -77,22 +69,5 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// v1.10.0 : un tap sur la notification quand l'app est DÉJÀ ouverte
// ouvre aussi le dialog (avant : setIntent seul, la demande était
// silencieusement perdue).
consumeLogDoseExtras(intent)?.let { logDoseRequest.value = it }
}
/**
* Lit les extras de demande de log puis les RETIRE de l'intent : un
* recreate de l'activité (rotation, processus restauré) ne doit JAMAIS
* rouvrir le dialog d'une notification traitée il y a longtemps.
*/
private fun consumeLogDoseExtras(intent: Intent?): Long? {
val open = intent?.getBooleanExtra("open_log_dose", false) ?: false
val id = intent?.getLongExtra("treatment_id", -1L) ?: -1L
intent?.removeExtra("open_log_dose")
intent?.removeExtra("treatment_id")
return if (open && id > 0) id else null
}
}

View File

@ -36,12 +36,7 @@ data class UserSettings(
val alertE2High: Double? = null,
val alertE2Low: Double? = null,
val alertTHigh: 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
val alertTLow: Double? = null
)
data class BackupData(

View File

@ -18,8 +18,7 @@ data class PKPreset(
object PKPresets {
val all: List<PKPreset> = listOf(
// --- Injections : modèle Estrannaise ANALYTIQUE (v1.9.0 — la
// source est estrannaise.js, plus de tables ODS au runtime) ---
// --- Injections: Estrannaise models (profiles from the ODS) ---
PKPreset(
nameRes = R.string.preset_ev_ese,
type = TreatmentType.ESTRADIOL,
@ -47,39 +46,6 @@ object PKPresets {
absorptionHours = 152f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
// --- v1.9.4 : le modèle ESE est ANALYTIQUE (estrannaise.js, cf
// pk/EstrannaiseModels.kt) et couvre 6 esters injectables —
// presets ajoutés pour EC (huile), EB et EUCS (undécylate
// suspension cristalline, exclusif à ce fit). Champs Bateman =
// t½ terminales des fits (informatifs, pas utilisés au calcul —
// le dispatch passe par la forme close).
PKPreset(
nameRes = R.string.preset_ec_ese,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EC,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 7f, eliminationHalfLifeHours = 202f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_eb_ese,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EB,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 1f, eliminationHalfLifeHours = 4f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
),
PKPreset(
nameRes = R.string.preset_eucs_ese,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EUCS,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 22f, eliminationHalfLifeHours = 756f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
// --- Injections: Transfem Science models ---
PKPreset(
nameRes = R.string.preset_ev_tfs,

View File

@ -94,8 +94,7 @@ data class Treatment(
val isActive: Boolean = true,
val notes: String? = null,
// PK: analytical models for injections — Estrannaise (v1.9.0, 6 esters),
// Transfem Science (V3C, 7), WHSAH (6); see Esters.choicesForModel().
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science)
val esterType: String = Esters.NONE,
val pkModel: String = PKModels.ESTRANNAISE,

View File

@ -16,23 +16,13 @@ import com.hormonetrack.data.model.TreatmentType
* 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).
* QU'EST-CE QUE LE NUAGE REPRÉSENTE : la superposition des doses des
* traitements E2 **à profil ESE** évaluée avec UN échantillon MCMC par
* courbe — chaque polyligne du nuage est « ce que serait la courbe si le
* vrai paramètre physiologique était celui-là ». Les contributions des
* autres traitements (TFS/WHSAH/Bateman) ne sont PAS incluses dans le
* nuage : elles n'ont pas d'incertitude publiée (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
@ -58,11 +48,14 @@ object EstrannaiseCloud {
* @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 nowMs « maintenant » (les doses futures du porteur ne sont
* pas loguées — seules les doses à horizon prévisionnel
* alimentées par le caller seraient incluses ; v1.9.0
* limite le nuage aux doses RÉELLES, comme le tracé labs)
* @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é.
* calculable : aucun traitement E2 actif à profil ESE avec un ester
* couvert, aucune dose, ou MCMC non chargé.
*/
fun compute(
treatments: List<Treatment>,
@ -70,65 +63,39 @@ object EstrannaiseCloud {
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
nowMs: Long,
nbCurves: Int = DEFAULT_NB_CURVES
): 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
// ── Porteurs : traitements E2 actifs à profil ESE avec ester couvert ──
val carriers = treatments.filter {
it.type == TreatmentType.ESTRADIOL && it.isActive &&
it.usesProfileModel && it.pkModel == PKModels.ESTRANNAISE &&
EstrannaiseModels.hasModel(it.esterType)
}
carrierDoses.add(dose to tr)
}
if (carrierDoses.isEmpty()) return emptyList()
if (carriers.isEmpty()) return emptyList()
val carrierIds = carriers.map { it.id }.toSet()
val carrierDoses = doseLogs
.filter { it.treatmentId in carrierIds && it.timestamp <= endMs }
.ifEmpty { 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 samplesByEster = carriers.associate {
it.esterType to EstrannaiseModels.mcmcSamples(it.esterType)
}
if (samplesByEster.values.any { it.isEmpty() }) return emptyList()
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 firstDoseMs = carrierDoses.minOf { it.timestamp }
val t0 = maxOf(startMs, firstDoseMs)
if (t0 > endMs) return emptyList()
@ -143,15 +110,12 @@ object EstrannaiseCloud {
var t = t0
while (t <= endMs) {
var e2 = 0.0
for ((dose, tr) in carrierDoses) {
for (dose 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
val tr = carriers.first { it.id == dose.treatmentId }
val params = paramsByEster.getValue(tr.esterType)
e2 += EstrannaiseModels.sampleWithParams(tr.esterType, dtH, params) * dose.doseAmount
}
curve.add(LevelPoint(t, e2, 0.0))
t += stepMs

View File

@ -20,10 +20,8 @@ import kotlin.math.ceil
* 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).
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le
* premier créneau d'injection ≥ date de stabilisation du régime courant.
*
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
@ -38,13 +36,10 @@ import kotlin.math.ceil
* 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) ;
* - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux
* injections. 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 ≥
@ -77,37 +72,6 @@ object LabTiming {
/** 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). */
@ -128,15 +92,7 @@ object LabTiming {
* 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
val wasAlreadyStabilized: Boolean
)
/**
@ -151,14 +107,7 @@ object LabTiming {
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
nowMs: Long = System.currentTimeMillis()
): LabRecommendation? {
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
val carrier = treatments.firstOrNull {
@ -184,14 +133,13 @@ object LabTiming {
// ── 3) Début du régime courant + date de stabilisation ──────────────
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
if (myDoses.isEmpty()) return null
val regimeStartMs = regimeStartMs(carrier, myDoses)
val stabilizedAtMs = regimeStartMs +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui
// qui gouverne les creux futurs et le texte de la carte
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!!
@ -217,47 +165,25 @@ object LabTiming {
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
}
// Le creux du créneau = minimum de la courbe entre l'injection
// précédente (ou maintenant) et ce créneau
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp }
val trough = window.minByOrNull { it.e2 }
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
// le creux d'un créneau est stabilisé si le CRÉNEAU l'est
slot.timestamp >= stabilizedAtMs
) {
return LabRecommendation(
troughMs = trough.timestamp,
injectionMs = slot.timestamp,
ester = activeEster,
terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStart,
regimeStartMs = regimeStartMs,
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 }
wasAlreadyStabilized = stabilizedAtMs <= nowMs
)
}
windowStart = slot.timestamp
@ -268,61 +194,6 @@ object LabTiming {
/** 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**).
*
@ -331,35 +202,25 @@ object LabTiming {
* 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.
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
* à celles de la dose la plus récente, ET (b) **l'écart entre doses
* consécutives est constant** (= l'écart des deux doses les plus
* récentes). 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.
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce
* qui est pharmacocinétiquement vrai (le trough n'est comparable que
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un
* vrai changement de créneau.
*
* @param carrier traitement porteur (injectable E2 actif à Posologie)
* @param myDoses doses du porteur (ordre indifférent)
@ -370,23 +231,18 @@ object LabTiming {
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
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
// (inconnu si une seule dose — la séquence s'arrête là de toute façon)
var gapExpected = -1L
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
// Remonter seulement si même (ester, dose) ET même écart
val sameGap = gapExpected < 0L || gap == gapExpected
if (!sameMarker || !sameGap) break
gapExpected = gap
regimeStart = prev.timestamp
current = prev
idx--

View File

@ -178,19 +178,17 @@ object LabTrajectoryModel {
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.
// facteur — combiner avec le facteur stocké = double correction)
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)
val predicted = PharmacokineticEngine.e2At(
unscaled, doseLogs, lab.timestamp, modelOverride, scalePerEster = null
)
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
anchors.add(lab.timestamp to (lab.value / predicted))
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
@ -222,7 +220,7 @@ object LabTrajectoryModel {
val points = mutableListOf<LevelPoint>()
var t = t0
while (t <= t1) {
val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, scalePerEster = null)
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

View File

@ -164,65 +164,6 @@ object PharmacokineticEngine {
// Aggregated levels
// ------------------------------------------------------------------
/**
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
* bit-identique (mêmes boucles, même ordre de sommation flottante).
*/
class E2Context internal constructor(
internal val treatments: List<Treatment>,
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
internal val cutoffByTreatment: HashMap<Long, Double>,
internal val batemanCache: HashMap<Long, BatemanParams>,
)
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
val cutoffByTreatment = HashMap<Long, Double>()
val batemanCache = HashMap<Long, BatemanParams>()
for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
if (!treatment.usesProfileModel) {
batemanCache[treatment.id] = batemanParams(treatment)
}
}
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
}
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
fun e2At(
ctx: E2Context,
tMs: Long,
modelOverride: String? = null,
scalePerEster: Map<String, Double>? = null
): Double {
var total = 0.0
for (treatment in ctx.treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
if (dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
val c = concentrationOfDose(
treatment, dose, tMs, ctx.batemanCache[treatment.id], modelOverride
)
if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
total += c * scale
}
}
}
return total
}
/** e2At à appel unique : prépare le contexte puis délègue. */
fun e2At(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
@ -236,7 +177,43 @@ object PharmacokineticEngine {
*/
scalePerEster: Map<String, Double>? = null
): Double {
return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
// Pré-calculs PAR TRAITEMENT (v1.8.2 — le chemin chaud du tracé labs
// appelle e2At par point de grille : 720 pts × N traitements × D doses
// recalculaient cutoffHours et batemanParams à chaque itération) :
// - cutoff : déterministe par traitement → calculé UNE fois ;
// - Bateman (bisection 50 itérations + ln) : inutile pour les
// traitements à PROFIL (concentrationOfDose ne lit le paramètre
// que dans la branche Bateman, inatteignable quand
// usesProfileModel == true) → calcul paresseux.
// - doses pré-groupées par traitement (fini le scan complet de
// doseLogs par traitement) — l'ordre d'itération des doses d'UN
// traitement est préservé (groupBy préserve l'ordre relatif) →
// sommation flottante identique.
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
val cutoffByTreatment = HashMap<Long, Double>()
val batemanCache = HashMap<Long, BatemanParams>()
var total = 0.0
for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
if (!treatment.usesProfileModel) {
batemanCache[treatment.id] = batemanParams(treatment)
}
for (dose in dosesByTreatment[treatment.id].orEmpty()) {
if (dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > cutoffByTreatment.getValue(treatment.id)) 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 {
@ -416,14 +393,6 @@ object PharmacokineticEngine {
): List<DoseLog> {
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
if (intervalDays <= 0.0) return emptyList()
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
// projette PLUS de créneaux — l'archivage arrête les prédictions
// d'injections futures (graphique Prévision, extension de fenêtre,
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
// FUTUR s'arrête.
if (!treatment.isActive) return emptyList()
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs <= 0L) return emptyList()
@ -491,8 +460,6 @@ object PharmacokineticEngine {
// stocké du traitement, ce qui produisait des facteurs absurdes dès
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
val ctx = prepareE2Context(unscaled, doseLogs)
val ratiosByEster = HashMap<String, MutableList<Double>>()
var maxPredictionSoFar = 0.0
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
@ -500,7 +467,7 @@ object PharmacokineticEngine {
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
// tombait à ×2,21
for (lab in e2Labs.sortedBy { it.timestamp }) {
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride)
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
@ -744,32 +711,6 @@ object PharmacokineticEngine {
return current.copy(k = medianRounded(ks, 3))
}
/**
* Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
*
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
* pour une prise déjà faite — le rappel suivant repart au créneau
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
* S'applique identiquement au fallback quotidien (oral sans Posologie :
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
*
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
*/
fun hasDoseLoggedOnDay(
treatmentId: Long,
doseLogs: List<DoseLog>,
atMs: Long,
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
): Boolean {
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
return doseLogs.any {
it.treatmentId == treatmentId &&
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
}
}
/**
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
*

View File

@ -43,14 +43,7 @@ class DoseActionReceiver : BroadcastReceiver() {
ReminderContract.ACTION_SNOOZE -> {
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
// 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)
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose)
}
}
}

View File

@ -15,11 +15,11 @@ import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.util.AppLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object ReminderContract {
const val CHANNEL_ID = "hormonetrack_reminders"
@ -30,17 +30,6 @@ object ReminderContract {
const val EXTRA_TREATMENT_NAME = "treatment_name"
const val EXTRA_DOSE_AMOUNT = "dose_amount"
/**
* Instant PLANIFIÉ de l'alarme (v1.10.1) — embarqué par
* AlarmScheduler.scheduleFor au moment du scheduling et relu par
* [ReminderReceiver]. ⚠️ Nécessaire car une alarme INEXACTE (setWindow
* sans SCHEDULE_EXACT_ALARM) ou retardée (Doze) peut fire après la
* frontière de jour : le garde « créneau déjà honoré » doit évaluer le
* jour PLANIFIÉ, pas le jour réel du feu. Absent (vieux PendingIntents)
* → le receiver retombe sur l'heure courante.
*/
const val EXTRA_PLANNED_AT = "planned_at"
fun reminderIntent(context: Context, treatmentId: Long): Intent =
Intent(context, ReminderReceiver::class.java).apply {
action = ACTION_REMINDER
@ -64,16 +53,10 @@ class AlarmScheduler(private val context: Context) {
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private fun pendingBroadcast(
treatmentId: Long,
name: String?,
dose: Double?,
plannedAt: Long? = null
): PendingIntent {
private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent {
val intent = ReminderContract.reminderIntent(context, treatmentId)
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) }
return PendingIntent.getBroadcast(
context,
treatmentId.toInt(),
@ -97,27 +80,12 @@ class AlarmScheduler(private val context: Context) {
val fireAt = PharmacokineticEngine.nextReminderFireFor(
treatment, doseLogs, System.currentTimeMillis()
) ?: return false
// 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)
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount)
return scheduleAt(fireAt, pi)
}
/**
* 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)
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean {
val pi = pendingBroadcast(treatmentId, name, dose)
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
}
@ -240,41 +208,19 @@ class ReminderReceiver : BroadcastReceiver() {
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(treatmentId.toInt(), notification)
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
// (grille Posologie si elle existe, sinon quotidien).
// v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée
// (prise saisie avant l'heure de rappel), la notification est SAUTÉE —
// elle ne doit pas sonner pour une prise déjà faite — et le rappel
// suivant repart au créneau suivant (re-schedule dans tous les cas).
// v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ
// (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte
// peut glisser après minuit sans changer de créneau.
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
} else {
System.currentTimeMillis()
}
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val db = AppDatabase.getInstance(context)
val doses = db.doseLogDao().getAllOnce()
if (PharmacokineticEngine.hasDoseLoggedOnDay(
treatmentId, doses, plannedAtMs
)
) {
AppLog.log(
"reminder",
"créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)"
)
} else {
manager.notify(treatmentId.toInt(), notification)
}
db.treatmentDao().getById(treatmentId)?.let { tr ->
AlarmScheduler(context).scheduleFor(tr, doses)
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce())
}
} finally {
pending.finish()
@ -286,23 +232,23 @@ class ReminderReceiver : BroadcastReceiver() {
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
// v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver —
// remplace le Thread + double runBlocking de la v1.0, dette §20.bis #2) :
// une seule lecture séquentielle des DAO dans un scope IO dédié, finish()
// garanti par le finally.
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
val result = goAsync()
Thread {
try {
val db = AppDatabase.getInstance(context)
// TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner) ;
// les doses alimentent la grille Posologie (fix #52)
val treatments = db.treatmentDao().getAllOnce()
val doses = db.doseLogDao().getAllOnce()
val treatments = runBlocking {
db.treatmentDao().getAllOnce()
}
val doses = runBlocking {
db.doseLogDao().getAllOnce()
}
AlarmScheduler(context).rescheduleAll(treatments, doses)
} finally {
pending.finish()
}
}
result.finish()
}
}.start()
}
}

View File

@ -79,15 +79,6 @@ class AppSettings(private val context: Context) {
/** 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 ->
@ -230,30 +221,6 @@ class AppSettings(private val context: Context) {
}
}
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
val low = prefs[Keys.TROUGH_TARGET_LOW]
val high = prefs[Keys.TROUGH_TARGET_HIGH]
if (low != null && high != null) low to high else null
}
/**
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
* « champs vides = cible désactivée ». La cohérence high > low est
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
*/
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
context.dataStore.edit { prefs ->
if (target == null) {
prefs.remove(Keys.TROUGH_TARGET_LOW)
prefs.remove(Keys.TROUGH_TARGET_HIGH)
} else {
prefs[Keys.TROUGH_TARGET_LOW] = target.first
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
}
}
}
/**
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
* (DataStore : remove) — « champ vide = pas d'alerte ».

View File

@ -46,7 +46,7 @@ val LocalAppContainer = compositionLocalOf<AppContainer> {
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
@Composable
fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
val context = androidx.compose.ui.platform.LocalContext.current
@ -132,11 +132,7 @@ fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?
) {
composable("home") {
HomeScreen(
// 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 },
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
onOpenSettings = { navController.navigate("settings") },
onOpenCharts = {
// Tap sur le mini-graphique de l'accueil → écran Graphiques

View File

@ -3,7 +3,6 @@ package com.hormonetrack.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@ -15,7 +14,6 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine
@ -103,20 +101,12 @@ fun xLabelTicks(
cal.set(Calendar.MILLISECOND, 0)
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
}
val stepMs = stepHours * 3_600_000L
val out = mutableListOf<Long>()
var t = cal.timeInMillis
while (t <= t1) {
out.add(t)
// 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
t += stepMs
}
return out
}
@ -184,12 +174,12 @@ fun niceCeil(value: Double): Double {
*/
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)
"ESE" to pkModelsUsed.contains("ESE"),
"TFS" to pkModelsUsed.contains("TFS"),
"WHS" to pkModelsUsed.contains("WHS")
)
// 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
return if (toggles.values.none { it }) toggles + ("TFS" to true) else toggles
}
/**
@ -320,35 +310,6 @@ fun CurveChart(
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
// se redessine à chaque geste).
val extremaBySeries = remember(series, options) {
series.map { s ->
Pair(
if (s.showExtrema) {
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
} else emptyList(),
if (s.showExtrema) {
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
} else null
)
}
}
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
// thread-safe — thread UI seul ici — et l'allouer par frame était du
// gaspillage, même standard que le fix des marqueurs v1.8.2).
val markerTimeFmt = remember(zone) {
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
}
val xAxisHourFmt = remember(zone) {
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
}
val xAxisDayFmt = remember(zone) {
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
}
Canvas(modifier = modifier) {
val allPoints = series.flatMap { it.points }
if (allPoints.size < 2) return@Canvas
@ -403,19 +364,27 @@ fun CurveChart(
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, xAxisHourFmt, xAxisDayFmt)
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone)
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
if (forecastDoses.isNotEmpty()) {
val markerColor = forecastMarkerColor
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
color = android.graphics.Color.argb(
(markerColor.alpha * 255).toInt(),
(markerColor.red * 255).toInt(),
(markerColor.green * 255).toInt(),
(markerColor.blue * 255).toInt()
)
}
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
// redessine à chaque pan/zoom, une allocation par dose × frame
// était du gaspillage (la classe n'est de toute façon pas
// thread-safe — ici on reste dans le DrawScope UI)
val hourFmt = markerTimeFmt
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
hourFmt.timeZone = zone
for (dose in forecastDoses) {
if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp)
@ -470,7 +439,7 @@ fun CurveChart(
}
// Une courbe E2 + une courbe T par série
for ((sIdx, s) in series.withIndex()) {
for (s in series) {
if (s.points.size < 2) continue
val e2Path = Path()
s.points.forEachIndexed { i, p ->
@ -510,24 +479,21 @@ fun CurveChart(
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
// Canvas, recalculés seulement quand séries/options changent) — le
// scan local était refait PAR FRAME, x2 séries.
if (options.showExtrema && s.showExtrema) {
extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
e2Extrema.forEach { ex ->
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
}
if (options.showT) {
s.tStyle?.let { tStyle ->
tExtrema.orEmpty().forEach { ex ->
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
}
}
}
}
}
}
// Labs (points/carrés oranges)
if (options.showLabs) {
@ -608,9 +574,19 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: Pharmacokinet
}
path.close()
drawPath(path, color)
// Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
// en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
// Valeur estimée du pic/creux, dans la couleur de la courbe
val paint = android.graphics.Paint().apply {
this.color = android.graphics.Color.argb(
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
)
textSize = 9.dp.toPx()
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText(
formatValue(extremum.value), cx, valueY, paint
)
@ -643,41 +619,21 @@ private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: F
}
}
/**
* Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
* (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
* label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
* dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
* Les clés sont des value classes (Color) → pas de fuite.
*/
private val textPaintCache = HashMap<String, android.graphics.Paint>()
private fun DrawScope.textPaint(
color: Color,
textSizeDp: Float,
align: android.graphics.Paint.Align,
fakeBold: Boolean = false
): android.graphics.Paint {
val key = "$color|$textSizeDp|$align|$fakeBold"
return textPaintCache.getOrPut(key) {
android.graphics.Paint().apply {
this.color = android.graphics.Color.argb(
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
/** Paint partagé pour les labels texte (couleur gris neutre fixe). */
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
val c = Color(0xFF6B7280)
return android.graphics.Paint().apply {
color = android.graphics.Color.argb(
(c.alpha * 255).toInt(),
(c.red * 255).toInt(),
(c.green * 255).toInt(),
(c.blue * 255).toInt()
)
textSize = textSizeDp.dp.toPx()
textSize = 10.dp.toPx()
textAlign = align
isAntiAlias = true
isFakeBoldText = fakeBold
}
}
}
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
textPaint(Color(0xFF6B7280), 10f, align)
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
@ -700,11 +656,7 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
private fun DrawScope.drawXLabels(
color: Color, t0: Long, t1: Long,
padLeft: Float, yBottom: Float, w: Float,
zone: java.util.TimeZone = java.util.TimeZone.getDefault(),
// 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())
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
val spanH = (t1 - t0) / 3_600_000.0
@ -718,25 +670,28 @@ private fun DrawScope.drawXLabels(
}
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
// tick « minuit local » serait étiqueté avec l'heure système.
// v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
val fmt = if (spanH <= 25) hourFmt else dayFmt
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
else SimpleDateFormat("dd/MM", Locale.getDefault())
fmt.timeZone = zone
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
// (minuit local pour les pas en jours) — l'ancien alignement sur des
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
// 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)) {
for (tick in xLabelTicks(t0, t1, stepHours.toLong())) {
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
}
}
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
// Paint en cache (v1.9.5 : une allocation par lab × frame → une par
// couleur, réutilisée entre frames)
val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
color = android.graphics.Color.rgb(
(LabDot.red * 255).toInt(),
(LabDot.green * 255).toInt(),
(LabDot.blue * 255).toInt()
)
textSize = 9.dp.toPx()
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
}

View File

@ -42,7 +42,6 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.EstrannaiseCloud
import com.hormonetrack.pk.LabTrajectoryModel
@ -154,9 +153,9 @@ fun ChartScreen() {
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)
showEse = toggles.getValue("ESE")
showTfs = toggles.getValue("TFS")
showWhs = toggles.getValue("WHS")
modelTogglesInitialized = true
}
}
@ -193,14 +192,14 @@ fun ChartScreen() {
} else {
withContext(Dispatchers.Default) {
mapOf(
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
"ESE" to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "ESE"
),
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
"TFS" to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "TFS"
),
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
"WHS" to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "WHS"
)
)
}
@ -209,14 +208,7 @@ fun ChartScreen() {
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
// ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
// Posologie d'un traitement archivé ne doit ni activer le chip
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
// créneaux, cf generateForecastDoses).
val maxIntervalDays = treatments
.filter { it.isActive }
.mapNotNull { it.forecastIntervalDays }
.maxOrNull()
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
@ -271,11 +263,6 @@ fun ChartScreen() {
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
// décalée d'un jour).
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
// recomposition (leçon #65 : un producer doit redériver de ses vraies
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
val forecastDoses by produceState(
emptyList<com.hormonetrack.data.model.DoseLog>(),
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
@ -351,8 +338,8 @@ fun ChartScreen() {
// 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
val effTreatments = autoByModel["TFS"]?.treatments ?: treatments
val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig
// Doses réelles + doses prévisionnelles éventuelles
val forecastDoses = if (showForecast) {
effTreatments.flatMap {
@ -365,7 +352,7 @@ fun ChartScreen() {
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
// le modèle est la seule variable (même calcul, même calibration
// PAR MODÈLE via autoByModel[model], fix #60)
for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
for ((model, enabled) in listOf("ESE" to showEse, "TFS" to showTfs, "WHS" to showWhs)) {
if (!enabled) continue
val autoM = autoByModel[model]
val curve = PharmacokineticEngine.computeCurve(
@ -378,12 +365,9 @@ fun ChartScreen() {
// v1.9.0 : le nuage d'incertitude est attaché à la série
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
// publient pas de posterior MCMC)
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
// MÊME calibration que la courbe ESE (fix v1.9.1 :
// nuage brut ≠ échelle de la courbe calibrée)
val cloud = if (model == "ESE" && showCloud) {
EstrannaiseCloud.compute(
treatments, doseLogs, startMs, endMs, step,
scalePerEster = autoM?.esterScales
treatments, doseLogs, startMs, endMs, step, nowMs
)
} else emptyList()
list.add(ChartCurve(model, curve, cloud))
@ -407,9 +391,9 @@ fun ChartScreen() {
// T Estrannaise = rose plein, T TFS = rose pointillé.
val series = curves.map { (model, points, cloud) ->
when (model) {
PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = curves.first { it.model == "ESE" }.cloud)
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
"WHS" -> 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)
@ -671,7 +655,7 @@ fun ChartScreen() {
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
curves.forEach { (model, _) ->
when (model) {
PKModels.ESTRANNAISE -> {
"ESE" -> {
Text(
stringResource(R.string.legend_ese_e2),
style = MaterialTheme.typography.labelMedium,
@ -686,7 +670,7 @@ fun ChartScreen() {
}
}
// v1.4.6 : légende WHSAH (vert / violet pointillé)
PKModels.WHSAH -> {
"WHS" -> {
Text(
stringResource(R.string.legend_whs_e2),
style = MaterialTheme.typography.labelMedium,
@ -754,7 +738,7 @@ fun ChartScreen() {
}
// 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() }) {
if (showCloud && showEse && curves.any { it.model == "ESE" && it.cloud.isNotEmpty() }) {
Text(
stringResource(R.string.legend_cloud),
style = MaterialTheme.typography.labelMedium,

View File

@ -2,24 +2,17 @@ package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Colorize
import androidx.compose.material.icons.filled.Healing
import androidx.compose.material.icons.filled.Medication
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Vaccines
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
@ -29,7 +22,6 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -41,20 +33,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.DoseDialog
import com.hormonetrack.ui.theme.LabDot
import com.hormonetrack.util.HrtDuration
import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope
@ -64,54 +49,6 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Icône de FORME DE PRISE (v1.12.0, demande utilisatrice : « des icônes pour
* bien séparer les différentes formes de prises des traitements »). Mapping :
* ORAL = pilule (Medication) ; IM/SC = seringue (Vaccines — le glyphe est
* partagé, la TEinte les distingue côté UI : IM = couleur courante, SC =
* tertiary) ; GEL = goutte (WaterDrop) ; PATCH = sparadrap (Healing) ;
* OTHER/inconnu = compte-gouttes (Colorize). PUR → testable en JVM
* (DosesExtrasTest) ; miroir web `routeGlyph` (js/data/models.js).
*/
fun routeIcon(route: AdministrationRoute?): ImageVector = when (route) {
AdministrationRoute.ORAL -> Icons.Filled.Medication
AdministrationRoute.INJECTION_IM -> Icons.Filled.Vaccines
AdministrationRoute.INJECTION_SUBCUT -> Icons.Filled.Vaccines
AdministrationRoute.TRANSDERMAL_GEL -> Icons.Filled.WaterDrop
AdministrationRoute.TRANSDERMAL_PATCH -> Icons.Filled.Healing
else -> Icons.Filled.Colorize
}
/**
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, demande utilisatrice :
* « une petite indication … dans le cas où un test sanguin a été effectué
* avant ou après une prise — peut-être un petit encart sur le côté »).
*
* Une analyse compte si elle tombe le MÊME JOUR calendaire LOCAL que la
* dose : au-delà, le lien clinique (labs au creux/pic autour d'une
* injection) devient ténu et chaque dose finirait marquée.
*
* @return Pair(AVANT la dose, APRÈS la dose) — une analyse exactement À
* l'heure de la dose compte comme APRÈS (convention arbitraire documentée).
* PUR → testable en JVM (DosesExtrasTest) ; miroir web `labMarkersForDose`
* (js/util/format.js).
*/
fun labMarkersForDose(
labs: List<LabResult>,
doseMs: Long,
zone: ZoneId = ZoneId.systemDefault()
): Pair<Boolean, Boolean> {
val day = Instant.ofEpochMilli(doseMs).atZone(zone).toLocalDate()
var before = false
var after = false
for (lab in labs) {
if (Instant.ofEpochMilli(lab.timestamp).atZone(zone).toLocalDate() == day) {
if (lab.timestamp < doseMs) before = true else after = true
}
}
return before to after
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DosesScreen() {
@ -119,8 +56,6 @@ fun DosesScreen() {
val repo = container.repository
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
// v1.12.0 : les labs alimentent les marqueurs « prise de sang avant/après »
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
var showLogDialog by remember { mutableStateOf(false) }
@ -219,24 +154,6 @@ fun DosesScreen() {
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
ListItem(
modifier = Modifier.clickable { toEdit = log },
// v1.12.0 : icône de FORME DE PRISE à gauche —
// oral / injection IM / injection SC / gel /
// patch / autre se repèrent d'un coup d'œil.
leadingContent = {
Icon(
routeIcon(tr?.route),
contentDescription = tr?.let {
stringResource(routeLabelRes(it.route))
},
tint = if (tr?.route == AdministrationRoute.INJECTION_SUBCUT) {
// IM/SC partagent le glyphe seringue :
// la teinte tertiary distingue la SC
MaterialTheme.colorScheme.tertiary
} else {
LocalContentColor.current
}
)
},
headlineContent = {
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
},
@ -261,18 +178,10 @@ fun DosesScreen() {
}
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// v1.12.0 : encart latéral « prise de sang
// le même jour » — ↑ = lab AVANT la dose,
// ↓ = lab APRÈS (cf labMarkersForDose)
val (labBefore, labAfter) = labMarkersForDose(labResults, log.timestamp, zone)
if (labBefore) LabSideMarker(up = true)
if (labAfter) LabSideMarker(up = false)
IconButton(onClick = { toDelete = log }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
}
}
)
}
}
@ -325,37 +234,11 @@ fun DosesScreen() {
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = { TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } },
dismissButton = {
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) }
)
}
}
/**
* Encart latéral « prise de sang le même jour que cette dose » (v1.12.0,
* demande utilisatrice) : icône laboratoire + flèche — **↑** = analyse
* effectuée AVANT la dose, **↓** = APRÈS. La description sémantique porte
* la phrase complète (accessibilité TalkBack) ; le glyphe seul est décoratif
* (contentDescription = null sur l'icône, description portée par le Row).
*/
@Composable
private fun LabSideMarker(up: Boolean) {
val description = stringResource(if (up) R.string.lab_before_dose else R.string.lab_after_dose)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.semantics { contentDescription = description }
) {
Icon(
Icons.Filled.Science,
contentDescription = null,
tint = LabDot,
modifier = Modifier.size(16.dp)
)
Text(
if (up) "↑" else "↓",
style = MaterialTheme.typography.labelSmall,
color = LabDot
)
}
}

View File

@ -78,7 +78,6 @@ private const val HOUR_MS = 3_600_000L
@Composable
fun HomeScreen(
openLogDoseForTreatmentId: Long?,
onLogDoseConsumed: () -> Unit,
onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit
@ -114,16 +113,10 @@ fun HomeScreen(
var showLogDialog by remember { mutableStateOf(false) }
var preselectId by remember { mutableStateOf<Long?>(null) }
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
LaunchedEffect(openLogDoseForTreatmentId) {
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
showLogDialog = true
preselectId = it
onLogDoseConsumed()
}
}
@ -241,20 +234,14 @@ fun HomeScreen(
// (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick, effectiveAuto
allTreatments, doseLogs, labResults, tick
) {
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
allTreatments, doseLogs, labResults, System.currentTimeMillis()
)
}
}
// 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())
@ -276,37 +263,6 @@ fun HomeScreen(
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,
@ -502,76 +458,48 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
}
}
/**
* Ligne « prochaine prise » d'un traitement (v1.9.6 — PUR, testable JVM) :
* « Xj Yh · ven. 25 10:00 (nom) » au-delà de 24 h (les heures deviennent peu
* lisibles, v1.4.1), « XhMM · HH:mm (nom) » en dessous, « N min · HH:mm
* (nom) » dans l'heure. Utilisée par chaque ligne de [NextDoseCard].
*/
internal fun nextDoseLine(
nowMs: Long,
fireMs: Long,
name: String,
/** Template localisé du cas ≥ 24 h (string `next_dose_days`, 4 args). */
daysTemplate: String,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault()
): String {
val deltaMs = fireMs - nowMs
val h = deltaMs / HOUR_MS
val m = (deltaMs % HOUR_MS) / 60_000L
return if (h >= 24) {
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
.withZone(zone)
.withLocale(locale)
.format(Instant.ofEpochMilli(fireMs))
String.format(locale, daysTemplate, days, remH, dayTimeStr, name)
} else {
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
.withZone(zone)
.withLocale(locale)
.format(Instant.ofEpochMilli(fireMs))
if (h > 0) {
String.format(locale, "%dh%02d · %s (%s)", h, m, timeStr, name)
} else {
String.format(locale, "%d min · %s (%s)", m, timeStr, name)
}
}
}
@Composable
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
// ligne par traitement, TRIÉES par prochaine prise — l'ancienne carte ne
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
// totalement le rappel de l'injection hebdomadaire (le rappel EEn
// « n'arrivait qu'après celui de la CPA »). Le titre passe au pluriel
// dès qu'il y a au moins deux lignes.
val nowMs = System.currentTimeMillis()
val fires = treatments
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
.sortedBy { it.second }
if (fires.isEmpty()) return
val (nextTreatment, next) = fires.minByOrNull { it.second }!!
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
stringResource(R.string.next_dose),
style = MaterialTheme.typography.labelMedium
)
Spacer(Modifier.height(4.dp))
// Première ligne (la plus proche) mise en avant, les suivantes en
// style secondaire — même hiérarchie visuelle que v1.4.x.
fires.forEachIndexed { idx, (treatment, next) ->
val deltaMs = next - System.currentTimeMillis()
val h = deltaMs / HOUR_MS
val m = (deltaMs % HOUR_MS) / 60_000L
val name = nextTreatment?.name ?: ""
// v1.4.1 : au-delà de 24 h, afficher en JOURS (les heures
// deviennent peu lisibles) + le jour de la semaine dans la date.
if (h >= 24) {
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text(
nextDoseLine(
nowMs, next, treatment?.name ?: "",
stringResource(R.string.next_dose_days)
),
style = if (idx == 0) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.bodyMedium
stringResource(R.string.next_dose_days, days, remH, dayTimeStr, name),
style = MaterialTheme.typography.titleMedium
)
} else {
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text(
if (h > 0) {
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, name)
} else {
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, name)
},
style = MaterialTheme.typography.titleMedium
)
}
}

View File

@ -40,8 +40,6 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue
@ -145,30 +143,14 @@ fun LabsScreen() {
}
}
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
treatments, doseLogs, labs, nowMs
) {
value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
)
LabTiming.nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs)
}
}
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
@ -211,36 +193,6 @@ fun LabsScreen() {
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,

View File

@ -1,10 +1,17 @@
package com.hormonetrack.ui.screens
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -12,10 +19,16 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -34,84 +47,49 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.hormonetrack.R
import com.hormonetrack.data.backup.BackupManager
import com.hormonetrack.data.backup.UserSettings
import com.hormonetrack.util.AppLog
import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.screens.settings.AboutCard
import com.hormonetrack.ui.screens.settings.AlertThresholdsCard
import com.hormonetrack.ui.screens.settings.AutoBackupCard
import com.hormonetrack.ui.screens.settings.AutoCalibrationCard
import com.hormonetrack.ui.screens.settings.BackupCard
import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
import com.hormonetrack.ui.screens.settings.LanguageCard
import com.hormonetrack.ui.screens.settings.LogsCard
import com.hormonetrack.ui.screens.settings.RemindersCard
import com.hormonetrack.ui.screens.settings.TroughTargetCard
import com.hormonetrack.ui.screens.settings.TModelCard
import com.hormonetrack.util.AppLog
import com.hormonetrack.util.ExportFileNames
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.util.Locale
/**
* Écran PARAMÈTRES (v1.9.8 : découpé en cartes thématiques, cf le
* sous-package [com.hormonetrack.ui.screens.settings] — comportement et
* rendu IDENTIQUES, chaque carte est un fichier dédié documenté).
*
* Cet orchestrateur garde ce qui est PARTAGÉ entre cartes :
* - la collecte des réglages DataStore (collectAsState) ;
* - les 4 launchers SAF (export JSON, export logs, import, dossier
* auto-backup) : leurs callbacks écrivent l'état `message` et le dialog
* de confirmation d'import, déclarés ICI ;
* - le dialog de confirmation d'import (écrasement) ;
* - l'ordre d'affichage des cartes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val container = LocalAppContainer.current
val repo = container.repository
val context = LocalContext.current
val scheduler = remember { AlarmScheduler(context) }
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val currentLanguage by container.settings.language.collectAsState(initial = "system")
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = Alerts.Thresholds()
initial = com.hormonetrack.pk.Alerts.Thresholds()
)
// Cible de creux (v1.13.0) : référence de lab personnelle — distincte
// des seuils d'alerte (cf TroughTargetCard)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
// ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ──
val autoBackupEnabled by container.settings.autoBackupEnabled
.collectAsState(initial = false)
val autoBackupTreeUri by container.settings.autoBackupTreeUri
.collectAsState(initial = null as String?)
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
val autoBackupLastRun by container.settings.autoBackupLastRun
.collectAsState(initial = null as Pair<String?, Long?>?)
var 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)) }
var showImportConfirm by remember { mutableStateOf(false) }
var importJson by remember { mutableStateOf<String?>(null) }
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
// logExportLauncher l'écrit (un state local doit être déclaré avant le
// lambda qui le capture).
var message by remember { mutableStateOf<String?>(null) }
/** Écrit le feedback depuis le thread principal (les cartes l'appellent). */
val showMessage: (String) -> Unit = { message = it }
val logExportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
@ -156,6 +134,7 @@ fun SettingsScreen(onBack: () -> Unit) {
}
}
}
var importJson by remember { mutableStateOf<String?>(null) }
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
@ -168,17 +147,13 @@ fun SettingsScreen(onBack: () -> Unit) {
// 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(
val settings = com.hormonetrack.data.backup.UserSettings(
language = container.settings.language.first(),
autoCalibrate = container.settings.autoCalibrate.first(),
alertE2High = thresholds.e2High,
alertE2Low = thresholds.e2Low,
alertTHigh = thresholds.tHigh,
alertTLow = thresholds.tLow,
// v1.13.0 : la cible de creux voyage dans le backup (champs
// optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles)
troughTargetLow = container.settings.troughTarget.first()?.first,
troughTargetHigh = container.settings.troughTarget.first()?.second
alertTLow = thresholds.tLow
)
val json = BackupManager.exportJson(repo, tConfig, settings)
val ok = BackupManager.writeBackup(context, uri, json)
@ -211,8 +186,22 @@ fun SettingsScreen(onBack: () -> Unit) {
// 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).
// pickFolderToEnable distingue les deux entrées du picker :
// - via le SWITCH quand aucun dossier n'existe encore → 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é.
val autoBackupEnabled by container.settings.autoBackupEnabled
.collectAsState(initial = false)
val autoBackupTreeUri by container.settings.autoBackupTreeUri
.collectAsState(initial = null as String?)
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
val autoBackupLastRun by container.settings.autoBackupLastRun
.collectAsState(initial = null as Pair<String?, Long?>?)
var autoBackupKeepText by remember(autoBackupKeep) {
mutableStateOf(autoBackupKeep.toString())
}
var pickFolderToEnable by remember { mutableStateOf(false) }
val folderPickerLauncher = rememberLauncherForActivityResult(
@ -246,7 +235,6 @@ fun SettingsScreen(onBack: () -> Unit) {
}
}
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
Column(
Modifier
.fillMaxSize()
@ -268,56 +256,503 @@ fun SettingsScreen(onBack: () -> Unit) {
Spacer(Modifier.height(8.dp))
}
LanguageCard(currentLanguage)
Spacer(Modifier.height(12.dp))
AutoCalibrationCard(autoCalibrate)
Spacer(Modifier.height(12.dp))
TModelCard(tConfig, showMessage)
Spacer(Modifier.height(12.dp))
AlertThresholdsCard(alertThresholds, showMessage)
Spacer(Modifier.height(12.dp))
TroughTargetCard(troughTarget, showMessage)
Spacer(Modifier.height(12.dp))
ChartTimezoneCard(chartTimezone, showMessage)
Spacer(Modifier.height(12.dp))
RemindersCard()
Spacer(Modifier.height(12.dp))
BackupCard(
onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
onImport = { importLauncher.launch(arrayOf("application/json")) }
// --- Language ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = currentLanguage == "system",
onClick = {
CoroutineScope(Dispatchers.IO).launch {
container.settings.setLanguage("system")
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.getEmptyLocaleList()
)
Spacer(Modifier.height(12.dp))
AutoBackupCard(
enabled = autoBackupEnabled,
treeUri = autoBackupTreeUri,
keep = autoBackupKeep,
lastRun = autoBackupLastRun,
onPickFolderToEnable = {
pickFolderToEnable = true
folderPickerLauncher.launch(null)
}
}
},
onChooseFolder = {
pickFolderToEnable = false
folderPickerLauncher.launch(null)
},
onMessage = showMessage
label = { Text(stringResource(R.string.language_system)) }
)
Spacer(Modifier.height(12.dp))
LogsCard(
logLineCount = logLineCount,
onExport = {
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
// et testé — passer un LocalDate au pattern horaire crashait
// l'app au tap (bug v1.3.1→v1.3.3).
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
FilterChip(
selected = currentLanguage == "fr",
onClick = {
CoroutineScope(Dispatchers.IO).launch {
container.settings.setLanguage("fr")
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags("fr")
)
}
}
},
onClear = {
AppLog.clear()
logLineCount = AppLog.count()
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))
// --- 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))
AboutCard()
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
// remplace à l'affichage quand elle est activée) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.t_model_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = tBaseText,
onValueChange = { tBaseText = it },
label = { Text(stringResource(R.string.t_base)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tFloorText,
onValueChange = { tFloorText = it },
label = { Text(stringResource(R.string.t_floor)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tKText,
onValueChange = { tKText = it },
label = { Text("k") },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
container.settings.setTConfig(TConfig(base, floor, k))
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}) { Text(stringResource(R.string.save)) }
OutlinedButton(onClick = {
CoroutineScope(Dispatchers.IO).launch {
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
val trs = repo.allTreatmentsOnce()
val doses = repo.allDoseLogsOnce()
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
labs, trs, doses, tConfig
)
calibrated?.let {
container.settings.setTConfig(it)
withContext(Dispatchers.Main) {
message = context.getString(R.string.t_calibrated)
}
}
}
}) { Text(stringResource(R.string.calibrate_from_labs)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Seuils d'alerte (v1.4.2) : limites E2/T évaluées contre le taux
// estimé actuel de l'accueil (pk/Alerts.kt). Opt-in : champ vide =
// pas d'alerte. Haut > bas requis si les deux sont définis.
var e2HighText by remember(alertThresholds.e2High) {
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
}
var e2LowText by remember(alertThresholds.e2Low) {
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
}
var tHighText by remember(alertThresholds.tHigh) {
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
}
var tLowText by remember(alertThresholds.tLow) {
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
}
var alertError by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.alert_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = e2HighText,
onValueChange = { e2HighText = it },
label = { Text(stringResource(R.string.alert_e2_high)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = e2LowText,
onValueChange = { e2LowText = it },
label = { Text(stringResource(R.string.alert_e2_low)) },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = tHighText,
onValueChange = { tHighText = it },
label = { Text(stringResource(R.string.alert_t_high)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tLowText,
onValueChange = { tLowText = it },
label = { Text(stringResource(R.string.alert_t_low)) },
modifier = Modifier.weight(1f)
)
}
alertError?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
// Parsing tolérant (virgule décimale FR), champ vide = null
fun parse(s: String): Double? =
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
val e2High = parse(e2HighText)
val e2Low = parse(e2LowText)
val tHigh = parse(tHighText)
val tLow = parse(tLowText)
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
.any { it.trim().isNotEmpty() && parse(it) == null }
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
when {
invalidSyntax || !thresholds.isCoherent() -> {
alertError = context.getString(R.string.alert_invalid)
}
else -> {
alertError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAlertThresholds(thresholds)
// v1.4.2 : check IMMÉDIAT (worker one-time) —
// si le seuil est déjà franchi, la notification
// part sans attendre le cycle périodique de
// 15 min (état anti-spam réinitialisé au besoin).
com.hormonetrack.reminder.AlertNotifier.checkNow(context)
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}
}
}) { Text(stringResource(R.string.save)) }
}
}
Spacer(Modifier.height(12.dp))
// --- Fuseau horaire du graphique (v1.4.5) : auto (téléphone) ou choix.
// Applique l'alignement minuit des labels X dans le fuseau choisi.
var tzText by remember(chartTimezone) {
mutableStateOf(chartTimezone ?: "")
}
var tzError by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.timezone_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = tzText,
onValueChange = { tzText = it },
label = { Text(stringResource(R.string.timezone_field)) },
placeholder = { Text(stringResource(R.string.timezone_auto)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
tzError?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val id = tzText.trim()
val valid = id.isEmpty() || try {
java.util.TimeZone.getTimeZone(id)
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
} catch (_: Exception) { false }
if (!valid) {
tzError = context.getString(R.string.timezone_invalid)
} else {
tzError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setChartTimezone(id.ifBlank { null })
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}
}) { Text(stringResource(R.string.save)) }
OutlinedButton(onClick = {
tzText = ""
tzError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setChartTimezone(null)
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}) { Text(stringResource(R.string.timezone_auto)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Reminders / exact alarms ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
if (!scheduler.canScheduleExact()) {
Text(
stringResource(R.string.exact_alarm_needed),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(6.dp))
Button(onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}) { Text(stringResource(R.string.grant_exact_alarm)) }
} else {
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
}
}
}
Spacer(Modifier.height(12.dp))
// --- Backup ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.backup_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
exportLauncher.launch(ExportFileNames.backupFileName())
}) { Text(stringResource(R.string.export_json)) }
OutlinedButton(onClick = {
importLauncher.launch(arrayOf("application/json"))
}) { Text(stringResource(R.string.import_json)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel ---
// Opt-in pur : désactivée par défaut ; nécessite un dossier choisi
// (SAF tree persisté) ; un run immédiat est déclenché à l'activation.
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 = autoBackupEnabled,
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 (autoBackupTreeUri == null) {
// Pas de dossier → le picker EST la suite :
// la sélection activera (+ premier run)
pickFolderToEnable = true
folderPickerLauncher.launch(null)
} else {
// Dossier existant → activation directe + run
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupEnabled(true)
com.hormonetrack.data.backup.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) + " : " +
(autoBackupTreeUri?.let { folderDisplayName(it) }
?: stringResource(R.string.autobackup_no_folder)),
style = MaterialTheme.typography.bodyMedium
)
TextButton(onClick = {
pickFolderToEnable = false
folderPickerLauncher.launch(null)
}) { Text(stringResource(R.string.autobackup_choose_folder)) }
// Dernier run (statut alimenté par le worker) : réussi/échec + date
autoBackupLastRun?.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 = autoBackupKeepText,
onValueChange = { autoBackupKeepText = it },
label = { Text(stringResource(R.string.autobackup_keep)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
Button(onClick = {
val keep = autoBackupKeepText.trim().toIntOrNull() ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupKeep(keep) // borné [1;30]
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}) { Text(stringResource(R.string.save)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Logs de diagnostic (v1.3.1) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.logs_count, logLineCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
// Nom de fichier via ExportFileNames (cf §14 #48) :
// extrait et testé — passer un LocalDate au pattern
// horaire crashait l'app au tap (bug v1.3.1→v1.3.3).
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))
// --- About : version installée + lien releases Gitea ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodyMedium
)
TextButton(onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
)
}) { Text(stringResource(R.string.releases_link)) }
Text(
stringResource(R.string.disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.models_credit),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(32.dp))
}
@ -347,23 +782,11 @@ fun SettingsScreen(onBack: () -> Unit) {
it.settings?.let { us ->
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
container.settings.setAlertThresholds(
Alerts.Thresholds(
com.hormonetrack.pk.Alerts.Thresholds(
e2High = us.alertE2High, e2Low = us.alertE2Low,
tHigh = us.alertTHigh, tLow = us.alertTLow
)
)
// v1.13.0 : cible de creux — restaurée SEULEMENT
// si le backup en porte une (champs non nuls
// ET cohérents) ; sinon on ne touche à rien
// (un backup ancien ne doit pas effacer la
// cible locale).
if (us.troughTargetLow != null && us.troughTargetHigh != null &&
us.troughTargetHigh > us.troughTargetLow
) {
container.settings.setTroughTarget(
us.troughTargetLow to us.troughTargetHigh
)
}
withContext(Dispatchers.Main) {
us.language?.let { lang ->
container.settings.setLanguage(lang)
@ -397,3 +820,21 @@ fun SettingsScreen(onBack: () -> Unit) {
)
}
}
private 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.
*/
private fun folderDisplayName(treeUriString: String): String =
try {
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
id.substringAfter(':', id)
} catch (_: Exception) {
treeUriString
}

View File

@ -48,7 +48,6 @@ import com.hormonetrack.R
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.modelLabelRes
import com.hormonetrack.data.model.PKPresets
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
@ -272,17 +271,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
Spacer(Modifier.height(8.dp))
if (PharmacokineticEngine.isInjectionRoute(route)) {
// v1.9.4 : la liste des esters suit la couverture RÉELLE du
// modèle choisi (Esters.choicesForModel — miroir du DoseDialog
// et du treatment-editor web) : le modèle ESE ANALYTIQUE offre
// ses 6 esters injectables (EV/EU/EEn/EC/EB/EUCS) directement
// dans l'éditeur, au lieu des seuls EV/EU/EEn de l'époque des
// tables ODS. Chaque ester listé est dispatché par le moteur —
// aucun choix possible ne produit 0 pg/mL.
DropdownField(
label = stringResource(R.string.ester),
selectedLabel = ester,
options = Esters.choicesForModel(model).map { it to it },
options = listOf(Esters.EV, Esters.EU, Esters.EEN).map { it to it },
onSelect = { ester = it }
)
Spacer(Modifier.height(8.dp))
@ -290,26 +282,17 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
label = stringResource(R.string.pk_model),
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
// cf pk/WhsahModels.kt) superposable dans le graphique.
// 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)),
selectedLabel = when (model) {
PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs)
PKModels.WHSAH -> stringResource(R.string.model_whs)
else -> stringResource(R.string.model_ese)
},
options = listOf(
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
PKModels.WHSAH to stringResource(R.string.model_whs)
),
onSelect = { model = it
// 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()
}
}
onSelect = { model = it }
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -450,9 +433,8 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
)
}
// Le scheduler est déjà créé au niveau de l'écran
// (var `scheduler`) — pas de recréation par recompose.
if (!scheduler.canScheduleExact()) {
val scheduler2 = AlarmScheduler(context)
if (!scheduler2.canScheduleExact()) {
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.exact_alarm_needed),

View File

@ -27,7 +27,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
@ -37,21 +36,6 @@ import com.hormonetrack.data.model.modelLabelRes
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose
/**
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande utilisatrice) :
* les ACTIFS d'abord, les INACTIFS regroupés TOUT EN BAS — l'ancien ordre
* brut du DAO mélangeait les deux groupes et le badge discret ne suffisait
* plus à les discerner (« peu discernables », remontée).
*
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé
* (le tri ne réordonne jamais les actifs entre eux, ni les inactifs entre
* eux). PUR (aucune dépendance Android) → testable en JVM
* (TreatmentsDisplayTest) ; miroir web `treatmentsForDisplay`
* (js/data/models.js, mêmes sémantiques).
*/
fun treatmentsForDisplay(treatments: List<Treatment>): List<Treatment> =
treatments.sortedBy { !it.isActive }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TreatmentsScreen(
@ -61,11 +45,6 @@ fun TreatmentsScreen(
val container = LocalAppContainer.current
val repo = container.repository
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
// v1.11.0 : actifs d'abord, inactifs regroupés tout en bas
// (cf [treatmentsForDisplay]) — mémoïsé sur la liste brute.
val displayTreatments = remember(treatments) { treatmentsForDisplay(treatments) }
val activeTreatments = displayTreatments.filter { it.isActive }
val inactiveTreatments = displayTreatments.filter { !it.isActive }
Scaffold(
floatingActionButton = {
@ -79,44 +58,22 @@ fun TreatmentsScreen(
title = { Text(stringResource(R.string.nav_treatments)) }
)
LazyColumn(Modifier.fillMaxSize()) {
items(activeTreatments, key = { it.id }) { tr ->
items(treatments, key = { it.id }) { tr ->
TreatmentCard(tr, onClick = { onEdit(tr.id) })
}
// En-tête de section SEULEMENT s'il existe des inactifs —
// la séparation visuelle (en-tête + atténuation, cf
// TreatmentCard) rend le groupe lisible d'un coup d'œil.
if (inactiveTreatments.isNotEmpty()) {
item(key = "inactive_section") {
Text(
stringResource(R.string.inactive_section),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
}
items(inactiveTreatments, key = { it.id }) { tr ->
TreatmentCard(tr, dimmed = true, onClick = { onEdit(tr.id) })
}
}
}
}
}
/**
* Carte d'un traitement. @param dimmed : carte INACTIVE (v1.11.0) → contenu
* atténué (alpha réduit) en PLUS du badge : second signal visuel demandé
* (« plus distincts des actifs ») — la carte reste lisible et tappable.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TreatmentCard(tr: Treatment, dimmed: Boolean = false, onClick: () -> Unit) {
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.alpha(if (dimmed) 0.55f else 1f)
) {
Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {

View File

@ -1,55 +0,0 @@
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
)
}
}
}

View File

@ -1,144 +0,0 @@
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)) }
}
}
}

View File

@ -1,164 +0,0 @@
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)) }
}
}
}
}

View File

@ -1,58 +0,0 @@
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
)
}
}
}

View File

@ -1,48 +0,0 @@
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)) }
}
}
}
}

View File

@ -1,110 +0,0 @@
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)) }
}
}
}
}

View File

@ -1,77 +0,0 @@
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") }
)
}
}
}
}

View File

@ -1,55 +0,0 @@
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)) }
}
}
}
}

View File

@ -1,58 +0,0 @@
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)
}
}
}
}

View File

@ -1,33 +0,0 @@
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
}

View File

@ -1,118 +0,0 @@
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)) }
}
}
}
}

View File

@ -1,122 +0,0 @@
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)) }
}
}
}

View File

@ -17,7 +17,6 @@
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
<string name="logged_today">Log rapide</string>
<string name="next_dose">Prochaine dose</string>
<string name="next_doses">Prochaines doses</string>
<string name="next_dose_days">%1$d j %2$d h · %3$s (%4$s)</string>
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
<string name="home_chart_title">Dernières 24 h (estimation)</string>
@ -144,26 +143,6 @@
<string name="dose_unit">Unité</string>
<string name="active">Actif</string>
<string name="inactive">inactif</string>
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
en bas de la page Traitements) -->
<string name="inactive_section">Traitements inactifs</string>
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
si la calibration est disponible) + statut face à la cible de creux -->
<string name="labrec_predicted">Attendu à ce creux : ≈ %1$s pg/mL</string>
<string name="labrec_in_target">dans ta cible</string>
<string name="labrec_out_of_target">hors de ta cible</string>
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
d'alerte (surveillance continue + notifications) -->
<string name="trough_target_section">Cible de creux (prise de sang)</string>
<string name="trough_target_hint">Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.</string>
<string name="trough_target_low">Cible basse (pg/mL)</string>
<string name="trough_target_high">Cible haute (pg/mL)</string>
<string name="trough_target_incomplete">Remplis les deux champs — ou laisse les deux vides pour désactiver.</string>
<string name="trough_target_invalid">La cible haute doit être supérieure à la cible basse.</string>
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
<string name="type_estradiol">Œstradiol</string>
@ -259,9 +238,6 @@
<!-- Presets -->
<string name="preset_ev_ese">Injection EV — Estrannaise</string>
<string name="preset_ec_ese">Injection EC (huile) — Estrannaise</string>
<string name="preset_eb_ese">Injection EB — Estrannaise</string>
<string name="preset_eucs_ese">Injection EUCS (suspension cristalline) — Estrannaise</string>
<string name="preset_eu_ese">Injection EU — Estrannaise</string>
<string name="preset_een_ese">Injection EEn — Estrannaise</string>
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>

View File

@ -17,7 +17,6 @@
<string name="no_data">No data yet — add a treatment and log a dose.</string>
<string name="logged_today">Quick log</string>
<string name="next_dose">Next dose</string>
<string name="next_doses">Upcoming doses</string>
<string name="next_dose_days">%1$d d %2$d h · %3$s (%4$s)</string>
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
<string name="home_chart_title">Last 24 hours (estimate)</string>
@ -144,26 +143,6 @@
<string name="dose_unit">Unit</string>
<string name="active">Active</string>
<string name="inactive">inactive</string>
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
en bas de la page Traitements) -->
<string name="inactive_section">Inactive treatments</string>
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
si la calibration est disponible) + statut face à la cible de creux -->
<string name="labrec_predicted">Expected at this trough: ≈ %1$s pg/mL</string>
<string name="labrec_in_target">within your target</string>
<string name="labrec_out_of_target">outside your target</string>
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
d'alerte (surveillance continue + notifications) -->
<string name="trough_target_section">Trough target (blood draw)</string>
<string name="trough_target_hint">Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.</string>
<string name="trough_target_low">Target low (pg/mL)</string>
<string name="trough_target_high">Target high (pg/mL)</string>
<string name="trough_target_incomplete">Fill both fields — or leave both empty to disable.</string>
<string name="trough_target_invalid">Target high must be greater than target low.</string>
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
<string name="type_estradiol">Estradiol</string>
@ -259,9 +238,6 @@
<!-- Presets -->
<string name="preset_ev_ese">EV injection — Estrannaise</string>
<string name="preset_ec_ese">EC injection (oil) — Estrannaise</string>
<string name="preset_eb_ese">EB injection — Estrannaise</string>
<string name="preset_eucs_ese">EUCS injection (crystal suspension) — Estrannaise</string>
<string name="preset_eu_ese">EU injection — Estrannaise</string>
<string name="preset_een_ese">EEn injection — Estrannaise</string>
<string name="preset_ev_tfs">EV injection — Transfem Science</string>

View File

@ -124,34 +124,6 @@ class BackupGsonTest {
assertEquals(0.3, s.alertTLow!!, 1e-9)
}
@Test
fun `backup round-trip preserves the trough target (v1_13_0) and stays absent when unset`() {
// v1.13.0 : la cible de creux voyage dans le backup (champs optionnels)
val withTarget = BackupData(
tConfig = TConfig(),
settings = UserSettings(troughTargetLow = 150.0, troughTargetHigh = 200.0)
)
val parsed: BackupData = Gson().fromJson(
Gson().toJson(withTarget), BackupData::class.java
)
val s = parsed.settings
org.junit.Assert.assertNotNull(s)
assertEquals(150.0, s!!.troughTargetLow!!, 1e-9)
assertEquals(200.0, s.troughTargetHigh!!, 1e-9)
// Rétrocompatibilité : un backup SANS cible (v1.4.2→v1.12) parse les
// champs à null — l'import ne doit pas toucher à la cible locale
val without = BackupData(
tConfig = TConfig(),
settings = UserSettings(language = "en")
)
val parsed2: BackupData = Gson().fromJson(
Gson().toJson(without), BackupData::class.java
)
assertEquals(null, parsed2.settings!!.troughTargetLow)
assertEquals(null, parsed2.settings!!.troughTargetHigh)
}
@Test
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :

View File

@ -1,84 +0,0 @@
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)
}
}

View File

@ -56,7 +56,7 @@ class EstrannaiseCloudTest {
@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
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
)
assertEquals("32 courbes par défaut", 32, cloud.size)
assertTrue(cloud.all { it.size >= 2 })
@ -74,86 +74,36 @@ class EstrannaiseCloudTest {
val start = NOW - 10 * DAY_MS
val end = NOW
val cloud = EstrannaiseCloud.compute(
listOf(ese()), doses(), start, end, 3_600_000L
listOf(ese()), doses(), start, end, 3_600_000L, NOW
)
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)
fun `non-ESE carriers produce no cloud (feature exclusive to ESE)`() {
// TFS : pas de posterior publié → nuage vide
val tfs = ese().copy(pkModel = "TFS")
assertTrue(EstrannaiseCloud.compute(listOf(tfs), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty())
// Oral (Bateman) : idem
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())
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).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
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
).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
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW, 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
)
}
}

View File

@ -1,77 +0,0 @@
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)
}
}

View File

@ -25,8 +25,7 @@ import java.io.File
* 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).
* - modèle Estrannaise : t½ LUE dans la table (aucun crash, valeur finie).
*/
class LabTimingTest {
@ -175,55 +174,16 @@ class LabTimingTest {
@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).
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
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)!!
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}

View File

@ -1,166 +0,0 @@
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))
}
}

View File

@ -160,11 +160,16 @@ class PharmacokineticEngineTest {
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
}
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
// 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)
// DIAG v1.8.2 : comparaison e2At (chemin production) vs Σ
// concentrationOfDose (chemin computeScaleFactor)
val ratiosDiag = labs.sortedBy { it.timestamp }.map { lab ->
val viaE2At = PharmacokineticEngine.e2At(listOf(tr), doses, lab.timestamp)
val viaDirect = doses.filter { it.timestamp <= lab.timestamp }.sumOf { d ->
PharmacokineticEngine.concentrationOfDose(tr, d, lab.timestamp)
}
"e2At=%.6f/direct=%.6f/ratioSF=%.6f".format(viaE2At, viaDirect, lab.value / viaDirect)
}
assertEquals("ratios=$ratiosDiag sf=$sf", 0.9, sf!!, 0.01)
}
@Test

View File

@ -1,169 +0,0 @@
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
)
}
}
}

View File

@ -1,187 +0,0 @@
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)
)
}
}

View File

@ -1,82 +0,0 @@
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))
}
}

View File

@ -281,32 +281,6 @@ class ChartZoomTest {
}
}
@Test
fun `xLabelTicks daily steps stay at local midnight across DST transition`() {
// v1.9.5 (audit) : l'avancement du pas se fait par Calendar (et non
// un +24h fixe en ms) — sans ça, le tick qui FRANCHIT la transition
// tombait à 23h/01h locale au lieu de minuit. Europe/Paris 2026 :
// passage à l'heure d'été le 29 mars (02:00 → 03:00).
val zone = java.util.TimeZone.getTimeZone("Europe/Paris")
val cal = java.util.Calendar.getInstance(zone).apply {
clear()
set(2026, 2, 27, 12, 0, 0) // 27/03/2026 12:00 — la fenêtre chevauche la transition
}
val t0 = cal.timeInMillis
val ticks = com.hormonetrack.ui.components.xLabelTicks(
t0, t0 + 6L * 86_400_000L, 24L, zone
)
assertTrue(ticks.isNotEmpty())
var crossed = false
for (t in ticks) {
val c = java.util.Calendar.getInstance(zone).apply { timeInMillis = t }
assertEquals("tick à ${c.time} doit être minuit Paris (DST franchi)",
0, c.get(java.util.Calendar.HOUR_OF_DAY))
if (c.get(java.util.Calendar.DAY_OF_MONTH) >= 30) crossed = true
}
assertTrue("la fenêtre doit franchir la transition DST pour distinguer les deux implémentations", crossed)
}
@Test
fun `xLabelTicks hourly steps land at whole local hours`() {
val now = System.currentTimeMillis()

View File

@ -1,106 +0,0 @@
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)
}
}

View File

@ -1,45 +0,0 @@
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)
}
}

View File

@ -1,86 +0,0 @@
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)
}
}

View File

@ -3,382 +3,6 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
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

View File

@ -11,12 +11,12 @@
1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées — à jour v1.10.0)](#3-stack--versions-épinglées--à-jour-v1100)
3. [Stack & versions (épinglées — à jour v1.8.1)](#3-stack--versions-épinglées--à-jour-v181)
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data) + [8.ter Tests UI Compose](#8ter-tests-ui-compose-appsrcandroidtest-v198)
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
10. [UI & navigation](#10-ui--navigation)
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
@ -24,7 +24,7 @@
13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.quater Signature release : keystore dédié](#16quater-signature-release-keystore-dédié-v198) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
18. [Espace disque & coûts](#18-espace-disque--coûts)
19. [Limites connues](#19-limites-connues)
@ -63,18 +63,6 @@ Fonctionnalités v1 :
| Date | Événement |
|---|---|
| 30 sept. 2026 (session v1.13.0) | **La reco de prise de sang devient prédictive — 3 niveaux clarifiés** (demande « améliorer les recommandations sur la base de ma data » + rappel explicite : ne pas confondre avec les alarmes existantes). **Système à 3 niveaux documentés (§7.11)** : seuils d'alerte (continu + notifications, §9.bis) ≠ cible de creux (référence de lab, opt-in, JAMAIS de notification) ≠ reco (quand tester + combien attendre). (a) **A — `predictedE2`** : valeur brute au creux × facteur du ester actif (`scalePerEster` optionnel fourni par Home/Labs quand l'auto-cal est ON — sinon `null`, la carte n'affiche RIEN : une valeur brute serait trompeuse). (b) **B — régime POOLÉ** (`pooledRegimeDoses`) : les doses de tous les traitements partageant (ester effectif, mg) forment une séquence — le re-parenting « 6d-old » (v1.12.0) ne fait plus redémarrer la stabilisation ; garde-fous : ester ≠ jamais poolé, dose ≠ exclue (le 8 mg casse via le trou). Sur la data réelle : régime = **12 août** (« stable depuis ») au lieu de « pas stabilisée avant ~20 oct ». (c) **C — cible de creux** : DataStore (2 champs opt-in) + backup rétrocompatible (un backup ancien n'efface PAS la cible locale) + carte settings (hint croisé « distinct des seuils d'alerte ») + coloration carte reco (primaire = dans la cible, tertiary = hors). **Régression n°7** data-driven sur le nouvel export (`backup-v1.12.0.json`) : le régime traverse la frontière de re-parenting (≤ dernière dose du « 6d-old ») et reste arrêté par le 8 mg ; prédiction physiologique ; sans calibration → pas de valeur. ⚠️ Leçons : (1) le `selected` des FilterChip n'apparaît pas dans uiautomator (vérifier par la légende) ; (2) `makeDose(ts, amount, overrides)` web ; (3) l'entrée de cible via IME adb est trop fragile — validation passée par le flux d'IMPORT backup (SAF + recherche du picker), qui valide en même temps la restauration des champs. Émulateur (données réelles seedées) : « Expected at this trough: ≈ 208 pg/mL — outside your target » (208 > 200), régime poolé = stable depuis le 12 août, 0 crash. **284 tests JVM** (250 sans données locales) + 14 UI + lint ; **web 199 tests + E2E** (check.sh) — leçon E2E : bump de WEB_VERSION SANS section CHANGELOG = dialog vide (v1.7.1 re-mordue). **Publication** : commit ae14cee + tag v1.13.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 84, farewell id 1588598) ; **web v1.13.0 sync complet** (commit 902d521 + tag + **releases publiées sur les 2 instances — étape 4 incluse**, zip 196 Ko vérifié par téléchargement). |
| 29 sept. 2026 (session v1.12.0) | **Fix #70 + 2 améliorations Doses** (demandes). (a) **#70 — inactif à Posologie projetait encore** : `generateForecastDoses` (source unique des créneaux) ne vérifiait pas l'archivage, et l'horizon du chip Prévision incluait les Posologies INACTIVES. FIX : garde `!isActive` dans le moteur + horizon/chip sur les ACTIFS — §6.bis inchangé (historique toujours simulé, seul le FUTUR s'arrête, comme les rappels v1.4.0). **Miroir web** (pk-engine.js + chart.js). 3 tests JVM + 3 web. Émulateur : seed modifié (2 inactifs à Posologie 7 j), Prévision on/off 0 crash — leçon uiautomator : l'attribut `selected` des FilterChip ne reflète PAS la sélection Compose (vérifier par la LÉGENDE attendue, comme dit la recette §16.ter). (b) **Icônes de forme de prise** dans Doses (demande) : `routeIcon` pur — pilule/seringue (IM+SC, teinte tertiary pour SC)/goutte/sparadrap/compte-gouttes ; ⚠️ `MedicationLiquid` N'EXISTE PAS dans icons-extended 1.7.8 (résolu hors BOM) → `Colorize` pour OTHER (vérifié au jar). (c) **Marqueurs « prise de sang le même jour »** : `labMarkersForDose` pur (jour calendaire LOCAL, lab à l'heure exacte = APRÈS — convention documentée) → encart latéral 🧪↑/🧪↓ sur la ligne ; miroir web émojis + title. 7 tests JVM `DosesExtrasTest` + 10 web `doses-extras.test.js` (⚠️ helper web `makeDose(ts, amount, overrides)` — le 1ᵉʳ arg est le TIMESTAMP). Validé émulateur : content-desc des icônes (IM injection/Oral) + marqueur « Blood test later the same day » sur la dose du 10 sept. 274 tests JVM + 14 UI + lint ; web 194 tests + E2E (check.sh). **Publication** : commit b3372a8 + tag v1.12.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 82, farewell id 1588590) ; web v1.12.0 sync (commit 4bdcd52 + tag, farewell = miroir serveur déjà répliqué). **⚠️ OUBLI de l'utilisatrice : les RELEASES web n'avaient pas été publiées depuis v1.9.7** (étape 4 du processus web sautée — tags seuls) → backfill le jour même : v1.10.0, v1.11.0, v1.12.0 sur cloudyfy ET farewell + **v1.9.5 backfillée sur farewell** (jamais publiée) ; zips vérifiés par téléchargement ; leçon documentée §10 web (« commit + tag » ≠ « release »). |
| 29 sept. 2026 (session v1.11.0) | **Page Traitements : inactifs regroupés en bas + atténués** (demande : « peu discernables — les placer à la fin, et peut-être les rendre plus distincts »). Helper PUR `treatmentsForDisplay` (tri STABLE : actifs d'abord, inactifs après, ordre relatif conservé DANS chaque groupe) + LazyColumn en 3 sections (actifs / en-tête `inactive_section` SEULEMENT si des inactifs existent / inactifs `dimmed = true`) + cartes inactives **alpha 0,55** en plus du badge (lisibles et tappables — l'édition d'un archivé doit rester possible). **Miroir web** : `treatmentsForDisplay` dans `js/data/models.js` (node-safe, pas de DOM) + rendu sections + `opacity:0.55` + clés i18n `inactive_section` FR/EN. Tests : **5 JVM** (`TreatmentsDisplayTest` : regroupement, stabilité, tout-actif, tout-inactif, vide) + **4 web** (`tests/treatments-display.test.js`) — pas de test UI instrumenté pour l'en-tête (dépendrait de données inactives, contre la convention data-indépendance §8.ter) : couverture JVM + validation émulateur (5 actifs en haut, en-tête + EV-old/EEn-old tout en bas, dumps tr1/tr2). 264 tests JVM + lint ; web 184 tests + E2E verts (check.sh). **Publication** : commit a0f3ac5 + tag v1.11.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 80, farewell id 1588588) ; web v1.11.0 sync (commit 0dd5fa7 + tag, farewell = miroir serveur déjà répliqué). |
| 28 sept. 2026 (session v1.10.1) | **Durcissement rappels — revue de code post-v1.10.0** (audit demandé : « optimisations ou problèmes, web et Android »). (a) **`launchMode="singleTop"`** : le combo `standard`+`CLEAR_TOP` recréait l'activité au tap notification app-ouverte et rendait le `onNewIntent` de la v1.10.0 inopérant (code mort) — désormais délivré SANS recreation (état UI préservé). (b) **`EXTRA_PLANNED_AT`** : le garde « créneau déjà honoré » évaluait le jour au moment du FEU — une alarme inexacte (`setWindow`/Doze) glissant 23:58→00:02 évaluait le mauvais jour ; l'instant planifié voyage dans l'intent (scheduleFor), le snooze le FORWARDE, le garde évalue le jour du CRÉNEAU ; vérifié émulateur (broadcast `planned_at` hier → notif sautée). (c) **`BootReceiver` consolidé** (dette §20.bis #2 FAIT) : `goAsync`+coroutine, `runBlocking` supprimés. (d) Style (import `mutableStateOf`) + audit doc (comptes 223 résiduels §14/#64 et §16.ter → 259 ; §9 et §20.bis à jour). Web NON concerné (miroir lab-timing laissé tel quel — boucle correcte et testée ; `importJson` par lots reste en dette). 259 tests JVM + 14 UI + lint verts. **Publication** : commit d531eca + tag v1.10.1 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 78, farewell id 1588586) ; validation §16.ter sur l'APK RELEASE AVANT tag (accueil + données, singleTop, planned_at A/B, 0 crash). |
| 27-28 sept. 2026 (session v1.10.0) | **2 fixes + 1 amélioration rappels** (remontées + nouvelles données réelles → 6ᵉ régression épinglée `backup-v1.9.8.json`). (a) **#68 — reco de prise de sang antérieure à la stabilisation** : le « creux » = minimum de la FENÊTRE ENTIÈRE tombait juste après l'injection PRÉCÉDENTE pour les esters à montée lente (EEn pic ~J+5 ≈ intervalle 7 j → creux d'absorption ~3 h post-injection) — date gouvernée par l'injection N−1 (antérieure à la stab) et « juste avant ton injection du … » faux de plusieurs jours (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau PRÉ-INJECTION du créneau (restaure le principe v1.8.0 documenté ; EV inchangé — le point pré-injection EST son min de fenêtre) ; **miroir web appliqué** (lab-timing.js). Tests : RegressionUserCase6Test data-driven dont balayage de `now` ±60 j (invariant « creux ≥ stab » à TOUT INSTANT) ; les 17 LabTimingTest passent inchangés. (b) **#69 — dialog fantôme** : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le ROUVRAIT à chaque fois (paramètre d'activité vs consommation dans les remember de Home, détruits à chaque sortie d'onglet — reproduit sur émulateur AVANT fix). FIX : `MutableState<Long?>` détenu par MainActivity, consommé UNE fois (extras retirés de l'intent après lecture ; bonus : tap notification app-ouverte ouvre aussi le dialog). Test UI `LogDoseRequestTest` + vérification émulateur. (c) **Rappel sauté si dose déjà loggée** (demande) : garde au déclenchement — `hasDoseLoggedOnDay` (pur, jour calendaire LOCAL) dans ReminderReceiver → notif sautée + re-programmation créneau suivant dans tous les cas ; 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine sans dose → 1 notif). **259 tests JVM + 14 UI + lint** ; **web 180 tests + E2E verts** (check.sh). **Publication** : commit a4f859f + tag v1.10.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 77, farewell id 1588583). **Web miroir v1.10.0 sync** (fix #68 lab-timing, WEB_VERSION alignée, commit b865b16 + tag poussés ; #69/garde rappel = Android-only, notés dans le changelog web). |
| 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). **Publication** : commit 09cddfc + tag v1.9.8 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 75, farewell id 1588581). Web miroir NON concerné (v1.9.8 = socle Android uniquement : tests instrumentés, signature APK, découpage Compose, README du dépôt Android — rien à porter côté web, qui reste à v1.9.7). |
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
| 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. |
| 19 sept. 2026 (session v1.9.2) | **Fix nuage vide sans traitement stocké ESE** (remontée : « il ne s'active que autour du tracé émulé, pas autour du modèle Estrannaise ») : le filtre du nuage testait le pkModel STOCKÉ — or la courbe ESE redessine TOUTES les doses E2 (modelOverride ESE) quel que soit le modèle stocké. FIX : le nuage couvre les doses à ester effectif couvert par ESE (peu importe le stocké) ; l'exclusivité reste portée par le chip ; l'oral Bateman reste hors nuage. 2 tests réécrits (237 verts / 207 sans). Web miroir v1.9.2. |
| 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. |
| 17 sept. 2026 (session v1.9.0) | **Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC (Android + web sync)** (demande) : découverte des sources originelles (github.com/WHSAH/estrannaise.js — forme close 3C + paramètres publiés + posterior MCMC 313/ester). Vérification numérique AVANT débranchement : **RMS 0,00 vs tables ODS** (l'ODS était l'échantillonnage de ces formules). ESE → analytique (dispatch, cutoff 10×t½, t½ LabTiming), PKProfileStore retiré du runtime (asset ODS → src/test/assets, −550 Ko d'APK), ESE couvre 6 esters (+EUCS), TFS = liste explicite (EUCS sans V3C TFS). **Nuage MCMC exclusif ESE** : chip `Nuage` (off, activable si ESE affiché, coupure auto si ESE off), 32 courbes du posterior déterministes, alpha faible sous les courbes, hors échelles/labels/extrema. 11 nouveaux tests (234 verts / 204 sans données locales) + lint + check web (166). Validé émulateur §16.ter : courbe ESE analytique affichée, unités, chips Extend/Cloud, 0 crash. |
| 17 sept. 2026 (session v1.8.2) | **Maintenance — audit complet code + doc** : (a) **perf moteur** : cutoffHours pré-calculé par traitement dans e2At/computeCurve (le chemin chaud du tracé labs recalculait ~430 k appels/refresh), Bateman paresseux (inutile pour les traitements à profil), doses pré-groupées par traitement — résultat identique (ordre de sommation préservé, 223 tests verts inchangés) ; (b) **2 fixes de fraîcheur** (#65 accueil : clés produceState sans allTreatments/tConfig — éditer un scaleFactor n'actualisait pas la courbe avant le tick ; #66 graphiques : clé `labResults.size` — éditer la valeur d'un lab ne recalculait pas) ; (c) **nettoyage** : ~9 imports morts, qualifications raccourcies, code mort (Repository/DAO, currentLevel, scheduler2), médiane factorisée (4 copies), SimpleDateFormat hors boucle, AppLog sans recopie, Regex précompilées, formatDose dédupliqué ; (d) **doc** : ~35 corrections (compteurs actuels 223/193 partout, TOC + ancres réparées, inventaire §8 à jour et fusionné, doublons CHANGELOG, §19 backup, fautes). Audit préalable par double exploration lecture-seule (46 fichiers + 4 docs). 223 verts + lint + émulateur smoke. Android seul (web non concerné — re-render complet, aucune release web). |
| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). |
@ -123,7 +111,7 @@ Leçon importante de la session build : **les erreurs de compilation et les bugs
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
et en testant** — aucun build n'avait été lancé avant la session 3.
## 3. Stack & versions (épinglées — à jour v1.10.0)
## 3. Stack & versions (épinglées — à jour v1.8.1)
| Composant | Version | Où |
|---|---|---|
@ -137,8 +125,7 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
| AppCompat | 1.8.0 (langue par app) | idem |
| DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation + androidTestImplementation explicite) | idem |
| Tests UI Compose | **ui-test-junit4 via le BOM** + `androidx.test.ext:junit-ktx:1.3.0` + `androidx.test.uiautomator:uiautomator:2.3.0` (androidTest, v1.9.8/v1.10.0 — §8.ter) | idem |
| JUnit | 4.13.2 (testImplementation) | idem |
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
@ -208,14 +195,10 @@ MainActivity (AppCompatActivity)
```
Points clés :
- `HormoneTrackApp.onCreate()` : init `EstrannaiseModels` (asset MCMC —
`PKProfileStore` HORS runtime depuis v1.9.0), canal de notification
- `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
demande POST_NOTIFICATIONS (API 33+) ; demande « logger une dose »
(`open_log_dose` + `treatment_id` venus de la notification) = `MutableState<Long?>`
détenu par l'activité, **consommé UNE fois** par Home via
[HormoneTrackRoot] (fix #69 — extras retirés de l'intent après lecture,
tap notification app-ouverte géré par `onNewIntent`)
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` +
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
## 6. Modèle de données (Room)
@ -266,7 +249,6 @@ simulation de tout l'historique EV disparaissait) :
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` |
| ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) |
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs |
| ✅ **Plus AUCUN créneau prévisionnel (v1.12.0, fix #70)** | `generateForecastDoses` garde `!isActive` — ni graphique Prévision, ni extension de fenêtre, ni horizon LabTiming ; le chip Prévision du graphique ne considère que les Posologies ACTIVES |
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
@ -301,16 +283,11 @@ Pics de référence (pg/mL par mg) :
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
### 7.2 `PKProfileStore` (⚠️ HORS RUNTIME depuis v1.9.0 — voir §7.12)
### 7.2 `PKProfileStore` (asset loader + échantillonnage)
- **v1.9.0 (§7.12)** : le moteur Estrannaise est devenu ANALYTIQUE (forme
close de estrannaise.js) et `PKProfileStore` a quitté le runtime (asset
déplacé en `src/test/assets/`, −550 Ko d'APK). Ce qui suit décrit
l'architecture HISTORIQUE, encore active pour le TEST de fidélité RMS.
- Asset `src/test/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
- Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)`
(ancienne lecture d'asset) n'existe plus au runtime
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset
- `sample(ester, model, dtHours)` :
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
cf §14)
@ -325,9 +302,8 @@ Pics de référence (pg/mL par mg) :
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
Coupure par dose `cutoffHours` (§7.12 pour l'ESE actuel) : **10 × t½ terminale**
du modèle de profil (v1.9.0 — analytique), `30 × t½` pour Bateman ;
la longueur de table 8001 h ne s'applique qu'à l'historique pré-1.9.0.
Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
`30 × t½` pour Bateman.
### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
@ -634,79 +610,39 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
2. un creux n'interprétable que si le régime est **stabilisé** : 5 × t½
terminale après le dernier changement (≈ 97 % de l'équilibre).
**v1.13.0 — le suivi s'appuie sur un système à TROIS niveaux distincts** :
| Niveau | Rôle | Notifications |
|---|---|---|
| Seuils d'alerte (§9.bis, `pk/Alerts.kt`) | surveiller le niveau estimé **en continu** | ✅ carte accueil + worker 15 min |
| **Cible de creux** (v1.13.0, opt-in) | référence **de lab** : « au creux recommandé, je veux être dans [basse, haute] » | ❌ jamais — lecture seule |
| Reco de prise de sang (cette section) | **quand** tester + **combien attendre** (`predictedE2`, v1.13.0) | — |
Les bandes sont volontairement indépendantes (ex. réelles : alertes
210–350, cible de creux 150–200 — la cible basse est SOUS l'alerte basse,
les fusionner serait faux).
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
lien Posologie → recommandation). Les deux encarts sont exclusifs.
- **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ;
**Estrannaise analytique** depuis v1.9.0
([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ;
l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12).
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ;
**Estrannaise** : lue dans la table par
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
≥ 1 % du pic, 48 h précédentes — est partagée).
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
critique : « changé d'ester, de dosage ET de posologie, et l'app disait
stabilisée depuis février ») : le régime courant = la **séquence terminale
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
à la dose la plus récente, ET (b) **l'écart entre doses consécutives
reste dans l'intervalle de Posologie ± 24 h** (v1.9.7 — la comparaison
exacte v1.8.1 cassait le régime à chaque variation d'heure de log et
faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure
près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste
hors fenêtre). **v1.13.0 : séquence POOLÉE entre traitements**
([LabTiming.pooledRegimeDoses]) — les doses de tous les traitements
partageant (ester effectif, mg) forment UNE séquence : le re-parenting
d'historique (« … - old » créé a posteriori, remontée v1.12.0) ne fait
plus redémarrer la stabilisation. Garde-fous : ester ≠ → jamais poolé
(transition EV→EEn, régression n°3) ; dose ≠ → exclue (le 8 mg casse le
régime via le trou qui suit — conservateur). Sur la data réelle de la
remontée : régime = 12 août (« stable depuis ») au lieu de « pas
stabilisée avant ~20 oct ». Stabilisation = régimeStart +
à la dose la plus récente, ET (b) **l'écart entre doses consécutives est
constant** (= l'écart des deux doses les plus récentes, comparé
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier).
- **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau —
dernier point de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant
l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière »
tombait juste APRÈS l'injection précédente pour les esters à montée
lente (EEn : pic ~J+5 ≈ intervalle 7 j → creux d'absorption) : date
antérieure à la stabilisation + texte « juste avant ton injection » faux
de plusieurs jours. Pour EV (t½ courte) le point pré-injection EST le
minimum de fenêtre → comportement inchangé. FORME brute (sans
calibration — le facteur ne déplace pas le minimum) ; horizons : au
moins 3 créneaux, étendu pour couvrir la stabilisation, borné à 12.
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
injections ; FORME brute (sans calibration — le facteur ne déplace pas le
minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la
stabilisation, borné à 12.
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le
garde s'exprime sur le creux lui-même, instant réellement proposé).
- **Valeur prédite + cible (v1.13.0)** : `LabRecommendation.predictedE2`
= valeur brute du modèle au creux × facteur du ester actif
(`scalePerEster` optionnel, fourni par Home/Labs quand l'auto-cal est ON ;
`null` → la carte n'affiche RIEN — une valeur brute non calibrée serait
trompeuse) ; statut face à la cible de creux personnelle via
[LabTiming.troughTargetStatus] (IN_TARGET/BELOW/ABOVE, bornes incluses,
`null` si cible absente) → coloration primaire (dans la cible) /
tertiary (hors) + suffixe « dans/hors de ta cible ».
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux
du créneau suivant — décision v1.8.0 « sauter »).
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- Tests `LabTimingTest` (17 — v1.9.7 : 2 tests de tolérance ± 24 h ;
sémantique pré-injection inchangée par le fix #68) + régressions n°5 et
**n°6** (data-driven `backup-v1.9.8.json` : invariant « creux ≥
stabilisation » balayé sur `now` ± 60 j, pas 6 h).
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
@ -730,21 +666,13 @@ devient à son tour**.
l'ODS n'était que l'échantillonnage horaire de ces formules. Épinglé par
`EstrannaiseModelsTest` (Android) et `estrannaise-models.test.js` (web).
- **Débranchement runtime** : `concentrationOfDose` dispatche ESE →
`EstrannaiseModels.sample` (plus de fallback `PKProfileStore.sample`) ;
`cutoffHours` ESE → 10 × t½ terminale analytique (plus la longueur de
table) ; `LabTiming` t½ ESE analytique ; `PKProfileStore.init` retirée
du démarrage. **L'asset `pk_profiles.json` quitte `src/main/assets`
(−550 Ko d'APK) pour `src/test/assets/`** : l'ODS survit UNIQUEMENT
comme référence des tests de fidélité. `PKProfileStore` est conservé
(lecteur pur) pour ces tests.
- **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 — indépendamment du `pkModel` stocké du
traitement (fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE` laissait
le nuage vide dès qu'aucun traitement n'était stocké en ESE — la courbe
ESE affichée trace pourtant toutes ces doses via modelOverride). Les
doses des traitements inactifs sont incluses (§6.bis) ; l'oral Bateman
reste hors nuage (pas d'ester échantillonnable).
[EstrannaiseModels.sample] (plus de fallback
[PKProfileStore.sample]) ; `cutoffHours` ESE → 10 × t½ terminale
analytique (plus la longueur de table) ; [LabTiming] t½ ESE analytique ;
[PKProfileStore.init] retirée du démarrage. **L'asset `pk_profiles.json`
quitte `src/main/assets` (−550 Ko d'APK) pour `src/test/assets/`** :
l'ODS survit UNIQUEMENT comme référence des tests de fidélité.
[PKProfileStore] est conservé (lecteur pur) pour ces tests.
- **Couverture étendue** : ESE passe de 3 à **6 esters injectables**
(EV/EU/EEn/EC/EB/**EUCS**) — `Esters.choicesForModel` mis à jour (TFS
reste 7, WHSAH 6 ; ⚠️ TFS = liste EXPLICITE : EUCS n'a pas de V3C TFS).
@ -763,31 +691,56 @@ devient à son tour**.
miroirs ; le fetch des tables ODS au démarrage web est remplacé par le
fetch MCMC (démarrage plus rapide) ; nuage dessiné par `chart-canvas.js`.
#### Presets & éditeur — les 6 esters sélectionnables à la création (v1.9.4)
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
- **`PKPresets.kt` (25 entrées)** : 3 presets ESE ajoutés — EC huile
(5 mg IM), EB (1 mg IM), EUCS suspension cristalline (10 mg SC, ester
exclusif à ce fit). Champs Bateman **informatifs** (t½ terminales des
fits : EC ≈ 202 h pour k3 0,669/j, EB ≈ 4 h pour k3 4,34/j, EUCS
≈ 756 h pour k2 0,022/j) — pas utilisés au calcul, le dispatch passe
par la forme close.
- **`TreatmentEditorScreen.kt`** : la liste des esters =
`Esters.choicesForModel(model)` (même helper que l'override du
DoseDialog — plus aucune divergence des listes). Au changement de
modèle, si l'ester courant sort de la couverture (EUCS + TFS par ex.),
il rebascule sur EV : aucun choix possible ne produit 0 pg/mL.
- **Garde épinglée** : `PKPresetsTest` (miroir web
`tests/presets.test.js`) — « aucun preset injectable hors couverture de
son propre modèle » ; ESE 6 / TFS 7 explicite (sans EUCS) / WHSAH 6.
- **Web sync v1.9.4** : mêmes presets + i18n ; 2 trous du portage v1.9.0
corrigés au passage (`Esters.EUCS` manquant dans `pk-engine.js`,
`choicesForModel` resté à 3 esters).
**DÉCISION v1.9.0** (utilisatrice) : « 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 ». TFS
(v1.4.0) et WHSAH (v1.4.6) étaient déjà analytiques ; **l'Estrannaise le
devient à son tour**.
- **SOURCE** : https://github.com/WHSAH/estrannaise.js/ — modèle 3
compartiments en forme close : `C(t) = dose·d·k1·k2·[e^(−k1t)/((k1−k2)(k1−k3))
− e^(−k2t)/((k1−k2)(k2−k3)) + e^(−k3t)/((k1−k3)(k2−k3))]` (t en JOURS).
Paramètres publiés par ester : EV [478, 0,236, 4,85, 1,24], EU [471,5,
0,01729, 6,528, 2,285], EEn [191,4, 0,119, 0,601, 0,402], EC [246,
0,0825, 3,57, 0,669], EB [1893,1, 0,67, 61,5, 4,34], EUCS [16,15,
0,046, 0,022, 0,101] (nouveau code ester v1.9.0 : « EUn casubq »).
- **FIDÉLITÉ (vérifiée AVANT débranchement)** : la forme close reproduit
les tables ODS **RMS 0,00** sur 0→200 j (EV/EU/EEn), écart de pic
≤ 0,1 % — l'ODS n'était que l'échantillonnage horaire de ces formules.
Épinglé par `EstrannaiseModelsTest` (Android) et
`estrannaise-models.test.js` (web).
- **Débranchement runtime** : `concentrationOfDose` dispatche ESE →
`EstrannaiseModels.sample` (plus de fallback `PKProfileStore.sample`) ;
`cutoffHours` ESE → 10 × t½ terminale analytique (plus la longueur de
table) ; `LabTiming` t½ ESE analytique ; `PKProfileStore.init` retirée
du démarrage. **L'asset `pk_profiles.json` quitte `src/main/assets`
(−550 Ko d'APK) pour `src/test/assets/`** : l'ODS survit UNIQUEMENT
comme référence des tests de fidélité. `PKProfileStore` est conservé
(lecteur pur) pour ces tests.
- **Couverture étendue** : ESE passe de 3 à **6 esters injectables**
(EV/EU/EEn/EC/EB/**EUCS**) — `Esters.choicesForModel` mis à jour (TFS
reste 7, WHSAH 6 ; ⚠️ TFS = liste EXPLICITE : EUCS n'a pas de V3C TFS).
- **NUAGE D'INCERTITUDE (exclusif ESE, demande v1.9.0)** : Estrannaise
publie le **posterior MCMC** de ses paramètres (313 échantillons par
ester — asset `mcmc_samples.json` ≈ 48 Ko, chargé au démarrage). Chip
`Nuage` du graphique (off par défaut, activable à volonté, ACTIVABLE
SEULEMENT si ESE est affiché — éteindre ESE coupe le nuage) →
`EstrannaiseCloud.compute` trace 32 courbes du posterior (échelonnées,
déterministes) superposant les doses des porteurs ESE. Dessin en alpha
faible SOUS les courbes ; hors échelles/labels/extrema. Limites : les
contributions TFS/WHSAH ne sont pas dans le nuage (pas de posterior
publié) ; les patchs du repo (tw/ow) ne sont pas portés.
- **Web sync** : `js/pk/estrannaise-models.js` + `js/pk/estrannaise-cloud.js`
miroirs ; le fetch des tables ODS au démarrage web est remplacé par le
fetch MCMC (démarrage plus rapide) ; nuage dessiné par `chart-canvas.js`.
## 8. Tests unitaires
**284 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **250 sans
les données de test locales** (cf §8.bis : les 6 classes de régression sont
skippées via `Assume`). Dépendance : JUnit 4.13.2.
**234 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans
les données de test locales** (cf §8.bis : les 5 classes de régression,
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -955,11 +908,6 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
- **`EstrannaiseCloudTest`** (5) : v1.9.0 — nuage d'incertitude : 32 courbes
à dispersion réelle (les posterior couvrent une plage), fenêtre respectée,
exclusivité ESE (TFS/oral → vide), sans dose → vide, nbCurves < 2 → vide.
- **`PKPresetsTest`** (6) : v1.9.4 — presets de création : les presets ESE
couvrent les 6 esters du fit (unicité, même ensemble que
`choicesForModel`), TFS (7) / WHSAH (6) inchangés, **garde anti
« 0 pg/mL »** (aucun preset injectable hors couverture de son modèle),
champs Bateman informatifs plausibles (t½ des fits).
- **`LabTimingTest`** (11) : v1.8.0/v1.8.1 — recommandation de prochaine
prise de sang : creux juste avant le créneau, saut au premier creux
STABILISÉ (5 × t½ ; régime non stabilisé → creux suivant), prise récente →
@ -994,20 +942,14 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
- emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`,
`backup-v1.2.3.json` (ancien export, plus consommé par un test),
`backup-v1.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json`, `backup-v1.9.8.json` et
`backup-v1.12.0.json`
`backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json`
(copiés tels quels depuis l'export JSON de l'app) ;
`RegressionUserCaseTest` lit v1.0.0,
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit
**v1.9.8** (profil à 7 traitements, logs à heures variables ; épine le
fix #68 « creux avant stabilisation » — invariant balayé sur `now` ±60 j),
`RegressionUserCase7Test` lit **v1.12.0** (export avec re-parenting
« 6d-old » — épine le régime POOLÉ v1.13.0 : le régime doit traverser la
frontière de re-parenting et rester arrêté par la dose de mg différent) ;
épine le fix « une note sur deux » v1.7.1) ;
- `.gitignore` contient `local-test-data/` → jamais commités.
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
1. `git check-ignore -v local-test-data/…` → la règle matche ;
@ -1021,9 +963,9 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 250 tests au lieu de 284 (v1.13.0 ; chiffres historiques : 44/87 à v1.3.x,
exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x,
150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0,
181/211 à v1.7.1, 190/220 à v1.8.0, 219/249 à v1.9.x, 225/259 à v1.10.x, 230/264 à v1.11.x, 240/274 à v1.12.x) ;
181/211 à v1.7.1, 190/220 à v1.8.0) ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
@ -1050,51 +992,8 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
**Toute modification du moteur passe par ces tests.** Suite envisagée : Robolectric
(UI/logic Android — les tests Compose existent depuis v1.9.8, cf §8.ter).
### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8)
**14 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil :
```bash
./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36)
```
- **`NavigationTest` (7)** : la barre d'onglets affiche les 5 entrées, chaque
onglet mène bien à son écran (preuves = éléments INCONDITIONNELS : chips de
plage « 24 h » du graphique, FAB « + » de Doses/Analyses/Traitements —
indépendants des données Room), et les Paramètres s'ouvrent depuis l'icône
de l'accueil ;
- **`ChangelogDialogTest` (3)** : dialog « Nouveautés » affiché quand la
version installée est plus récente que la dernière vue, absent sinon, et
**ne réapparaît pas après dismiss + redémarrage** (épinglage du fix v1.3.2
en test UI) ;
- **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes
présentes + chips de langue littéraux (« Français »/« English ») +
**version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions
jamais bumpées dans les tags — ne peut plus revenir silencieusement) ;
- **`LogDoseRequestTest` (1, v1.10.0)** : le dialog de dose s'ouvre UNE fois
au lancement avec les extras de notification, puis ne se rouvre JAMAIS
après fermeture + aller-retour d'onglets (épinglage du fix #69). Lancement
manuel avec intent (règle vide + ActivityScenario) ; BACK = fermeture
indépendante des libellés (UiDevice, dep `uiautomator:2.3.0`).
**Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) :
- permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le
lancement (sinon le dialog SYSTÈME masque l'app → timeout) ;
- dialog « Nouveautés » neutralisé en marquant la version vue
(`UiTestEnv.markChangelogSeen`) SAUF pour les tests qui le ciblent — ceux-ci
lancent l'activité MANUELLEMENT (`ActivityScenario` + `createEmptyComposeRule`)
pour régler le DataStore AVANT la lecture au démarrage ;
- assertions de texte via `targetContext.getString(...)` : résolues dans la
MÊME configuration que l'UI (per-app locales incluses) → indépendant de la
langue système et du réglage in-app FR/EN ;
- aucune assertion dépendante des données Room (listes/curbes) — les tests
restent verts sur émulateur vierge OU seedé (§16.ter).
⚠️ `assertExists`/`assertDoesNotExist` sont des MEMBRES de
`SemanticsNodeInteraction` depuis Compose 1.12 (plus des extensions à importer).
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
(UI/logic Android), tests Compose, lint.
## 9. Système de rappels
@ -1118,21 +1017,7 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
reprogramme le prochain déclenchement** (goAsync + IO).
**v1.10.0 — garde « créneau déjà honoré »** : au déclenchement,
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL —
6 tests `ReminderLoggedDayTest`) consulte les doses : dose du jour déjà
loguée → **notification sautée** (prise saisie avant l'heure de rappel —
la notif ne sonne pas pour une prise faite) ; la re-programmation du
créneau SUIVANT reste exécutée dans tous les cas. Vérifié A/B sur
émulateur (`am broadcast -n …ReminderReceiver` : EEn avec dose du jour →
0 notif, Fluoxetine sans dose → 1 notif).
**v1.10.1 — instant PLANIFIÉ (`EXTRA_PLANNED_AT`)** : le garde évalue le
jour du CRÉNEAU PLANIFIÉ (embarqué dans l'intent de l'alarme par
`scheduleFor`, forwardé tel quel par le snooze), pas le jour du feu
réel — une alarme inexacte (`setWindow`) ou retardée (Doze) glissant
après minuit évaluait le mauvais jour (23:58 → 00:02). Vérifié émulateur
(broadcast avec `planned_at` daté d'hier → notification sautée).
reprogramme le prochain déclenchement** (goAsync + IO)
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
@ -1159,11 +1044,6 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2)
> ⚠️ À NE PAS CONFONDRE avec la **cible de creux** (v1.13.0, §7.11) : les
> seuils ci-dessous surveillent le niveau estimé EN CONTINU et notifient ;
> la cible de creux est une référence de lab évaluée en lecture seule sur
> la carte reco — elle ne notifie jamais.
- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
@ -1228,11 +1108,7 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ;
**v1.12.0** : **icône de forme de prise** en tête de ligne (`routeIcon` pur —
pilule/seringue IM+SC teinte tertiary pour SC/goutte/sparadrap/compte-gouttes)
et **encart latéral « prise de sang le même jour »** (`labMarkersForDose` pur —
🧪↑ avant la dose, 🧪↓ après ; jour calendaire LOCAL, lab à l'heure exacte =
APRÈS) ; **en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours
**en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours
totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés)
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
@ -1242,12 +1118,7 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur. **v1.11.0** : ordre d'affichage =
`treatmentsForDisplay` (pur, tri STABLE — actifs d'abord, inactifs
regroupés TOUT EN BAS sous un en-tête `inactive_section` qui n'apparaît
que s'il existe des inactifs) + cartes inactives **atténuées** (alpha
0,55) — 5 tests `TreatmentsDisplayTest` ; miroir web
(`js/data/models.js` + `tests/treatments-display.test.js`)
badge inactif), FAB → éditeur
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
@ -1265,19 +1136,8 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
fermable, ne réapparaît pas avant la prochaine mise à jour.
- `SettingsScreen` (**v1.9.8 : découpé en cartes thématiques** — 840 lignes
→ orchestrateur de 377 + 11 fichiers de 33–164 lignes dans le sous-package
`ui/screens/settings/`, un par carte : `LanguageCard`, `AutoCalibrationCard`,
`TModelCard`, `AlertThresholdsCard`, `ChartTimezoneCard`, `RemindersCard`,
`BackupCard`, `AutoBackupCard`, `LogsCard`, `AboutCard` + `SettingsFormat`
(helpers `formatDoubles`/`folderDisplayName`). L'orchestrateur garde ce qui
est PARTAGÉ : collecte DataStore, les 4 launchers SAF (leurs callbacks
écrivent `message`/dialog d'import), dialog de confirmation d'import,
ordre des cartes. Comportement et rendu IDENTIQUES (déplacement pur avec
les commentaires historiques — validé par les tests UI §8.ter + smoke
release §16.ter) ; contenu fonctionnel inchangé : langue
(Système/Français/English, chips reflétant l'état) ; **carte « Seuils
d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
@ -1285,12 +1145,7 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
Export/Import JSON ; auto-backup (v1.7.0) ; **carte « Cible de creux » (v1.13.0, opt-in — référence de lab distincte des seuils d'alerte, hint croisé)** ; logs ; à propos.
⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de
source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc
`detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier —
le découper reviendrait à réécrire la garde et ré-exposer les captures
figées de pointerInput(Unit). À reconsidérer si une refonte chart a lieu.
Export/Import JSON ; à propos
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
@ -1378,10 +1233,7 @@ Pièges :
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
appliquée sur le thread MAIN car `setApplicationLocales` recrée
l'activité ; **v1.13.0 : cible de creux** `troughTargetLow`/`High`
restaurée SEULEMENT si le backup en porte une cohérente (haut > bas) —
un backup ancien n'efface pas la cible locale) et les rappels sont
reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera
l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera
remplacé (bouton « Effacer & restaurer »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
@ -1859,7 +1711,7 @@ l'optimiseur.
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel
#C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même
chip sur les mêmes données fonctionne (légende + 7090 px rose après fix).
**Les 191 tests JVM de l'époque sont verts** (259 aujourd'hui — ils ne passent JAMAIS par R8).
**Les 191 tests JVM de l'époque sont verts** (223 aujourd'hui — ils ne passent JAMAIS par R8).
**Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) :
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
@ -1881,53 +1733,6 @@ valeur d'un lab ne rafraîchissait pas les courbes avant le tick de 60 s. Leçon
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
un proxy comme `.size`).
**#67 (v1.9.5) — ticks X alignés sur le mauvais fuseau + dérive DST** : `drawXLabels`
formatait les labels dans le fuseau CHOISI mais appelait `xLabelTicks(t0, t1, step)`
sans lui passer la zone — l'alignement « minuit local » (fix #55) se faisait sur le
fuseau de l'APPAREIL (heures décalées dès que les deux divergent). Accessoirement le
pas avançait en ms fixes (`t += stepMs`) : dérive d'1 h aux transitions DST. Leçon :
quand un helper prend un fuseau pour FORMATTER, la même zone doit régler
l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms.
**#68 (v1.10.0) — recommandation de prise de sang antérieure à la stabilisation** :
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 quelques
heures après l'injection PRÉCÉDENTE (creux d'absorption) : la date proposée était
gouvernée par l'injection N−1 (antérieure à la stabilisation) et le texte « juste
avant ton injection du … » était faux de plusieurs jours. Réel : creux 5 oct 02:32
vs « pas stabilisée avant » 7 oct 04:03. FIX : creux = niveau PRÉ-INJECTION du
créneau (restaure le principe v1.8.0 ; pour EV le point pré-injection EST le min de
fenêtre → inchangé). Leçon : quand une valeur affichée est QUALIFIÉE par un texte
(« juste avant l'injection »), le calcul doit produire CETTE valeur — et un
minimum de fenêtre n'est pas un creux pré-injection si la courbe remonte dans
l'intervalle. Épinglé par RegressionUserCase6Test (balayage de `now` ±60 j :
l'invariant doit tenir à tout instant, pas seulement à l'instant du rapport).
**#69 (v1.10.0) — dialog de dose fantôme après une notification** : tap
notification → dialog de saisie ; le fermer puis changer d'onglet et revenir sur
l'accueil le ROUVRAIT à chaque fois. Cause : la demande vivait au niveau ACTIVITÉ
(paramètre d'intent) tandis que 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 rouvrait. FIX : `MutableState<Long?>` détenu par
MainActivity, consommé UNE fois ; extras retirés de l'intent après lecture (un
recreate ne rouvre pas une notification traitée) ; bonus : tap notification
app-ouverte ouvre désormais aussi le dialog. Leçon : une demande à consommer une
fois doit vivre à la MÊME durée de vie que sa consommation — jamais « paramètre
longue-vie + effet courte-vie ». Épinglé par le test UI LogDoseRequestTest.
**#70 (v1.12.0) — inactif à Posologie projetait encore ses injections** :
`generateForecastDoses` (source unique des créneaux prévisionnels) ne vérifiait
pas l'archivage — un injectable INACTIF avec Posologie et de l'historique
continuait d'alimenter le graphique Prévision, l'extension de fenêtre et
l'horizon LabTiming ; accessoirement le chip Prévision s'activait sur des
Posologies inactives. FIX : garde `!isActive` dans le moteur + horizon/chip sur
les ACTIFS. Cohérence d'ensemble (§6.bis) : l'INACTIF garde son historique
simulé/calibré mais ne projette plus rien — exactement comme ses rappels
(v1.4.0). Leçon : quand un état administratif gouverne DÉJÀ une consommation
d'une source (rappels), vérifier les AUTRES consommateurs de la même source.
Épinglé par ForecastDosesGuardTest (3 tests : inactif vide, actif inchangé,
avant/après archivage).
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
@ -1965,9 +1770,8 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
```bash
cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 259 tests (225 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater)
./gradlew connectedDebugAndroidTest # 14 tests UI Compose (émulateur requis, §8.ter)
./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
@ -2052,66 +1856,27 @@ Le script :
scope requis : `write:repository` (suffit pour releases + assets, pas pour
créer un repo — cf plus haut).
**Build release (v1.2.5, signature conditionnelle depuis v1.9.8)** :
`./gradlew assembleRelease` — R8 full mode + `isShrinkResources`
(20 Mo → 2,7 Mo). **Signature** : si `keystore.properties` existe à la racine
du dépôt (gitignoré, créé par `scripts/make-release-keystore.sh` — cf §16.quater),
la build release utilise CE keystore ; sinon elle reste signée avec la **clé
debug** (même signature que les APK debug distribués → mise à jour par-dessus
sans perte de données — comportement historique). Garde-fous dans
`proguard-rules.pro` : Gson lit les champs des modèles par réflexion →
`-keep` explicites sur `data.model.**`, `BackupData`, `TConfig` (+ attributs
`Signature`) — sinon l'export/import JSON casse UNIQUEMENT en release.
⚠️ À chaque activation d'optimisation : tester sur téléphone l'export/import
de backup + les graphiques (R8 ne se vérifie pas en tests JVM).
### 16.quater Signature release : keystore dédié (v1.9.8)
Historique : les APK release étaient signés avec la **clé debug** (choix
v1.2.5 — installer par-dessus l'APK debug sans perte de données). Le jour où
l'app est distribuée plus largement (dépôt public, F-Droid…), une vraie clé
de signature est requise — elle est désormais câblée :
- **Création (une fois)** : `scripts/make-release-keystore.sh` — génère
`release.keystore` (RSA 4096, alias `hormonetrack`, validité 10 000 j) +
`keystore.properties` à la racine, TOUS DEUX GITIGNORÉS (`.gitignore` :
`keystore.properties`, `*.keystore`, `*.jks`). Le script refuse
d'écraser une paire existante (une régénération = perte de la capacité
de mise à jour des installs existantes). Mots de passe saisis
interactivement (jamais en argument, jamais committés) ;
- **Effet** : dès que `keystore.properties` existe, `assembleRelease` signe
avec ce keystore (détection dans `app/build.gradle.kts`) ; sans le
fichier, fallback clé debug inchangé ;
- **⚠️ MIGRATION téléphone obligatoire** (signature ≠ clé debug ⇒ Android
refuse la mise à jour par-dessus) :
1. exporter le JSON (Paramètres → Sauvegarde → Exporter, ou auto-backup) ;
2. désinstaller l'app ;
3. installer l'APK release signé par le nouveau keystore ;
4. réimporter le JSON. La base Room ne voyage QUE par le JSON ;
- **Sauvegarde du keystore** : le copier hors du dépôt (coffre, second
disque) — sa perte = même migration subie par TOUS les utilisateurs à la
version suivante ;
- **Checklist avant release publiée avec la nouvelle clé** : build release →
`apksigner verify --print-certs` (l'empreinte doit être celle du NOUVEAU
keystore) → smoke test émulateur §16.ter → publication + note de migration
dans le corps de la release Gitea.
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode +
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des
modèles par réflexion → `-keep` explicites sur `data.model.**`,
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
vérifie pas en tests JVM).
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au
total, 250 si `local-test-data/` est absent (les 6 classes de régression
réelles sont skippées via `Assume`) ; **lint vert
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au
total, 193 si `local-test-data/` est absent (les 5 classes de régression
réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1).
**v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest`
(14 tests §8.ter — émulateur requis).
**v1.10.0+ — miroir web** : si la session touche une logique miroirée
(lab-timing, moteur…), `bash scripts/check.sh` dans `~/projects/HormoneTrack-web`
doit être vert (180 tests + E2E) AVANT le tag. Paliers de test du projet :
chaque commit = tests + lint ; release = + tests UI + scan confidentialité
+ APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
chaque commit = tests + lint ; release = + scan confidentialité + APK au
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
corrigé, + §2 (historique) si notable.
@ -2121,13 +1886,12 @@ de signature est requise — elle est désormais câblée :
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur
**l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
les 259 tests JVM ne le voient jamais, cf #64). Parcours minimum :
les 223 tests JVM ne le voient jamais, cf #64). Parcours minimum :
l'app démarre 0 crash → écran Graphiques → activer les chips de la
release (rangée modèles défilable : `adb shell input swipe 900 716 150
716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le
dump `text=` que la légende attendue apparaît → `adb logcat -b crash
-d | grep -c FATAL` reste à 0 (les **259** tests JVM ne voient jamais
R8, cf #64). Limites : le pan/pinch n'est PAS
-d | grep -c FATAL` reste à 0. Limites : le pan/pinch n'est PAS
testable par adb (§16.ter 4.bis) — le geste reste validé par
l'utilisatrice sur Pixel 9.
4. **Commit** (message descriptif par couche) + **tag annoté** :
@ -2209,14 +1973,6 @@ adb shell run-as com.hormonetrack rm -f databases/hormonetrack.db-wal \
databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed)
```
⚠️ **Piège WAL constaté à la v1.9.8** : après un `force-stop` (= SIGKILL, PAS de
checkpoint), le schéma entier peut vivre SEULEMENT dans le `-wal` — extraire la
seule `hormonetrack.db` donne un fichier de 4 Ko SANS tables (`no such table:
treatments` au seed). Solution : extraire `hormonetrack.db` **ET**
`hormonetrack.db-wal` CÔTE À CÔTE sous leurs noms d'origine (ex. `/tmp/hrtseed/`)
— sqlite rejoue le WAL à l'ouverture et le fusionne à la fermeture (le `-wal`
disparaît après le seed) ; on pousse alors la seule `.db` résultat.
Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin
(Room n'applique pas de snake_case), enums stockés en String — on insère
SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table`
@ -2308,13 +2064,7 @@ Volontaires (v1) :
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
téléphone** (export/import backup, graphiques) avant chaque publication ;
signature : clé debug PAR DÉFAUT, keystore dédié dès que `keystore.properties`
existe (v1.9.8, §16.quater — migration backup→désinstall→réimport requise)
- **ChartScreen reste un fichier unique (790 lignes)** : la garde de source
#54 (ChartScreenSourceGuardTest) exige le bloc de gestes + les alias
`gesture*` dans CE fichier — découpage = réécriture de la garde + risque
de réintroduction des captures figées. SettingsScreen a été découpé
(v1.9.8, §10) ; le chart attend une refonte dédiée
signé clé debug → upgradable sans perte, mais pas une signature « officielle »
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
@ -2327,27 +2077,22 @@ Volontaires (v1) :
## 20. Idées d'évolution
1. **Robolectric** (VM Android en JVM — pas besoin d'appareil ; les tests
Compose instrumentés existent depuis v1.9.8, §8.ter)
2. **Étendre les tests UI** : dialogs dose/lab avec données seedées (§16.ter),
parcours complet (créer un traitement → loguer une dose → courbe visible) —
les tests actuels sont volontairement data-indépendants
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
5. Verrou biométrique (BiometricPrompt), widget, export CSV
6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ;
MaterialExpressiveTheme quand l'API passera publique (cf §3)
7. ~~Vrai keystore de distribution~~ **FAIT v1.9.8** (infra complète, §16.quater :
script + détection Gradle + migration documentée — la bascule réelle se
fera quand la distribution s'élargira)
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite
une migration : backup → désinstallation → installation signée → réimport)
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**,
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
(push session), **tests UI Compose + signature release dédiée + découpage
Settings (v1.9.8)**, **README anglais (v1.9.8)**.
(push session).
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
@ -2360,9 +2105,10 @@ pour ne pas les redécouvrir et re-reposer les mêmes questions :
Room par ligne. `@Insert` en liste préserverait l'ordre (IDs) et
accélérerait l'import d'un gros historique. Différé : surface DAO +
repository à modifier, validation manuelle (aucun test Room auto).
2. ~~**`BootReceiver` : consolidation `goAsync` + une seule lecture`~~**
**FAIT v1.10.1** (pattern `goAsync`+coroutine de ReminderReceiver,
`runBlocking` supprimés du fichier).
2. **`BootReceiver` : consolidation `goAsync` + une seule lecture** :
les deux `runBlocking` séquentiels peuvent fusionner trivialement ; le
passage complet au pattern `goAsync`+coroutine de ReminderReceiver est
équivalent mais re-testé sur émulateur (boot). Différé.
3. **Double évaluation de `generateForecastDoses`** dans ChartScreen
(producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel
quel — le premier utilise `System.currentTimeMillis()`, le second le
@ -2393,22 +2139,6 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
- [ ] **v1.10.0** : carte « Prochaine prise de sang » — la date du creux est
POSTÉRIEURE à la mention « pas stabilisée avant le … » (fix #68), et
cohérente avec le créneau affiché (~juste avant)
- [ ] **v1.10.0** : logger la dose du jour AVANT l'heure de rappel → la
notification ne sonne pas ; le rappel suivant repart au créneau suivant
- [ ] **v1.11.0** : page Traitements — les inactifs sont TOUT EN BAS sous
l'en-tête « Traitements inactifs », cartes atténuées ; éditer un
inactif fonctionne toujours
- [ ] **v1.12.0** : archiver un injectable à Posologie → plus AUCUN créneau
projeté (chip Prévision : ses injections disparaissent de la projection)
- [ ] **v1.12.0** : page Doses — chaque ligne porte l'icône de sa forme
(pilule/seringue/goutte/sparadrap) ; une dose du jour d'une prise de
sang affiche l'encart 🧪↑ et/ou 🧪↓
- [ ] **v1.13.0** : auto-calibration ON → la carte reco affiche « ≈ X pg/mL » ;
régler une Cible de creux (Paramètres) → la prédiction est colorée
dans/hors cible (jamais de notification — cf §7.11/§9.bis)
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
@ -2561,13 +2291,6 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
recommandé → la carte passe au creux SUIVANT ; enlever la Posologie
→ carte remplacée par l'invite « renseigne une Posologie » ;
traitement oral seul → aucune carte
- [ ] **v1.9.2** : **nuage sans traitement stocké ESE** : avec un
traitement E2 injectable STOCKÉ en TFS (ou autre), activer Estrannaise
dans le graphique → la courbe ESE apparaît ; activer Nuage (exclusif
ESE) → le nuage ENTOURE la courbe ESE tracée sur ces doses (le
pkModel stocké n'est pas un prérequis) ; l'oral Bateman seul → pas de
nuage ; l'exclusivité ESE reste sur le chip
- [ ] **v1.8.1** : **stabilisation corrigée + suggestion Accueil** : après
changement de dose/ester/intervalle, la carte ne dit plus « stabilisé
depuis » l'ancienne période (régime = séquence terminale (ester, dose,
@ -2582,5 +2305,5 @@ maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 c
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57) ; v1.10.0 = creux pré-injection dans la reco (#68), dialog fantôme tué (#69), rappel sauté si dose déjà loggée. Dette connue : fragments de
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57). Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*

View File

@ -88,10 +88,7 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
## 6. Premiers pas dans l'app
1. **Traitements → +** → choisis un preset (ex : *Injection EV —
Estrannaise*). 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.
1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*)
- Tu as changé d'ester ? Crée (ou garde) le traitement de l'ancien ester et
passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes
rappels, mais **son historique reste simulé et calibré** sur les graphiques —
@ -105,11 +102,7 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées
- L'accueil 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
- L'accueil affiche la **prochaine dose en jours** quand elle est à plus de 24 h
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
- La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h**
(flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation
@ -228,9 +221,7 @@ Pics de référence (dose unique de 5 mg, IM) :
- **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
(comme sur le site estrannaise)
- **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

View File

@ -1,68 +0,0 @@
#!/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."