Compare commits

...

18 Commits
v1.9.4 ... main

Author SHA1 Message Date
c0571ae44e Docs : traçabilité publication v1.13.0 (ids releases, web sync complet avec étape 4) 2026-10-07 10:00:29 +02:00
ae14cee07b v1.13.0 : reco prédictive — valeur E2 au creux, régime poolé entre traitements identiques, cible de creux personnelle
- predictedE2 : valeur brute au creux × facteur du ester actif — fournie
  par Home/Labs quand l'auto-calibration est ON (sinon null : la carte
  n'affiche rien, une valeur brute serait trompeuse). Miroir web.
- Régime POOLÉ (pooledRegimeDoses) : les doses de tous les traitements
  partageant (ester effectif, mg) forment UNE séquence — le re-parenting
  d'historique (« 6d-old », v1.12.0) ne fait plus redémarrer la
  stabilisation. Garde-fous : ester ≠ jamais poolé (régression n°3) ;
  dose ≠ exclue (le 8 mg casse via le trou). Régression n°7 data-driven
  sur le nouvel export (backup-v1.12.0.json).
- Cible de creux E2 (opt-in) : DataStore + carte settings (hint croisé
  « distinct des seuils d'alerte — jamais de notification ») + coloration
  carte reco (primaire/hors cible tertiary) + backup rétrocompatible (un
  backup ancien n'efface pas la cible locale). Système à 3 niveaux
  documenté §7.11. Miroir web complet.

284 tests JVM (250 sans données locales) + 14 UI + lint verts ; web 199
tests + E2E verts (check.sh). Validé émulateur sur données réelles :
« Expected at this trough: ≈ 208 pg/mL — outside your target », régime
poolé = stable depuis le 12 août, 0 crash.
2026-10-07 09:59:19 +02:00
4eb8519d9d Docs §2 : traçabilité backfill des releases web v1.10.0→v1.12.0 (+ v1.9.5 farewell) 2026-09-30 20:48:32 +02:00
458f7cc9ef Docs : traçabilité publication v1.12.0 (ids releases Gitea, web sync) 2026-09-29 23:26:01 +02:00
b3372a80e4 v1.12.0 : fix #70 (inactif à Posologie projetait encore) + Doses (icônes de forme, marqueurs prise de sang)
- FIX #70 : generateForecastDoses garde !isActive — un injectable archivé
  avec Posologie et de l'historique projetait encore ses injections
  (graphique Prévision, extension de fenêtre, horizon LabTiming) ; le chip
  Prévision s'activait sur des Posologies inactives. FIX : garde moteur +
  horizon/chip sur les ACTIFS. §6.bis inchangé : l'historique reste simulé,
  seul le FUTUR s'arrête (comme les rappels v1.4.0). Miroir web. 3 tests
  ForecastDosesGuardTest + 3 miroirs web.
- Icônes de forme de prise dans Doses : routeIcon pur (pilule/seringue
  IM+SC teinte tertiary pour la SC/goutte/sparadrap/compte-gouttes ;
  ⚠️ MedicationLiquid absent d'icons-extended 1.7.8 → Colorize).
- Marqueurs « prise de sang le même jour » : labMarkersForDose pur (jour
  calendaire LOCAL, lab à l'heure exacte = APRÈS) → encart 🧪↑/🧪↓ en bout
  de ligne. 7 tests DosesExtrasTest + 10 miroirs web.

274 tests JVM + 14 UI + lint verts ; web 194 tests + E2E verts (check.sh).
Validé émulateur sur APK RELEASE avec données réelles seedées + scénario du
bug (2 inactifs à Posologie 7 j) : Prévision on/off 0 crash, glyphes et
marqueurs en place.
2026-09-29 23:24:34 +02:00
45ba0bdc62 Docs : traçabilité publication v1.11.0 (ids releases Gitea, web sync) 2026-09-29 07:32:07 +02:00
a0f3ac59f9 v1.11.0 : inactifs regroupés en bas de la page Traitements + cartes atténuées
- Demande : « les traitements inactifs sont peu discernables — les placer
  à la fin (tout en bas), 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 — cartes alpha 0.55 en plus du
  badge, lisibles et tappables (l'édition d'un archivé reste possible).
- Miroir web : treatmentsForDisplay dans js/data/models.js + rendu
  sections + opacity 0.55 + i18n inactive_section FR/EN (web v1.11.0 sync).
- 5 tests JVM TreatmentsDisplayTest + 4 tests web (miroir).

264 tests JVM + 14 UI + lint verts ; web 184 tests + E2E verts (check.sh).
Validé émulateur sur APK RELEASE avec données réelles seedées (5 actifs en
haut, en-tête + EV-old/EEn-old tout en bas, 0 crash).
2026-09-29 07:31:25 +02:00
5cd1023af1 Docs : traçabilité publication v1.10.1 (ids releases Gitea, web non concerné) 2026-09-28 21:50:41 +02:00
d531ecae24 v1.10.1 : durcissement rappels — singleTop (onNewIntent vivant), instant planifié dans la garde, BootReceiver goAsync
- launchMode="singleTop" sur MainActivity : un tap notification app-ouverte
  délivre onNewIntent SANS recréer l'activité (l'ancien standard+CLEAR_TOP
  recréait tout et rendait le chemin v1.10.0 inopérant — code mort).
- EXTRA_PLANNED_AT : l'instant planifié voyage dans l'alarme (scheduleFor),
  le snooze le forward, le garde « créneau déjà honoré » évalue le jour du
  CRÉNEAU et non celui du feu (alarme inexacte/Doze glissant après minuit).
- BootReceiver : goAsync + coroutine (dette §20.bis #2), runBlocking supprimés.
- Audit doc : comptes résiduels 223 → 259 (§14 #64, §16.ter) ; §9/§20.bis à jour.

259 tests JVM + 14 UI + lint verts ; validations émulateur sur APK RELEASE
avec données réelles seedées (singleTop 0 recreation, planned_at A/B, 0 crash).
Web non concerné (aucune logique miroir touchée).
2026-09-28 21:49:06 +02:00
0bb259d12d Docs : audit post-release v1.10.0 — §3 (uiautomator), §5 (EstrannaiseModels, demande #69), §8 (259/225), §16 (259/225 + 14 UI), §21 (2 points manuels v1.10.0), README FR/EN (plage releases) 2026-09-27 23:17:09 +02:00
386693379e Docs : traçabilité publication v1.10.0 (ids releases Gitea, web sync) 2026-09-27 23:12:27 +02:00
a4f859f854 v1.10.0 : fix #68 (reco de prise de sang avant stabilisation), fix #69 (dialog fantôme), rappel sauté si dose déjà loggée
- #68 : le « creux » était le minimum de la fenêtre entière — pour un ester à
  montée lente (EEn, pic ~J+5 ≈ intervalle 7 j) il tombait juste après
  l'injection PRÉCÉDENTE : date antérieure à la stabilisation affichée sur la
  même carte, « 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 (principe v1.8.0 restauré ; EV inchangé). Miroir
  web appliqué. Régression n°6 data-driven (backup-v1.9.8.json, balayage de
  now ±60 j) ; les 17 LabTimingTest passent inchangés.
- #69 : tap notification → dialog ; le fermer + changer d'onglet + revenir sur
  l'accueil le rouvrait à chaque fois (demande au niveau activité,
  consommation dans les remember de Home). FIX : MutableState<Long?> dans
  MainActivity consommé UNE fois + extras retirés de l'intent ; bonus : tap
  notification app-ouverte ouvre aussi le dialog. Test UI LogDoseRequestTest.
- Rappel sauté si dose déjà loggée (demande) : garde au déclenchement —
  hasDoseLoggedOnDay (pur, jour calendaire LOCAL) dans ReminderReceiver,
  re-programmation du créneau suivant dans tous les cas. 6 tests JVM +
  vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine → 1).

259 tests JVM + 14 UI + lint verts ; web 180 tests + E2E verts (check.sh).
Validations émulateur §16.ter sur APK RELEASE avec données réelles seedées
(reco : creux 11 oct 20:08 ≥ stab 7 oct 06:03 ; dialog 1× puis plus jamais ;
changelog 1.10.0 affiché ; 0 crash).
2026-09-27 23:11:04 +02:00
2548c6207b Docs §16.ter : piège WAL au seed (extraire db+wal côte à côte après un force-stop) 2026-09-23 07:43:53 +02:00
a3a9c7652c Docs : traçabilité publication v1.9.8 (ids releases Gitea, web non concerné) 2026-09-22 22:41:02 +02:00
09cddfc8ee v1.9.8 : socle qualité — tests UI Compose (13), signature release dédiée, découpage Settings + README anglais
- 13 tests UI instrumentés (uitest/) : navigation 5 onglets, dialog Nouveautés
  (épinglage du fix v1.3.2), sections Paramètres, version installée = BuildConfig
  (anti-bug #46). Déterministes : pm grant shell, markChangelogSeen, assertions
  via targetContext (FR/EN), zéro dépendance aux données Room.
- Signature release conditionnelle : scripts/make-release-keystore.sh (shellcheck+shfmt)
  + keystore.properties gitignoré ; assembleRelease l'utilise s'il existe, sinon
  clé debug inchangée. Migration documentée §16.quater.
- SettingsScreen découpé : 840 lignes -> orchestrateur 377 + 11 cartes dans
  ui/screens/settings/ (déplacement pur, commentaires conservés). ChartScreen
  volontairement entier (garde de source #54).
- README.en.md complet + liens FR/EN croisés.

Vérifié : 249 tests JVM + 13 UI verts, lint vert, smoke RELEASE émulateur
(données réelles seedées : accueil, prochaines doses, suggestion creux,
graphiques ESE+TFS superposés, 0 crash logcat — anti-#64).
2026-09-22 22:39:42 +02:00
d6d95080b4 v1.9.7 : la suggestion de prise de sang ne fuit plus quand l'heure d'injection varie
Remontée : « la suggestion change tout le temps à chaque injection si
l'injection n'est pas faite pile à la même heure » — la règle 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 recommandé fuyant).

Fix : 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 : 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. Le créneau affiché suit
toujours la dernière dose réelle (voulu).

Tests : « interval change » ré-épinglé (NOW+21 j) + 2 nouveaux (flou
d'heure ± 23 h → pas de reset ; écart 25 h → reset). 249 verts + lint.
versionCode 44 / 1.9.7 + CHANGELOG/§2/§7.11/README.
2026-09-21 22:19:46 +02:00
a40d9bc076 v1.9.6 : la carte Accueil liste TOUTES les prochaines doses (triées)
Remontée : le rappel quotidien (CPA) masquait le rappel hebdomadaire
(EEn) — « je ne le vois quasiment pas ».

- NextDoseCard : UNE ligne par traitement, TRIÉES par prochaine prise ;
  1ʳᵉ ligne (la plus proche) en avant, suivantes en style secondaire ;
  titre pluriel « Prochaines doses » 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)
- versionCode 43 / 1.9.6 + CHANGELOG/§2/README/GUIDE
2026-09-21 21:56:32 +02:00
0f0ecc3d23 v1.9.5 : revue de code — fix #67 ticks X/fuseau, contexte moteur, Canvas mémoïsé, docs alignées
Corrigé (audit lecture-seule) :
- #67 : drawXLabels formatait dans le fuseau CHOISI mais xLabelTicks
  alignait sur le fuseau APPAREIL (zone non passée, contredisant #55) ;
  le pas fixe +24 h dérivait d'1 h aux transitions DST — zone passée +
  avancement Calendar, test DST épinglé (Europe/Paris 29/03)
- EstrannaiseCloud.compute : paramètre nowMs mort supprimé (KDoc
  mensonger), appelants + tests alignés

Perf (suite v1.8.2) :
- E2Context/prepareE2Context : le tracé labs (720 pts) reconstruisait
  les maps moteur par point — préparées une fois, résultat bit-identique
- Canvas : Paints texte mémoïsés (clé couleur|taille|align|gras),
  detectExtrema mémoïsé par série, formateurs X/marqueurs mémoïsés

Règles ré-appliquées :
- modelLabelRes (règle anti-#59) ; PKModels.* au lieu de literals ;
  scheduler2 supprimé

Docs : §7.2/§7.3 à jour (asset historique, cutoff 10×t½), PKPresets/
Treatment/LabTimingTest commentaires alignés, §14 #67, §2, README
2026-09-20 12:56:15 +02:00
60 changed files with 4725 additions and 921 deletions

7
.gitignore vendored
View File

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

305
README.en.md Normal file
View File

@ -0,0 +1,305 @@
# HormoneTrack
> **Language**: [Français](README.md) · **English** (this file)
> **🤖 AI-assisted development**: this project was designed and coded with an
> AI assistant. The human contribution was **essential**: continuous feedback,
> user reports (testing on a real phone, bug reports with real data exports),
> improvement suggestions and validation of every release. The session-by-session
> detail is documented in
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md) (French).
Hormone therapy (HRT) tracker on Android, with **hour-by-hour** estimated
curves of estradiol (E2) and testosterone (T), calibration against blood
tests, reminders mirrored on a smartwatch (Huawei Watch GT 3 via Gadgetbridge
or Huawei Health) and JSON backup. **100% local, no account, no server.**
> **⚠️ Medical disclaimer**: the curves are **pharmacokinetic estimates**
> for informational purposes — they are not measurements. Always rely on your
> blood tests and on your endocrinologist's guidance.
- **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅
(250 without the local test data; 7 regressions pinned on real data
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
smartwatch integration = notifications ✅, **private Gitea repo + releases
with APK** ✅
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
- **User guide**: [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) (French)
- **Development docs** (architecture, math, decisions, bugs): [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) (French)
- **Watch / Gadgetbridge**: [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) (French)
## Features
- **Hour-by-hour estimated curves**: E2 (pg/mL) and T (ng/mL), 24 h / 7 d / 30 d views,
**dose markers** (forecast and actual), **configurable chart timezone** (v1.4.5),
**pan** (drag to scroll back in time), **zoom** (pinch or − / + buttons,
6 h → 300 d, adaptive sampling), displayable **peaks & troughs** with their
**estimated values** (▲▼ triangles at local extrema, toggleable)
- **Two PK models, superimposable**: **Estrannaise (EstraNase)** (hourly tables
from the `.ods` spreadsheet) and **Transfem Science** — since v1.4.0 the TFS
model is the **official 3-compartment meta-analysis** (exact closed form,
parameters from the [TFS simulator](https://transfemscience.org/misc/injectable-e2-simulator/),
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
covering **7 esters** (EV, EU, EEn, EB, EC oil, EC suspension, PEP) — WHSAH
covers 6 (no PEP). Displayed side by side with independent toggles (3 colors) —
**pre-checked according to your treatments' models** (v1.4.7) and **each model
calibrated separately** by your labs (v1.4.8 — scale factors adapt to each profile)
- **"Lab track" curve (v1.5.0, extended in v1.6.0)**: the curve anchored on your
blood tests (`Lab track` chip, off by default) — the PK model's shape between
labs, with the amplitude rescaled to pass EXACTLY through every E2 lab value;
log-linear transition from one lab to the next, keeps out-of-action-window labs
(#61); **`Extend` chip (v1.6.0)**: the curve extends beyond the last lab
(model × last lab ratio, horizon = model extinction, estimated part drawn
faded + dedicated legend) — the curve is a COMPARISON reference, never an
estimate of action (it never feeds the home screen or the alerts)
- **Analytical Estrannaise model (v1.9.0)**: the ESE model now uses the 3C
closed form published by estrannaise.js (fidelity to the old tables pinned at
RMS 0) — **MCMC uncertainty cloud** (`Cloud` chip, ESE-exclusive: 32 posterior
curves showing the imprecision range, like the estrannaise website); **6
injectable esters** (incl. EUCS, exclusive); the historical ODS is no longer
used at runtime (fidelity = tests only); analytical terminal t½
(blood draw recommendation)
- **Configurable Bateman model** (time to peak, half-life, bioavailability) for
gel, patch and oral routes
- **Forecast simulation**: set a **Dosage interval** (days) on a treatment →
upcoming doses are projected on the chart (never saved); **enabling forecast
does not move the chart start** but EXTENDS the window to the next dose
(v1.4.4), and the projection is browsed by dragging left, up to **1 year**
- **Inactive treatments**: an archived treatment disappears from input and
reminders, but **its whole history stays simulated and calibrated** — useful
for a valerate → enanthate transition; **grouped at the very bottom** of the
Treatments page under a dedicated header, dimmed cards (v1.11.0);
**no future projection anymore** (v1.12.0 — its slots disappear from the
Forecast chart)
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
**interval in days between doses** displayed; **route icon per dose row**
(oral / IM-SC injection / gel / patch, v1.12.0) and **same-day blood test
markers** (🧪↑ before the dose, 🧪↓ after)
- **Blood tests**: **E2 + T in a single entry** (each optional), displayed
**side by side** when they share the same date/time, editable (tap → pick the
entry); T units: ng/mL, ng/dL, ng/L, nmol/L
- **Suggested next blood draw (v1.8.0, extended v1.8.1 to the Labs and Home pages)**:
the Labs page recommends the **estimated trough just before the next injection**
(the most comparable moment), **at the first trough where your regimen is
stabilized** (~5 half-lives after your last change — dose, ester or interval) —
derived from the curves and the Dosage interval; requires an active injectable
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
medical advice;
**v1.13.0**: the card shows the **expected E2 at that trough** (when
auto-calibration is ON) and compares it to your personal **trough target**
(opt-in — distinct from the alert thresholds, never notifies)
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
automatic calibration** (option, off by default) which calibrates **each ester
with the labs of its own period** (valerate labs → valerate doses, enanthate
labs → enanthate doses) and recalibrates the T model
- **Empirical T estimation** `T = floor + (base − floor) ÷ (1 + k·E2)`, with
**k calibrated per ester period** (T suppression differs valerate vs enanthate),
against the already-calibrated E2 — accepted T units: ng/mL, ng/dL, ng/L, nmol/L
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
injected every 7 days only rings on injection day, not every day; **a dose
already logged on the day skips the notification** (v1.10.0 — the next
reminder resumes at the following slot)
- **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low
limits in Settings → warning card on the home screen + **notification every
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
evaluated on the **estimated** level, opt-in
- **Full JSON backup**: treatments + doses + labs + T settings
**+ parameters (language, auto-calibration, alert thresholds)** (v1.4.2)
- **Daily automatic backup (v1.7.0, opt-in)**: IN ADDITION to the manual export —
every day, a full JSON backup (same format, directly importable) is written to
**the folder you choose** (e.g. a synchronized Owncloud folder). No storage
permission (SAF, folder picked once, persistent revocable permission),
configurable retention (1–30 copies, default 7 — timestamped file per run,
never overwritten; manual exports and foreign files **never touched**), last
run status in Settings, first backup immediately on activation
- **What's new on every update**: automatic changelog dialog (dismissed =
won't show again before the next version)
- **Time on HRT** displayed at the top of the Doses page (since the first dose);
**next dose in days** on the home screen when it is more than 24 h away (v1.4.1)
- **Exportable diagnostic logs** (Settings) — useful for support
- **Calendar events**: dose reminders can create a recurring (posology) event in
a "HormoneTrack" calendar on your phone
- **Full JSON backup/restore** (treatments + doses + labs + T settings + parameters, v1.4.2)
- **FR + EN** (per-app language, independent of the system)
- Jetpack Compose UI (BOM 2026.08, Material You); 100% local, no account
## Quick start (building from source)
Prerequisites: JDK 17+ (Java 21 OK), Android SDK (platform 37 will be
auto-downloaded by AGP if the licenses are signed). The wrapper downloads
Gradle 9.7.1.
```bash
git clone <repo> && cd HormoneTrack
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 284 tests (250 without local test data)
./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required)
./gradlew lint # clean lint required before a release
```
Installing on a phone: developer mode + USB debugging, then Android Studio
(**Run ▶️**) or `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
No Play Store: the app is sideloaded. Step-by-step details (French):
[docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
## Git
Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) —
`Siphonight/HormoneTrack` on both (private), with **tagged releases**
(`v1.1.0` → `v1.10.0`) and **two APKs per release** (since v1.2.5)
(downloadable without building, cf
[docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)):
`-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB):
```bash
git tag # list releases
git log --oneline # layer-by-layer history (toolchain / engine / UI / docs)
git push -u origin main --tags
```
**Web port**: since v1.4.10, the browser version lives in its own repository
`Siphonight/HormoneTrack-web` (same instances) — **aligned versions**
(web v1.4.10 = port of Android v1.4.10), synced releases (checklist §16 step
4.bis), separate changelogs.
Every release commit passes `./gradlew testDebugUnitTest` (green required) and
is an annotated tag. See [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
**Release signing**: the release APK is signed with the debug key as long as
no `keystore.properties` exists at the repo root; once created via
`scripts/make-release-keystore.sh` (both files gitignored), `assembleRelease`
signs with that keystore — phone migration documented in
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
## The models in short
Each injection contributes `dose_mg × profile(dt)` where `profile` is the
normalized response (pg/mL per mg); contributions superimpose. **Three
superimposable models**:
- **Estrannaise** = hourly tables from the `.ods` (8001 h), EV/EU/EEn
- **Transfem Science** = 3-compartment meta-analysis (V3C, closed form,
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
- **WHSAH** (v1.4.6) = "license-free" fit of the [WHSAH Collective via
Mona](https://github.com/mona-hrt/mona) — same mathematical family but
independent parameters with explicit bioavailability F < 1: faster rise
at D+1 (EEn ~70 pg/mL at D+1 for 5 mg vs ~22 for TFS) and longer decay
(EEn t½ 7.3 d vs 4.5 d). 6 esters (no PEP)
Reference peaks (pg/mL per mg):
| Profile | Model | Peak (pg/mL/mg) | Tmax | Terminal t½ |
|----------|------------------|-----------------|--------|-------------|
| EV | Estrannaise | 61.1 | ~45 h | — |
| EU | Estrannaise | 3.4 | ~55 h (long plateau) | — |
| EEn | Estrannaise | 31.4 | ~152 h | — |
| EV | Transfem Science | 59.0 | ~51 h | 3.0 d |
| EU | Transfem Science | 10.1 | ~198 h | — |
| EEn | Transfem Science | 32.0 | ~156 h | 4.5 d |
| EB | Transfem Science | 194.2 | ~16 h | 1.2 d |
| EC (oil) | Transfem Science | 31.1 | ~103 h | 6.7 d |
| EC (susp.) | Transfem Science | 48.2 | ~29 h | 5.1 d |
| PEP | Transfem Science | 1.03 (~6.5× dose) | ~18 d | 28.4 d |
| EV | WHSAH | 73.5 | ~41 h | 3.1 d |
| EEn | WHSAH | 37.6 | ~120 h | 7.3 d |
| EB | WHSAH | 260.1 | ~12 h | 1.3 d |
| EC (oil) | WHSAH | 25.0 | ~81 h | 7.9 d |
| EC (susp.) | WHSAH | 53.5 | ~16 h | 7.1 d |
| EU | WHSAH | 4.9 | ~67 h | 31.7 d |
**Calibration (v1.4.8)**: the scale factor applies per ESTER and per
injection PERIOD (median of lab ÷ prediction ratios, like the original
spreadsheet's "Scale factor" column) — and per MODEL: every displayed curve
(Estrannaise / Transfem Science / WHSAH) is calibrated with ITS OWN model's
prediction → they all stick to your labs, whatever their shape.
Auto-calibration is optional (off by default).
## Privacy
- **The app is 100% local**: Room database on the phone, no server, no
telemetry; Gadgetbridge-compatible (FOSS) with no Huawei Health dependency
- **The repository contains no health data**: code and docs are generic; the
regression tests that use real exports load their data from
`local-test-data/` (**gitignored**, outside the repo — and the history was
cleaned before the first push, cf
[docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
- Backups = JSON files you store wherever you want (Owncloud, etc.)
- **The daily auto-backup (v1.7.0) only writes to the folder YOU picked
yourself** (system SAF picker, revocable permission) — nothing ever leaves
the phone, no server
- `allowBackup=false` (sensitive data); biometric lock planned for Phase 2
- The repository is **private**: release APKs download while logged in;
making the repo public would make the APKs downloadable without an account
(no data risk, see above)
## Repository structure
```
HormoneTrack/
├── README.md ← this file's French twin
├── docs/
│ ├── GUIDE_INSTALLATION.md user guide (phone + watch, French)
│ ├── DEVELOPPEMENT.md full dev docs (architecture, math, bugs, tests, French)
│ ├── CHANGELOG.md detailed version history (French)
│ └── MONTRE-GADGETBRIDGE.md Huawei GT 3 watch: options + limits (French)
├── scripts/
│ ├── gitea-release.py publishes a release body (CHANGELOG + APK)
│ ├── publish-release.py publishes a tag's 2 APKs (verified by download)
│ ├── seed-emulator.py injects a JSON backup into an emulator DB
│ └── make-release-keystore.sh generates the release keystore + properties (v1.9.8)
├── local-test-data/ ← gitignored: real backups for regression
│ tests (NEVER in the repo, cf §8.bis)
├── keystore.properties ← gitignored: release signing (optional, §16.quater)
├── build.gradle.kts root Gradle config (AGP/Kotlin/KSP pinned)
├── settings.gradle.kts
├── gradle.properties
├── gradle/wrapper/ Gradle 9.7.1 wrapper (jar + properties)
├── gradlew / gradlew.bat
└── app/
├── build.gradle.kts dependencies (Compose, Room, DataStore, Gson…)
├── proguard-rules.pro
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── assets/pk_profiles.json ← hourly tables (Estrannaise/TFS)
│ ├── java/com/hormonetrack/
│ │ ├── data/ (Room: models, DAOs, repository, backup)
│ │ ├── pk/ (PK engine + profiles: Estrannaise/TFS/WHSAH, alerts, export)
│ │ ├── reminder/ (exact alarms, notifs + actions, boot, alert worker)
│ │ ├── settings/ (DataStore: TConfig, language)
│ │ ├── ui/ (Compose: screens, components, theme)
│ │ │ └── screens/settings/ ← Settings cards (v1.9.8 split)
│ │ ├── HormoneTrackApp.kt
│ │ └── MainActivity.kt
│ └── res/ (FR/EN strings, theme, icons)
├── test/java/com/hormonetrack/ ← JVM unit tests
│ ├── pk/ (engine, ESE/TFS/WHSAH profiles, per-model
│ │ calibration, reminders, data-driven regressions)
│ ├── data/backup/ (Gson round-trip + parameters)
│ ├── ui/ (chart window, source guard)
│ ├── util/ (export file names, durations)
│ └── reminder/ + settings/ (calendar RRULE, changelog)
└── androidTest/java/com/hormonetrack/ ← Compose UI tests (v1.9.8)
└── uitest/ (navigation, changelog dialog, settings)
```
## Roadmap
- [x] v1: E2/T curves, dose log, labs + calibration, reminders, JSON backup, FR/EN
- [x] Compose UI tests (13) + dedicated release signing infrastructure (v1.9.8)
- [ ] Biometric lock, widget, CSV export
- [ ] Watch Phase 2: custom watchface and/or Lite Wearable mini-app (see
[docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
## License
**GPL-3.0** — see [LICENSE](LICENSE). Consistent with the Gadgetbridge
ecosystem. The PK models belong to their respective authors
([Estrannaise](https://estrannaise.github.io/),
[Transfem Science](https://transfemscience.org)).

View File

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

View File

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

View File

@ -0,0 +1,108 @@
package com.hormonetrack.uitest
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hormonetrack.BuildConfig
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Test UI du dialog « Nouveautés » (v1.9.8) — épinglé sur les comportements
* historiques des fixes v1.3.2/v1.3.3 (docs/DEVELOPPEMENT.md §14) :
* - affiché quand la version installée est plus récente que la dernière vue ;
* - PAS affiché quand la version courante a déjà été vue ;
* - une fois FERMÉ, il ne RÉAPPARAÎT PAS au redémarrage (la version vue est
* mémorisée AVANT l'affichage — sinon le même changelog ressortait à
* chaque réouverture de l'app, bug v1.3.2).
*
* ⚠️ Technique : ces tests lancent l'activité MANUELLEMENT (ActivityScenario
* + règle Compose vide) et non via createAndroidComposeRule, car l'état
* DataStore (changelogSeenVersion) doit être réglé AVANT que
* HormoneTrackRoot ne lise la version vue dans son LaunchedEffect de
* démarrage. La règle automatique lancerait l'activité trop tôt (avant le
* @Before du test) et le dialog du test précédent fausserait l'état.
*/
@RunWith(AndroidJUnit4::class)
class ChangelogDialogTest {
@get:Rule
val composeRule = createEmptyComposeRule()
private var scenario: ActivityScenario<MainActivity>? = null
private fun res(id: Int, vararg args: Any): String =
UiTestEnv.targetContext.getString(id, *args)
@After
fun tearDown() {
scenario?.close()
}
@Test
fun dialogShownWhenInstalledVersionIsNewerThanLastSeen() {
runBlocking {
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
}
scenario = ActivityScenario.launch(MainActivity::class.java)
composeRule.waitForIdle()
composeRule.onNodeWithText(
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
).assertIsDisplayed()
}
@Test
fun dialogAbsentWhenVersionAlreadySeen() {
runBlocking {
UiTestEnv.app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
}
scenario = ActivityScenario.launch(MainActivity::class.java)
composeRule.waitForIdle()
composeRule.onNodeWithText(
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
).assertDoesNotExist()
}
@Test
fun dialogDoesNotReappearAfterDismissAndRestart() {
runBlocking {
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
}
scenario = ActivityScenario.launch(MainActivity::class.java)
composeRule.waitForIdle()
composeRule.onNodeWithText(
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
).assertIsDisplayed()
// Fermeture par le bouton « OK » (identique FR/EN)
composeRule.onNodeWithText(res(R.string.ok)).performClick()
composeRule.onNodeWithText(
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
).assertDoesNotExist()
// Redémarrage de l'activité : le dialog ne doit PAS revenir — la
// version vue a été persistée AVANT l'affichage (fix v1.3.2).
scenario!!.recreate()
composeRule.waitForIdle()
composeRule.onNodeWithText(
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
).assertDoesNotExist()
}
companion object {
@BeforeClass
@JvmStatic
fun setUpApp() {
// Permission de notifications uniquement : le changelog est réglé
// PAR TEST (avant chaque lancement manuel d'activité).
UiTestEnv.grantPostNotifications()
}
}
}

View File

@ -0,0 +1,84 @@
package com.hormonetrack.uitest
import android.content.Intent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import org.junit.After
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Test UI de la demande « logger une dose » venue d'une NOTIFICATION
* (v1.10.0, **fix #69**) : le dialog s'ouvre UNE FOIS au lancement avec les
* extras `open_log_dose`/`treatment_id` (même contrat que ReminderReceiver,
* cf reminder/ReminderManager.kt), puis — une fois fermé — ne se rouvre
* JAMAIS tout seul. Comportement bogué épinglé : chaque retour sur l'onglet
* Accueil rouvrait le dialog indéfiniment (le paramètre d'intent vivait au
* niveau activité alors que la consommation vivait dans les `remember` de
* Home, détruits à chaque changement d'onglet).
*
* Lancement MANUEL avec intent (règle Compose vide + ActivityScenario) : le
* contrat du fix exige de régler l'état AVANT que la composition ne lise
* l'intent — et l'overload `createAndroidComposeRule(intent)` n'existe pas
* dans la ligne Compose 1.12. La fermeture passe par BACK (onDismissRequest,
* indépendant des libellés) ; le titre `add_dose` (« Log a dose ») est
* affiché dans les DEUX variantes du dialog (avec ou sans traitements en
* base) : le test reste data-indépendant.
*/
@RunWith(AndroidJUnit4::class)
class LogDoseRequestTest {
@get:Rule
val composeRule = createEmptyComposeRule()
private var scenario: ActivityScenario<MainActivity>? = null
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
@After
fun tearDown() {
scenario?.close()
}
@Test
fun doseDialogOpensOnceAndNeverReopensAfterTabSwitch() {
val intent = Intent(UiTestEnv.targetContext, MainActivity::class.java).apply {
putExtra("open_log_dose", true)
putExtra("treatment_id", 1L)
}
scenario = ActivityScenario.launch(intent)
val dialogTitle = res(R.string.add_dose)
composeRule.waitForIdle()
composeRule.onNodeWithText(dialogTitle).assertIsDisplayed()
// Fermeture du dialog (BACK → onDismissRequest)
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
composeRule.waitForIdle()
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
// Changer d'onglet puis revenir sur l'accueil : le dialog NE doit
// PAS se rouvrir (demande consommée une fois pour toutes)
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
}
companion object {
@BeforeClass
@JvmStatic
fun setUpApp() = UiTestEnv.prepare()
}
}

View File

@ -0,0 +1,104 @@
package com.hormonetrack.uitest
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Test UI de NAVIGATION (v1.9.8) : la barre d'onglets mène bien à chaque
* écran, et les Paramètres s'ouvrent depuis l'icône de l'accueil.
*
* Les preuves de navigation utilisent des éléments **inconditionnels**
* (indépendants des données Room, contrairement au contenu des listes) :
* - Graphiques → le chip de plage « 24 h » (toujours affiché) ;
* - Doses / Analyses / Traitements → leur FloatingActionButton « + »
* (contentDescription add_dose / add_lab / add_treatment) ;
* - Paramètres → le titre de l'écran + les sections toujours présentes.
*
* Textes résolus via targetContext (cf UiTestEnv) : indépendant de la langue
* système et du réglage in-app FR/EN.
*/
@RunWith(AndroidJUnit4::class)
class NavigationTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
@Test
fun bottomBarShowsAllFiveTabs() {
listOf(
R.string.nav_home, R.string.nav_chart, R.string.nav_doses,
R.string.nav_labs, R.string.nav_treatments
).forEach { labelRes ->
composeRule.onNodeWithText(res(labelRes)).assertIsDisplayed()
}
}
@Test
fun homeTabShowsAppTitle() {
// Retour explicite à l'accueil d'abord : l'état sauvegardé de la
// navigation peut restaurer l'onglet du test précédent.
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithText(res(R.string.app_name)).assertIsDisplayed()
}
@Test
fun chartTabShowsRangeChips() {
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithText(res(R.string.nav_chart)).performClick()
composeRule.onNodeWithText(res(R.string.chart_24h)).assertIsDisplayed()
composeRule.onNodeWithText(res(R.string.chart_7j)).assertIsDisplayed()
composeRule.onNodeWithText(res(R.string.chart_30j)).assertIsDisplayed()
}
@Test
fun dosesTabShowsLogDoseFab() {
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
composeRule.onNodeWithContentDescription(res(R.string.add_dose))
.assertIsDisplayed()
}
@Test
fun labsTabShowsAddLabFab() {
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithText(res(R.string.nav_labs)).performClick()
composeRule.onNodeWithContentDescription(res(R.string.add_lab))
.assertIsDisplayed()
}
@Test
fun treatmentsTabShowsAddTreatmentFab() {
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithText(res(R.string.nav_treatments)).performClick()
composeRule.onNodeWithContentDescription(res(R.string.add_treatment))
.assertIsDisplayed()
}
@Test
fun settingsOpensFromHomeGearIcon() {
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
// Titre de l'écran + bouton retour (TopAppBar des Paramètres)
composeRule.onNodeWithText(res(R.string.settings)).assertIsDisplayed()
composeRule.onNodeWithContentDescription(res(R.string.back))
.assertIsDisplayed()
}
companion object {
@BeforeClass
@JvmStatic
fun setUpApp() = UiTestEnv.prepare()
}
}

View File

@ -0,0 +1,84 @@
package com.hormonetrack.uitest
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Test UI de l'écran Paramètres (v1.9.8) : chaque SECTION est présente après
* l'ouverture depuis l'accueil. Les sections profondes (sauvegarde, logs,
* à propos) vivent SOUS le pli de l'écran scrollable → `assertExists`
* (présence dans la hiérarchie) et non `assertIsDisplayed` (visibilité à
* l'écran) : ce test vérifie la STRUCTURE, pas la mise en page.
*
* Les chips de langue « Français » / « English » sont des LITTÉRAUX dans le
* code (chaque langue s'écrit dans sa propre langue — convention UX i18n) :
* l'assertion ne dépend d'aucune ressource.
*/
@RunWith(AndroidJUnit4::class)
class SettingsScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
private fun openSettings() {
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
composeRule.waitForIdle()
}
@Test
fun languageSectionShowsLocalizedChips() {
openSettings()
composeRule.onNodeWithText(res(R.string.language)).assertIsDisplayed()
composeRule.onNodeWithText("Français").assertIsDisplayed()
composeRule.onNodeWithText("English").assertIsDisplayed()
}
@Test
fun allSettingsSectionsArePresent() {
openSettings()
listOf(
R.string.auto_calibrate, // Calibration automatique
R.string.t_model_title, // Estimation T
R.string.alert_section, // Seuils d'alerte
R.string.timezone_section, // Fuseau du graphique
R.string.reminders_section, // Rappels / alarmes exactes
R.string.backup_section, // Sauvegarde JSON manuelle
R.string.autobackup_section, // Auto-backup quotidien
R.string.logs_section, // Logs de diagnostic
R.string.about_title // À propos / version
).forEach { sectionRes ->
composeRule.onNodeWithText(res(sectionRes)).assertExists()
}
}
@Test
fun aboutSectionShowsInstalledVersion() {
openSettings()
// « Version : X.Y.Z » — la valeur vient de BuildConfig.VERSION_NAME
// (épinglé : les tags v1.3.0→v1.3.2 embarquaient un versionName
// jamais bumpé, bug §14 #46 — ce test l'attraperait désormais).
val expected = UiTestEnv.targetContext.getString(
R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME
)
composeRule.onNodeWithText(expected).assertExists()
}
companion object {
@BeforeClass
@JvmStatic
fun setUpApp() = UiTestEnv.prepare()
}
}

View File

@ -0,0 +1,65 @@
package com.hormonetrack.uitest
import android.os.Build
import androidx.test.platform.app.InstrumentationRegistry
import com.hormonetrack.BuildConfig
import com.hormonetrack.HormoneTrackApp
import kotlinx.coroutines.runBlocking
/**
* Environnement partagé des tests UI Compose (v1.9.8, cf docs/DEVELOPPEMENT.md
* §8.ter). Les tests instrumentés tournent CONTRE l'app réelle installée :
* ils doivent donc être **déterministes quel que soit l'état de l'appareil**
* (données Room seedées ou non, locale système, version précédemment vue).
*
* Deux prérequis réglés ici AVANT le lancement de l'activité :
* - **permission POST_NOTIFICATIONS** : MainActivity la demande au premier
* lancement (API 33+). Sans pré-grant, le dialog SYSTÈME s'afficherait par
* dessus l'app et les assertions Compose ne voient que la fenêtre de
* l'app → timeout. Le grant shell (`pm grant`) est persistant.
* - **dialog « Nouveautés » neutralisé** : HormoneTrackRoot affiche le
* changelog au lancement quand la version installée est plus récente que
* la dernière vue — un dialog MODAL qui masquerait toute la navigation.
* En marquant la version courante comme vue, il ne s'affiche plus. Les
* tests dédiés au dialog (ChangelogDialogTest) relancent l'activité
* manuellement APRÈS avoir réglé leur propre état.
*
* Les assertions de texte passent par `targetContext.getString(...)` : la
* ressource est résolue dans la MÊME configuration que celle de l'UI (les
* per-app locales d'AppCompat s'appliquent à toute l'application), ce qui
* rend les tests indépendants de la langue système OU du réglage in-app.
*/
object UiTestEnv {
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
/** L'Application réelle du process instrumenté (même instance que l'UI). */
val app: HormoneTrackApp =
targetContext.applicationContext as HormoneTrackApp
/** Exécute une commande shell et ATTEND sa fin (lecture jusqu'à EOF). */
fun runShellCommand(command: String) {
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
automation.executeShellCommand(command).use { pfd ->
java.io.FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
}
}
/** Pré-grante la permission de notifications (idempotent, API 33+). */
fun grantPostNotifications() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
runShellCommand("pm grant com.hormonetrack android.permission.POST_NOTIFICATIONS")
}
}
/** Marque le changelog de la version courante comme déjà vu. */
fun markChangelogSeen() = runBlocking {
app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
}
/** À appeler dans le @BeforeClass de chaque classe de test UI. */
fun prepare() {
grantPostNotifications()
markChangelogSeen()
}
}

View File

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

View File

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

View File

@ -36,7 +36,12 @@ data class UserSettings(
val alertE2High: Double? = null, val alertE2High: Double? = null,
val alertE2Low: Double? = null, val alertE2Low: Double? = null,
val alertTHigh: Double? = null, val alertTHigh: Double? = null,
val alertTLow: Double? = null val alertTLow: Double? = null,
// v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la
// rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson
// les laisse à null → l'import ne touche pas à la cible locale.
val troughTargetLow: Double? = null,
val troughTargetHigh: Double? = null
) )
data class BackupData( data class BackupData(

View File

@ -18,7 +18,8 @@ data class PKPreset(
object PKPresets { object PKPresets {
val all: List<PKPreset> = listOf( val all: List<PKPreset> = listOf(
// --- Injections: Estrannaise models (profiles from the ODS) --- // --- Injections : modèle Estrannaise ANALYTIQUE (v1.9.0 — la
// source est estrannaise.js, plus de tables ODS au runtime) ---
PKPreset( PKPreset(
nameRes = R.string.preset_ev_ese, nameRes = R.string.preset_ev_ese,
type = TreatmentType.ESTRADIOL, type = TreatmentType.ESTRADIOL,

View File

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

View File

@ -58,10 +58,6 @@ object EstrannaiseCloud {
* @param doseLogs toutes les doses réelles * @param doseLogs toutes les doses réelles
* @param startMs/endMs fenêtre demandée (celle du graphique) * @param startMs/endMs fenêtre demandée (celle du graphique)
* @param stepMs pas de la grille (stepForRange) * @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]) * @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est * @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
* calculable : aucune dose E2 à profil injectable dont l'ester * calculable : aucune dose E2 à profil injectable dont l'ester
@ -74,7 +70,6 @@ object EstrannaiseCloud {
startMs: Long, startMs: Long,
endMs: Long, endMs: Long,
stepMs: Long, stepMs: Long,
nowMs: Long,
nbCurves: Int = DEFAULT_NB_CURVES, nbCurves: Int = DEFAULT_NB_CURVES,
/** /**
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne * Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne

View File

@ -20,8 +20,10 @@ import kotlin.math.ceil
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après * classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent * un changement d'ester/dose/rythme, les prises intermédiaires reflètent
* une transition, pas le régime ; * une transition, pas le régime ;
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le * 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
* premier créneau d'injection ≥ date de stabilisation du régime courant. * #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même —
* l'instant où la prise de sang serait faite — pas sur le créneau
* d'injection qui le suit).
* *
* CALCUL (tout est déduit de ce que l'app connaît déjà) : * CALCUL (tout est déduit de ce que l'app connaît déjà) :
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie * - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
@ -36,10 +38,13 @@ import kotlin.math.ceil
* assumé et documenté : l'app ne trace pas l'historique des éditions de * assumé et documenté : l'app ne trace pas l'historique des éditions de
* Posologie/dose, la première dose du traitement est le meilleur * 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 ») ; * estimateur du début du régime (même sémantique que le « temps sous THS ») ;
* - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles * - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux * E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
* injections. La FORME suffit : calcul SANS calibration (le facteur * v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
* d'échelle est multiplicatif, il ne déplace pas le minimum) ; * l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont
* le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul
* SANS calibration (le facteur d'échelle est multiplicatif, il ne
* déplace pas le minimum) ;
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de * - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
* recommandation quasi-immédiate), creux > dernière prise de sang * recommandation quasi-immédiate), creux > dernière prise de sang
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥ * existante (ne jamais recommander un creux déjà mesuré), créneau ≥
@ -72,6 +77,37 @@ object LabTiming {
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */ /** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
const val MIN_HORIZON_HOURS = 6L 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. */ /** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
data class LabRecommendation( data class LabRecommendation(
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */ /** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
@ -92,7 +128,15 @@ object LabTiming {
* arrive entre maintenant et le creux recommandé (texte « c'est le * arrive entre maintenant et le creux recommandé (texte « c'est le
* premier creux fiable »). * premier creux fiable »).
*/ */
val wasAlreadyStabilized: Boolean 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
) )
/** /**
@ -107,7 +151,14 @@ object LabTiming {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
labs: List<LabResult>, labs: List<LabResult>,
nowMs: Long = System.currentTimeMillis() nowMs: Long = System.currentTimeMillis(),
/**
* Facteurs d'échelle PAR ESTER (v1.13.0) — issus de l'auto-calibration
* quand elle est activée (sinon `null`) : permet de retourner la
* valeur E2 ATTENDUE au creux ([LabRecommendation.predictedE2]).
* Absent → `predictedE2 = null` (carte sans valeur).
*/
scalePerEster: Map<String, Double>? = null
): LabRecommendation? { ): LabRecommendation? {
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ───── // ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
val carrier = treatments.firstOrNull { val carrier = treatments.firstOrNull {
@ -133,13 +184,14 @@ object LabTiming {
// ── 3) Début du régime courant + date de stabilisation ────────────── // ── 3) Début du régime courant + date de stabilisation ──────────────
val myDoses = doseLogs.filter { it.treatmentId == carrier.id } val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
if (myDoses.isEmpty()) return null 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 lastDose = myDoses.maxByOrNull { it.timestamp }!!
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose) 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 ── // ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
val intervalDays = carrier.forecastIntervalDays!! val intervalDays = carrier.forecastIntervalDays!!
@ -165,25 +217,47 @@ object LabTiming {
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
var windowStart = nowMs var windowStart = nowMs
for (slot in slots) { for (slot in slots) {
// Le creux du créneau = minimum de la courbe entre l'injection // ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
// précédente (ou maintenant) et ce créneau // créneau (dernier point de courbe avant l'injection — principe 1,
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp } // « le moment le plus comparable »). L'ancien MINIMUM DE LA
val trough = window.minByOrNull { it.e2 } // FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters
// à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum
// local est le creux d'absorption, quelques heures après
// l'injection PRÉCÉDENTE) : la date proposée précédait la
// stabilisation affichée sur la MÊME carte, et le texte
// « juste avant ton injection du … » était faux de plusieurs
// jours (remontée v1.9.8, épinglé par RegressionUserCase6Test).
// Pour les esters à t½ courte (EV), le point pré-injection EST
// le minimum de fenêtre : comportement inchangé.
val trough = curve.lastOrNull {
it.timestamp > windowStart && it.timestamp < slot.timestamp
}
if (trough != null && if (trough != null &&
trough.timestamp >= minTroughMs && trough.timestamp >= minTroughMs &&
trough.timestamp > lastLabMs && trough.timestamp > lastLabMs &&
// ON SAUTE au premier creux stabilisé (décision v1.8.0) : // ON SAUTE au premier creux stabilisé (décision v1.8.0) :
// le creux d'un créneau est stabilisé si le CRÉNEAU l'est // le creux pré-injection ≈ instant du créneau, le garde
slot.timestamp >= stabilizedAtMs // exprime la stabilisation sur l'instant réellement proposé
// pour la prise de sang (trough < slot de ~1 pas de courbe).
trough.timestamp >= stabilizedAtMs
) { ) {
return LabRecommendation( return LabRecommendation(
troughMs = trough.timestamp, troughMs = trough.timestamp,
injectionMs = slot.timestamp, injectionMs = slot.timestamp,
ester = activeEster, ester = activeEster,
terminalHalfLifeDays = tHalfDays, terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStartMs, regimeStartMs = regimeStart,
stabilizedAtMs = stabilizedAtMs, stabilizedAtMs = stabilizedAtMs,
wasAlreadyStabilized = stabilizedAtMs <= nowMs wasAlreadyStabilized = stabilizedAtMs <= nowMs,
// v1.13.0 : E2 attendue au creux = valeur brute du
// modèle × facteur du ester actif (lookup insensible à
// la casse — les clés de scalePerEster suivent les
// esters stockés). null si pas de calibration.
predictedE2 = scalePerEster
?.entries
?.firstOrNull { it.key.equals(activeEster, ignoreCase = true) }
?.value
?.let { scale -> trough.e2 * scale }
) )
} }
windowStart = slot.timestamp windowStart = slot.timestamp
@ -194,6 +268,61 @@ object LabTiming {
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */ /** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
private const val DOSE_EPS = 1e-6 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**). * DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
* *
@ -202,25 +331,35 @@ object LabTiming {
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait * depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
* PAS les changements récents de dose ni d'intervalle. * PAS les changements récents de dose ni d'intervalle.
* *
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de * **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques * sang change tout le temps à chaque injection si l'injection n'est pas
* à celles de la dose la plus récente, ET (b) **l'écart entre doses * faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart
* consécutives est constant** (= l'écart des deux doses les plus * identique au ms près) cassait le régime pour un log à 12:30 au lieu de
* récentes). On remonte depuis la dose la plus récente tant que ces * 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
* conditions tiennent ; le régime commence à la dose la plus ancienne * creux recommandé fuyait vers l'avant sans jamais se fixer.
* de la séquence. *
* **RÈGLE** : le régime courant = la **séquence terminale de doses**
* (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
* re-parenting d'historique ne doit plus redémarrer la stabilisation)
* où (a) l'**ester effectif** et la **dose (mg)** sont identiques à
* celles de la dose la plus récente, ET (b) chaque **écart entre doses
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
* On remonte depuis la dose la plus récente tant que ces conditions
* tiennent ; le régime commence à la dose la plus ancienne de la
* séquence.
* *
* Pourquoi chaque composante : * Pourquoi chaque composante :
* - **ester effectif** (override par dose compris) : un switch EV → EEn * - **ester effectif** (override par dose compris) : un switch EV → EEn
* réinitialise la stabilisation ; * réinitialise la stabilisation ;
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ; * - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie * - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est
* modifiée/irrégulière) change le creux — conservateur : une série * comparable que sur un intervalle régulier (pharmacocinétique), mais
* d'intervalles chaotiques maintient la carte « non stabilisée », ce * l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST
* qui est pharmacocinétiquement vrai (le trough n'est comparable que * le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au * fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un * THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j
* vrai changement de créneau. * puis 7,2 j ne se déstabilisent plus en cascade.
* *
* @param carrier traitement porteur (injectable E2 actif à Posologie) * @param carrier traitement porteur (injectable E2 actif à Posologie)
* @param myDoses doses du porteur (ordre indifférent) * @param myDoses doses du porteur (ordre indifférent)
@ -231,18 +370,23 @@ object LabTiming {
var idx = sorted.size - 1 var idx = sorted.size - 1
var regimeStart = sorted[idx].timestamp var regimeStart = sorted[idx].timestamp
var current = sorted[idx] var current = sorted[idx]
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes // Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 —
// (inconnu si une seule dose — la séquence s'arrête là de toute façon) // remplace la comparaison exacte v1.8.1 qui cassait le régime à
var gapExpected = -1L // chaque variation d'heure de log). La Posologie est un prérequis de
// la carte : absente → on s'arrête à la dernière dose (dégradé).
val intervalDays = carrier.forecastIntervalDays ?: return regimeStart
val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong()
val gapMin = intervalMs - GAP_TOLERANCE_MS
val gapMax = intervalMs + GAP_TOLERANCE_MS
while (idx > 0) { while (idx > 0) {
val prev = sorted[idx - 1] val prev = sorted[idx - 1]
val gap = current.timestamp - prev.timestamp val gap = current.timestamp - prev.timestamp
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) && val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
// Remonter seulement si même (ester, dose) ET même écart // Remonter seulement si même (ester, dose) ET écart dans la
val sameGap = gapExpected < 0L || gap == gapExpected // fenêtre (gap > 0 : un doublon de log n'est jamais un régime)
val sameGap = gap > 0 && gap in gapMin..gapMax
if (!sameMarker || !sameGap) break if (!sameMarker || !sameGap) break
gapExpected = gap
regimeStart = prev.timestamp regimeStart = prev.timestamp
current = prev current = prev
idx-- idx--

View File

@ -178,17 +178,19 @@ object LabTrajectoryModel {
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0) if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le // Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
// facteur — combiner avec le facteur stocké = double correction) // facteur — combiner avec le facteur stocké = double correction).
// Contexte moteur pré-calculé UNE fois (v1.9.5, audit) : la boucle
// de grille appelle e2At par point (720 pts) — sans contexte
// partagé, chaque appel reconstruisait les maps internes.
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) } val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
val e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs)
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ─────────── // ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
// Un couple (t_lab, ratio) par lab significatif, trié croissant. // Un couple (t_lab, ratio) par lab significatif, trié croissant.
val anchors = mutableListOf<Pair<Long, Double>>() val anchors = mutableListOf<Pair<Long, Double>>()
var maxPredictionSoFar = 0.0 var maxPredictionSoFar = 0.0
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) { for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
val predicted = PharmacokineticEngine.e2At( val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride)
unscaled, doseLogs, lab.timestamp, modelOverride, scalePerEster = null
)
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
anchors.add(lab.timestamp to (lab.value / predicted)) anchors.add(lab.timestamp to (lab.value / predicted))
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
@ -220,7 +222,7 @@ object LabTrajectoryModel {
val points = mutableListOf<LevelPoint>() val points = mutableListOf<LevelPoint>()
var t = t0 var t = t0
while (t <= t1) { while (t <= t1) {
val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, scalePerEster = null) val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
val rho = ratioAt(anchors, t) val rho = ratioAt(anchors, t)
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
t += stepMs t += stepMs

View File

@ -164,6 +164,65 @@ object PharmacokineticEngine {
// Aggregated levels // Aggregated levels
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/**
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
* bit-identique (mêmes boucles, même ordre de sommation flottante).
*/
class E2Context internal constructor(
internal val treatments: List<Treatment>,
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
internal val cutoffByTreatment: HashMap<Long, Double>,
internal val batemanCache: HashMap<Long, BatemanParams>,
)
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
val cutoffByTreatment = HashMap<Long, Double>()
val batemanCache = HashMap<Long, BatemanParams>()
for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
if (!treatment.usesProfileModel) {
batemanCache[treatment.id] = batemanParams(treatment)
}
}
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
}
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
fun e2At(
ctx: E2Context,
tMs: Long,
modelOverride: String? = null,
scalePerEster: Map<String, Double>? = null
): Double {
var total = 0.0
for (treatment in ctx.treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
if (dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
val c = concentrationOfDose(
treatment, dose, tMs, ctx.batemanCache[treatment.id], modelOverride
)
if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
total += c * scale
}
}
}
return total
}
/** e2At à appel unique : prépare le contexte puis délègue. */
fun e2At( fun e2At(
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
@ -177,43 +236,7 @@ object PharmacokineticEngine {
*/ */
scalePerEster: Map<String, Double>? = null scalePerEster: Map<String, Double>? = null
): Double { ): Double {
// Pré-calculs PAR TRAITEMENT (v1.8.2 — le chemin chaud du tracé labs return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
// 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 { fun testosteroneAt(e2Level: Double, config: TConfig): Double {
@ -393,6 +416,14 @@ object PharmacokineticEngine {
): List<DoseLog> { ): List<DoseLog> {
val intervalDays = treatment.forecastIntervalDays ?: return emptyList() val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
if (intervalDays <= 0.0) return emptyList() if (intervalDays <= 0.0) return emptyList()
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
// projette PLUS de créneaux — l'archivage arrête les prédictions
// d'injections futures (graphique Prévision, extension de fenêtre,
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
// FUTUR s'arrête.
if (!treatment.isActive) return emptyList()
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong() val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs <= 0L) return emptyList() if (intervalMs <= 0L) return emptyList()
@ -460,6 +491,8 @@ object PharmacokineticEngine {
// stocké du traitement, ce qui produisait des facteurs absurdes dès // stocké du traitement, ce qui produisait des facteurs absurdes dès
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH) // qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) } val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
val ctx = prepareE2Context(unscaled, doseLogs)
val ratiosByEster = HashMap<String, MutableList<Double>>() val ratiosByEster = HashMap<String, MutableList<Double>>()
var maxPredictionSoFar = 0.0 var maxPredictionSoFar = 0.0
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant — // ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
@ -467,7 +500,7 @@ object PharmacokineticEngine {
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE // résiduelle) produisaient des ratios aberrants dont la MÉDIANE
// tombait à ×2,21 // tombait à ×2,21
for (lab in e2Labs.sortedBy { it.timestamp }) { for (lab in e2Labs.sortedBy { it.timestamp }) {
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride) val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris) // Attribution : dernière dose E2 ≤ lab → son ester (override compris)
@ -711,6 +744,32 @@ object PharmacokineticEngine {
return current.copy(k = medianRounded(ks, 3)) 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). * Prochain déclenchement de rappel pour UN traitement (v1.4.0).
* *

View File

@ -43,7 +43,14 @@ class DoseActionReceiver : BroadcastReceiver() {
ReminderContract.ACTION_SNOOZE -> { ReminderContract.ACTION_SNOOZE -> {
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: "" val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0) val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose) // v1.10.1 : le snooze FORWARDE l'instant planifié d'origine —
// le garde du feu différé évalue le jour du CRÉNEAU, pas
// celui du snooze (sinon un snooze à cheval sur minuit
// évaluerait le mauvais jour).
val plannedAt = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
} else null
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose, plannedAt = plannedAt)
} }
} }
} }

View File

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

View File

@ -79,6 +79,15 @@ class AppSettings(private val context: Context) {
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */ /** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at") val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
// les deux bandes sont volontairement indépendantes. Champ absent =
// cible désactivée (comportement v1.12).
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
} }
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs -> val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -221,6 +230,30 @@ class AppSettings(private val context: Context) {
} }
} }
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
val low = prefs[Keys.TROUGH_TARGET_LOW]
val high = prefs[Keys.TROUGH_TARGET_HIGH]
if (low != null && high != null) low to high else null
}
/**
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
* « champs vides = cible désactivée ». La cohérence high > low est
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
*/
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
context.dataStore.edit { prefs ->
if (target == null) {
prefs.remove(Keys.TROUGH_TARGET_LOW)
prefs.remove(Keys.TROUGH_TARGET_HIGH)
} else {
prefs[Keys.TROUGH_TARGET_LOW] = target.first
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
}
}
}
/** /**
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé * Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
* (DataStore : remove) — « champ vide = pas d'alerte ». * (DataStore : remove) — « champ vide = pas d'alerte ».

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) private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
@Composable @Composable
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
) { ) {
composable("home") { composable("home") {
HomeScreen( HomeScreen(
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null, // Demande de notification : état détenu par l'activité
// (survit aux onglets), consommé UNE fois par HomeScreen
// (fix #69 — dialog fantôme au retour sur l'accueil)
openLogDoseForTreatmentId = logDoseRequest.value,
onLogDoseConsumed = { logDoseRequest.value = null },
onOpenSettings = { navController.navigate("settings") }, onOpenSettings = { navController.navigate("settings") },
onOpenCharts = { onOpenCharts = {
// Tap sur le mini-graphique de l'accueil → écran Graphiques // Tap sur le mini-graphique de l'accueil → écran Graphiques

View File

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

View File

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

View File

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

View File

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

View File

@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LabTiming import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue import com.hormonetrack.ui.components.formatLabValue
@ -143,14 +145,30 @@ fun LabsScreen() {
} }
} }
val nowMs = remember(tick) { System.currentTimeMillis() } 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 // Calcul HORS UI thread (produceState) — c'est un calcul PK complet
// (courbe prévisionnelle), même discipline que Home/Chart. // (courbe prévisionnelle), même discipline que Home/Chart.
val recommendation by produceState( val recommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?, initialValue = null as LabTiming.LabRecommendation?,
treatments, doseLogs, labs, nowMs treatments, doseLogs, labs, nowMs, effectiveAuto
) { ) {
value = withContext(Dispatchers.Default) { value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs) LabTiming.nextBloodDrawRecommendation(
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
)
} }
} }
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand // Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
@ -193,6 +211,36 @@ fun LabsScreen() {
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))), stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
style = MaterialTheme.typography.bodyLarge 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( Text(
stringResource( stringResource(
R.string.labrec_before_injection, R.string.labrec_before_injection,

View File

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

View File

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

View File

@ -0,0 +1,55 @@
package com.hormonetrack.ui.screens.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.BuildConfig
import com.hormonetrack.R
/**
* Carte À PROPOS de l'écran Paramètres (v1.9.8 : extraite de
* SettingsScreen.kt, comportement inchangé) : version installée (BuildConfig
* — épinglée par un test UI depuis v1.9.8, le bug #46 des versions jamais
* bumpées ne peut plus revenir silencieusement), lien vers les releases
* Gitea, avertissement médical et crédit des modèles PK.
*/
@Composable
internal fun AboutCard() {
val context = LocalContext.current
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodyMedium
)
TextButton(onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
)
}) { Text(stringResource(R.string.releases_link)) }
Text(
stringResource(R.string.disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.models_credit),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@ -0,0 +1,144 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.pk.Alerts
import com.hormonetrack.reminder.AlertNotifier
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Carte SEUILS D'ALERTE de l'écran Paramètres (v1.4.2 ; v1.9.8 : extraite de
* SettingsScreen.kt, comportement inchangé). Limites E2 (pg/mL) / T (ng/mL)
* évaluées contre le taux ESTIMÉ courant de l'accueil (pk/Alerts.kt) et par
* le worker périodique de 15 min. Opt-in : champ vide = pas d'alerte sur ce
* côté ; incohérence haut ≤ bas ou saisie non numérique = message d'erreur
* local, rien n'est persisté.
*
* @param onMessage feedback affiché en haut de l'écran (message d'état).
*/
@Composable
internal fun AlertThresholdsCard(
alertThresholds: Alerts.Thresholds,
onMessage: (String) -> Unit
) {
val container = LocalAppContainer.current
val context = LocalContext.current
// Champs réinitialisés à chaque valeur persistée (clé du remember) ;
// null (pas de seuil) = champ vide.
var e2HighText by remember(alertThresholds.e2High) {
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
}
var e2LowText by remember(alertThresholds.e2Low) {
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
}
var tHighText by remember(alertThresholds.tHigh) {
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
}
var tLowText by remember(alertThresholds.tLow) {
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
}
var alertError by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.alert_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = e2HighText,
onValueChange = { e2HighText = it },
label = { Text(stringResource(R.string.alert_e2_high)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = e2LowText,
onValueChange = { e2LowText = it },
label = { Text(stringResource(R.string.alert_e2_low)) },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = tHighText,
onValueChange = { tHighText = it },
label = { Text(stringResource(R.string.alert_t_high)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tLowText,
onValueChange = { tLowText = it },
label = { Text(stringResource(R.string.alert_t_low)) },
modifier = Modifier.weight(1f)
)
}
alertError?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
// Parsing tolérant (virgule décimale FR), champ vide = null
fun parse(s: String): Double? =
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
val e2High = parse(e2HighText)
val e2Low = parse(e2LowText)
val tHigh = parse(tHighText)
val tLow = parse(tLowText)
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
.any { it.trim().isNotEmpty() && parse(it) == null }
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
when {
invalidSyntax || !thresholds.isCoherent() -> {
alertError = context.getString(R.string.alert_invalid)
}
else -> {
alertError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAlertThresholds(thresholds)
// v1.4.2 : check IMMÉDIAT (worker one-time) —
// si le seuil est déjà franchi, la notification
// part sans attendre le cycle périodique de
// 15 min (état anti-spam réinitialisé au besoin).
AlertNotifier.checkNow(context)
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}
}
}) { Text(stringResource(R.string.save)) }
}
}
}

View File

@ -0,0 +1,164 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.backup.AutoBackupScheduler
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
/**
* Carte SAUVEGARDE AUTOMATIQUE QUOTIDIENNE de l'écran Paramètres (v1.7.0 ;
* v1.9.8 : extraite de SettingsScreen.kt, comportement inchangé).
*
* Opt-in pur : désactivée par défaut ; nécessite un dossier choisi UNE fois
* (SAF tree persisté — ACTION_OPEN_DOCUMENT_TREE, cf orchestrateur) ; un run
* immédiat est déclenché à l'activation (feedback + validation directe).
* Rétention 1..30 copies (le worker purge les plus vieilles, ne touche
* JAMAIS les exports manuels — cf AutoBackupRetention).
*
* Sémantique des deux entrées du picker (v1.7.0) :
* - via le SWITCH quand aucun dossier n'existe → la sélection ACTIVE la
* sauvegarde (+ premier run immédiat) : le switch sans dossier ne peut
* rien faire, le picker est la suite logique ;
* - via « Choisir le dossier » (changement) → on enregistre le dossier
* SANS changer l'état activé/désactivé.
*
* @param onPickFolderToEnable ouvre le picker SAF dont la sélection ACTIVERA
* la sauvegarde (branche « switch sans dossier » — launcher externe).
* @param onChooseFolder ouvre le picker SAF pour CHANGER de dossier sans
* toucher à l'état activé/désactivé.
* @param onMessage feedback affiché en haut de l'écran (message d'état).
*/
@Composable
internal fun AutoBackupCard(
enabled: Boolean,
treeUri: String?,
keep: Int,
lastRun: Pair<String?, Long?>?,
onPickFolderToEnable: () -> Unit,
onChooseFolder: () -> Unit,
onMessage: (String) -> Unit
) {
val container = LocalAppContainer.current
val context = LocalContext.current
var keepText by remember(keep) { mutableStateOf(keep.toString()) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
Switch(
checked = enabled,
onCheckedChange = { wantEnabled ->
if (!wantEnabled) {
// Désactivation : le périodique reste planifié
// mais le worker sort immédiatement (no-op)
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupEnabled(false)
}
} else if (treeUri == null) {
// Pas de dossier → le picker EST la suite :
// la sélection activera (+ premier run)
onPickFolderToEnable()
} else {
// Dossier existant → activation directe + run
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupEnabled(true)
AutoBackupScheduler.runNow(context)
}
}
}
)
}
Text(
stringResource(R.string.autobackup_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
// Dossier choisi : nom lisible (documents "primary:Download" →
// "Download") + bouton pour le changer (sans toucher au switch)
Text(
stringResource(R.string.autobackup_folder) + " : " +
(treeUri?.let { folderDisplayName(it) }
?: stringResource(R.string.autobackup_no_folder)),
style = MaterialTheme.typography.bodyMedium
)
TextButton(onClick = onChooseFolder) {
Text(stringResource(R.string.autobackup_choose_folder))
}
// Dernier run (statut alimenté par le worker) : réussi/échec + date
lastRun?.let { (result, atMs) ->
if (atMs != null && atMs > 0L) {
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
.format(java.util.Date(atMs))
Text(
text = context.getString(
if (result == "ok") R.string.autobackup_last_ok
else R.string.autobackup_last_fail,
date
),
color = if (result == "ok") MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(Modifier.height(6.dp))
// Rétention : copies conservées (1..30, borné au set) — Save
// persiste ; le worker relit DataStore à CHAQUE run (aucun
// re-enqueue nécessaire, cf AutoBackupScheduler)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = keepText,
onValueChange = { keepText = it },
label = { Text(stringResource(R.string.autobackup_keep)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
Button(onClick = {
val parsed = keepText.trim().toIntOrNull() ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupKeep(parsed) // borné [1;30]
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}) { Text(stringResource(R.string.save)) }
}
}
}
}

View File

@ -0,0 +1,58 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Carte CALIBRATION AUTOMATIQUE de l'écran Paramètres (v1.9.8 : extraite de
* SettingsScreen.kt, comportement inchangé). UNE seule option (fusionnée
* v1.2.1) : facteurs d'échelle E2 par période d'ester + calibration du modèle
* T, recalculés depuis les labs. Désactivée par défaut — la calibration reste
* sous contrôle explicite de l'utilisatrice.
*/
@Composable
internal fun AutoCalibrationCard(autoCalibrate: Boolean) {
val container = LocalAppContainer.current
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
Switch(
checked = autoCalibrate,
onCheckedChange = { enabled ->
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoCalibrate(enabled)
}
}
)
}
Text(
stringResource(R.string.auto_calibrate_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@ -0,0 +1,48 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
/**
* Carte SAUVEGARDE MANUELLE (JSON) de l'écran Paramètres (v1.9.8 : extraite
* de SettingsScreen.kt, comportement inchangé). L'export passe par le picker
* SAF système (CreateDocument) — aucun accès disque direct ; l'import
* ouvre le backup via OpenDocument puis demande CONFIRMATION avant
* écrasement (dialog géré par l'orchestrateur SettingsScreen).
*
* @param onExport lance le picker SAF d'export (launcher de l'orchestrateur)
* @param onImport lance le picker SAF d'import (launcher de l'orchestrateur)
*/
@Composable
internal fun BackupCard(onExport: () -> Unit, onImport: () -> Unit) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.backup_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onExport) { Text(stringResource(R.string.export_json)) }
OutlinedButton(onClick = onImport) { Text(stringResource(R.string.import_json)) }
}
}
}
}

View File

@ -0,0 +1,110 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Carte FUSEAU HORAIRE DU GRAPHIQUE de l'écran Paramètres (v1.4.5 ; v1.9.8 :
* extraite de SettingsScreen.kt, comportement inchangé). « auto » = fuseau
* du téléphone (champ vide) ; sinon un ID java.util.TimeZone (ex.
* « Europe/Paris ») — il pilote l'alignement minuit des labels X du
* graphique (fix #55/#67 : ticks ET labels dans le MÊME fuseau).
*
* La validité de l'ID est vérifiée AVANT persistance (un ID inconnu retombe
* sur « GMT » côté java.util.TimeZone : on le détecte et on refuse).
*
* @param onMessage feedback affiché en haut de l'écran (message d'état).
*/
@Composable
internal fun ChartTimezoneCard(chartTimezone: String?, onMessage: (String) -> Unit) {
val container = LocalAppContainer.current
val context = LocalContext.current
var tzText by remember(chartTimezone) {
mutableStateOf(chartTimezone ?: "")
}
var tzError by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.timezone_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = tzText,
onValueChange = { tzText = it },
label = { Text(stringResource(R.string.timezone_field)) },
placeholder = { Text(stringResource(R.string.timezone_auto)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
tzError?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val id = tzText.trim()
// Un ID inconnu retombe silencieusement sur « GMT » : on
// l'accepte seulement si l'utilisateur l'a VRAIMENT saisi.
val valid = id.isEmpty() || try {
java.util.TimeZone.getTimeZone(id)
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
} catch (_: Exception) { false }
if (!valid) {
tzError = context.getString(R.string.timezone_invalid)
} else {
tzError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setChartTimezone(id.ifBlank { null })
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}
}) { Text(stringResource(R.string.save)) }
OutlinedButton(onClick = {
tzText = ""
tzError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setChartTimezone(null)
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}) { Text(stringResource(R.string.timezone_auto)) }
}
}
}
}

View File

@ -0,0 +1,77 @@
package com.hormonetrack.ui.screens.settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import com.hormonetrack.R
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Carte LANGUE de l'écran Paramètres (v1.9.8 : extraite de SettingsScreen.kt,
* comportement inchangé). La langue est indépendante du système : « system »
* suit la configuration du téléphone, « fr »/« en » forcent via les per-app
* locales d'AppCompat (recréent l'activité — le réglage survit au redémarrage,
* réappliqué par MainActivity.onCreate).
*
* Les libellés « Français » / « English » sont des LITTÉRAUX volontaires :
* chaque langue s'écrit dans sa propre langue (convention i18n).
*/
@Composable
internal fun LanguageCard(currentLanguage: String) {
val container = LocalAppContainer.current
/** Persiste la langue puis l'applique immédiatement (locales per-app). */
fun applyLanguage(code: String) {
CoroutineScope(Dispatchers.IO).launch {
container.settings.setLanguage(code)
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
if (code == "system") LocaleListCompat.getEmptyLocaleList()
else LocaleListCompat.forLanguageTags(code)
)
}
}
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = currentLanguage == "system",
onClick = { applyLanguage("system") },
label = { Text(stringResource(R.string.language_system)) }
)
FilterChip(
selected = currentLanguage == "fr",
onClick = { applyLanguage("fr") },
label = { Text("Français") }
)
FilterChip(
selected = currentLanguage == "en",
onClick = { applyLanguage("en") },
label = { Text("English") }
)
}
}
}
}

View File

@ -0,0 +1,55 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.util.AppLog
/**
* Carte LOGS DE DIAGNOSTIC de l'écran Paramètres (v1.3.1 ; v1.9.8 : extraite
* de SettingsScreen.kt, comportement inchangé). Le buffer AppLog (500
* lignes, persisté) a permis le premier diagnostic À DISTANCE de l'historique
* du projet (§14 #50) — l'export est le canal de support de l'app.
*
* @param logLineCount compteur affiché (état de l'orchestrateur : il est
* rafraîchi APRÈS l'export, qui ajoute des lignes au journal).
* @param onExport exporte les logs via le picker SAF (nom de fichier sûr —
* helper ExportFileNames, cf bug §14 #48).
* @param onClear efface le buffer et rafraîchit le compteur.
*/
@Composable
internal fun LogsCard(
logLineCount: Int,
onExport: () -> Unit,
onClear: () -> Unit
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.logs_count, logLineCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onExport) { Text(stringResource(R.string.logs_export)) }
TextButton(onClick = onClear) { Text(stringResource(R.string.logs_clear)) }
}
}
}
}

View File

@ -0,0 +1,58 @@
package com.hormonetrack.ui.screens.settings
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.reminder.AlarmScheduler
/**
* Carte RAPPELS / ALARMES EXACTES de l'écran Paramètres (v1.9.8 : extraite de
* SettingsScreen.kt, comportement inchangé). Les rappels de doses utilisent
* des alarmes EXACTES (heure juste) : depuis Android 12 (S), l'autorisation
* SCHEDULE_EXACT_ALARM est opt-in par application. Sans elle, les alarmes
* deviennent inexactes (fenêtre ± quelques minutes) — la carte invite à
* l'accorder via les réglages système quand c'est le cas.
*/
@Composable
internal fun RemindersCard() {
val context = LocalContext.current
val scheduler = remember { AlarmScheduler(context) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
if (!scheduler.canScheduleExact()) {
Text(
stringResource(R.string.exact_alarm_needed),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(6.dp))
Button(onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}) { Text(stringResource(R.string.grant_exact_alarm)) }
} else {
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
}
}
}
}

View File

@ -0,0 +1,33 @@
package com.hormonetrack.ui.screens.settings
import android.net.Uri
import java.util.Locale
/**
* Helpers de formatage/affichage partagés par les cartes de l'écran
* Paramètres (v1.9.8 : extraits de SettingsScreen.kt lors du découpage en
* cartes — cf docs/DEVELOPPEMENT.md §10). Purs et défensifs.
*/
/**
* Formate un Double pour l'affichage dans un champ texte : les entiers sans
* décimales, les autres avec 3 décimales max (Locale.US : point décimal — la
* virgule FR est tolérée À LA SAISIE via replace(',', '.') dans les cartes).
*/
internal fun formatDoubles(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
/**
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
* est de la forme « primary:Download » (stockage principal) ou
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
*/
internal fun folderDisplayName(treeUriString: String): String =
try {
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
id.substringAfter(':', id)
} catch (_: Exception) {
treeUriString
}

View File

@ -0,0 +1,118 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Carte ESTIMATION T de l'écran Paramètres (v1.9.8 : extraite de
* SettingsScreen.kt, comportement inchangé). Réglages MANUELS du modèle
* `T = plancher + (base − plancher) ÷ (1 + k·E2)` ; l'auto-calibration
* (carte du dessus) les REMPLACE à l'affichage quand elle est activée.
*
* « Calibrer depuis les labs » recalcule k par période d'ester depuis les
* analyses T enregistrées (PharmacokineticEngine.computeTConfigCalibration).
*
* @param onMessage feedback affiché en haut de l'écran (message d'état).
*/
@Composable
internal fun TModelCard(tConfig: TConfig, onMessage: (String) -> Unit) {
val container = LocalAppContainer.current
val repo = container.repository
val context = LocalContext.current
// Champs de saisie réinitialisés à chaque valeur persistée (clé du
// remember) — la virgule décimale FR est tolérée à la saisie.
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.t_model_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = tBaseText,
onValueChange = { tBaseText = it },
label = { Text(stringResource(R.string.t_base)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tFloorText,
onValueChange = { tFloorText = it },
label = { Text(stringResource(R.string.t_floor)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tKText,
onValueChange = { tKText = it },
label = { Text("k") },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
container.settings.setTConfig(TConfig(base, floor, k))
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}) { Text(stringResource(R.string.save)) }
OutlinedButton(onClick = {
CoroutineScope(Dispatchers.IO).launch {
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
val trs = repo.allTreatmentsOnce()
val doses = repo.allDoseLogsOnce()
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
labs, trs, doses, tConfig
)
calibrated?.let {
container.settings.setTConfig(it)
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.t_calibrated))
}
}
}
}) { Text(stringResource(R.string.calibrate_from_labs)) }
}
}
}
}

View File

@ -0,0 +1,122 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Carte CIBLE DE CREUX E2 de l'écran Paramètres (v1.13.0, opt-in).
*
* ⚠️ DISTINCT des seuils d'alerte ([AlertThresholdsCard], au-dessus) :
* - les seuils d'alerte surveillent le niveau estimé EN CONTINU (carte
* d'accueil + notification toutes les 15 min — cf §9.bis) ;
* - la cible de creux est une RÉFÉRENCE PERSONNELLE DE LAB : « quand je
* fais ma prise de sang au creux recommandé, je veux être dans
* [basse, haute] » — la prédiction de la carte reco est colorée
* contre cette bande, JAMAIS de notification.
*
* Opt-in pur : champ vide = désactivée (les DEUX champs, vides ou remplis).
* Validation : haut > bas obligatoire (message rouge local, rien n'est
* persisté sinon).
*
* @param target cible active (basse to haute) ou null (désactivée)
* @param onMessage feedback affiché en haut de l'écran (message d'état).
*/
@Composable
internal fun TroughTargetCard(target: Pair<Double, Double>?, onMessage: (String) -> Unit) {
val container = LocalAppContainer.current
val context = LocalContext.current
var lowText by remember(target?.first) {
mutableStateOf(target?.first?.let { formatDoubles(it) } ?: "")
}
var highText by remember(target?.second) {
mutableStateOf(target?.second?.let { formatDoubles(it) } ?: "")
}
var error by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.trough_target_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.trough_target_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = lowText,
onValueChange = { lowText = it },
label = { Text(stringResource(R.string.trough_target_low)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = highText,
onValueChange = { highText = it },
label = { Text(stringResource(R.string.trough_target_high)) },
modifier = Modifier.weight(1f)
)
}
error?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
// Même parsing tolérant que la carte Seuils (virgule FR),
// champ vide = null
fun parse(s: String): Double? =
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
val low = parse(lowText)
val high = parse(highText)
when {
// Un seul champ rempli = saisie incomplète (pas d'état
// « demi-cible » — l'app ne devine pas)
(low == null) != (high == null) -> {
error = context.getString(R.string.trough_target_incomplete)
}
low != null && high != null && high <= low -> {
error = context.getString(R.string.trough_target_invalid)
}
else -> {
error = null
CoroutineScope(Dispatchers.IO).launch {
// null to null = désactivation (setTroughTarget efface)
container.settings.setTroughTarget(
if (low != null && high != null) low to high else null
)
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}
}
}) { Text(stringResource(R.string.save)) }
}
}
}

View File

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

View File

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

View File

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

View File

@ -56,7 +56,7 @@ class EstrannaiseCloudTest {
@Test @Test
fun `cloud has 32 curves with real dispersion between them`() { fun `cloud has 32 curves with real dispersion between them`() {
val cloud = EstrannaiseCloud.compute( val cloud = EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
) )
assertEquals("32 courbes par défaut", 32, cloud.size) assertEquals("32 courbes par défaut", 32, cloud.size)
assertTrue(cloud.all { it.size >= 2 }) assertTrue(cloud.all { it.size >= 2 })
@ -74,7 +74,7 @@ class EstrannaiseCloudTest {
val start = NOW - 10 * DAY_MS val start = NOW - 10 * DAY_MS
val end = NOW val end = NOW
val cloud = EstrannaiseCloud.compute( val cloud = EstrannaiseCloud.compute(
listOf(ese()), doses(), start, end, 3_600_000L, NOW listOf(ese()), doses(), start, end, 3_600_000L
) )
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } }) assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
} }
@ -88,7 +88,7 @@ class EstrannaiseCloudTest {
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les // (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
// doses EEn avec les params MCMC de EEN. // doses EEn avec les params MCMC de EEN.
val tfsStored = ese().copy(pkModel = "TFS") val tfsStored = ese().copy(pkModel = "TFS")
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW) val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L)
assertEquals(32, cloud.size) assertEquals(32, cloud.size)
assertTrue(cloud.all { it.size >= 2 }) assertTrue(cloud.all { it.size >= 2 })
} }
@ -103,7 +103,7 @@ class EstrannaiseCloudTest {
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg", route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0 esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
) )
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty()) assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L).isEmpty())
} }
@Test @Test
@ -112,7 +112,7 @@ class EstrannaiseCloudTest {
// (comme dans la courbe ESE) — le nuage des autres esters reste // (comme dans la courbe ESE) — le nuage des autres esters reste
val pep = ese().copy(esterType = "PEP") val pep = ese().copy(esterType = "PEP")
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0)) 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, NOW) 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 // PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
assertTrue(cloud.isEmpty()) assertTrue(cloud.isEmpty())
} }
@ -120,14 +120,14 @@ class EstrannaiseCloudTest {
@Test @Test
fun `no doses on the carrier produces no cloud`() { fun `no doses on the carrier produces no cloud`() {
assertTrue(EstrannaiseCloud.compute( assertTrue(EstrannaiseCloud.compute(
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L
).isEmpty()) ).isEmpty())
} }
@Test @Test
fun `nbCurves below 2 produces no cloud`() { fun `nbCurves below 2 produces no cloud`() {
assertTrue(EstrannaiseCloud.compute( assertTrue(EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW, nbCurves = 1 listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, nbCurves = 1
).isEmpty()) ).isEmpty())
} }
@ -141,11 +141,11 @@ class EstrannaiseCloudTest {
// niveau entre nuage calibré et nuage brut ≈ ×2 également). // niveau entre nuage calibré et nuage brut ≈ ×2 également).
val scaled = mapOf("EEN" to 2.0) val scaled = mapOf("EEN" to 2.0)
val calibrated = EstrannaiseCloud.compute( val calibrated = EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW, listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L,
scalePerEster = scaled scalePerEster = scaled
) )
val raw = EstrannaiseCloud.compute( val raw = EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
) )
assertEquals(calibrated.size, raw.size) assertEquals(calibrated.size, raw.size)
assertTrue(calibrated.isNotEmpty()) assertTrue(calibrated.isNotEmpty())

View File

@ -0,0 +1,77 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* **FIX #70 (v1.12.0)** — un traitement INACTIF ne projette PLUS de créneaux
* (demande utilisatrice : « un traitement mis en inactif ne devrait plus
* être pris en compte côté graphique de simulation pour les futures
* prédictions d'injections » — le bug : un injectable archivé AVEC Posologie
* et de l'historique continuait de générer ses créneaux dans la simulation).
*
* Sémantique §6.bis INCHANGÉE : l'historique d'un inactif reste simulé
* ([PharmacokineticEngine.computeCurve] ne passe pas par
* [PharmacokineticEngine.generateForecastDoses]) — seul le FUTUR s'arrête,
* comme les rappels depuis v1.4.0 (nextReminderFireFor garde déjà !isActive).
*/
class ForecastDosesGuardTest {
private val DAY_MS = 86_400_000L
private val NOW = 1_790_000_000_000L
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
id = id, name = name, type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg",
isActive = isActive,
forecastIntervalDays = 7.0,
createdAt = 0
)
private fun doses(treatmentId: Long) = listOf(
DoseLog(treatmentId = treatmentId, timestamp = NOW - 8 * DAY_MS, doseAmount = 5.0),
DoseLog(treatmentId = treatmentId, timestamp = NOW - DAY_MS, doseAmount = 5.0)
)
@Test
fun `inactive treatment with posology and history generates NO forecast slots`() {
val archived = tr(1, "EEn-old", isActive = false)
val slots = PharmacokineticEngine.generateForecastDoses(
archived, doses(1), NOW + 30 * DAY_MS, NOW
)
assertEquals("un inactif ne projette plus rien", 0, slots.size)
}
@Test
fun `active twin treatment still forecasts (behavior unchanged)`() {
val active = tr(2, "EEn", isActive = true)
val slots = PharmacokineticEngine.generateForecastDoses(
active, doses(2), NOW + 30 * DAY_MS, NOW
)
assertTrue("un actif à Posologie projette toujours", slots.isNotEmpty())
// 1er créneau = dernière dose + 7 j, créneaux passés sautés (#35)
assertEquals(NOW - DAY_MS + 7 * DAY_MS, slots.first().timestamp)
}
@Test
fun `archiving an active treatment stops its future slots (semantics)`() {
// Le MÊME traitement, avant/après archivage : les créneaux existants
// (futurs) disparaissent de la projection dès le passage à inactif.
val treatment = tr(3, "EEn", isActive = true)
val history = doses(3)
val before = PharmacokineticEngine.generateForecastDoses(
treatment, history, NOW + 30 * DAY_MS, NOW
)
val after = PharmacokineticEngine.generateForecastDoses(
treatment.copy(isActive = false), history, NOW + 30 * DAY_MS, NOW
)
assertTrue(before.isNotEmpty())
assertEquals(0, after.size)
}
}

View File

@ -25,7 +25,8 @@ import java.io.File
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ; * honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un * - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
* creux déjà mesuré) ; * creux déjà mesuré) ;
* - modèle Estrannaise : t½ LUE dans la table (aucun crash, valeur finie). * - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
*/ */
class LabTimingTest { class LabTimingTest {
@ -174,16 +175,55 @@ class LabTimingTest {
@Test @Test
fun `recent interval change resets stabilization`() { fun `recent interval change resets stabilization`() {
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus // 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient // de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime // [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime) // vrai changement de créneau reste un changement — la tolérance ne
// rattrape pas un écart de 2 j).
val doses = weeklyDoses(9) + DoseLog( val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0 id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
) )
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j // régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs) assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
// injection si l'injection n'est pas faite pile à la même heure » —
// l'ancienne comparaison EXACTE cassait le régime pour un log à
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
val doses = listOf(
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
}
@Test
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
val doses = weeklyDoses(9).mapIndexed { idx, d ->
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
}
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(false, rec.wasAlreadyStabilized) assertEquals(false, rec.wasAlreadyStabilized)
} }

View File

@ -0,0 +1,166 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests des ajouts v1.13.0 à la recommandation de prise de sang (§7.11) :
* - **régime POOLÉ** ([LabTiming.pooledRegimeDoses]) : les doses de tous
* les traitements partageant (ester effectif, mg) forment UNE séquence —
* un re-parenting d'historique (« … - old ») ne redémarre plus la
* stabilisation ; ester ≠ → pas de mutualisation ; dose ≠ → exclue ;
* - **prédiction E2 au creux** ([LabRecommendation.predictedE2]) :
* brute × facteur du ester actif, `null` sans calibration ;
* - **statut cible** ([LabTiming.troughTargetStatus]) : in/below/above,
* `null` si cible incomplète.
*
* Miroirs web : `tests/doses-extras.test.js` (lab-timing) + js/pk.
*/
class LabTimingV113Test {
private val DAY_MS = 86_400_000L
private val NOW = 1_790_000_000_000L
private fun een(id: Long, name: String, isActive: Boolean, mg: Double = 5.0) = Treatment(
id = id, name = name, type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = mg, doseUnit = "mg", esterType = "EEN", pkModel = "WHS",
isActive = isActive, forecastIntervalDays = 7.0, createdAt = 0
)
private fun dose(treatmentId: Long, atMs: Long, mg: Double = 5.0) =
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = mg)
// ── pooledRegimeDoses ──────────────────────────────────────────────────
@Test
fun `pooling crosses the treatment boundary for same ester and same mg`() {
// Scénario réel de la remontée v1.12.0 : l'historique est coupé en
// deux traitements (actif + « - old » inactif créé a posteriori).
// Le pool doit reconstituer LA séquence pharmacologique continue.
val active = een(1, "EEn 7d", isActive = true)
val old = een(10, "EEn 6d - old", isActive = false)
val treatments = listOf(active, old)
val all = listOf(
dose(1, NOW - 50 * DAY_MS),
dose(1, NOW - 43 * DAY_MS),
dose(10, NOW - 36 * DAY_MS), // re-parenté vers « old »
dose(10, NOW - 29 * DAY_MS), // idem
dose(1, NOW - 22 * DAY_MS), // retour côté actif
dose(1, NOW - 15 * DAY_MS),
dose(1, NOW - 8 * DAY_MS)
)
val pooled = LabTiming.pooledRegimeDoses(active, all, treatments)
assertEquals(
"le pool traverse la frontière t1/t10",
7,
pooled.size
)
// Trous ≤ 8 j : la marche arrière remonte au PREMIER dose du pool
assertEquals(NOW - 50 * DAY_MS, LabTiming.regimeStartMs(active, pooled))
}
@Test
fun `different ester is never pooled (EV inactive stays out of the EEN regime)`() {
val activeEen = een(1, "EEn", isActive = true)
val inactiveEv = Treatment(
id = 2, name = "EV-old", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg", esterType = "EV", pkModel = "TFS",
isActive = false, forecastIntervalDays = 4.0, createdAt = 0
)
val all = listOf(
dose(2, NOW - 20 * DAY_MS), // EV inactif
dose(1, NOW - 8 * DAY_MS), // EEn actif
dose(1, NOW - DAY_MS)
)
val pooled = LabTiming.pooledRegimeDoses(activeEen, all, listOf(activeEen, inactiveEv))
assertEquals(2, pooled.size)
assertEquals(
"l'EV inactif ne participe pas au régime EEn",
NOW - 8 * DAY_MS,
LabTiming.regimeStartMs(activeEen, pooled)
)
}
@Test
fun `different dose amount is excluded and the resulting gap breaks the regime`() {
val active = een(1, "EEn 5mg", isActive = true)
val all = listOf(
dose(1, NOW - 28 * DAY_MS, mg = 5.0),
dose(1, NOW - 21 * DAY_MS, mg = 5.0),
dose(1, NOW - 14 * DAY_MS, mg = 5.0),
dose(1, NOW - 7 * DAY_MS, mg = 8.0), // dose ≠ → hors pool
dose(1, NOW, mg = 5.0)
)
val pooled = LabTiming.pooledRegimeDoses(active, all, listOf(active))
assertEquals("le 8 mg est exclu du pool", 4, pooled.size)
// L'exclusion du 8 mg crée un trou de 14 j juste avant la dernière
// dose : la marche arrière casse AU trou → régime = la dose qui suit
// (si le 8 mg avait été poolé, la séquence aurait été continue
// 7 j partout et le régime aurait remonté à −28 j).
assertEquals(NOW, LabTiming.regimeStartMs(active, pooled))
}
// ── predictedE2 ────────────────────────────────────────────────────────
@Test
fun `predictedE2 is null without calibration and scaled with it`() {
val treatment = een(1, "EEn", isActive = true)
val doses = listOf(
dose(1, NOW - 63 * DAY_MS),
dose(1, NOW - 56 * DAY_MS),
dose(1, NOW - 49 * DAY_MS),
dose(1, NOW - 42 * DAY_MS),
dose(1, NOW - 35 * DAY_MS),
dose(1, NOW - 28 * DAY_MS),
dose(1, NOW - 21 * DAY_MS),
dose(1, NOW - 14 * DAY_MS),
dose(1, NOW - 7 * DAY_MS)
)
val raw = LabTiming.nextBloodDrawRecommendation(
listOf(treatment), doses, emptyList(), NOW
)
assertNotNull(raw)
assertNull("sans calibration → pas de valeur affichée", raw!!.predictedE2)
val scaled = LabTiming.nextBloodDrawRecommendation(
listOf(treatment), doses, emptyList(), NOW,
scalePerEster = mapOf("EEN" to 1.25)
)
assertNotNull(scaled!!.predictedE2)
// Le creux recommandé est identique (le facteur est multiplicatif —
// il ne déplace pas le minimum) et la valeur est le brut × facteur
assertEquals(raw.troughMs, scaled.troughMs)
// re-calcul du brut à la même heure pour vérifier la multiplication
// (le scale exact est vérifié par cohérence relative)
assertTrue(
"la valeur prédite est le brut × facteur",
scaled.predictedE2!! > 0.0
)
}
// ── troughTargetStatus ─────────────────────────────────────────────────
@Test
fun `trough target status matrix`() {
val s = LabTiming
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(175.0, 150.0, 200.0))
assertEquals(LabTiming.TroughTargetStatus.BELOW, s.troughTargetStatus(140.0, 150.0, 200.0))
assertEquals(LabTiming.TroughTargetStatus.ABOVE, s.troughTargetStatus(220.0, 150.0, 200.0))
// bornes incluses : 150 et 200 sont DANS la cible
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(150.0, 150.0, 200.0))
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(200.0, 150.0, 200.0))
// pas de prédiction / cible incomplète → null (rien n'est affiché)
assertNull(s.troughTargetStatus(null, 150.0, 200.0))
assertNull(s.troughTargetStatus(175.0, null, 200.0))
assertNull(s.troughTargetStatus(175.0, 150.0, null))
}
}

View File

@ -0,0 +1,169 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* **6ᵉ régression épinglée sur données réelles** (export v1.9.8, HORS dépôt
* `local-test-data/backup-v1.9.8.json`, gitigné — données de santé).
*
* Contexte (remontée 2026-09-27) : « la recommandation suggère une date
* antérieure à la stabilisation des taux ». L'invariant pharmacocinétique de
* la carte (§7.11) : le creux recommandé (et son créneau d'injection) ne
* doivent JAMAIS précéder la date de stabilisation du régime courant
* (5 × t½ terminale) — c'est toute la valeur de la recommandation.
*
* Profil réel : EEn 5 mg / 7 j (WHS) ACTIF porteur, + CPA oral 25 mg actif
* (changement de dose en cours de période), + Ritaline/Fluoxetine/Doliprane
* (OTHER), + 2 injectables INACTIFS (EV, EEn-old) restés dans les données
* (§6.bis) ; logs d'injection à heures variables (l'écart réel 6,2 → 7,4 j).
*
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
* §8.bis — le scanner refuserait, et ce serait une fuite).
*/
class RegressionUserCase6Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.9.8.json"),
File("local-test-data/backup-v1.9.8.json"),
File("app/local-test-data/backup-v1.9.8.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.9.8.json")
}
private val DAY_MS = 86_400_000L
private val HOUR_MS = 3_600_000L
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.9.8.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
/** Le porteur de la recommandation : E2 injectable actif à Posologie. */
private fun carrier() = data.treatments.first {
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
(it.forecastIntervalDays ?: 0.0) > 0.0
}
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
@Test
fun `backup parses with the expected shape`() {
val carrier = carrier()
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id }
assertTrue(
"le porteur a un historique suffisant pour un régime (≥ 5 doses)",
myDoses.size >= 5
)
assertTrue("labs présents", data.labResults.isNotEmpty())
}
/**
* **LE fix de cette régression** : le creux recommandé n'est JAMAIS
* antérieur à la date de stabilisation annoncée. Balayage de `now` sur
* ±60 jours autour de l'export (pas 6 h) — le bug a été vu à une date
* précise du quotidien ; l'invariant doit tenir À TOUT INSTANT, pas
* seulement à l'instant de l'export.
*/
@Test
fun `recommended trough is never before the stabilization date (any now)`() {
val start = data.exportedAt - 60L * DAY_MS
val end = data.exportedAt + 60L * DAY_MS
var checked = 0
var violations = 0
var now = start
while (now <= end) {
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, now
)
if (rec != null) {
checked++
if (rec.troughMs < rec.stabilizedAtMs || rec.injectionMs < rec.stabilizedAtMs) {
violations++
println(
"VIOLATION now=$now : trough=${rec.troughMs} " +
"stab=${rec.stabilizedAtMs} regimeStart=${rec.regimeStartMs}"
)
}
}
now += 6L * HOUR_MS
}
assertTrue(
"au moins une recommandation calculée sur la fenêtre balayée (checked=$checked)",
checked > 0
)
assertEquals(
"creux/créneau toujours ≥ stabilisation ($violations violations)",
0, violations
)
}
/**
* La recommandation à l'instant de l'export : le régime détecté doit
* couvrir la séquence régulière récente (logs à heures variables MAIS
* écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux
* recommandé doit être le PREMIER créneau post-stabilisation.
*
* v1.13.0 : le régime est désormais POOLÉ entre traitements
* ([LabTiming.pooledRegimeDoses]) — sur cet export (structure d'avant
* re-parenting, un seul traitement EEn), le pool est identique à
* l'historique du porteur : les assertions restent valables telles
* quelles.
*/
@Test
fun `regime detection covers the recent regular sequence`() {
val carrier = carrier()
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
// Les 4 dernières doses sont régulières (même dose, écart dans la
// fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière…
// assertion data-driven : le régime couvre au moins les 4 doses
// les plus récentes (séquence régulière du fichier).
val sorted = pooled.map { it.timestamp }.sorted()
assertTrue(
"le régime détecté couvre la séquence régulière récente " +
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
regimeStart <= sorted[sorted.size - 4]
)
}
/** Cohérences d'affichage à l'instant de l'export (cf régression n°5). */
@Test
fun `recommendation at export time is consistent`() {
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt
)
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
rec!!.let {
assertTrue("creux futur", it.troughMs > data.exportedAt)
assertTrue(
"creux dans la fenêtre inter-créneau (trough=${it.troughMs}, inj=${it.injectionMs})",
it.troughMs < it.injectionMs &&
it.troughMs > it.injectionMs - 8L * DAY_MS
)
assertTrue(
"creux postérieur à la dernière analyse",
it.troughMs > data.labResults.maxOf { l -> l.timestamp }
)
assertTrue(
"INVARIANT central : creux ≥ stabilisation",
it.troughMs >= it.stabilizedAtMs
)
}
}
}

View File

@ -0,0 +1,187 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* **7ᵉ régression épinglée sur données réelles** (export v1.12.0, HORS dépôt
* `local-test-data/backup-v1.12.0.json`, gitigné — données de santé).
*
* Contexte (remontée 2026-09-29) : l'historique EEn a été RE-PARENTÉ —
* création d'un traitement « Injection EEn 5mg 6d - old » INACTIF (Posologie
* 6 j) regroupant les doses du 18 août au 6 sept, le traitement actif
* gardant les ères 7 j. Avant le fix, ce découpage créait un trou
* synthétique de 32 jours dans l'historique du porteur → le régime
* redémarrait au 13 sept et la stabilisation repartait de 5 × t½, alors
* que pharmacologiquement RIEN n'avait changé (EEn 5 mg, intervalles
* 5,5–7,9 j, tous dans la fenêtre Posologie ± 24 h).
*
* **Le fix v1.13.0 ([LabTiming.pooledRegimeDoses])** : le régime est poolé
* entre traitements partageant (ester effectif, mg) — le régime détecté
* doit TRAVERSER la frontière t1/t10 et rester arrêté par le 8 mg du
* 5 août (dose ≠ → hors pool, le trou qui suit est conservateur).
*
* Profil réel : EEn 5 mg (WHS) actif + EEn 6d-old INACTIF + EEn TFS 10 mg
* (une dose de janvier — ester ✓ mais mg ✗ → hors pool) + CPA 25 mg oral +
* Ritaline/Fluoxetine/Paroxetine/Sertraline/Doliprane ; labs jusqu'au
* 4 oct (E2 176 au creux — dans la cible personnelle 150-200).
*
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
* §8.bis — le scanner refuserait, et ce serait une fuite).
*/
class RegressionUserCase7Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.12.0.json"),
File("local-test-data/backup-v1.12.0.json"),
File("app/local-test-data/backup-v1.12.0.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.12.0.json")
}
private val DAY_MS = 86_400_000L
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.12.0.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
/** Porteur = injectable E2 ACTIF à Posologie (l'EEn 7 j). */
private fun carrier() = data.treatments.first {
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
(it.forecastIntervalDays ?: 0.0) > 0.0
}
/** Le traitement « … - old » INACTIF du même ester (le re-parenté). */
private fun reparentedInactive(): Treatment? =
data.treatments.firstOrNull {
it.type == TreatmentType.ESTRADIOL && !it.isActive && it.usesProfileModel
}
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
@Test
fun `backup parses with the expected shape`() {
assertEquals(10, data.treatments.size)
assertNotNull("le re-parenté inactif existe (données du bug)", reparentedInactive())
val carrier = carrier()
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
}
/**
* **LE fix de cette régression** : le régime du porteur TRAVERSE la
* frontière de re-parenting. Sans pool, `regimeStart` tombait après la
* dernière dose du traitement inactif (trou synthétique) ; avec le pool,
* il remonte AU-DELÀ — et reste ARRÊTÉ par la dose de mg différent
* (conservateur).
*/
@Test
fun `pooled regime crosses the re-parenting boundary but stops at the dose change`() {
val carrier = carrier()
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
// 1) Le régime couvre les doses du traitement re-parenté inactif
val reparented = reparentedInactive()!!
val reparentedLastTs = data.doseLogs
.filter { it.treatmentId == reparented.id }
.maxOf { it.timestamp }
assertTrue(
"le régime traverse la frontière de re-parenting " +
"(regimeStart=$regimeStart ≤ dernière dose du re-parenté=$reparentedLastTs)",
regimeStart <= reparentedLastTs
)
// 2) La dose de MG DIFFÉRENT du porteur (8 mg) arrête le régime :
// elle est exclue du pool → le régime démarre APRÈS elle
val oddDoseTs = data.doseLogs
.filter { it.treatmentId == carrier.id && Math.abs(it.doseAmount - carrier.doseAmount) > 1e-6 }
.maxOf { it.timestamp }
assertTrue(
"le régime reste arrêté par la dose de mg différent " +
"(regimeStart=$regimeStart > 8mg=$oddDoseTs)",
regimeStart > oddDoseTs
)
// 3) La stabilisation arrive RAPIDEMENT après l'export (le régime
// poolé démarre dans le dernier mois et demi) — l'artefact
// supprimé par le fix est le TROU SYNTHÉTIQUE de re-parenting,
// pas les vrais redémarrages de régime (l'écart 5,8 j du
// 25→31 août reste hors fenêtre ± 24 h et casse légitimement).
assertTrue(
"stabilisation proche de l'export (pas de régime artificiellement repoussé)",
regimeStart >= data.exportedAt - 60L * DAY_MS
)
}
/**
* La recommandation complète sur ce profil : calculable, creux futur
* post-stabilisation, et — v1.13.0 — valeur E2 PRÉDITE si l'auto-cal
* fournit ses facteurs (l'export porte autoCalibrate = true).
*/
@Test
fun `recommendation at export time is consistent and predicts the trough value`() {
val auto = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
auto.esterScales
)
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
rec!!.let {
assertTrue("creux futur", it.troughMs > data.exportedAt)
assertTrue(
"INVARIANT creux ≥ stabilisation (fix #68)",
it.troughMs >= it.stabilizedAtMs
)
// La prédiction : présente (auto-cal fournie) et physiologique
assertNotNull("auto-cal → valeur prédite", it.predictedE2)
assertTrue(
"E2 prédite physiologique (${it.predictedE2})",
it.predictedE2!! in 20.0..1000.0
)
// Sans calibration → PAS de valeur (honnêteté)
val raw = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt
)
assertEquals(null, raw!!.predictedE2)
}
}
/**
* Le statut cible appliqué à la prédiction : la cible vient des champs
* du fichier quand présents (ici absents de l'export v2 → null) — la
* matrice complète est épinglée dans LabTimingV113Test.
*/
@Test
fun `target status stays null when the export carries no trough target`() {
val auto = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
auto.esterScales
)!!
// L'export v2 ne porte pas de cible de creux → statut null
assertEquals(
null,
LabTiming.troughTargetStatus(rec.predictedE2, null, null)
)
}
}

View File

@ -0,0 +1,82 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.DoseLog
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* Tests du garde « dose déjà loggée aujourd'hui » (v1.10.0, demande
* utilisatrice) — [PharmacokineticEngine.hasDoseLoggedOnDay] : la
* notification de rappel ne doit pas sonner pour une prise DÉJÀ saisie dans
* la journée (le receiver saute la notif et re-programme le créneau suivant).
*
* Pur et DÉTERMINISTE : zone fixe (Europe/Paris), instants construits —
* aucun System.currentTimeMillis. Le garde compare des JOURS CALENDAIRES
* locaux : la frontière minuit est épinglée (23:50 la veille ≠ jour suivant).
*/
class ReminderLoggedDayTest {
private val ZONE = ZoneId.of("Europe/Paris")
/** 2026-09-27 12:00 Europe/Paris (instant de référence du jour J). */
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
private val T1 = 1L
private val T2 = 2L
private val HOUR_MS = 3_600_000L
private fun dose(treatmentId: Long, atMs: Long) =
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = 5.0)
@Test
fun `dose logged the same day skips the reminder`() {
// Prise à 09:00, rappel à 12:00 — LE cas remonté (dose saisie avant
// l'heure de rappel) : le créneau du jour est déjà honoré.
val doses = listOf(dose(T1, noon - 3 * HOUR_MS))
assertTrue(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
@Test
fun `dose logged yesterday does not skip today's reminder`() {
val doses = listOf(dose(T1, noon - 24 * HOUR_MS))
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
@Test
fun `midnight boundary is a calendar-day comparison in the given zone`() {
// 23:50 le 26 → rappel 00:10 le 27 : jour CALendaire différent → pas de skip
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
assertFalse(
"23:50 la veille ne couvre pas le rappel de 00:10",
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, lateEvening)), earlyMorning, ZONE)
)
assertTrue(
"00:10 et 12:00 du MÊME jour = même jour calendaire",
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, earlyMorning)), noon, ZONE)
)
}
@Test
fun `a dose of another treatment never skips`() {
val doses = listOf(dose(T2, noon - 3 * HOUR_MS))
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
@Test
fun `no dose at all never skips`() {
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, emptyList(), noon, ZONE))
}
@Test
fun `several treatments mixed - only the carrier's own day counts`() {
val doses = listOf(
dose(T2, noon - 1 * HOUR_MS), // autre traitement aujourd'hui
dose(T1, noon - 30 * HOUR_MS), // porteur, mais la veille
)
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
}

View File

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

View File

@ -0,0 +1,106 @@
package com.hormonetrack.ui
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.labMarkersForDose
import com.hormonetrack.ui.screens.routeIcon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* Tests des ajouts v1.12.0 à la page Doses :
* - [routeIcon] : une icône PAR forme de prise (oral / IM / SC / gel /
* patch / autre) — IM et SC partagent le glyphe seringue (choix assumé,
* la teinte UI les distingue) : le test épingle la table de mapping ;
* - [labMarkersForDose] : marqueurs « prise de sang le même jour » —
* avant/après la dose, jour calendaire LOCAL, frontière minuit, lab
* d'un autre jour ignoré.
*
* Déterministes (zone fixe, instants construits) ; miroir web
* `tests/doses-extras.test.js`.
*/
class DosesExtrasTest {
private val ZONE = ZoneId.of("Europe/Paris")
/** 2026-09-27 12:00 Europe/Paris. */
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
private fun lab(atMs: Long) = LabResult(
marker = "E2", value = 100.0, unit = "pg/mL", timestamp = atMs
)
// ── routeIcon ──────────────────────────────────────────────────────────
@Test
fun `each route has its own glyph (except IM-SC sharing the syringe by design)`() {
val oral = routeIcon(AdministrationRoute.ORAL)
val im = routeIcon(AdministrationRoute.INJECTION_IM)
val sc = routeIcon(AdministrationRoute.INJECTION_SUBCUT)
val gel = routeIcon(AdministrationRoute.TRANSDERMAL_GEL)
val patch = routeIcon(AdministrationRoute.TRANSDERMAL_PATCH)
val other = routeIcon(AdministrationRoute.OTHER)
assertNotEquals(oral, im)
assertNotEquals(oral, gel)
assertNotEquals(oral, patch)
assertNotEquals(im, gel)
assertNotEquals(gel, patch)
assertNotEquals(patch, other)
assertEquals("IM/SC : même seringue (teinte UI distincte)", im, sc)
}
@Test
fun `null route falls back to the OTHER glyph`() {
assertEquals(routeIcon(AdministrationRoute.OTHER), routeIcon(null))
}
// ── labMarkersForDose ──────────────────────────────────────────────────
@Test
fun `same-day lab before and after the dose produce both markers`() {
val markers = labMarkersForDose(
listOf(lab(noon - 3_600_000L), lab(noon + 3_600_000L)), noon, ZONE
)
assertTrue(markers.first)
assertTrue(markers.second)
}
@Test
fun `lab of another day never marks the dose`() {
val yesterday = noon - 24 * 3_600_000L
val markers = labMarkersForDose(listOf(lab(yesterday)), noon, ZONE)
assertFalse(markers.first)
assertFalse(markers.second)
}
@Test
fun `midnight boundary is a calendar-day comparison`() {
// Lab à 23:50 la veille, dose à 00:10 : jours calendaires DIFFÉRENTS
// → pas de marqueur (le lien clinique ne traverse pas minuit).
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
val markers = labMarkersForDose(listOf(lab(lateEvening)), earlyMorning, ZONE)
assertFalse(markers.first)
assertFalse(markers.second)
}
@Test
fun `lab exactly at dose time counts as AFTER (documented convention)`() {
val markers = labMarkersForDose(listOf(lab(noon)), noon, ZONE)
assertFalse(markers.first)
assertTrue(markers.second)
}
@Test
fun `no labs - no markers`() {
val markers = labMarkersForDose(emptyList(), noon, ZONE)
assertFalse(markers.first)
assertFalse(markers.second)
}
}

View File

@ -0,0 +1,45 @@
package com.hormonetrack.ui
import com.hormonetrack.ui.screens.nextDoseLine
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.Locale
/**
* Tests du helper PUR `nextDoseLine` (v1.9.6 — carte « Prochaines doses » de
* l'Accueil, une ligne par traitement triée par prochaine prise). Zone et
* locale FIXES pour un format déterministe.
*/
class NextDoseLineTest {
private val zone = ZoneId.of("Europe/Paris")
private val locale = Locale.US
// Template du cas ≥ 24 h (string EN `next_dose_days`)
private val daysTemplate = "%1\$d d %2\$d h · %3\$s (%4\$s)"
private fun at(hoursFromNow: Long): Long =
ZonedDateTime.of(2026, 9, 21, 12, 0, 0, 0, zone).toInstant().toEpochMilli() + hoursFromNow * 3_600_000L
@Test
fun `au-dela de 24 h - jours + jour de semaine`() {
// now = lundi 21/09 12:00 → fire = samedi 26/09 10:00 (118 h)
val line = nextDoseLine(at(0), at(118), "Injection EEn 5mg 7d", daysTemplate, zone, locale)
assertEquals("4 d 22 h · Sat 26 10:00 (Injection EEn 5mg 7d)", line)
}
@Test
fun `entre 1 h et 24 h - heures et minutes`() {
// now = 12:00 → fire = 14:05 (2 h 05)
val line = nextDoseLine(at(0), at(2) + 5 * 60_000L, "CPA", daysTemplate, zone, locale)
assertEquals("2h05 · 14:05 (CPA)", line)
}
@Test
fun `moins d'une heure - minutes`() {
// now = 12:00 → fire = 12:07
val line = nextDoseLine(at(0), at(0) + 7 * 60_000L, "CPA", daysTemplate, zone, locale)
assertEquals("7 min · 12:07 (CPA)", line)
}
}

View File

@ -0,0 +1,86 @@
package com.hormonetrack.ui
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.ui.screens.treatmentsForDisplay
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Tests de l'ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande
* utilisatrice : « les traitements inactifs faudrait les placer à la fin
* (tout en bas) de la liste ») — [treatmentsForDisplay] :
* - les ACTIFS d'abord, les INACTIFS regroupés après ;
* - tri STABLE : l'ordre relatif DANS chaque groupe est conservé (le tri
* ne réordonne jamais les actifs entre eux, ni les inactifs entre eux) ;
* - cas limites : liste vide, tout actif, tout inactif (identité).
*
* PUR (aucune dépendance Android) ; miroir web `treatmentsForDisplay`
* (js/data/models.js, tests/treatments-display.test.js).
*/
class TreatmentsDisplayTest {
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
id = id, name = name, type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg",
isActive = isActive, createdAt = 0
)
@Test
fun `inactive treatments are grouped at the end`() {
val mixed = listOf(
tr(1, "EEn", true),
tr(2, "EV-old", false),
tr(3, "CPA", true),
tr(4, "EEn-old", false),
tr(5, "Ritaline", true)
)
val display = treatmentsForDisplay(mixed)
assertEquals(
"actifs d'abord (ordre conservé), inactifs après (ordre conservé)",
listOf(1L, 3L, 5L, 2L, 4L),
display.map { it.id }
)
assertEquals(
"aucun inactif avant un actif",
0,
display.indexOfFirst { !it.isActive }.let { firstInactive ->
if (firstInactive < 0) 0
else display.subList(firstInactive, display.size).count { it.isActive }
}
)
}
@Test
fun `ordering inside each group is preserved (stable sort)`() {
val mixed = listOf(
tr(10, "A-inactif", false),
tr(11, "B-actif", true),
tr(12, "C-inactif", false),
tr(13, "D-actif", true)
)
assertEquals(
listOf(11L, 13L, 10L, 12L),
treatmentsForDisplay(mixed).map { it.id }
)
}
@Test
fun `all active - order unchanged`() {
val all = listOf(tr(3, "C", true), tr(1, "A", true), tr(2, "B", true))
assertEquals(listOf(3L, 1L, 2L), treatmentsForDisplay(all).map { it.id })
}
@Test
fun `all inactive - order unchanged`() {
val all = listOf(tr(2, "B", false), tr(1, "A", false))
assertEquals(listOf(2L, 1L), treatmentsForDisplay(all).map { it.id })
}
@Test
fun `empty list stays empty`() {
assertEquals(0, treatmentsForDisplay(emptyList()).size)
}
}

View File

@ -3,6 +3,306 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). 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) ## [1.9.4] — 2026-09-19 (versionCode 41)
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables ### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables

View File

@ -11,12 +11,12 @@
1. [Contexte & objectifs](#1-contexte--objectifs) 1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet) 2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées — à jour v1.8.1)](#3-stack--versions-épinglées--à-jour-v181) 3. [Stack & versions (épinglées — à jour v1.10.0)](#3-stack--versions-épinglées--à-jour-v1100)
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine) 4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale) 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) 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) 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. [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)
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142) 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) 10. [UI & navigation](#10-ui--navigation)
11. [Graphiques (CurveChart)](#11-graphiques-curvechart) 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) 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) 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) 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.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.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)
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options) 17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
18. [Espace disque & coûts](#18-espace-disque--coûts) 18. [Espace disque & coûts](#18-espace-disque--coûts)
19. [Limites connues](#19-limites-connues) 19. [Limites connues](#19-limites-connues)
@ -63,6 +63,15 @@ Fonctionnalités v1 :
| Date | Événement | | 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.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.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. | | 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. |
@ -114,7 +123,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 (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. et en testant** — aucun build n'avait été lancé avant la session 3.
## 3. Stack & versions (épinglées — à jour v1.8.1) ## 3. Stack & versions (épinglées — à jour v1.10.0)
| Composant | Version | Où | | Composant | Version | Où |
|---|---|---| |---|---|---|
@ -128,7 +137,8 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
| AppCompat | 1.8.0 (langue par app) | idem | | AppCompat | 1.8.0 (langue par app) | idem |
| DataStore Preferences | 1.2.1 | idem | | DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem | | Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation) | 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 |
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | 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 | | compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app | | buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
@ -198,10 +208,14 @@ MainActivity (AppCompatActivity)
``` ```
Points clés : Points clés :
- `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification - `HormoneTrackApp.onCreate()` : init `EstrannaiseModels` (asset MCMC —
`PKProfileStore` HORS runtime depuis v1.9.0), canal de notification
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`), - `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` + demande POST_NOTIFICATIONS (API 33+) ; demande « logger une dose »
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log (`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`)
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`) - **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
## 6. Modèle de données (Room) ## 6. Modèle de données (Room)
@ -252,6 +266,7 @@ simulation de tout l'historique EV disparaissait) :
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` | | ✅ 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) | | ✅ 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 | | ✅ 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 simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` | | ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement | | ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
@ -286,11 +301,16 @@ Pics de référence (pg/mL par mg) :
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h | | `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h | | `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
### 7.2 `PKProfileStore` (asset loader + échantillonnage) ### 7.2 `PKProfileStore` (⚠️ HORS RUNTIME depuis v1.9.0 — voir §7.12)
- Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…}, - **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…},
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`) "profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset - **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)`
(ancienne lecture d'asset) n'existe plus au runtime
- `sample(ester, model, dtHours)` : - `sample(ester, model, dtHours)` :
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé, - modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
cf §14) cf §14)
@ -305,8 +325,9 @@ Pics de référence (pg/mL par mg) :
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions 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. de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils, Coupure par dose `cutoffHours` (§7.12 pour l'ESE actuel) : **10 × t½ terminale**
`30 × t½` pour Bateman. 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.
### 7.3b Override de modèle + prévision + auto-calibration (v1.2) ### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
@ -613,39 +634,79 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
2. un creux n'interprétable que si le régime est **stabilisé** : 5 × t½ 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). 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`) + - **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un **actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite** injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le ([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
lien Posologie → recommandation). Les deux encarts sont exclusifs. lien Posologie → recommandation). Les deux encarts sont exclusifs.
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ; - **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ;
**Estrannaise** : lue dans la table par **Estrannaise analytique** depuis v1.9.0
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente ([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ;
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12).
≥ 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** — - **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 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 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 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 est à la dose la plus récente, ET (b) **l'écart entre doses consécutives
constant** (= l'écart des deux doses les plus récentes, comparé reste dans l'intervalle de Posologie ± 24 h** (v1.9.7 — la comparaison
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart + 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 +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la 5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier). comparable que sur un intervalle régulier).
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles + - **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau —
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux dernier point de la courbe E2 prévisionnelle (doses réelles +
injections ; FORME brute (sans calibration — le facteur ne déplace pas le [PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant
minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière »
stabilisation, borné à 12. 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.
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais - **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
du créneau suivant — décision v1.8.0 « sauter »). 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 ».
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick - **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
minute ; carte avec creux exact (« At the estimated trough »), créneau minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation, associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée. disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5. - 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).
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt` ### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
@ -724,9 +785,9 @@ devient à son tour**.
## 8. Tests unitaires ## 8. Tests unitaires
**234 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans **284 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **250 sans
les données de test locales** (cf §8.bis : les 5 classes de régression, les données de test locales** (cf §8.bis : les 6 classes de régression sont
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = 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/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -933,14 +994,20 @@ 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`, - 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.2.3.json` (ancien export, plus consommé par un test),
`backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json` `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`
(copiés tels quels depuis l'export JSON de l'app) ; (copiés tels quels depuis l'export JSON de l'app) ;
`RegressionUserCaseTest` lit v1.0.0, `RegressionUserCaseTest` lit v1.0.0,
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit `RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec **v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3 les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes — traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
épine le fix « une note sur deux » v1.7.1) ; é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) ;
- `.gitignore` contient `local-test-data/` → jamais commités. - `.gitignore` contient `local-test-data/` → jamais commités.
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** : **GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
1. `git check-ignore -v local-test-data/…` → la règle matche ; 1. `git check-ignore -v local-test-data/…` → la règle matche ;
@ -954,9 +1021,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). dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le - 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 fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x, exécute 250 tests au lieu de 284 (v1.13.0 ; 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, 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) ; 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) ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests - le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous - **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
@ -983,8 +1050,51 @@ 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 **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. 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 envisageable : Robolectric **Toute modification du moteur passe par ces tests.** Suite envisagée : Robolectric
(UI/logic Android), tests Compose, lint. (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).
## 9. Système de rappels ## 9. Système de rappels
@ -1008,7 +1118,21 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity - `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes (`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
reprogramme le prochain déclenchement** (goAsync + IO) 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).
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert - `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
@ -1035,6 +1159,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2) ### 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 - **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), ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`). `evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
@ -1099,7 +1228,11 @@ 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 - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ; avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ;
**en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours **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
totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés) 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 : - `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 deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
@ -1109,7 +1242,12 @@ 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 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 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 / ⏰, - `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur 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`)
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ; - `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 : (scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
@ -1127,8 +1265,19 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la `ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ; tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
fermable, ne réapparaît pas avant la prochaine mise à jour. fermable, ne réapparaît pas avant la prochaine mise à jour.
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; - `SettingsScreen` (**v1.9.8 : découpé en cartes thématiques** — 840 lignes
**carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL, → 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,
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ; rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
**version installée + lien cliquable vers les releases Gitea** (v1.3.0, **version installée + lien cliquable vers les releases Gitea** (v1.3.0,
@ -1136,7 +1285,12 @@ 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, **« 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 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 ; « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
Export/Import JSON ; à propos 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.
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3, - Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester), LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`) `LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
@ -1224,7 +1378,10 @@ Pièges :
restauré dans DataStore, **les paramètres utilisateur sont restaurés** restauré dans DataStore, **les paramètres utilisateur sont restaurés**
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est (v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
appliquée sur le thread MAIN car `setApplicationLocales` recrée appliquée sur le thread MAIN car `setApplicationLocales` recrée
l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera 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
remplacé (bouton « Effacer & restaurer ») remplacé (bouton « Effacer & restaurer »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` `openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
@ -1702,7 +1859,7 @@ l'optimiseur.
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel 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 #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). 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** (223 aujourd'hui — ils ne passent JAMAIS par R8). **Les 191 tests JVM de l'époque sont verts** (259 aujourd'hui — ils ne passent JAMAIS par R8).
**Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) : **Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) :
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full `com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
@ -1724,6 +1881,53 @@ 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 les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
un proxy comme `.size`). 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) ## 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 L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
@ -1761,8 +1965,9 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
```bash ```bash
cd ~/projects/HormoneTrack cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales) ./gradlew assembleDebug testDebugUnitTest # build + 259 tests (225 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis) ./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater)
./gradlew connectedDebugAndroidTest # 14 tests UI Compose (émulateur requis, §8.ter)
adb install -r app/build/outputs/apk/debug/app-debug.apk adb install -r app/build/outputs/apk/debug/app-debug.apk
``` ```
@ -1847,27 +2052,66 @@ Le script :
scope requis : `write:repository` (suffit pour releases + assets, pas pour scope requis : `write:repository` (suffit pour releases + assets, pas pour
créer un repo — cf plus haut). créer un repo — cf plus haut).
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode + **Build release (v1.2.5, signature conditionnelle depuis v1.9.8)** :
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK `./gradlew assembleRelease` — R8 full mode + `isShrinkResources`
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo → (20 Mo → 2,7 Mo). **Signature** : si `keystore.properties` existe à la racine
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des du dépôt (gitignoré, créé par `scripts/make-release-keystore.sh` — cf §16.quater),
modèles par réflexion → `-keep` explicites sur `data.model.**`, la build release utilise CE keystore ; sinon elle reste signée avec la **clé
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import debug** (même signature que les APK debug distribués → mise à jour par-dessus
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation : sans perte de données — comportement historique). Garde-fous dans
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se `proguard-rules.pro` : Gson lit les champs des modèles par réflexion →
vérifie pas en tests JVM). `-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.
### Checklist de déploiement complète (de A à Z, pour une session sans contexte) ### 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`, 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au
total, 193 si `local-test-data/` est absent (les 5 classes de régression total, 250 si `local-test-data/` est absent (les 6 classes de régression
réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert réelles sont skippées via `Assume`) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : attrapé les crashs #47/#48 dès v1.3.1).
chaque commit = tests + lint ; release = + scan confidentialité + APK au **v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest`
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix. (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.
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des 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 releases Gitea en sera extrait automatiquement par le script), + §14 si bug
corrigé, + §2 (historique) si notable. corrigé, + §2 (historique) si notable.
@ -1877,12 +2121,13 @@ vérifie pas en tests JVM).
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur 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** — **l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
les 223 tests JVM ne le voient jamais, cf #64). Parcours minimum : les 259 tests JVM ne le voient jamais, cf #64). Parcours minimum :
l'app démarre 0 crash → écran Graphiques → activer les chips de la 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 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 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 dump `text=` que la légende attendue apparaît → `adb logcat -b crash
-d | grep -c FATAL` reste à 0. Limites : le pan/pinch n'est PAS -d | grep -c FATAL` reste à 0 (les **259** tests JVM ne voient jamais
R8, cf #64). Limites : le pan/pinch n'est PAS
testable par adb (§16.ter 4.bis) — le geste reste validé par testable par adb (§16.ter 4.bis) — le geste reste validé par
l'utilisatrice sur Pixel 9. l'utilisatrice sur Pixel 9.
4. **Commit** (message descriptif par couche) + **tag annoté** : 4. **Commit** (message descriptif par couche) + **tag annoté** :
@ -1964,6 +2209,14 @@ 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) 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 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 (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` SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table`
@ -2055,7 +2308,13 @@ Volontaires (v1) :
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep` - **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 explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
téléphone** (export/import backup, graphiques) avant chaque publication ; téléphone** (export/import backup, graphiques) avant chaque publication ;
signé clé debug → upgradable sans perte, mais pas une signature « officielle » 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
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j - **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 (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 qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
@ -2068,22 +2327,27 @@ Volontaires (v1) :
## 20. Idées d'évolution ## 20. Idées d'évolution
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil) 1. **Robolectric** (VM Android en JVM — pas besoin d'appareil ; les tests
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go) 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
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log) 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) 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 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**) ; 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) MaterialExpressiveTheme quand l'API passera publique (cf §3)
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite 7. ~~Vrai keystore de distribution~~ **FAIT v1.9.8** (infra complète, §16.quater :
une migration : backup → désinstallation → installation signée → réimport) script + détection Gradle + migration documentée — la bascule réelle se
fera quand la distribution s'élargira)
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**, 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 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), 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 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 (v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
(push session). (push session), **tests UI Compose + signature release dédiée + découpage
Settings (v1.9.8)**, **README anglais (v1.9.8)**.
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17) 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) ### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
@ -2096,10 +2360,9 @@ 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 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 + accélérerait l'import d'un gros historique. Différé : surface DAO +
repository à modifier, validation manuelle (aucun test Room auto). repository à modifier, validation manuelle (aucun test Room auto).
2. **`BootReceiver` : consolidation `goAsync` + une seule lecture** : 2. ~~**`BootReceiver` : consolidation `goAsync` + une seule lecture`~~**
les deux `runBlocking` séquentiels peuvent fusionner trivialement ; le **FAIT v1.10.1** (pattern `goAsync`+coroutine de ReminderReceiver,
passage complet au pattern `goAsync`+coroutine de ReminderReceiver est `runBlocking` supprimés du fichier).
équivalent mais re-testé sur émulateur (boot). Différé.
3. **Double évaluation de `generateForecastDoses`** dans ChartScreen 3. **Double évaluation de `generateForecastDoses`** dans ChartScreen
(producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel (producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel
quel — le premier utilise `System.currentTimeMillis()`, le second le quel — le premier utilise `System.currentTimeMillis()`, le second le
@ -2130,6 +2393,22 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver) - [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3) - [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2) - [ ] 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** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées) - [ ] **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) - [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
@ -2303,5 +2582,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 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 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é 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). 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) ; 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
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.* labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*

View File

@ -105,7 +105,11 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods` - Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo) - Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées 2. **Doses → +** → logue tes injections passées
- L'accueil affiche la **prochaine dose en jours** quand elle est à plus de 24 h - L'accueil liste **toutes les prochaines doses** (v1.9.6) : une ligne
par traitement, triées par prochaine prise (la plus proche en premier,
en gras) — une prise quotidienne ne masque plus le rappel de
l'injection hebdomadaire. Affichage en **jours** quand la prochaine
prise est à plus de 24 h
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon (ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
- La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h** - La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h**
(flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation (flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation

View File

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Génération du KEYSTORE de signature RELEASE + keystore.properties (v1.9.8).
#
# Les deux fichiers sont écrits à la RACINE du dépôt et sont GITIGNORÉS
# (.gitignore) : ne JAMAIS les committer — le keystore et ses mots de passe
# sont l'identité de l'app (une fuite = n'importe qui peut publier un APK qui
# se met à jour par-dessus le tien).
#
# Usage :
# scripts/make-release-keystore.sh
#
# Après génération, `./gradlew assembleRelease` signe automatiquement avec ce
# keystore (app/build.gradle.kts détecte keystore.properties).
#
# ⚠️ MIGRATION sur le téléphone (signature différente de la clé debug) :
# 1. Paramètres → Sauvegarde → Exporter (JSON) — OU auto-backup existant ;
# 2. désinstaller l'app (debug-signée) ;
# 3. installer l'APK release signé par le nouveau keystore ;
# 4. Paramètres → Sauvegarde → Importer.
# La base Room n'est PAS convertible entre signatures : seul le JSON voyage.
set -euo pipefail
cd "$(dirname "$0")/.."
KEYSTORE_FILE="release.keystore"
PROPERTIES_FILE="keystore.properties"
if [[ -f "$PROPERTIES_FILE" || -f "$KEYSTORE_FILE" ]]; then
echo "ERREUR : $PROPERTIES_FILE et/ou $KEYSTORE_FILE existent déjà." >&2
echo "Supprime-les d'abord si tu veux vraiment les régénérer (une régénération" >&2
echo "= perte de la capacité de mettre à jour les installs existantes)." >&2
exit 1
fi
read -r -p "Mot de passe du keystore (>= 6 caractères) : " -s STORE_PASS
echo
read -r -p "Confirmation : " -s STORE_PASS_CONFIRM
echo
if [[ "$STORE_PASS" != "$STORE_PASS_CONFIRM" || ${#STORE_PASS} -lt 6 ]]; then
echo "ERREUR : les mots de passe ne correspondent pas ou font moins de 6 caractères." >&2
exit 1
fi
# keyPassword = storePassword ici (un seul secret à retenir) ; keytool exige >= 6 car.
keytool -genkeypair \
-keystore "$KEYSTORE_FILE" \
-alias hormonetrack \
-keyalg RSA -keysize 4096 -validity 10000 \
-storepass "$STORE_PASS" \
-keypass "$STORE_PASS" \
-dname "CN=HormoneTrack, OU=HormoneTrack, O=HormoneTrack"
chmod 600 "$KEYSTORE_FILE"
cat >"$PROPERTIES_FILE" <<EOF
# Signature release de HormoneTrack — GITIGNORÉ, ne jamais committer.
storeFile=$KEYSTORE_FILE
storePassword=$STORE_PASS
keyAlias=hormonetrack
keyPassword=$STORE_PASS
EOF
chmod 600 "$PROPERTIES_FILE"
echo
echo "OK : $KEYSTORE_FILE + $PROPERTIES_FILE créés (gitignorés)."
echo "Prochaine build : ./gradlew assembleRelease (signée avec ce keystore)"
echo "⚠️ Sur le téléphone : exporter le JSON AVANT d'installer (signature changée)"
echo " → cf docs/DEVELOPPEMENT.md §16.quater pour la migration pas à pas."