Compare commits

..

16 Commits
v1.9.6 ... 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
50 changed files with 4285 additions and 738 deletions

7
.gitignore vendored
View File

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

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

View File

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

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

View File

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

View File

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

View File

@ -20,8 +20,10 @@ import kotlin.math.ceil
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
* une transition, pas le régime ;
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le
* premier créneau d'injection ≥ date de stabilisation du régime courant.
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
* #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même —
* l'instant où la prise de sang serait faite — pas sur le créneau
* d'injection qui le suit).
*
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
@ -36,10 +38,13 @@ import kotlin.math.ceil
* assumé et documenté : l'app ne trace pas l'historique des éditions de
* Posologie/dose, la première dose du traitement est le meilleur
* estimateur du début du régime (même sémantique que le « temps sous THS ») ;
* - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux
* injections. La FORME suffit : calcul SANS calibration (le facteur
* d'échelle est multiplicatif, il ne déplace pas le minimum) ;
* - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
* E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
* v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
* l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont
* le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul
* SANS calibration (le facteur d'échelle est multiplicatif, il ne
* déplace pas le minimum) ;
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
* recommandation quasi-immédiate), creux > dernière prise de sang
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
@ -72,6 +77,37 @@ object LabTiming {
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
const val MIN_HORIZON_HOURS = 6L
/**
* Position d'une valeur par rapport à la CIBLE DE CREUX personnelle
* (v1.13.0, opt-in). ⚠️ DISTINCT des seuils d'alerte (pk/Alerts.kt,
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB — « quand je
* pique au creux recommandé, je veux être dans [low, high] » — évaluée
* en lecture seule sur la carte, JAMAIS de notification.
*/
enum class TroughTargetStatus { IN_TARGET, BELOW, ABOVE }
/**
* Statut de [predictedE2] par rapport à la cible [low, high].
* `null` si pas de prédiction OU cible incomplète/désactivée.
* PUR → testable en JVM ; miroir web `troughTargetStatus`
* (js/pk/lab-timing.js).
*/
fun troughTargetStatus(
predictedE2: Double?,
low: Double?,
high: Double?
): TroughTargetStatus? {
val value = predictedE2 ?: return null
val lo = low ?: return null
val hi = high ?: return null
return when {
value < lo -> TroughTargetStatus.BELOW
value > hi -> TroughTargetStatus.ABOVE
else -> TroughTargetStatus.IN_TARGET
}
}
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
data class LabRecommendation(
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
@ -92,7 +128,15 @@ object LabTiming {
* arrive entre maintenant et le creux recommandé (texte « c'est le
* 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>,
doseLogs: List<DoseLog>,
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? {
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
val carrier = treatments.firstOrNull {
@ -133,13 +184,14 @@ object LabTiming {
// ── 3) Début du régime courant + date de stabilisation ──────────────
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
if (myDoses.isEmpty()) return null
val regimeStartMs = regimeStartMs(carrier, myDoses)
val stabilizedAtMs = regimeStartMs +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui
// qui gouverne les creux futurs et le texte de la carte
val lastDose = myDoses.maxByOrNull { it.timestamp }!!
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
// v1.13.0 : régime POOLÉ (cf [pooledRegimeDoses]) — le re-parenting
// d'historique entre traitements (ex. « 6d - old » créé a posteriori,
// remontée v1.12.0) ne doit pas faire croire à un nouveau régime.
val regimeStart = regimeStartMs(carrier, pooledRegimeDoses(carrier, doseLogs, treatments))
val stabilizedAtMs = regimeStart +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
val intervalDays = carrier.forecastIntervalDays!!
@ -165,25 +217,47 @@ object LabTiming {
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
var windowStart = nowMs
for (slot in slots) {
// Le creux du créneau = minimum de la courbe entre l'injection
// précédente (ou maintenant) et ce créneau
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp }
val trough = window.minByOrNull { it.e2 }
// ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
// créneau (dernier point de courbe avant l'injection — principe 1,
// « le moment le plus comparable »). L'ancien MINIMUM DE LA
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters
// à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum
// local est le creux d'absorption, quelques heures après
// l'injection PRÉCÉDENTE) : la date proposée précédait la
// stabilisation affichée sur la MÊME carte, et le texte
// « juste avant ton injection du … » était faux de plusieurs
// jours (remontée v1.9.8, épinglé par RegressionUserCase6Test).
// Pour les esters à t½ courte (EV), le point pré-injection EST
// le minimum de fenêtre : comportement inchangé.
val trough = curve.lastOrNull {
it.timestamp > windowStart && it.timestamp < slot.timestamp
}
if (trough != null &&
trough.timestamp >= minTroughMs &&
trough.timestamp > lastLabMs &&
// ON SAUTE au premier creux stabilisé (décision v1.8.0) :
// le creux d'un créneau est stabilisé si le CRÉNEAU l'est
slot.timestamp >= stabilizedAtMs
// le creux pré-injection ≈ instant du créneau, le garde
// exprime la stabilisation sur l'instant réellement proposé
// pour la prise de sang (trough < slot de ~1 pas de courbe).
trough.timestamp >= stabilizedAtMs
) {
return LabRecommendation(
troughMs = trough.timestamp,
injectionMs = slot.timestamp,
ester = activeEster,
terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStartMs,
regimeStartMs = regimeStart,
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
@ -194,6 +268,61 @@ object LabTiming {
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
private const val DOSE_EPS = 1e-6
/**
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0) : toutes les doses de TOUS les
* traitements partageant l'**ester effectif** du porteur ET son **mg
* exact** (± [DOSE_EPS]), triées par timestamp.
*
* **Pourquoi** (remontée v1.12.0) : le re-parenting d'historique — créer
* un traitement « … - old » inactif et y déplacer des doses (ex. séparer
* une ère « 6 j » d'une ère « 7 j ») — créait un TROU synthétique dans
* l'historique du porteur : le régime redémarrait à la date du
* re-parenting et la stabilisation (5 × t½) repartait de plusieurs
* semaines, alors que pharmacologiquement rien n'avait changé (même
* ester, même dose).
*
* Garde-fous (épinglés par tests) :
* - **ester ≠ → pas de mutualisation** (transition EV → EEn : deux
* régimes distincts — régression n°3) ;
* - **dose ≠ → exclue du pool** (une dose « 8 mg » au milieu d'un
* régime « 5 mg » casse naturellement la séquence via le trou qui
* suit — conservateur, ~1 semaine du côté prudent).
*
* PUR → testable en JVM (DosesExtras? non — LabTimingTest +
* RegressionUserCase7Test) ; miroir web `pooledRegimeDoses`
* (js/pk/lab-timing.js).
*/
internal fun pooledRegimeDoses(
carrier: Treatment,
allDoseLogs: List<DoseLog>,
allTreatments: List<Treatment>
): List<DoseLog> {
val last = allDoseLogs
.filter { it.treatmentId == carrier.id }
.maxByOrNull { it.timestamp } ?: return emptyList()
val activeEster = PharmacokineticEngine.doseEster(carrier, last)
val amount = last.doseAmount
val ownerById = allTreatments.associateBy { it.id }
return allDoseLogs
.filter { d ->
// Ester EFFECTIF via le traitement PROPRIÉTAIRE de la dose
// (override par dose compris) — jamais via le porteur
val owner = ownerById[d.treatmentId] ?: return@filter false
PharmacokineticEngine.doseEster(owner, d) == activeEster &&
Math.abs(d.doseAmount - amount) < DOSE_EPS
}
.sortedBy { it.timestamp }
}
/**
* Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de
* l'intervalle de Posologie — « je m'injecte le même jour de la semaine,
* à l'heure près ». Un log à 12:30 au lieu de 12:00 ne doit pas
* repousser la stabilisation de 5 × t½ (remontée v1.9.7) ; un vrai
* changement de créneau (2 j au lieu de 7 j) reste hors fenêtre.
*/
private val GAP_TOLERANCE_MS = 24L * 3_600_000L
/**
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
*
@ -202,25 +331,35 @@ object LabTiming {
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
* PAS les changements récents de dose ni d'intervalle.
*
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
* à celles de la dose la plus récente, ET (b) **l'écart entre doses
* consécutives est constant** (= l'écart des deux doses les plus
* récentes). On remonte depuis la dose la plus récente tant que ces
* conditions tiennent ; le régime commence à la dose la plus ancienne
* de la séquence.
* **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de
* sang change tout le temps à chaque injection si l'injection n'est pas
* faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart
* identique au ms près) cassait le régime pour un log à 12:30 au lieu de
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
* creux recommandé fuyait vers l'avant sans jamais se fixer.
*
* **RÈGLE** : le régime courant = la **séquence terminale de doses**
* (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
* re-parenting d'historique ne doit plus redémarrer la stabilisation)
* où (a) l'**ester effectif** et la **dose (mg)** sont identiques à
* celles de la dose la plus récente, ET (b) chaque **écart entre doses
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
* On remonte depuis la dose la plus récente tant que ces conditions
* tiennent ; le régime commence à la dose la plus ancienne de la
* séquence.
*
* Pourquoi chaque composante :
* - **ester effectif** (override par dose compris) : un switch EV → EEn
* réinitialise la stabilisation ;
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce
* qui est pharmacocinétiquement vrai (le trough n'est comparable que
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un
* vrai changement de créneau.
* - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est
* comparable que sur un intervalle régulier (pharmacocinétique), mais
* l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST
* le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors
* fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE
* THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j
* puis 7,2 j ne se déstabilisent plus en cascade.
*
* @param carrier traitement porteur (injectable E2 actif à Posologie)
* @param myDoses doses du porteur (ordre indifférent)
@ -231,18 +370,23 @@ object LabTiming {
var idx = sorted.size - 1
var regimeStart = sorted[idx].timestamp
var current = sorted[idx]
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
// (inconnu si une seule dose — la séquence s'arrête là de toute façon)
var gapExpected = -1L
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 —
// remplace la comparaison exacte v1.8.1 qui cassait le régime à
// chaque variation d'heure de log). La Posologie est un prérequis de
// la carte : absente → on s'arrête à la dernière dose (dégradé).
val intervalDays = carrier.forecastIntervalDays ?: return regimeStart
val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong()
val gapMin = intervalMs - GAP_TOLERANCE_MS
val gapMax = intervalMs + GAP_TOLERANCE_MS
while (idx > 0) {
val prev = sorted[idx - 1]
val gap = current.timestamp - prev.timestamp
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
// Remonter seulement si même (ester, dose) ET même écart
val sameGap = gapExpected < 0L || gap == gapExpected
// Remonter seulement si même (ester, dose) ET écart dans la
// fenêtre (gap > 0 : un doublon de log n'est jamais un régime)
val sameGap = gap > 0 && gap in gapMin..gapMax
if (!sameMarker || !sameGap) break
gapExpected = gap
regimeStart = prev.timestamp
current = prev
idx--

View File

@ -416,6 +416,14 @@ object PharmacokineticEngine {
): List<DoseLog> {
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
if (intervalDays <= 0.0) return emptyList()
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
// projette PLUS de créneaux — l'archivage arrête les prédictions
// d'injections futures (graphique Prévision, extension de fenêtre,
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
// FUTUR s'arrête.
if (!treatment.isActive) return emptyList()
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs <= 0L) return emptyList()
@ -736,6 +744,32 @@ object PharmacokineticEngine {
return current.copy(k = medianRounded(ks, 3))
}
/**
* Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
*
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
* pour une prise déjà faite — le rappel suivant repart au créneau
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
* S'applique identiquement au fallback quotidien (oral sans Posologie :
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
*
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
*/
fun hasDoseLoggedOnDay(
treatmentId: Long,
doseLogs: List<DoseLog>,
atMs: Long,
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
): Boolean {
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
return doseLogs.any {
it.treatmentId == treatmentId &&
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
}
}
/**
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
*

View File

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

View File

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

View File

@ -79,6 +79,15 @@ class AppSettings(private val context: Context) {
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
// les deux bandes sont volontairement indépendantes. Champ absent =
// cible désactivée (comportement v1.12).
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
}
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -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é
* (DataStore : remove) — « champ vide = pas d'alerte ».

View File

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

View File

@ -209,7 +209,14 @@ fun ChartScreen() {
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
// ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
// Posologie d'un traitement archivé ne doit ni activer le chip
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
// créneaux, cf generateForecastDoses).
val maxIntervalDays = treatments
.filter { it.isActive }
.mapNotNull { it.forecastIntervalDays }
.maxOrNull()
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──

View File

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

View File

@ -78,6 +78,7 @@ private const val HOUR_MS = 3_600_000L
@Composable
fun HomeScreen(
openLogDoseForTreatmentId: Long?,
onLogDoseConsumed: () -> Unit,
onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit
@ -113,10 +114,16 @@ fun HomeScreen(
var showLogDialog by remember { mutableStateOf(false) }
var preselectId by remember { mutableStateOf<Long?>(null) }
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
LaunchedEffect(openLogDoseForTreatmentId) {
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
showLogDialog = true
preselectId = it
onLogDoseConsumed()
}
}
@ -234,14 +241,20 @@ fun HomeScreen(
// (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick
allTreatments, doseLogs, labResults, tick, effectiveAuto
) {
value = withContext(Dispatchers.Default) {
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
// alimentent la prédiction « ≈ X pg/mL au creux » (null
// si l'option est OFF → carte sans valeur, inchangée)
LabTiming.nextBloodDrawRecommendation(
allTreatments, doseLogs, labResults, System.currentTimeMillis()
allTreatments, doseLogs, labResults, System.currentTimeMillis(),
effectiveAuto?.esterScales
)
}
}
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
labRecommendation?.let { rec ->
val fmtDate = remember {
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
@ -263,6 +276,37 @@ fun HomeScreen(
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut face à la
// cible de creux personnelle (lecture seule — jamais
// de notification, distinct des seuils d'alerte)
rec.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text(
stringResource(
R.string.labrec_before_injection,

View File

@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue
@ -143,14 +145,30 @@ fun LabsScreen() {
}
}
val nowMs = remember(tick) { System.currentTimeMillis() }
// Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la
// carte reco (null si l'option est OFF) — même pattern que HomeScreen.
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, treatments, doseLogs, labs, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) null
else withContext(Dispatchers.Default) {
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
}
}
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
// (courbe prévisionnelle), même discipline que Home/Chart.
val recommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
treatments, doseLogs, labs, nowMs
treatments, doseLogs, labs, nowMs, effectiveAuto
) {
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
@ -193,6 +211,36 @@ fun LabsScreen() {
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut cible —
// lecture seule, jamais de notification (cf §9.bis)
r.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text(
stringResource(
R.string.labrec_before_injection,

View File

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

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

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

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

View File

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

View File

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

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

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

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

@ -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,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,247 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.13.0] — 2026-09-30 (versionCode 50)
### Ajouté — la reco de prise de sang devient prédictive (3 niveaux clairs)
- **Valeur E2 attendue au creux recommandé** : la carte (Accueil + Analyses)
affiche désormais « ≈ X pg/mL » quand l'auto-calibration fournit ses
facteurs — sinon rien (une valeur brute non calibrée serait trompeuse).
Tu sais AVANT de piquer si le creux tombera dans ta cible.
- **Régime POOLÉ entre traitements identiques** : `pooledRegimeDoses` — les
doses de tous les traitements partageant (ester effectif, mg) forment UNE
séquence. Un re-parenting d'historique (créer un « … - old » et y
déplacer des doses) ne fait plus redémarrer la stabilisation. Garde-fous
épinglés : ester ≠ → jamais poolé (transition EV→EEn) ; dose ≠ → exclue
(le 8 mg casse le régime, trou conservateur).
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle « je
veux être dans [basse, haute] au creux recommandé » — la prédiction est
colorée dans/hors cible sur la carte. ⚠️ **DISTINCT des seuils d'alerte**
(§9.bis) : les seuils surveillent le niveau en continu et notifient ; la
cible est une référence de lab, elle ne notifie JAMAIS. Voyage dans le
backup (champs optionnels rétrocompatibles — un backup ancien n'efface
pas la cible locale).
- **Régression n°7** épinglée sur données réelles (`backup-v1.12.0.json`,
gitigné — l'export avec le re-parenting « 6d-old ») : le régime poolé
traverse la frontière de re-parenting et reste arrêté par le 8 mg.
Vérifié : **284 tests JVM** (250 sans données locales) + **14 tests UI** +
lint verts ; **web 199 tests + E2E verts** (`check.sh`).
## [1.12.0] — 2026-09-29 (versionCode 49)
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
- **Remontée** : « une injection qui n'est plus active est toujours prise en
compte dans le graphique de simulations si elle contient une posologie et
qu'elle a déjà été utilisée — un traitement mis en inactif ne devrait
plus être pris en compte pour les futures prédictions ».
- **Cause** : `generateForecastDoses` (source unique des créneaux
prévisionnels) ne vérifiait pas l'archivage ; de plus l'horizon du chip
Prévision du graphique incluait les Posologies INACTIVES.
- **Fix** : garde `!isActive` dans le moteur (l'historique reste simulé —
§6.bis inchangé — seul le FUTUR s'arrête, comme les rappels depuis
v1.4.0) + horizon/chip Prévision calculés sur les ACTIFS uniquement.
**Miroir web appliqué** (`js/pk/pk-engine.js` + `js/ui/chart.js`).
- Tests : 3 tests JVM (`ForecastDosesGuardTest`) + 3 miroirs web ;
émulateur avec données modifiées (2 inactifs à Posologie 7 j) :
Prévision on/off sans crash.
### Ajouté — icônes de forme de prise dans la page Doses
- Chaque ligne de dose porte désormais l'icône de sa forme : 💊-icône pilule
(oral), seringue (injection IM et SC — teinte distincte pour la SC),
goutte (gel), sparadrap (patch), compte-gouttes (autre). Miroir web :
émojis (`routeGlyph`, même mapping).
### Ajouté — marqueurs « prise de sang le même jour » sur les lignes de doses
- Une analyse de sang effectuée LE MÊME JOUR qu'une dose affiche un petit
encart latéral 🧪↑ (avant la dose) et/ou 🧪↓ (après) — pour visualiser
les prises de sang dans le tableau de dosages (demande utilisatrice).
Fenêtre = jour calendaire LOCAL (au-delà le lien clinique devient ténu).
Helper pur `labMarkersForDose` (Kotlin + miroir web) — 7 tests JVM
(`DosesExtrasTest` : mapping icônes + marqueurs, frontière minuit,
convention « à l'heure de la dose = après ») + 10 miroirs web.
- Vérifié émulateur avec données réelles : glyphes en tête de liste,
marqueur « Blood test later the same day » sur la dose du 10 sept
(analyse 35 min après).
Vérifié : **274 tests JVM** (240 sans données locales) + **14 tests UI** +
lint verts ; **web 194 tests + E2E verts** (`check.sh`).
## [1.11.0] — 2026-09-29 (versionCode 48)
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
- **Demande** : « les traitements inactifs sont peu discernables par rapport
aux traitements actifs — les placer à la fin (tout en bas) de la liste
pour éviter les confusions ».
- **Tri** : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**, sous
un en-tête de section « Traitements inactifs » (n'apparaît que s'il
existe des inactifs). Tri STABLE : l'ordre relatif à l'intérieur de
chaque groupe est conservé — le tri ne réordonne jamais les actifs entre
eux, ni les inactifs entre eux.
- **Distinction visuelle renforcée** : les cartes inactives sont ATTÉNUÉES
(alpha 0,55) en plus du badge « inactif » — second signal demandé
(« peut-être les rendre plus distincts ») ; elles restent lisibles et
tappables (l'édition d'un traitement archivé doit rester possible).
- Helper pur `treatmentsForDisplay` (Kotlin + **miroir web** en
`js/data/models.js`) — 5 tests JVM (`TreatmentsDisplayTest`) + 4 tests
web (`tests/treatments-display.test.js`).
- Vérifié émulateur avec données réelles seedées : les 5 actifs en haut,
en-tête puis les 2 inactifs (EV-old, EEn-old) tout en bas.
Vérifié : **264 tests JVM** (230 sans données locales) + **14 tests UI** +
lint verts ; **web 184 tests + E2E verts** (`check.sh`).
## [1.10.1] — 2026-09-28 (versionCode 47)
### Amélioré — durcissement du système de rappels (revue de code post-v1.10.0)
- **`launchMode="singleTop"` sur MainActivity** : un tap sur la notification
quand l'app est DÉJÀ ouverte délivre désormais `onNewIntent` (dialog de
saisie ouvert, extras traités) **sans détruire/recréer l'activité** —
l'ancien combo `standard` + `CLEAR_TOP` recréait tout et rendait le chemin
`onNewIntent` de la v1.10.0 inopérant (code mort).
- **Instant PLANIFIÉ dans l'alarme (`EXTRA_PLANNED_AT`)** : le garde
« créneau déjà honoré » (v1.10.0) évaluait le jour au moment du FEU — une
alarme inexacte (`setWindow`) ou retardée (Doze) glissant après minuit
évaluait le mauvais jour (23:58 → 00:02). L'instant planifié voyage
désormais dans l'intent de l'alarme, le snooze le FORWARDE tel quel, et
le garde évalue toujours le jour du CRÉNEAU. Vérifié sur émulateur par
broadcast avec `planned_at` daté d'hier → notification correctement
sautée.
- **`BootReceiver` consolidé** (dette §20.bis #2) : `goAsync` + coroutine IO
(pattern ReminderReceiver) — remplace le `Thread` + double `runBlocking`
de la v1.0 ; une seule lecture séquentielle des DAO, `finish()` garanti.
- Audit doc : comptes de tests périmés corrigés (§14 note #64, §16.ter).
Vérifié : 259 tests JVM + 14 tests UI + lint verts ; scénarios émulateur
(singleTop + planned_at). Web non concerné (aucune logique miroir touchée).
## [1.10.0] — 2026-09-27 (versionCode 46)
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
- **Remontée** : « la recommandation suggère une date antérieure à la
stabilisation des taux » (données réelles de la remontée → 6ᵉ régression
épinglée `local-test-data/backup-v1.9.8.json`, gitigné).
- **Cause (double)** : le « creux » était le **minimum de la fenêtre entière**
(créneau N−1 → créneau N). Pour un ester à montée lente (EEn : pic ~J+5 ≈
intervalle 7 j), ce minimum tombe **juste après l'injection PRÉCÉDENTE**
(creux d'absorption, ~3 h) — pas juste avant la suivante : la date proposée
était gouvernée par l'injection N−1, elle-même antérieure à la
stabilisation, et le texte « juste avant ton injection du … » était faux de
plusieurs jours. Balayage sur ±60 jours de `now` (test) : creux 5 oct 02:32
vs stabilisation 7 oct 04:03.
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
courbe avant l'injection) — restaure le principe v1.8.0 documenté (« juste
avant l'injection suivante, le moment le plus comparable »), rend la carte
cohérente (creux 46 min avant le créneau étiqueté) et le garde de
stabilisation s'exprime sur l'instant réellement proposé. Pour les esters à
t½ courte (EV), le point pré-injection EST le minimum de fenêtre :
comportement inchangé. **Miroir web appliqué** (`js/pk/lab-timing.js`).
- Tests : régression n°6 data-driven (4 tests dont un balayage de `now` sur
±60 j — l'invariant « creux ≥ stabilisation » doit tenir À TOUT INSTANT) ;
les 17 LabTimingTest passent inchangés (leur sémantique était celle du fix).
### Corrigé — le dialog de dose se rouvrait à chaque retour sur l'accueil (#69)
- **Remontée** : tap sur la notification de rappel → le dialog de saisie
s'ouvre ; le fermer puis changer d'onglet et revenir sur l'accueil le
ROUVRAIT à chaque fois (reproduit et revérifié sur émulateur).
- **Cause** : la demande vivait au niveau ACTIVITÉ (paramètre d'intent) mais
la consommation vivait dans un `LaunchedEffect` de Home — quitter l'onglet
détruit les `remember` locaux, le paramètre survivait → à chaque
ré-entrée en composition, le dialog se rouvrait.
- **Fix** : la demande est un état `MutableState<Long?>` détenu par
MainActivity (survit aux onglets), consommé UNE fois par HomeScreen ;
les extras sont retirés de l'intent après lecture (un recreate ne doit
jamais rouvrir une notification traitée) ; et un tap notification quand
l'app est déjà ouverte ouvre désormais aussi le dialog (avant :
silencieusement perdu).
- Tests : nouveau test UI instrumenté `LogDoseRequestTest` (ouverture 1× +
plus jamais après aller-retour d'onglets) + vérification émulateur.
### Ajouté — la notification de rappel saute un créneau déjà honoré
- **Demande** : si la dose prévue du jour est saisie AVANT l'heure de
rappel, la notification ne doit pas sonner pour une prise déjà faite ; le
rappel suivant repart au créneau suivant (idem pour le quotidien des
oraux sans Posologie).
- **Implémentation** : garde au moment du déclenchement —
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL,
testable) consultée par `ReminderReceiver` : dose loguée aujourd'hui →
notification sautée (AppLog) + re-programmation du créneau suivant dans
tous les cas. Robuste quel que soit le moment de la saisie.
- Tests : 6 tests JVM (`ReminderLoggedDayTest` : même jour, veille,
frontière minuit, autre traitement, liste vide, mélange) + vérification
A/B sur émulateur (EEn dose-loguée-ce-jour → 0 notif ; Fluoxetine sans
dose → 1 notif).
Vérifié : **259 tests unitaires** (dont régression n°6 sur données réelles)
+ **14 tests UI** + lint vert ; web miroir : **180 tests + E2E verts**
(`check.sh`). Version web alignée au moment de la release (checklist 4.bis).
## [1.9.8] — 2026-09-22 (versionCode 45)
### Ajouté — tests UI Compose, signature release dédiée, README anglais
- **13 tests UI instrumentés** (navigation 5 onglets, dialog « Nouveautés »
— épinglage du fix v1.3.2 « ne réapparaît pas après dismiss » —, sections
Paramètres, version installée = BuildConfig : le bug #46 « versions jamais
bumpées » ne peut plus revenir silencieusement). Déterministes : permission
de notifications pré-grantée, zéro dépendance aux données, indépendants de
la langue FR/EN. Exécution : `./gradlew connectedDebugAndroidTest`.
- **Signature release dédiée, prête à l'emploi** : `scripts/make-release-keystore.sh`
crée le keystore + `keystore.properties` (gitignorés) ; `assembleRelease`
l'utilise dès qu'il existe (sinon clé debug inchangée). Migration
téléphone documentée (export JSON → désinstallation → réinstallation →
réimport). Aucun changement pour l'utilisatrice tant que la bascule n'est
pas faite.
- **README anglais** ([README.en.md](README.en.md)) — lien FR/EN en tête de
chaque fichier ; la doc de développement reste en français.
### Modifié — écran Paramètres découpé (maintenance)
- `SettingsScreen.kt` (840 lignes) devient un orchestrateur de 377 lignes +
11 cartes thématiques dans `ui/screens/settings/` (langue, calibration,
modèle T, seuils, fuseau, rappels, sauvegarde, auto-backup, logs, à propos).
**Comportement et rendu strictement identiques** (déplacement pur) ;
validé par les tests UI + smoke test RELEASE sur émulateur (toutes les
sections, version 1.9.8, 0 crash — anti-bug #64).
- `ChartScreen` reste volontairement entier : sa garde de source (#54)
exige le bloc de gestes dans ce fichier.
Vérifié : 249 tests unitaires + 13 tests UI verts, lint vert, APK debug +
release (2,7 Mo) buildés, smoke test release complet (accueil, 5 onglets,
Paramètres, éditeur de traitement, dialog de dose — 0 crash).
## [1.9.7] — 2026-09-20 (versionCode 44)
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
- **Remontée** : « la suggestion de prochain test sanguin change tout le
temps à chaque injection si l'injection n'est pas faite pile à la même
heure ou si elle est faite 30 minutes plus tard que la précédente ».
- **Cause** : la règle de stabilisation v1.8.1 comparait l'écart inter-doses
**EXACTEMENT** à l'écart précédent (au ms près) — un log à 12:30 au lieu
de 12:00 cassait le régime, repoussant la stabilisation de 5 × t½ à
CHAQUE injection : le creux recommandé fuyait vers l'avant sans jamais
se fixer.
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
Posologie ± 24 h** (`GAP_TOLERANCE_MS` — « je m'injecte le même jour, à
l'heure près »). La fenêtre se réfère à l'INTERVALLE THÉORIQUE, pas à
l'écart précédent : des logs à 6,8 j puis 7,2 j ne se déstabilisent plus
en cascade. Un vrai changement de créneau (2 j au lieu de 7 j) reste
hors fenêtre et réinitialise, comme avant. Le créneau affiché se cale
toujours sur la dernière dose réelle (voulu).
- Tests : le test v1.8.1 « interval change » ré-épinglé (NOW+21 j) + 2
nouveaux (flou d'heure ± 23 h → PAS de reset ; écart 25 h → reset).
249 tests verts + lint. Web miroir v1.9.7 (12 tests lab-timing).
## [1.9.6] — 2026-09-20 (versionCode 43)
### Modifié — la carte de l'Accueil liste TOUTES les prochaines prises

View File

@ -11,12 +11,12 @@
1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées — à jour v1.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)
5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
8. [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)
10. [UI & navigation](#10-ui--navigation)
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
@ -24,7 +24,7 @@
13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.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)
18. [Espace disque & coûts](#18-espace-disque--coûts)
19. [Limites connues](#19-limites-connues)
@ -63,6 +63,13 @@ Fonctionnalités v1 :
| Date | Événement |
|---|---|
| 30 sept. 2026 (session v1.13.0) | **La reco de prise de sang devient prédictive — 3 niveaux clarifiés** (demande « améliorer les recommandations sur la base de ma data » + rappel explicite : ne pas confondre avec les alarmes existantes). **Système à 3 niveaux documentés (§7.11)** : seuils d'alerte (continu + notifications, §9.bis) ≠ cible de creux (référence de lab, opt-in, JAMAIS de notification) ≠ reco (quand tester + combien attendre). (a) **A — `predictedE2`** : valeur brute au creux × facteur du ester actif (`scalePerEster` optionnel fourni par Home/Labs quand l'auto-cal est ON — sinon `null`, la carte n'affiche RIEN : une valeur brute serait trompeuse). (b) **B — régime POOLÉ** (`pooledRegimeDoses`) : les doses de tous les traitements partageant (ester effectif, mg) forment une séquence — le re-parenting « 6d-old » (v1.12.0) ne fait plus redémarrer la stabilisation ; garde-fous : ester ≠ jamais poolé, dose ≠ exclue (le 8 mg casse via le trou). Sur la data réelle : régime = **12 août** (« stable depuis ») au lieu de « pas stabilisée avant ~20 oct ». (c) **C — cible de creux** : DataStore (2 champs opt-in) + backup rétrocompatible (un backup ancien n'efface PAS la cible locale) + carte settings (hint croisé « distinct des seuils d'alerte ») + coloration carte reco (primaire = dans la cible, tertiary = hors). **Régression n°7** data-driven sur le nouvel export (`backup-v1.12.0.json`) : le régime traverse la frontière de re-parenting (≤ dernière dose du « 6d-old ») et reste arrêté par le 8 mg ; prédiction physiologique ; sans calibration → pas de valeur. ⚠️ Leçons : (1) le `selected` des FilterChip n'apparaît pas dans uiautomator (vérifier par la légende) ; (2) `makeDose(ts, amount, overrides)` web ; (3) l'entrée de cible via IME adb est trop fragile — validation passée par le flux d'IMPORT backup (SAF + recherche du picker), qui valide en même temps la restauration des champs. Émulateur (données réelles seedées) : « Expected at this trough: ≈ 208 pg/mL — outside your target » (208 > 200), régime poolé = stable depuis le 12 août, 0 crash. **284 tests JVM** (250 sans données locales) + 14 UI + lint ; **web 199 tests + E2E** (check.sh) — leçon E2E : bump de WEB_VERSION SANS section CHANGELOG = dialog vide (v1.7.1 re-mordue). **Publication** : commit ae14cee + tag v1.13.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 84, farewell id 1588598) ; **web v1.13.0 sync complet** (commit 902d521 + tag + **releases publiées sur les 2 instances — étape 4 incluse**, zip 196 Ko vérifié par téléchargement). |
| 29 sept. 2026 (session v1.12.0) | **Fix #70 + 2 améliorations Doses** (demandes). (a) **#70 — inactif à Posologie projetait encore** : `generateForecastDoses` (source unique des créneaux) ne vérifiait pas l'archivage, et l'horizon du chip Prévision incluait les Posologies INACTIVES. FIX : garde `!isActive` dans le moteur + horizon/chip sur les ACTIFS — §6.bis inchangé (historique toujours simulé, seul le FUTUR s'arrête, comme les rappels v1.4.0). **Miroir web** (pk-engine.js + chart.js). 3 tests JVM + 3 web. Émulateur : seed modifié (2 inactifs à Posologie 7 j), Prévision on/off 0 crash — leçon uiautomator : l'attribut `selected` des FilterChip ne reflète PAS la sélection Compose (vérifier par la LÉGENDE attendue, comme dit la recette §16.ter). (b) **Icônes de forme de prise** dans Doses (demande) : `routeIcon` pur — pilule/seringue (IM+SC, teinte tertiary pour SC)/goutte/sparadrap/compte-gouttes ; ⚠️ `MedicationLiquid` N'EXISTE PAS dans icons-extended 1.7.8 (résolu hors BOM) → `Colorize` pour OTHER (vérifié au jar). (c) **Marqueurs « prise de sang le même jour »** : `labMarkersForDose` pur (jour calendaire LOCAL, lab à l'heure exacte = APRÈS — convention documentée) → encart latéral 🧪↑/🧪↓ sur la ligne ; miroir web émojis + title. 7 tests JVM `DosesExtrasTest` + 10 web `doses-extras.test.js` (⚠️ helper web `makeDose(ts, amount, overrides)` — le 1ᵉʳ arg est le TIMESTAMP). Validé émulateur : content-desc des icônes (IM injection/Oral) + marqueur « Blood test later the same day » sur la dose du 10 sept. 274 tests JVM + 14 UI + lint ; web 194 tests + E2E (check.sh). **Publication** : commit b3372a8 + tag v1.12.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 82, farewell id 1588590) ; web v1.12.0 sync (commit 4bdcd52 + tag, farewell = miroir serveur déjà répliqué). **⚠️ OUBLI de l'utilisatrice : les RELEASES web n'avaient pas été publiées depuis v1.9.7** (étape 4 du processus web sautée — tags seuls) → backfill le jour même : v1.10.0, v1.11.0, v1.12.0 sur cloudyfy ET farewell + **v1.9.5 backfillée sur farewell** (jamais publiée) ; zips vérifiés par téléchargement ; leçon documentée §10 web (« commit + tag » ≠ « release »). |
| 29 sept. 2026 (session v1.11.0) | **Page Traitements : inactifs regroupés en bas + atténués** (demande : « peu discernables — les placer à la fin, et peut-être les rendre plus distincts »). Helper PUR `treatmentsForDisplay` (tri STABLE : actifs d'abord, inactifs après, ordre relatif conservé DANS chaque groupe) + LazyColumn en 3 sections (actifs / en-tête `inactive_section` SEULEMENT si des inactifs existent / inactifs `dimmed = true`) + cartes inactives **alpha 0,55** en plus du badge (lisibles et tappables — l'édition d'un archivé doit rester possible). **Miroir web** : `treatmentsForDisplay` dans `js/data/models.js` (node-safe, pas de DOM) + rendu sections + `opacity:0.55` + clés i18n `inactive_section` FR/EN. Tests : **5 JVM** (`TreatmentsDisplayTest` : regroupement, stabilité, tout-actif, tout-inactif, vide) + **4 web** (`tests/treatments-display.test.js`) — pas de test UI instrumenté pour l'en-tête (dépendrait de données inactives, contre la convention data-indépendance §8.ter) : couverture JVM + validation émulateur (5 actifs en haut, en-tête + EV-old/EEn-old tout en bas, dumps tr1/tr2). 264 tests JVM + lint ; web 184 tests + E2E verts (check.sh). **Publication** : commit a0f3ac5 + tag v1.11.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 80, farewell id 1588588) ; web v1.11.0 sync (commit 0dd5fa7 + tag, farewell = miroir serveur déjà répliqué). |
| 28 sept. 2026 (session v1.10.1) | **Durcissement rappels — revue de code post-v1.10.0** (audit demandé : « optimisations ou problèmes, web et Android »). (a) **`launchMode="singleTop"`** : le combo `standard`+`CLEAR_TOP` recréait l'activité au tap notification app-ouverte et rendait le `onNewIntent` de la v1.10.0 inopérant (code mort) — désormais délivré SANS recreation (état UI préservé). (b) **`EXTRA_PLANNED_AT`** : le garde « créneau déjà honoré » évaluait le jour au moment du FEU — une alarme inexacte (`setWindow`/Doze) glissant 23:58→00:02 évaluait le mauvais jour ; l'instant planifié voyage dans l'intent (scheduleFor), le snooze le FORWARDE, le garde évalue le jour du CRÉNEAU ; vérifié émulateur (broadcast `planned_at` hier → notif sautée). (c) **`BootReceiver` consolidé** (dette §20.bis #2 FAIT) : `goAsync`+coroutine, `runBlocking` supprimés. (d) Style (import `mutableStateOf`) + audit doc (comptes 223 résiduels §14/#64 et §16.ter → 259 ; §9 et §20.bis à jour). Web NON concerné (miroir lab-timing laissé tel quel — boucle correcte et testée ; `importJson` par lots reste en dette). 259 tests JVM + 14 UI + lint verts. **Publication** : commit d531eca + tag v1.10.1 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 78, farewell id 1588586) ; validation §16.ter sur l'APK RELEASE AVANT tag (accueil + données, singleTop, planned_at A/B, 0 crash). |
| 27-28 sept. 2026 (session v1.10.0) | **2 fixes + 1 amélioration rappels** (remontées + nouvelles données réelles → 6ᵉ régression épinglée `backup-v1.9.8.json`). (a) **#68 — reco de prise de sang antérieure à la stabilisation** : le « creux » = minimum de la FENÊTRE ENTIÈRE tombait juste après l'injection PRÉCÉDENTE pour les esters à montée lente (EEn pic ~J+5 ≈ intervalle 7 j → creux d'absorption ~3 h post-injection) — date gouvernée par l'injection N−1 (antérieure à la stab) et « juste avant ton injection du … » faux de plusieurs jours (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau PRÉ-INJECTION du créneau (restaure le principe v1.8.0 documenté ; EV inchangé — le point pré-injection EST son min de fenêtre) ; **miroir web appliqué** (lab-timing.js). Tests : RegressionUserCase6Test data-driven dont balayage de `now` ±60 j (invariant « creux ≥ stab » à TOUT INSTANT) ; les 17 LabTimingTest passent inchangés. (b) **#69 — dialog fantôme** : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le ROUVRAIT à chaque fois (paramètre d'activité vs consommation dans les remember de Home, détruits à chaque sortie d'onglet — reproduit sur émulateur AVANT fix). FIX : `MutableState<Long?>` détenu par MainActivity, consommé UNE fois (extras retirés de l'intent après lecture ; bonus : tap notification app-ouverte ouvre aussi le dialog). Test UI `LogDoseRequestTest` + vérification émulateur. (c) **Rappel sauté si dose déjà loggée** (demande) : garde au déclenchement — `hasDoseLoggedOnDay` (pur, jour calendaire LOCAL) dans ReminderReceiver → notif sautée + re-programmation créneau suivant dans tous les cas ; 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine sans dose → 1 notif). **259 tests JVM + 14 UI + lint** ; **web 180 tests + E2E verts** (check.sh). **Publication** : commit a4f859f + tag v1.10.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 77, farewell id 1588583). **Web miroir v1.10.0 sync** (fix #68 lab-timing, WEB_VERSION alignée, commit b865b16 + tag poussés ; #69/garde rappel = Android-only, notés dans le changelog web). |
| 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). **Publication** : commit 09cddfc + tag v1.9.8 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 75, farewell id 1588581). Web miroir NON concerné (v1.9.8 = socle Android uniquement : tests instrumentés, signature APK, découpage Compose, README du dépôt Android — rien à porter côté web, qui reste à v1.9.7). |
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
| 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. |
@ -116,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
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ù |
|---|---|---|
@ -130,7 +137,8 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
| AppCompat | 1.8.0 (langue par app) | idem |
| DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation) | 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 |
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
@ -200,10 +208,14 @@ MainActivity (AppCompatActivity)
```
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`),
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` +
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log
demande POST_NOTIFICATIONS (API 33+) ; demande « logger une dose »
(`open_log_dose` + `treatment_id` venus de la notification) = `MutableState<Long?>`
détenu par l'activité, **consommé UNE fois** par Home via
[HormoneTrackRoot] (fix #69 — extras retirés de l'intent après lecture,
tap notification app-ouverte géré par `onNewIntent`)
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
## 6. Modèle de données (Room)
@ -254,6 +266,7 @@ simulation de tout l'historique EV disparaissait) :
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` |
| ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) |
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs |
| ✅ **Plus AUCUN créneau prévisionnel (v1.12.0, fix #70)** | `generateForecastDoses` garde `!isActive` — ni graphique Prévision, ni extension de fenêtre, ni horizon LabTiming ; le chip Prévision du graphique ne considère que les Posologies ACTIVES |
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
@ -621,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½
terminale après le dernier changement (≈ 97 % de l'équilibre).
**v1.13.0 — le suivi s'appuie sur un système à TROIS niveaux distincts** :
| Niveau | Rôle | Notifications |
|---|---|---|
| Seuils d'alerte (§9.bis, `pk/Alerts.kt`) | surveiller le niveau estimé **en continu** | ✅ carte accueil + worker 15 min |
| **Cible de creux** (v1.13.0, opt-in) | référence **de lab** : « au creux recommandé, je veux être dans [basse, haute] » | ❌ jamais — lecture seule |
| Reco de prise de sang (cette section) | **quand** tester + **combien attendre** (`predictedE2`, v1.13.0) | — |
Les bandes sont volontairement indépendantes (ex. réelles : alertes
210–350, cible de creux 150–200 — la cible basse est SOUS l'alerte basse,
les fusionner serait faux).
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
lien Posologie → recommandation). Les deux encarts sont exclusifs.
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ;
**Estrannaise** : lue dans la table par
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
≥ 1 % du pic, 48 h précédentes — est partagée).
- **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ;
**Estrannaise analytique** depuis v1.9.0
([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ;
l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12).
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
critique : « changé d'ester, de dosage ET de posologie, et l'app disait
stabilisée depuis février ») : le régime courant = la **séquence terminale
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
à la dose la plus récente, ET (b) **l'écart entre doses consécutives est
constant** (= l'écart des deux doses les plus récentes, comparé
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart +
à la dose la plus récente, ET (b) **l'écart entre doses consécutives
reste dans l'intervalle de Posologie ± 24 h** (v1.9.7 — la comparaison
exacte v1.8.1 cassait le régime à chaque variation d'heure de log et
faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure
près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste
hors fenêtre). **v1.13.0 : séquence POOLÉE entre traitements**
([LabTiming.pooledRegimeDoses]) — les doses de tous les traitements
partageant (ester effectif, mg) forment UNE séquence : le re-parenting
d'historique (« … - old » créé a posteriori, remontée v1.12.0) ne fait
plus redémarrer la stabilisation. Garde-fous : ester ≠ → jamais poolé
(transition EV→EEn, régression n°3) ; dose ≠ → exclue (le 8 mg casse le
régime via le trou qui suit — conservateur). Sur la data réelle de la
remontée : régime = 12 août (« stable depuis ») au lieu de « pas
stabilisée avant ~20 oct ». Stabilisation = régimeStart +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier).
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
injections ; FORME brute (sans calibration — le facteur ne déplace pas le
minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la
stabilisation, borné à 12.
- **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau —
dernier point de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant
l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière »
tombait juste APRÈS l'injection précédente pour les esters à montée
lente (EEn : pic ~J+5 ≈ intervalle 7 j → creux d'absorption) : date
antérieure à la stabilisation + texte « juste avant ton injection » faux
de plusieurs jours. Pour EV (t½ courte) le point pré-injection EST le
minimum de fenêtre → comportement inchangé. FORME brute (sans
calibration — le facteur ne déplace pas le minimum) ; horizons : au
moins 3 créneaux, étendu pour couvrir la stabilisation, borné à 12.
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux
du créneau suivant — décision v1.8.0 « sauter »).
recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le
garde s'exprime sur le creux lui-même, instant réellement proposé).
- **Valeur prédite + cible (v1.13.0)** : `LabRecommendation.predictedE2`
= valeur brute du modèle au creux × facteur du ester actif
(`scalePerEster` optionnel, fourni par Home/Labs quand l'auto-cal est ON ;
`null` → la carte n'affiche RIEN — une valeur brute non calibrée serait
trompeuse) ; statut face à la cible de creux personnelle via
[LabTiming.troughTargetStatus] (IN_TARGET/BELOW/ABOVE, bornes incluses,
`null` si cible absente) → coloration primaire (dans la cible) /
tertiary (hors) + suffixe « dans/hors de ta cible ».
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- 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`
@ -732,9 +785,9 @@ devient à son tour**.
## 8. Tests unitaires
**234 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans
les données de test locales** (cf §8.bis : les 5 classes de régression,
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
**284 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **250 sans
les données de test locales** (cf §8.bis : les 6 classes de régression sont
skippées via `Assume`). Dépendance : JUnit 4.13.2.
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/…`).
@ -941,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`,
`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) ;
`RegressionUserCaseTest` lit v1.0.0,
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
épine le fix « une note sur deux » v1.7.1) ;
é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.
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
1. `git check-ignore -v local-test-data/…` → la règle matche ;
@ -962,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).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 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,
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
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
@ -991,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
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
(UI/logic Android), tests Compose, lint.
**Toute modification du moteur passe par ces tests.** Suite envisagée : Robolectric
(UI/logic Android — les tests Compose existent depuis v1.9.8, cf §8.ter).
### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8)
**14 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil :
```bash
./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36)
```
- **`NavigationTest` (7)** : la barre d'onglets affiche les 5 entrées, chaque
onglet mène bien à son écran (preuves = éléments INCONDITIONNELS : chips de
plage « 24 h » du graphique, FAB « + » de Doses/Analyses/Traitements —
indépendants des données Room), et les Paramètres s'ouvrent depuis l'icône
de l'accueil ;
- **`ChangelogDialogTest` (3)** : dialog « Nouveautés » affiché quand la
version installée est plus récente que la dernière vue, absent sinon, et
**ne réapparaît pas après dismiss + redémarrage** (épinglage du fix v1.3.2
en test UI) ;
- **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes
présentes + chips de langue littéraux (« Français »/« English ») +
**version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions
jamais bumpées dans les tags — ne peut plus revenir silencieusement) ;
- **`LogDoseRequestTest` (1, v1.10.0)** : le dialog de dose s'ouvre UNE fois
au lancement avec les extras de notification, puis ne se rouvre JAMAIS
après fermeture + aller-retour d'onglets (épinglage du fix #69). Lancement
manuel avec intent (règle vide + ActivityScenario) ; BACK = fermeture
indépendante des libellés (UiDevice, dep `uiautomator:2.3.0`).
**Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) :
- permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le
lancement (sinon le dialog SYSTÈME masque l'app → timeout) ;
- dialog « Nouveautés » neutralisé en marquant la version vue
(`UiTestEnv.markChangelogSeen`) SAUF pour les tests qui le ciblent — ceux-ci
lancent l'activité MANUELLEMENT (`ActivityScenario` + `createEmptyComposeRule`)
pour régler le DataStore AVANT la lecture au démarrage ;
- assertions de texte via `targetContext.getString(...)` : résolues dans la
MÊME configuration que l'UI (per-app locales incluses) → indépendant de la
langue système et du réglage in-app FR/EN ;
- aucune assertion dépendante des données Room (listes/curbes) — les tests
restent verts sur émulateur vierge OU seedé (§16.ter).
⚠️ `assertExists`/`assertDoesNotExist` sont des MEMBRES de
`SemanticsNodeInteraction` depuis Compose 1.12 (plus des extensions à importer).
## 9. Système de rappels
@ -1016,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
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
reprogramme le prochain déclenchement** (goAsync + IO)
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
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
@ -1043,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)
> ⚠️ À NE PAS CONFONDRE avec la **cible de creux** (v1.13.0, §7.11) : les
> seuils ci-dessous surveillent le niveau estimé EN CONTINU et notifient ;
> la cible de creux est une référence de lab évaluée en lecture seule sur
> la carte reco — elle ne notifie jamais.
- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
@ -1107,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
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
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)
- `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
@ -1117,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
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur
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 ;
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
@ -1135,8 +1265,19 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
fermable, ne réapparaît pas avant la prochaine mise à jour.
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
- `SettingsScreen` (**v1.9.8 : découpé en cartes thématiques** — 840 lignes
→ orchestrateur de 377 + 11 fichiers de 33–164 lignes dans le sous-package
`ui/screens/settings/`, un par carte : `LanguageCard`, `AutoCalibrationCard`,
`TModelCard`, `AlertThresholdsCard`, `ChartTimezoneCard`, `RemindersCard`,
`BackupCard`, `AutoBackupCard`, `LogsCard`, `AboutCard` + `SettingsFormat`
(helpers `formatDoubles`/`folderDisplayName`). L'orchestrateur garde ce qui
est PARTAGÉ : collecte DataStore, les 4 launchers SAF (leurs callbacks
écrivent `message`/dialog d'import), dialog de confirmation d'import,
ordre des cartes. Comportement et rendu IDENTIQUES (déplacement pur avec
les commentaires historiques — validé par les tests UI §8.ter + smoke
release §16.ter) ; contenu fonctionnel inchangé : langue
(Système/Français/English, chips reflétant l'état) ; **carte « Seuils
d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
@ -1144,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,
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
Export/Import JSON ; à 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,
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
@ -1232,7 +1378,10 @@ Pièges :
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
appliquée sur le thread MAIN car `setApplicationLocales` recrée
l'activité) 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 »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
@ -1710,7 +1859,7 @@ l'optimiseur.
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel
#C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même
chip sur les mêmes données fonctionne (légende + 7090 px rose après fix).
**Les 191 tests JVM de l'époque sont verts** (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`) :
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
@ -1740,6 +1889,45 @@ pas avançait en ms fixes (`t += stepMs`) : dérive d'1 h aux transitions DST. L
quand un helper prend un fuseau pour FORMATTER, la même zone doit régler
l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms.
**#68 (v1.10.0) — recommandation de prise de sang antérieure à la stabilisation** :
le « creux » était le minimum de la FENÊTRE ENTIÈRE (créneau N−1 → créneau N) — pour
un ester à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j), ce minimum tombe quelques
heures après l'injection PRÉCÉDENTE (creux d'absorption) : la date proposée était
gouvernée par l'injection N−1 (antérieure à la stabilisation) et le texte « juste
avant ton injection du … » était faux de plusieurs jours. Réel : creux 5 oct 02:32
vs « pas stabilisée avant » 7 oct 04:03. FIX : creux = niveau PRÉ-INJECTION du
créneau (restaure le principe v1.8.0 ; pour EV le point pré-injection EST le min de
fenêtre → inchangé). Leçon : quand une valeur affichée est QUALIFIÉE par un texte
(« juste avant l'injection »), le calcul doit produire CETTE valeur — et un
minimum de fenêtre n'est pas un creux pré-injection si la courbe remonte dans
l'intervalle. Épinglé par RegressionUserCase6Test (balayage de `now` ±60 j :
l'invariant doit tenir à tout instant, pas seulement à l'instant du rapport).
**#69 (v1.10.0) — dialog de dose fantôme après une notification** : tap
notification → dialog de saisie ; le fermer puis changer d'onglet et revenir sur
l'accueil le ROUVRAIT à chaque fois. Cause : la demande vivait au niveau ACTIVITÉ
(paramètre d'intent) tandis que la consommation vivait dans un LaunchedEffect de
Home — quitter l'onglet détruit les `remember` locaux, le paramètre survivait →
chaque ré-entrée en composition rouvrait. FIX : `MutableState<Long?>` détenu par
MainActivity, consommé UNE fois ; extras retirés de l'intent après lecture (un
recreate ne rouvre pas une notification traitée) ; bonus : tap notification
app-ouverte ouvre désormais aussi le dialog. Leçon : une demande à consommer une
fois doit vivre à la MÊME durée de vie que sa consommation — jamais « paramètre
longue-vie + effet courte-vie ». Épinglé par le test UI LogDoseRequestTest.
**#70 (v1.12.0) — inactif à Posologie projetait encore ses injections** :
`generateForecastDoses` (source unique des créneaux prévisionnels) ne vérifiait
pas l'archivage — un injectable INACTIF avec Posologie et de l'historique
continuait d'alimenter le graphique Prévision, l'extension de fenêtre et
l'horizon LabTiming ; accessoirement le chip Prévision s'activait sur des
Posologies inactives. FIX : garde `!isActive` dans le moteur + horizon/chip sur
les ACTIFS. Cohérence d'ensemble (§6.bis) : l'INACTIF garde son historique
simulé/calibré mais ne projette plus rien — exactement comme ses rappels
(v1.4.0). Leçon : quand un état administratif gouverne DÉJÀ une consommation
d'une source (rappels), vérifier les AUTRES consommateurs de la même source.
Épinglé par ForecastDosesGuardTest (3 tests : inactif vide, actif inchangé,
avant/après archivage).
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
@ -1777,8 +1965,9 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
```bash
cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
./gradlew assembleDebug testDebugUnitTest # build + 259 tests (225 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater)
./gradlew connectedDebugAndroidTest # 14 tests UI Compose (émulateur requis, §8.ter)
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
@ -1863,27 +2052,66 @@ Le script :
scope requis : `write:repository` (suffit pour releases + assets, pas pour
créer un repo — cf plus haut).
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode +
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des
modèles par réflexion → `-keep` explicites sur `data.model.**`,
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
vérifie pas en tests JVM).
**Build release (v1.2.5, signature conditionnelle depuis v1.9.8)** :
`./gradlew assembleRelease` — R8 full mode + `isShrinkResources`
(20 Mo → 2,7 Mo). **Signature** : si `keystore.properties` existe à la racine
du dépôt (gitignoré, créé par `scripts/make-release-keystore.sh` — cf §16.quater),
la build release utilise CE keystore ; sinon elle reste signée avec la **clé
debug** (même signature que les APK debug distribués → mise à jour par-dessus
sans perte de données — comportement historique). Garde-fous dans
`proguard-rules.pro` : Gson lit les champs des modèles par réflexion →
`-keep` explicites sur `data.model.**`, `BackupData`, `TConfig` (+ attributs
`Signature`) — sinon l'export/import JSON casse UNIQUEMENT en release.
⚠️ À chaque activation d'optimisation : tester sur téléphone l'export/import
de backup + les graphiques (R8 ne se vérifie pas en tests JVM).
### 16.quater Signature release : keystore dédié (v1.9.8)
Historique : les APK release étaient signés avec la **clé debug** (choix
v1.2.5 — installer par-dessus l'APK debug sans perte de données). Le jour où
l'app est distribuée plus largement (dépôt public, F-Droid…), une vraie clé
de signature est requise — elle est désormais câblée :
- **Création (une fois)** : `scripts/make-release-keystore.sh` — génère
`release.keystore` (RSA 4096, alias `hormonetrack`, validité 10 000 j) +
`keystore.properties` à la racine, TOUS DEUX GITIGNORÉS (`.gitignore` :
`keystore.properties`, `*.keystore`, `*.jks`). Le script refuse
d'écraser une paire existante (une régénération = perte de la capacité
de mise à jour des installs existantes). Mots de passe saisis
interactivement (jamais en argument, jamais committés) ;
- **Effet** : dès que `keystore.properties` existe, `assembleRelease` signe
avec ce keystore (détection dans `app/build.gradle.kts`) ; sans le
fichier, fallback clé debug inchangé ;
- **⚠️ MIGRATION téléphone obligatoire** (signature ≠ clé debug ⇒ Android
refuse la mise à jour par-dessus) :
1. exporter le JSON (Paramètres → Sauvegarde → Exporter, ou auto-backup) ;
2. désinstaller l'app ;
3. installer l'APK release signé par le nouveau keystore ;
4. réimporter le JSON. La base Room ne voyage QUE par le JSON ;
- **Sauvegarde du keystore** : le copier hors du dépôt (coffre, second
disque) — sa perte = même migration subie par TOUS les utilisateurs à la
version suivante ;
- **Checklist avant release publiée avec la nouvelle clé** : build release →
`apksigner verify --print-certs` (l'empreinte doit être celle du NOUVEAU
keystore) → smoke test émulateur §16.ter → publication + note de migration
dans le corps de la release Gitea.
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au
total, 193 si `local-test-data/` est absent (les 5 classes de régression
réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au
total, 250 si `local-test-data/` est absent (les 6 classes de régression
réelles sont skippées via `Assume`) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
chaque commit = tests + lint ; release = + scan confidentialité + APK au
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
attrapé les crashs #47/#48 dès v1.3.1).
**v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest`
(14 tests §8.ter — émulateur requis).
**v1.10.0+ — miroir web** : si la session touche une logique miroirée
(lab-timing, moteur…), `bash scripts/check.sh` dans `~/projects/HormoneTrack-web`
doit être vert (180 tests + E2E) AVANT le tag. Paliers de test du projet :
chaque commit = tests + lint ; release = + tests UI + scan confidentialité
+ APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
corrigé, + §2 (historique) si notable.
@ -1893,12 +2121,13 @@ vérifie pas en tests JVM).
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur
**l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
les 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
release (rangée modèles défilable : `adb shell input swipe 900 716 150
716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le
dump `text=` que la légende attendue apparaît → `adb logcat -b crash
-d | grep -c FATAL` reste à 0. 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
l'utilisatrice sur Pixel 9.
4. **Commit** (message descriptif par couche) + **tag annoté** :
@ -1980,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)
```
⚠️ **Piège WAL constaté à la v1.9.8** : après un `force-stop` (= SIGKILL, PAS de
checkpoint), le schéma entier peut vivre SEULEMENT dans le `-wal` — extraire la
seule `hormonetrack.db` donne un fichier de 4 Ko SANS tables (`no such table:
treatments` au seed). Solution : extraire `hormonetrack.db` **ET**
`hormonetrack.db-wal` CÔTE À CÔTE sous leurs noms d'origine (ex. `/tmp/hrtseed/`)
— sqlite rejoue le WAL à l'ouverture et le fusionne à la fermeture (le `-wal`
disparaît après le seed) ; on pousse alors la seule `.db` résultat.
Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin
(Room n'applique pas de snake_case), enums stockés en String — on insère
SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table`
@ -2071,7 +2308,13 @@ Volontaires (v1) :
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
téléphone** (export/import backup, graphiques) avant chaque publication ;
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
(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
@ -2084,22 +2327,27 @@ Volontaires (v1) :
## 20. Idées d'évolution
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
1. **Robolectric** (VM Android en JVM — pas besoin d'appareil ; les tests
Compose instrumentés existent depuis v1.9.8, §8.ter)
2. **Étendre les tests UI** : dialogs dose/lab avec données seedées (§16.ter),
parcours complet (créer un traitement → loguer une dose → courbe visible) —
les tests actuels sont volontairement data-indépendants
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
5. Verrou biométrique (BiometricPrompt), widget, export CSV
6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ;
MaterialExpressiveTheme quand l'API passera publique (cf §3)
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite
une migration : backup → désinstallation → installation signée → réimport)
7. ~~Vrai keystore de distribution~~ **FAIT v1.9.8** (infra complète, §16.quater :
script + détection Gradle + migration documentée — la bascule réelle se
fera quand la distribution s'élargira)
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**,
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
(push session).
(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)
### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
@ -2112,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
accélérerait l'import d'un gros historique. Différé : surface DAO +
repository à modifier, validation manuelle (aucun test Room auto).
2. **`BootReceiver` : consolidation `goAsync` + une seule lecture** :
les deux `runBlocking` séquentiels peuvent fusionner trivialement ; le
passage complet au pattern `goAsync`+coroutine de ReminderReceiver est
équivalent mais re-testé sur émulateur (boot). Différé.
2. ~~**`BootReceiver` : consolidation `goAsync` + une seule lecture`~~**
**FAIT v1.10.1** (pattern `goAsync`+coroutine de ReminderReceiver,
`runBlocking` supprimés du fichier).
3. **Double évaluation de `generateForecastDoses`** dans ChartScreen
(producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel
quel — le premier utilise `System.currentTimeMillis()`, le second le
@ -2146,6 +2393,22 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
- [ ] **v1.10.0** : carte « Prochaine prise de sang » — la date du creux est
POSTÉRIEURE à la mention « pas stabilisée avant le … » (fix #68), et
cohérente avec le créneau affiché (~juste avant)
- [ ] **v1.10.0** : logger la dose du jour AVANT l'heure de rappel → la
notification ne sonne pas ; le rappel suivant repart au créneau suivant
- [ ] **v1.11.0** : page Traitements — les inactifs sont TOUT EN BAS sous
l'en-tête « Traitements inactifs », cartes atténuées ; éditer un
inactif fonctionne toujours
- [ ] **v1.12.0** : archiver un injectable à Posologie → plus AUCUN créneau
projeté (chip Prévision : ses injections disparaissent de la projection)
- [ ] **v1.12.0** : page Doses — chaque ligne porte l'icône de sa forme
(pilule/seringue/goutte/sparadrap) ; une dose du jour d'une prise de
sang affiche l'encart 🧪↑ et/ou 🧪↓
- [ ] **v1.13.0** : auto-calibration ON → la carte reco affiche « ≈ X pg/mL » ;
régler une Cible de creux (Paramètres) → la prédiction est colorée
dans/hors cible (jamais de notification — cf §7.11/§9.bis)
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
@ -2319,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
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57). 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.*

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."