Compare commits

...

25 Commits
v1.8.1 ... main

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

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

274 tests JVM + 14 UI + lint verts ; web 194 tests + E2E verts (check.sh).
Validé émulateur sur APK RELEASE avec données réelles seedées + scénario du
bug (2 inactifs à Posologie 7 j) : Prévision on/off 0 crash, glyphes et
marqueurs en place.
2026-09-29 23:24:34 +02:00
45ba0bdc62 Docs : traçabilité publication v1.11.0 (ids releases Gitea, web sync) 2026-09-29 07:32:07 +02:00
a0f3ac59f9 v1.11.0 : inactifs regroupés en bas de la page Traitements + cartes atténuées
- Demande : « les traitements inactifs sont peu discernables — les placer
  à la fin (tout en bas), et peut-être les rendre plus distincts ».
- Helper pur treatmentsForDisplay (tri STABLE : actifs d'abord, inactifs
  après, ordre relatif conservé dans chaque groupe).
- LazyColumn en 3 sections : actifs / en-tête inactive_section (seulement
  si des inactifs existent) / inactifs — cartes alpha 0.55 en plus du
  badge, lisibles et tappables (l'édition d'un archivé reste possible).
- Miroir web : treatmentsForDisplay dans js/data/models.js + rendu
  sections + opacity 0.55 + i18n inactive_section FR/EN (web v1.11.0 sync).
- 5 tests JVM TreatmentsDisplayTest + 4 tests web (miroir).

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

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

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

Vérifié : 249 tests JVM + 13 UI verts, lint vert, smoke RELEASE émulateur
(données réelles seedées : accueil, prochaines doses, suggestion creux,
graphiques ESE+TFS superposés, 0 crash logcat — anti-#64).
2026-09-22 22:39:42 +02:00
d6d95080b4 v1.9.7 : la suggestion de prise de sang ne fuit plus quand l'heure d'injection varie
Remontée : « la suggestion change tout le temps à chaque injection si
l'injection n'est pas faite pile à la même heure » — la règle v1.8.1
comparait l'écart inter-doses EXACTEMENT à l'écart précédent : un log
30 min plus tard cassait le régime et repoussait la stabilisation de
5 × t½ à chaque injection (creux recommandé fuyant).

Fix : chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h
(GAP_TOLERANCE_MS — « je m'injecte le même jour, à l'heure près »). La
fenêtre se réfère à l'intervalle THÉORIQUE : des logs à 6,8 j puis 7,2 j
ne se déstabilisent plus en cascade ; un vrai changement de créneau
(2 j au lieu de 7 j) reste hors fenêtre. Le créneau affiché suit
toujours la dernière dose réelle (voulu).

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

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

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

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

Docs : §7.2/§7.3 à jour (asset historique, cutoff 10×t½), PKPresets/
Treatment/LabTimingTest commentaires alignés, §14 #67, §2, README
2026-09-20 12:56:15 +02:00
e4f52bc6da v1.9.4 : presets du modèle ESE étendus à ses 6 esters injectables + éditeur dynamique
- PKPresets.kt (25 entrées) : EC huile (5 mg IM), EB (1 mg IM), EUCS
  suspension cristalline (10 mg SC) — champs Bateman informatifs = t½
  terminales des fits (pas utilisés au calcul)
- TreatmentEditorScreen.kt : liste des esters = Esters.choicesForModel
  (même helper que l'override du DoseDialog) ; au changement de modèle,
  l'ester rebascule sur EV si non couvert (garde anti 0 pg/mL)
- PKPresetsTest (nouveau, 6 tests) : couverture des 6 esters ESE,
  unicité, TFS 7 / WHSAH 6 inchangés, garde anti hors couverture,
  plausibilité des t½ informatives — 243 verts + lint
- versionCode 41 / versionName 1.9.4 + CHANGELOG/§2/§7.12/§8/README/GUIDE
- Doc : doublon de la section §7.12 supprimé (collé deux fois)
2026-09-19 23:30:23 +02:00
8fc6d07772 Docs : KDoc EstrannaiseCloud.compute — @return aligné sur le filtre v1.9.2 (ester effectif, pas de filtre pkModel stocké/actif) 2026-09-19 22:43:49 +02:00
03fdbf61ed v1.9.2 : fix nuage vide sans traitement stocké ESE (filtre par ester effectif)
Bug remonté : activer Estrannaise dans le graphique puis le nuage
n'affichait rien tant qu'aucun traitement n'était STOCKÉ avec le modèle
ESE — « la seule méthode trouvée était de mettre un traitement en cours
sur le modèle ESE, mais ça ne devrait pas être un prérequis ».

Cause : le filtre du nuage testait le pkModel STOCKÉ du traitement, alors
que la courbe ESE affichée redessine toutes les doses E2 avec
modelOverride = "ESE" (peu importe le modèle stocké) — courbe et nuage
n'utilisaient pas la même définition de « quelles doses sont tracées en
ESE ».

Fix : EstrannaiseCloud.compute couvre toutes les doses E2 à profil
injectable dont l'ESTER EFFECTIF (override compris) est couvert par le
fit Estrannaise — indépendamment du modèle stocké. L'exclusivité ESE
reste portée par le chip (activable seulement si ESE est affiché).
L'oral Bateman reste hors nuage (pas d'ester échantillonnable). Les
traitements inactifs sont inclus (§6.bis : la courbe ESE les trace aussi).

Tests réécrits (237 verts / 207 sans données locales) : « les doses d'un
traitement TFS sont couvertes quand ESE est affiché » (épinglé Android +
web) ; oral seul → vide (conservé). Validé émulateur §16.ter sur le
profil réel de l'utilisatrice (EEn stocké TFS, ESE affiché, nuage visible
autour de la courbe, 0 crash). Web miroir v1.9.2. versionCode 40 /
versionName 1.9.2.
2026-09-19 22:10:35 +02:00
96a0dbc56a v1.9.1 : fix nuage calibré (il entoure la courbe ESE calibrée) + doc +4 pg/mL
- EstrannaiseCloud.compute : paramètre scalePerEster — le nuage reçoit la
  MÊME calibration que la courbe ESE (autoByModel ESE). Avant : courbe ESE
  calibrée mais nuage BRUT → les deux flottaient à des échelles différentes
  (remontée : « le nuage ne s'active que autour du tracé, pas autour du
  modèle Estrannaise »). ChartScreen passe autoM?.esterScales.
- +1 test : nuage calibré ×2 = nuage brut ×2 (le nuage suit la calibration).
- Validé émulateur §16.ter (release, seed réel + traitement EV ESE actif
  de test) : nuage rendu autour de la courbe ESE calibrée, 0 crash.
- CHANGELOG 1.9.1 : explication documentée du +4 pg/mL constaté depuis
  v1.8.2 (l'arrondi de la médiane de calibration — plus juste que la
  troncature — a déplacé une échelle auto-calibrée d'un cran ; le niveau
  actuel est le CORRIGÉ). §2 session, §7.12 nuage calibré, README.
- DIAG du test médiane nettoyé (l'épinglage de l'arrondi reste, en
  assertion propre ±0,005).
- versionCode 39 / versionName 1.9.1. Web sync v1.9.1.
2026-09-19 21:13:17 +02:00
8078d5cbed v1.9.0 : modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
DÉCISION : tous les modèles ont maintenant leurs sources originelles —
on abandonne les liens avec le fichier ODS (qui lui-même était
l'échantillonnage horaire de ces formules ; fidélité vérifiée RMS 0,00).

- pk/EstrannaiseModels.kt (NOUVEAU) : forme close 3C de estrannaise.js
  (paramètres publiés EV/EU/EEn/EC/EB/EUCS), posterior MCMC (313
  échantillons/ester, asset mcmc_samples.json ≈ 48 Ko), t½ terminale
  analytique, cas dégénérés (taux égaux → limites, portés du JS source).
- PharmacokineticEngine : dispatch ESE → EstrannaiseModels (plus de
  fallback PKProfileStore) ; cutoffHours ESE → 10 × t½ analytique (plus
  la longueur de table).
- Esters : +EUCS (undécylate suspension cristalline, exclusif ESE) ;
  choicesForModel ESE = 6 injectables ; TFS = liste EXPLICITE (EUCS sans
  V3C TFS → dispatcher donnerait 0).
- LabTiming : t½ ESE analytique.
- pk/EstrannaiseCloud.kt (NOUVEAU) : nuage d'incertitude — 32 courbes du
  posterior (échelonnées, déterministes) superposant les doses des
  porteurs ESE ; exclusif ESE (TFS/WHSAH sans posterior publié).
- ChartScreen/CurveChart : chip « Nuage » (off, activable si ESE affiché,
  coupure auto si ESE off), ChartSeries.cloud dessiné en alpha faible
  sous les courbes, hors échelles/labels/extrema, légende dédiée.
- Runtime 100 % sources analytiques : PKProfileStore.init retirée du
  démarrage, asset pk_profiles.json → src/test/assets (−550 Ko d'APK ;
  conservé pour les tests de fidélité RMS 0).
- 11 nouveaux tests (234 verts / 204 sans données locales) : fidélité,
  pics ±0,2 %, dégénérés, MCMC 313×6, nuage (dispersion/fenêtre/
  exclusivité/gardes), choicesForModel ESE=6, t½ analytique.
- Validé émulateur §16.ter (release, seed réel v1.7.0) : courbe ESE
  analytique affichée, chips Extend/Cloud, recommandation de prise de
  sang, 0 crash. versionCode 38 / versionName 1.9.0. Web sync v1.9.0
  (publié séparément).
2026-09-19 20:44:30 +02:00
dd220e8933 Docs : compléments de l'audit v1.8.2 (commentaire clé labResults + pistes différées)
- ChartScreen : one-liner sur la clé « labResults entière » (le pourquoi
  du fix #66, là où on lit le code).
- §20.bis : les 4 pistes d'optimisation écartées/différées de l'audit
  (importJson batch, BootReceiver goAsync, fusion generateForecastDoses,
  cache terminalDecayParameters) — avec la raison de chaque report, pour
  qu'une future session ne redécouvre pas et ne re-pose pas les questions.
2026-09-18 14:32:44 +02:00
f6be3d86a9 v1.8.2 : maintenance — perf moteur, fraîcheur UI, code/doc nettoyés
Session d'audit complet (code + doc, 46 fichiers + 4 docs) et application
des constats sûrs.

PERF MOTEUR PK (résultat identique — ordre de sommation préservé, 223
tests verts inchangés) :
- e2At/computeCurve : cutoffHours pré-calculé par traitement (le chemin
  chaud du tracé labs recalculait ~430 k appels/refresh), Bateman
  paresseux (inutile pour les traitements à profil), doses pré-groupées
  par traitement (fin des scans complets).
- AppLog : purge sans recopie ; ChangelogHelper : Regex précompilées ;
  CurveChart : SimpleDateFormat hors boucle de dessin.

FIX FRAÎCHEUR (#65/#66) :
- Accueil : clés produceState complétées (allTreatments, tConfig) —
  éditer un scaleFactor/le modèle T actualise la courbe sans attendre le
  tick de 60 s.
- Graphiques : clé labResults (liste entière, plus .size) — éditer la
  valeur d'une analyse actualise les courbes.

MAINTENANCE :
- Code mort supprimé (Repository/DAO sans appelants, currentLevel,
  scheduler2, variable morte, Spacer no-op) ; imports morts/redondants
  nettoyés (9 fichiers) ; qualifications raccourcies.
- Médiane de calibration factorisée (4 copies → medianRounded) ;
  double-filtre computeTConfigCalibration supprimé (soussumé) ;
  formatDose dédupliqué ; en-têtes/KDoc obsolètes mis à jour.

DOC : ~35 corrections (compteurs actuels 223/193 partout — §16, §14,
§8.bis ; TOC + 4 ancres réparées ; inventaire §8 à jour + fusions
ChartZoom/WhsahEngine + ajout LabTiming/AutoBackupRetention/
ExportFileNames ; titres CHANGELOG doublés [1.4.9]/[1.4.8] supprimés ;
§19 backup = manuel + auto ; versions « v1.2.0 »/« Gradle 8.9 »/« API 34 »
corrigées ; ratios #61 complétés 383/391 ; §21 item v1.8.1 + chronologie ;
§14 #65/#66 ajoutés). Rétention Ratios (#14 #61) : vérifié via
ScaleFactorWhsahReproTest — les 5 valeurs 3,3/15,9/89,6/383/391 existent.

Validé émulateur §16.ter (release, seed réel v1.7.0) : accueil (encart
reco), Analyses (creux 27/09 + notes), Graphiques, 0 crash.
versionCode 37 / versionName 1.8.2. Web non concerné (aucun changement).
2026-09-18 14:21:32 +02:00
91 changed files with 6323 additions and 1217 deletions

7
.gitignore vendored
View File

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

305
README.en.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -4,11 +4,12 @@ import android.app.Application
import android.content.Context import android.content.Context
import com.hormonetrack.data.AppDatabase import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.repository.HormoneRepository import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.PKProfileStore import com.hormonetrack.pk.EstrannaiseModels
import com.hormonetrack.reminder.createNotificationChannel import com.hormonetrack.reminder.createNotificationChannel
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import com.hormonetrack.reminder.AlertNotifier import com.hormonetrack.reminder.AlertNotifier
import com.hormonetrack.settings.AppSettings import com.hormonetrack.settings.AppSettings
import com.hormonetrack.data.backup.AutoBackupScheduler
class HormoneTrackApp : Application() { class HormoneTrackApp : Application() {
@ -18,7 +19,10 @@ class HormoneTrackApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = AppContainer(this) container = AppContainer(this)
PKProfileStore.init(this) // v1.9.0 : les tables ODS ne sont plus chargées au démarrage (le
// runtime ne lit que des sources analytiques). L'asset MCMC
// (nuage d'incertitude ESE) les remplace (≈ 48 Ko).
EstrannaiseModels.init(this)
createNotificationChannel(this) createNotificationChannel(this)
AppLog.init(this) AppLog.init(this)
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte — // v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
@ -31,7 +35,7 @@ class HormoneTrackApp : Application() {
// le worker sort immédiatement si l'option est désactivée ; cf // le worker sort immédiatement si l'option est désactivée ; cf
// data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots : // data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots :
// BootReceiver n'a rien à faire. // BootReceiver n'a rien à faire.
com.hormonetrack.data.backup.AutoBackupScheduler.schedulePeriodic(this) AutoBackupScheduler.schedulePeriodic(this)
} }
} }

View File

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

View File

@ -8,7 +8,6 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.hormonetrack.data.backup.UserSettings
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -109,13 +108,15 @@ class AutoBackupWorker(
val ok = try { val ok = try {
// MÊME contenu que l'export manuel (format v2 avec paramètres) : // MÊME contenu que l'export manuel (format v2 avec paramètres) :
// un auto-backup est importable tel quel (rétrocompat assurée) // un auto-backup est importable tel quel (rétrocompat assurée)
// UNE seule collecte des seuils (même motif que l'export manuel)
val thresholds = settings.alertThresholds.first()
val userSettings = UserSettings( val userSettings = UserSettings(
language = settings.language.first(), language = settings.language.first(),
autoCalibrate = settings.autoCalibrate.first(), autoCalibrate = settings.autoCalibrate.first(),
alertE2High = settings.alertThresholds.first().e2High, alertE2High = thresholds.e2High,
alertE2Low = settings.alertThresholds.first().e2Low, alertE2Low = thresholds.e2Low,
alertTHigh = settings.alertThresholds.first().tHigh, alertTHigh = thresholds.tHigh,
alertTLow = settings.alertThresholds.first().tLow alertTLow = thresholds.tLow
) )
val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings) val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings)
val keep = settings.autoBackupKeep.first() val keep = settings.autoBackupKeep.first()

View File

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

View File

@ -9,17 +9,9 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC") @Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
fun getAll(): Flow<List<DoseLog>> fun getAll(): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId ORDER BY timestamp DESC")
fun getByTreatment(treatmentId: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTimeRange(start: Long, end: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC LIMIT 1")
fun getLatest(): Flow<DoseLog?>
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC") @Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<DoseLog> suspend fun getAllOnce(): List<DoseLog>

View File

@ -9,11 +9,7 @@ interface LabResultDao {
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC") @Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
fun getAll(): Flow<List<LabResult>> fun getAll(): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results WHERE marker = :marker ORDER BY timestamp DESC")
fun getByMarker(marker: String): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTimeRange(start: Long, end: Long): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC") @Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<LabResult> suspend fun getAllOnce(): List<LabResult>

View File

@ -15,9 +15,6 @@ interface TreatmentDao {
@Query("SELECT * FROM treatments WHERE id = :id") @Query("SELECT * FROM treatments WHERE id = :id")
suspend fun getById(id: Long): Treatment? suspend fun getById(id: Long): Treatment?
@Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC")
suspend fun getActiveOnce(): List<Treatment>
@Query("SELECT * FROM treatments ORDER BY createdAt DESC") @Query("SELECT * FROM treatments ORDER BY createdAt DESC")
suspend fun getAllOnce(): List<Treatment> suspend fun getAllOnce(): List<Treatment>
@ -35,6 +32,4 @@ interface TreatmentDao {
@Delete @Delete
suspend fun delete(treatment: Treatment) suspend fun delete(treatment: Treatment)
@Query("UPDATE treatments SET isActive = :active WHERE id = :id")
suspend fun setActive(id: Long, active: Boolean)
} }

View File

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

View File

@ -16,20 +16,30 @@ object Esters {
const val EC = "EC" // cypionate, solution huileuse const val EC = "EC" // cypionate, solution huileuse
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux) const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
const val PEP = "PEP" // polyestradiol phosphate const val PEP = "PEP" // polyestradiol phosphate
// v1.9.0 : undécylate en SUSPENSION CRISTALLINE — couvert par le modèle
// Estrannaise ANALYTIQUE (estrannaise.js « EUn casubq ») ; ni TFS ni les
// tables ODS ne le couvrent.
const val EUCS = "EUCS"
/** Esters injectables proposables par ester (override par dose). */ /** Esters injectables proposables par ester (override par dose). */
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP) val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP, EUCS)
/** /**
* Esters proposables dans l'override par dose pour un MODÈLE PK donné * Esters proposables dans l'override par dose pour un MODÈLE PK donné
* (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle — * (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle —
* TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6 * TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6
* (PEP non couvert par ce fit) ; Estrannaise (tables ODS) → EV/EU/EEn * (PEP non couvert par ce fit) ; ESE (Estrannaise ANALYTIQUE v1.9.0)
* seulement (un traitement ESE + EB produirait 0 pg/mL). * → les 6 injectables du fit estrannaise.js (dont EUCS, exclusif) ;
* les tables ODS historiques n'en couvraient que 3 (EV/EU/EEn).
*/ */
fun choicesForModel(pkModel: String): List<String> = when (pkModel) { fun choicesForModel(pkModel: String): List<String> = when (pkModel) {
PKModels.TRANSFEM_SCIENCE -> INJECTABLE // ⚠️ TFS = liste EXPLICITE (fix v1.8.2-étendu) : INJECTABLE a gagné
// EUCS (couvert par l'Estrannaise analytique) mais le V3C TFS n'a PAS
// de paramètres pour EUCS → le dispatcher donnerait 0. Chaque liste
// reflète la couverture RÉELLE de son modèle.
PKModels.TRANSFEM_SCIENCE -> listOf(EV, EU, EEN, EB, EC, ECS, PEP)
PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS) PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS)
PKModels.ESTRANNAISE -> listOf(EV, EU, EEN, EC, EB, EUCS)
else -> listOf(EV, EU, EEN) else -> listOf(EV, EU, EEN)
} }
} }
@ -84,7 +94,8 @@ data class Treatment(
val isActive: Boolean = true, val isActive: Boolean = true,
val notes: String? = null, val notes: String? = null,
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science) // PK: analytical models for injections — Estrannaise (v1.9.0, 6 esters),
// Transfem Science (V3C, 7), WHSAH (6); see Esters.choicesForModel().
val esterType: String = Esters.NONE, val esterType: String = Esters.NONE,
val pkModel: String = PKModels.ESTRANNAISE, val pkModel: String = PKModels.ESTRANNAISE,
@ -115,6 +126,9 @@ data class Treatment(
) { ) {
val isInjection: Boolean val isInjection: Boolean
get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
// (redondant avec PharmacokineticEngine.isInjectionRoute — volontaire :
// data.model ne doit PAS dépendre de pk (cycle de couches). Si la
// définition d'« injectable » change, changer les DEUX endroits.)
val usesProfileModel: Boolean val usesProfileModel: Boolean
get() = isInjection && esterType != Esters.NONE get() = isInjection && esterType != Esters.NONE

View File

@ -18,15 +18,6 @@ class HormoneRepository(
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll() val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
val allLabResults: Flow<List<LabResult>> = labResultDao.getAll() val allLabResults: Flow<List<LabResult>> = labResultDao.getAll()
fun getDosesByTimeRange(start: Long, end: Long): Flow<List<DoseLog>> =
doseLogDao.getByTimeRange(start, end)
fun getDosesByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>> =
doseLogDao.getByTreatmentAndTimeRange(treatmentId, start, end)
fun getLabsByMarker(marker: String): Flow<List<LabResult>> =
labResultDao.getByMarker(marker)
suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id) suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id)
suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce() suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce()

View File

@ -0,0 +1,163 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
/**
* NUAGE D'INCERTITUDE (v1.9.0) — feature **exclusive au modèle ESE**.
*
* SOURCE : Estrannaise publie le **posterior MCMC** de ses paramètres
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf
* [EstrannaiseModels]). Le graphique de simulation peut dessiner un
* **nuage diffus** : M courbes (une par échantillon échelonné) qui montrent
* la plage d'imprécision possible du modèle — exactement l'affichage du
* site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage
* pour eux (choix confirmé : le nuage reste sur ESE).
*
* QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à
* profil injectable dont l'ESTER EFFECTIF (override compris) est couvert
* par le fit Estrannaise** — c'est exactement l'ensemble des doses que la
* courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel`
* STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec
* ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle
* Estrannaise, et le nuage les redessine avec les paramètres MCMC de
* LEUR ester. (Fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE`
* laissait le nuage vide dès qu'aucun traitement n'était stocké en ESE —
* remontée : « le nuage ne s'active que autour du tracé émulé, pas autour
* du modèle Estrannaise ». L'exclusivité ESE reste portée par le CHIP,
* pas par le filtre des doses.)
*
* Les contributions Bateman (oral/gel — sans ester échantillonnable) ne
* sont PAS dans le nuage : le posterior MCMC ne s'applique qu'aux esters
* (limite documentée — le nuage montre l'incertitude du modèle
* Estrannaise, pas le niveau total).
*
* DÉTERMINISME : les M échantillons sont **échelonnés** (stride sur les
* 313, pas d'aléatoire) — le nuage est identique à chaque affichage et
* testable en JVM (c'est aussi la reproducibilité du site, qui tire au
* hasard à chaque trace, lui).
*
* PUR (aucune dépendance Android) → testable en JVM (EstrannaiseCloudTest).
*/
object EstrannaiseCloud {
/**
* Nombre de courbes du nuage : assez pour une plage visuellement
* « diffuse » (le site en trace des centaines, mais 32 échantillons
* échelonnés couvrent déjà l'étendue du posterior à un coût ×32 sur le
* calcul — le pas de la grille reste 1 h).
*/
const val DEFAULT_NB_CURVES = 32
/**
* Calcule le nuage d'incertitude.
*
* @param treatments TOUS les traitements (actifs ET inactifs, §6.bis)
* @param doseLogs toutes les doses réelles
* @param startMs/endMs fenêtre demandée (celle du graphique)
* @param stepMs pas de la grille (stepForRange)
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
* calculable : aucune dose E2 à profil injectable dont l'ester
* effectif est couvert par le fit Estrannaise (v1.9.2 :
* indépendamment du pkModel stocké), aucune dose, ou MCMC non chargé.
*/
fun compute(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
startMs: Long,
endMs: Long,
stepMs: Long,
nbCurves: Int = DEFAULT_NB_CURVES,
/**
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
* s'active que autour du tracé émulé, pas autour du modèle
* Estrannaise ») : la courbe ESE affichée est CALIBRÉE
* (scalePerEster des labs) mais le nuage était tracé BRUT → les deux
* flottaient à des échelles différentes. Passer le MÊME
* `scalePerEster` que la courbe ESE (autoByModel["ESE"]?.esterScales
* côté ChartScreen) pour que le nuage ENTOURE la courbe.
*/
scalePerEster: Map<String, Double>? = null
): List<List<LevelPoint>> {
if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList()
// ── Doses tracées par la courbe ESE (v1.9.2) ─────────────────────────
// Toutes les doses E2 à profil injectable dont l'ESTER EFFECTIF
// (override compris) est couvert par le fit Estrannaise — peu importe
// le pkModel STOCKÉ du traitement : quand ESE est affiché,
// computeCurve redessine toutes ces doses avec modelOverride = "ESE"
// et le nuage doit les suivre (fix v1.9.2 : l'ancien filtre
// `pkModel stocké == ESE` laissait le nuage vide dès qu'aucun
// traitement n'était stocké en ESE — remontée : « le nuage ne
// s'active que autour du tracé émulé, pas autour du modèle
// Estrannaise ». L'exclusivité ESE reste portée par le CHIP, pas
// par le filtre des doses). Les doses des traitements inactifs sont
// incluses (§6.bis : l'inactif reste simulé, la courbe ESE les
// trace aussi). La couche Bateman (oral/gel sans ester
// échantillonnable) est naturellement exclue.
val carrierDoses = ArrayList<Pair<DoseLog, Treatment>>()
val samplesByEster = HashMap<String, List<EstrannaiseModels.Param>>()
for (dose in doseLogs) {
if (dose.timestamp > endMs) continue
val tr = treatments.firstOrNull { it.id == dose.treatmentId } ?: continue
if (tr.type != TreatmentType.ESTRADIOL || !tr.usesProfileModel) continue
val ester = PharmacokineticEngine.doseEster(tr, dose)
if (!EstrannaiseModels.hasModel(ester)) continue
if (!samplesByEster.containsKey(ester)) {
// Posterior MCMC de cet ester (chargé au démarrage) — vide si
// l'asset n'est pas chargé → pas de nuage (sans posterior,
// pas de plage d'imprécision à dessiner)
val samples = EstrannaiseModels.mcmcSamples(ester)
if (samples.isEmpty()) return emptyList()
samplesByEster[ester] = samples
}
carrierDoses.add(dose to tr)
}
if (carrierDoses.isEmpty()) return emptyList()
// ── Échantillons échelonnés du posterior (déterministes) ─────────────
// Un échantillon m est appliqué À TOUS les esters du nuage (même
// index) : le nuage explore le posterior ester par ester, index par
// index — reproductible.
val maxSamples = samplesByEster.values.minOf { it.size }
val stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt()
.coerceAtLeast(1)
// Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la
// 1ʳᵉ dose (rien à faire varier)
val firstDoseMs = carrierDoses.minOf { (dose, _) -> dose.timestamp }
val t0 = maxOf(startMs, firstDoseMs)
if (t0 > endMs) return emptyList()
// ── M courbes ─────────────────────────────────────────────────────────
val cloud = mutableListOf<List<LevelPoint>>()
for (m in 0 until nbCurves) {
val sampleIdx = (m * stride).coerceAtMost(maxSamples - 1)
val paramsByEster = samplesByEster.mapValues { (_, samples) ->
samples[sampleIdx.coerceAtMost(samples.size - 1)]
}
val curve = mutableListOf<LevelPoint>()
var t = t0
while (t <= endMs) {
var e2 = 0.0
for ((dose, tr) in carrierDoses) {
if (dose.timestamp > t) continue
val dtH = (t - dose.timestamp) / 3_600_000.0
val ester = PharmacokineticEngine.doseEster(tr, dose)
val params = paramsByEster.getValue(ester)
// Même calibration que la courbe ESE : le nuage ENTOURE
// la courbe calibrée (fix v1.9.1)
val scale = scalePerEster?.get(ester) ?: tr.scaleFactor
e2 += EstrannaiseModels.sampleWithParams(ester, dtH, params) * dose.doseAmount * scale
}
curve.add(LevelPoint(t, e2, 0.0))
t += stepMs
}
if (curve.size >= 2) cloud.add(curve)
}
return cloud
}
}

View File

@ -0,0 +1,188 @@
package com.hormonetrack.pk
import android.content.Context
import com.google.gson.JsonParser
import kotlin.math.exp
import kotlin.math.ln
/**
* Modèle **Estrannaise analytique** (v1.9.0) — remplace définitivement les
* tables horaires ODS.
*
* SOURCE ORIGINELLE : le dépôt https://github.com/WHSAH/estrannaise.js/
* (site estrannaise) — le modèle 3 compartiments avec les paramètres
* d'ajustement **publiés par Estrannaise** eux-mêmes :
*
* ```
* dB(t)/dt = −k1·B(t)
* dEE(t)/dt = k1·B(t) − k2·EE(t)
* dE2(t)/dt = k2·EE(t) − k3·E2(t)
* ```
*
* forme close pour une dose `dose` (mg) à t = 0 (t en JOURS) :
*
* ```
* C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
* − e^(−k2·t)/((k1−k2)(k2−k3))
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
* ```
*
* **FIDÉLITÉ (épinglée par EstrannaiseModelsTest)** : cette forme close
* reproduit les anciennes tables ODS **à l'identique** (RMS 0,00 sur 0→200 j
* pour EV/EU/EEn, écart de pic ≤ 0,1 %) — vérifié avant le débranchement :
* l'ODS n'était que l'échantillonnage horaire de ces formules.
*
* **NUAGE D'INCERTITUDE (v1.9.0)** : Estrannaise publie le **posterior
* MCMC** de ses paramètres (313 échantillons `(d, k1, k2, k3)` par ester,
* embarqués dans l'asset `mcmc_samples.json` — cf §15). Le graphique peut
* dessiner un **nuage diffus** de courbes (une par échantillon) qui montre
* la plage d'imprécision possible du modèle — feature exclusive au modèle
* ESE (TFS/WHSAH ne publient pas de posterior).
*
* POURQUOI CE CHANGEMENT (décision v1.9.0) : « tous les modèles ont
* maintenant leurs sources originelles — on abandonne complètement les
* liens avec le fichier ODS, qui lui-même était une extrapolation de ces
* mêmes sources ». Consequences : plus aucune lecture des tables au
* runtime (l'asset `pk_profiles.json` reste uniquement pour les tests de
* fidélité), t½ terminale **analytique** (ln2/min(k1,k2,k3)) au lieu de
* l'estimation par pente de table.
*
* COUVERTURE : 6 esters injectables (EV, EU, EEn, EC, EB, EUCS) — le repo
* source expose aussi 2 patchs (`patch tw`/`patch ow`) et un cycle
* menstruel : hors scope v1.9.0 (documenté §7.12/§19).
*
* PUR sauf [init]/[initWithJson] (lecture de l'asset MCMC) — la formule
* elle-même est testable en JVM.
*/
object EstrannaiseModels {
/** Paramètres d'un ester : d (facteur), k1/k2/k3 (vitesses, j⁻¹). */
data class Param(val d: Double, val k1: Double, val k2: Double, val k3: Double) {
/** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */
val terminalHalfLifeDays: Double
get() = ln(2.0) / minOf(k1, k2, k3)
}
/**
* Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js,
* `PKParameters`). Clés = clés esters de l'app. ⚠️ Mapping depuis les
* clés du repo : « EV im »→EV, « EUn im »→EU, « EEn im »→EEn,
* « EC im »→EC, « EB im »→EB, « EUn casubq »→EUCS (nouveau code ester
* v1.9.0 : undécylate en suspension cristalline).
*/
private val PK_PARAMETERS: Map<String, Param> = mapOf(
"EV" to Param(478.0, 0.236, 4.85, 1.24),
"EU" to Param(471.5, 0.01729, 6.528, 2.285),
"EEN" to Param(191.4, 0.119, 0.601, 0.402),
"EC" to Param(246.0, 0.0825, 3.57, 0.669),
"EB" to Param(1893.1, 0.67, 61.5, 4.34),
"EUCS" to Param(16.15, 0.046, 0.022, 0.101),
)
fun hasModel(ester: String): Boolean = PK_PARAMETERS.containsKey(ester.uppercase())
/** Les paramètres de l'ester, ou null (lookup insensible à la casse). */
fun model(ester: String): Param? = PK_PARAMETERS[ester.uppercase()]
/**
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
* forme close exacte (cf KDoc de l'objet). dtHours ≤ 0 → 0.
* ⚠️ Le passage t heures → t jours est fait ici (le modèle travaille en jours).
*/
fun sample(ester: String, dtHours: Double): Double {
val m = model(ester) ?: return 0.0
if (dtHours <= 0.0) return 0.0
return curveDays(dtHours / 24.0, 1.0, m)
}
/**
* Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — utilisé par
* le nuage d'incertitude : chaque courbe du nuage superpose les doses du
* porteur ESE avec UN échantillon du posterior. Normalisé par mg comme
* [sample].
*/
fun sampleWithParams(ester: String, dtHours: Double, p: Param): Double {
if (dtHours <= 0.0) return 0.0
return curveDays(dtHours / 24.0, 1.0, p)
}
/**
* Forme close 3C (t en JOURS) — cas général + les 3 cas dégénérés
* (taux égaux → dénominateurs nuls : solution limite, portée du JS
* source de estrannaise.js).
*/
private fun curveDays(t: Double, dose: Double, m: Param): Double {
if (t < 0) return 0.0
var ret = 0.0
val (d, k1, k2, k3) = m
if (dose > 0 && d > 0) {
when {
k1 == k2 && k2 == k3 ->
ret += dose * d * k1 * k1 * t * t * exp(-k1 * t) / 2
k1 == k2 && k2 != k3 ->
ret += dose * d * k1 * k1 * (exp(-k3 * t) - exp(-k1 * t) * (1 + (k1 - k3) * t)) /
(k1 - k3) / (k1 - k3)
k1 != k2 && k1 == k3 ->
ret += dose * d * k1 * k2 * (exp(-k2 * t) - exp(-k1 * t) * (1 + (k1 - k2) * t)) /
(k1 - k2) / (k1 - k2)
k1 != k2 && k2 == k3 ->
ret += dose * d * k1 * k2 * (exp(-k1 * t) - exp(-k2 * t) * (1 - (k1 - k2) * t)) /
(k1 - k2) / (k1 - k2)
else ->
ret += dose * d * k1 * k2 * (
exp(-k1 * t) / (k1 - k2) / (k1 - k3) -
exp(-k2 * t) / (k1 - k2) / (k2 - k3) +
exp(-k3 * t) / (k1 - k3) / (k2 - k3)
)
}
}
return if (ret.isNaN()) 0.0 else ret
}
// ── Posterior MCMC (nuage d'incertitude) ────────────────────────────────
@Volatile
private var mcmc: Map<String, List<Param>>? = null
/**
* Charge le posterior MCMC depuis l'asset `mcmc_samples.json`
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf §15).
* Appelé au démarrage ([com.hormonetrack.HormoneTrackApp]) — le nuage
* n'en a besoin qu'au premier affichage, mais le chargement est court
* (≈ 48 Ko) et évite un état « chip actif sans données ».
*/
fun init(context: Context) {
if (mcmc != null) return
synchronized(this) {
if (mcmc != null) return
val json = context.assets.open("mcmc_samples.json").bufferedReader().use { it.readText() }
initWithJson(json)
}
}
/** Point d'entrée testable (JVM) — même pattern que PKProfileStore.initWithJson. */
fun initWithJson(json: String) {
synchronized(this) {
val root = JsonParser.parseString(json).asJsonObject
val parsed = LinkedHashMap<String, List<Param>>()
for ((ester, value) in root.entrySet()) {
val arr = value.asJsonArray
val samples = ArrayList<Param>(arr.size())
for (s in arr) {
val e = s.asJsonArray
samples.add(Param(e[0].asDouble, e[1].asDouble, e[2].asDouble, e[3].asDouble))
}
parsed[ester.uppercase()] = samples
}
mcmc = parsed
}
}
/**
* Les échantillons MCMC d'un ester (vide si l'asset n'est pas chargé ou
* ester inconnu) — le nuage d'incertitude en tire M jeux de paramètres
* (échelonnés, déterministes : pas d'aléatoire à chaque frame).
*/
fun mcmcSamples(ester: String): List<Param> =
mcmc?.get(ester.uppercase()) ?: emptyList()
}

View File

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

View File

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

View File

@ -1,8 +1,22 @@
package com.hormonetrack.pk package com.hormonetrack.pk
import android.content.Context
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.hormonetrack.data.model.PKModels
import kotlin.math.ln
/**
* ⚠️ v1.9.0 : ce lecteur de tables ODS n'est PLUS UTILISÉ AU RUNTIME —
* le modèle Estrannaise est devenu ANALYTIQUE (pk/EstrannaiseModels.kt,
* fidélité aux tables épinglée RMS 0) et tous les dispatchs moteur lisent
* des sources originelles. Il est conservé UNIQUEMENT pour :
* 1. le **test de fidélité** (EstrannaiseModelsTest : forme close vs
* tables, RMS 0 — garde anti-régression du débranchement) ;
* 2. le jeu de données historique (`pk_profiles.json`, déplacé en
* `src/test/assets/` — l'APK ne l'embarque plus, −550 Ko).
*
* L'ancienne [init] (lecture de l'asset au démarrage) a été retirée :
* [initWithJson] suffit pour les tests.
*/
object PKProfileStore { object PKProfileStore {
@Volatile @Volatile
@ -10,15 +24,6 @@ object PKProfileStore {
private lateinit var profiles: Map<String, FloatArray> private lateinit var profiles: Map<String, FloatArray>
fun init(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
val json = context.assets.open("pk_profiles.json").bufferedReader().use { it.readText() }
initWithJson(json)
}
}
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */ /** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
fun initWithJson(json: String) { fun initWithJson(json: String) {
synchronized(this) { synchronized(this) {
@ -36,7 +41,7 @@ object PKProfileStore {
} }
fun profileKey(ester: String, model: String): String = fun profileKey(ester: String, model: String): String =
"${ester}_${if (model == com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}" "${ester}_${if (model == PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
/** /**
* Case-insensitive lookup. The ODS asset keys use the biological casing * Case-insensitive lookup. The ODS asset keys use the biological casing
@ -56,11 +61,6 @@ object PKProfileStore {
fun profileLength(ester: String, model: String): Int = fun profileLength(ester: String, model: String): Int =
lookup(profileKey(ester, model))?.size ?: 0 lookup(profileKey(ester, model))?.size ?: 0
/**
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
* linearly interpolated between hourly points; beyond the table, extrapolated
* with the terminal exponential slope.
*/
/** /**
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) : * Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) :
* même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du * même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du
@ -80,14 +80,14 @@ object PKProfileStore {
fun terminalHalfLifeDays(ester: String, model: String): Double? { fun terminalHalfLifeDays(ester: String, model: String): Double? {
if (!initialized) return null if (!initialized) return null
val suffix = when (model) { val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" PKModels.ESTRANNAISE -> "ese"
else -> return 0.0 else -> return 0.0
} }
val arr = lookup("${ester}_$suffix") ?: return null val arr = lookup("${ester}_$suffix") ?: return null
val (j, rate) = terminalDecayParameters(arr) ?: return null val (j, rate) = terminalDecayParameters(arr) ?: return null
if (rate >= 0.0) return null if (rate >= 0.0) return null
return kotlin.math.ln(2.0) / -rate / 24.0 // pente /h → t½ en jours return ln(2.0) / -rate / 24.0 // pente /h → t½ en jours
} }
/** /**
@ -117,11 +117,16 @@ object PKProfileStore {
return j to rate return j to rate
} }
/**
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
* linearly interpolated between hourly points; beyond the table, extrapolated
* with the terminal exponential slope.
*/
fun sample(ester: String, model: String, dtHours: Double): Double { fun sample(ester: String, model: String, dtHours: Double): Double {
if (!initialized || dtHours <= 0.0) return 0.0 if (!initialized || dtHours <= 0.0) return 0.0
val suffix = when (model) { val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" PKModels.ESTRANNAISE -> "ese"
else -> return 0.0 else -> return 0.0
} }
val arr = lookup("${ester}_$suffix") ?: return 0.0 val arr = lookup("${ester}_$suffix") ?: return 0.0

View File

@ -88,16 +88,22 @@ object PharmacokineticEngine {
val model = modelOverride ?: treatment.pkModel val model = modelOverride ?: treatment.pkModel
// v1.4.0 : le modèle Transfem Science utilise les paramètres // v1.4.0 : le modèle Transfem Science utilise les paramètres
// V3C (3 compartiments) de la méta-analyse TFS, évalués en // V3C (3 compartiments) de la méta-analyse TFS, évalués en
// forme close — les tables ODS restent le fallback (esters // forme close.
// sans modèle V3C, ou modèle Estrannaise).
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la // v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
// 3ᵉ voie de dispatch, au même niveau que TFS. // 3ᵉ voie de dispatch, au même niveau que TFS.
// v1.9.0 : l'Estrannaise devient ANALYTIQUE (forme close de
// estrannaise.js — fidélité aux tables ODS épinglée RMS 0) :
// les 4 voies de dispatch sont toutes des sources originelles,
// PLUS AUCUNE lecture des tables ODS au runtime (décision
// v1.9.0 : l'ODS n'était que l'échantillonnage de ces sources).
return when { return when {
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) -> model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) ->
TransfemScienceModels.sample(ester, dtH) * mg TransfemScienceModels.sample(ester, dtH) * mg
model == PKModels.WHSAH && WhsahModels.hasModel(ester) -> model == PKModels.WHSAH && WhsahModels.hasModel(ester) ->
WhsahModels.sample(ester, dtH) * mg WhsahModels.sample(ester, dtH) * mg
else -> PKProfileStore.sample(ester, model, dtH) * mg model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(ester) ->
EstrannaiseModels.sample(ester, dtH) * mg
else -> 0.0 // ester sans modèle (documenté : ni TFS ni WHSAH ni ESE)
} }
} }
} }
@ -119,7 +125,9 @@ object PharmacokineticEngine {
* ne contribue plus à la prédiction : * ne contribue plus à la prédiction :
* - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit * - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit
* (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ; * (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ;
* - profil tables ODS (Estrannaise) → longueur de la table (8001 h ≈ 333 j) ; * - profil Estrannaise → 10 demi-vies TERMINALES analytiques (v1.9.0 ;
* l'ancienne coupure « longueur de table ODS 8001 h » a disparu avec
* les tables du runtime) ;
* - Bateman (gel/patch/oral) → 30 demi-vies d'élimination. * - Bateman (gel/patch/oral) → 30 demi-vies d'élimination.
* *
* Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs] * Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs]
@ -139,8 +147,12 @@ object PharmacokineticEngine {
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) -> model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
TransfemScienceModels.model(treatment.esterType)!! TransfemScienceModels.model(treatment.esterType)!!
.terminalHalfLifeDays * 24.0 * 10.0 .terminalHalfLifeDays * 24.0 * 10.0
else -> // v1.9.0 : ESE analytique → t½ terminale ANALYTIQUE (comme
PKProfileStore.profileLength(treatment.esterType, model).toDouble() // TFS/WHS) au lieu de la longueur de table ODS
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(treatment.esterType) ->
EstrannaiseModels.model(treatment.esterType)!!
.terminalHalfLifeDays * 24.0 * 10.0
else -> 0.0
} }
} else 0.0 } else 0.0
val batemanH = if (treatment.usesProfileModel) 0.0 val batemanH = if (treatment.usesProfileModel) 0.0
@ -152,6 +164,65 @@ object PharmacokineticEngine {
// Aggregated levels // Aggregated levels
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/**
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
* bit-identique (mêmes boucles, même ordre de sommation flottante).
*/
class E2Context internal constructor(
internal val treatments: List<Treatment>,
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
internal val cutoffByTreatment: HashMap<Long, Double>,
internal val batemanCache: HashMap<Long, BatemanParams>,
)
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
val cutoffByTreatment = HashMap<Long, Double>()
val batemanCache = HashMap<Long, BatemanParams>()
for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
if (!treatment.usesProfileModel) {
batemanCache[treatment.id] = batemanParams(treatment)
}
}
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
}
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
fun e2At(
ctx: E2Context,
tMs: Long,
modelOverride: String? = null,
scalePerEster: Map<String, Double>? = null
): Double {
var total = 0.0
for (treatment in ctx.treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
if (dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
val c = concentrationOfDose(
treatment, dose, tMs, ctx.batemanCache[treatment.id], modelOverride
)
if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
total += c * scale
}
}
}
return total
}
/** e2At à appel unique : prépare le contexte puis délègue. */
fun e2At( fun e2At(
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
@ -165,28 +236,7 @@ object PharmacokineticEngine {
*/ */
scalePerEster: Map<String, Double>? = null scalePerEster: Map<String, Double>? = null
): Double { ): Double {
val batemanCache = HashMap<Long, BatemanParams>() return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
var total = 0.0
for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
if (!batemanCache.containsKey(treatment.id)) {
batemanCache[treatment.id] = batemanParams(treatment)
}
for (dose in doseLogs) {
if (dose.treatmentId != treatment.id || dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > cutoffHours(treatment)) continue
val c = concentrationOfDose(
treatment, dose, tMs, batemanCache[treatment.id], modelOverride
)
if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
total += c * scale
}
}
}
return total
} }
fun testosteroneAt(e2Level: Double, config: TConfig): Double { fun testosteroneAt(e2Level: Double, config: TConfig): Double {
@ -292,7 +342,15 @@ object PharmacokineticEngine {
val searchStart = maxOf(startMs, earliestDose) val searchStart = maxOf(startMs, earliestDose)
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) } relevantTreatments.forEach {
// Bateman paresseux (v1.8.2) : inutile pour les traitements à profil
if (!it.usesProfileModel) batemanCache[it.id] = batemanParams(it)
}
// v1.8.2 : cutoffs pré-calculés (déterministes par traitement) et
// doses pré-groupées — finis les scans complets de doseLogs à chaque
// point × traitement (l'ordre de sommation est préservé, cf e2At)
val cutoffByTreatment = relevantTreatments.associate { it.id to cutoffHours(it) }
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T) // Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
val sortedEstrogenDoses = doseLogs val sortedEstrogenDoses = doseLogs
@ -314,10 +372,10 @@ object PharmacokineticEngine {
} }
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
for (treatment in relevantTreatments) { for (treatment in relevantTreatments) {
val cutoff = cutoffHours(treatment) val cutoff = cutoffByTreatment.getValue(treatment.id)
val p = batemanCache[treatment.id] val p = batemanCache[treatment.id]
for (dose in doseLogs) { for (dose in dosesByTreatment[treatment.id].orEmpty()) {
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue if (dose.timestamp > t) continue
val dtH = (t - dose.timestamp) / 3_600_000.0 val dtH = (t - dose.timestamp) / 3_600_000.0
if (dtH > cutoff) continue if (dtH > cutoff) continue
val c = concentrationOfDose(treatment, dose, t, p, modelOverride) val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
@ -358,6 +416,14 @@ object PharmacokineticEngine {
): List<DoseLog> { ): List<DoseLog> {
val intervalDays = treatment.forecastIntervalDays ?: return emptyList() val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
if (intervalDays <= 0.0) return emptyList() if (intervalDays <= 0.0) return emptyList()
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
// projette PLUS de créneaux — l'archivage arrête les prédictions
// d'injections futures (graphique Prévision, extension de fenêtre,
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
// FUTUR s'arrête.
if (!treatment.isActive) return emptyList()
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong() val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs <= 0L) return emptyList() if (intervalMs <= 0L) return emptyList()
@ -425,6 +491,8 @@ object PharmacokineticEngine {
// stocké du traitement, ce qui produisait des facteurs absurdes dès // stocké du traitement, ce qui produisait des facteurs absurdes dès
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH) // qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) } val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
val ctx = prepareE2Context(unscaled, doseLogs)
val ratiosByEster = HashMap<String, MutableList<Double>>() val ratiosByEster = HashMap<String, MutableList<Double>>()
var maxPredictionSoFar = 0.0 var maxPredictionSoFar = 0.0
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant — // ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
@ -432,7 +500,7 @@ object PharmacokineticEngine {
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE // résiduelle) produisaient des ratios aberrants dont la MÉDIANE
// tombait à ×2,21 // tombait à ×2,21
for (lab in e2Labs.sortedBy { it.timestamp }) { for (lab in e2Labs.sortedBy { it.timestamp }) {
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride) val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris) // Attribution : dernière dose E2 ≤ lab → son ester (override compris)
@ -449,10 +517,7 @@ object PharmacokineticEngine {
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
} }
return ratiosByEster.mapValues { (_, ratios) -> return ratiosByEster.mapValues { (_, ratios) ->
val sorted = ratios.sorted() medianRounded(ratios, 2)
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 100).toInt() / 100.0
} }
} }
@ -508,12 +573,7 @@ object PharmacokineticEngine {
} }
if (e2 > maxE2SoFar) maxE2SoFar = e2 if (e2 > maxE2SoFar) maxE2SoFar = e2
} }
return ksByEster.mapValues { (_, ks) -> return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) }
val sorted = ks.sorted()
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 1000).toInt() / 1000.0
}
} }
/** /**
@ -602,6 +662,26 @@ object PharmacokineticEngine {
* jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ; * jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ;
* 0 = premier lab → seule la garde > 0,5 s'applique. * 0 = premier lab → seule la garde > 0,5 s'applique.
*/ */
/**
* MÉDIANE arrondie d'une liste de ratios (helper partagé par les 4
* pipelines de calibration — était dupliqué 4×). Liste triée en place.
* @param decimals nombre de décimales après arrondi (via troncature ×10^n)
*/
private fun medianRounded(values: MutableList<Double>, decimals: Int): Double {
values.sort()
val median = if (values.size % 2 == 1) values[values.size / 2]
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
val factor = Math.pow(10.0, decimals.toDouble())
// ⚠️ ARRONDI (v1.8.2), pas troncature : l'ancien motif
// `(median * factor).toInt() / factor` tronquait au dernier bit
// flottant — 0.8999999999999999 × 100 → toInt() = 89 → « 0,89 »
// (le modèle ESE analytique a déplacé ce bit, révélant le piège ;
// le bug #61 historique du même genre : 6,5 → 6 avec
// kotlin.math.round — là, Math.round est l'arrondi au plus proche).
return Math.round(median * factor) / factor
}
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean = fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
predicted > 0.5 && predicted > 0.5 &&
(maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar) (maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar)
@ -636,13 +716,7 @@ object PharmacokineticEngine {
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
} }
if (ratios.isEmpty()) return null if (ratios.isEmpty()) return null
ratios.sort() return medianRounded(ratios, 2)
val median = if (ratios.size % 2 == 1) {
ratios[ratios.size / 2]
} else {
(ratios[ratios.size / 2 - 1] + ratios[ratios.size / 2]) / 2.0
}
return (median * 100).toInt() / 100.0
} }
fun computeTConfigCalibration( fun computeTConfigCalibration(
@ -651,8 +725,10 @@ object PharmacokineticEngine {
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
current: TConfig current: TConfig
): TConfig? { ): TConfig? {
// Le premier filtre « brut > floor » est soussumé par celui sur la
// valeur CONVERTIE (toute conversion est ≤ la valeur brute) : un seul
// filtre après conversion suffit — résultat strictement identique.
val usable = tLabs val usable = tLabs
.filter { it.value > current.floor + 0.02 }
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) } .map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
.filter { it.value > current.floor + 0.02 } .filter { it.value > current.floor + 0.02 }
if (usable.isEmpty()) return null if (usable.isEmpty()) return null
@ -665,13 +741,33 @@ object PharmacokineticEngine {
if (k > 1e-4 && k < 10.0) ks.add(k) if (k > 1e-4 && k < 10.0) ks.add(k)
} }
if (ks.isEmpty()) return null if (ks.isEmpty()) return null
ks.sort() return current.copy(k = medianRounded(ks, 3))
val median = if (ks.size % 2 == 1) { }
ks[ks.size / 2]
} else { /**
(ks[ks.size / 2 - 1] + ks[ks.size / 2]) / 2.0 * Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
*
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
* pour une prise déjà faite — le rappel suivant repart au créneau
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
* S'applique identiquement au fallback quotidien (oral sans Posologie :
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
*
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
*/
fun hasDoseLoggedOnDay(
treatmentId: Long,
doseLogs: List<DoseLog>,
atMs: Long,
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
): Boolean {
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
return doseLogs.any {
it.treatmentId == treatmentId &&
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
} }
return current.copy(k = (median * 1000).toInt() / 1000.0)
} }
/** /**

View File

@ -20,7 +20,6 @@ import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase import com.hormonetrack.data.AppDatabase
import com.hormonetrack.pk.Alerts import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit

View File

@ -5,6 +5,7 @@ import android.content.Context
import android.content.ContentValues import android.content.ContentValues
import android.provider.CalendarContract import android.provider.CalendarContract
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.components.formatDose
import java.util.Calendar import java.util.Calendar
import java.util.TimeZone import java.util.TimeZone
@ -188,6 +189,6 @@ object CalendarEvents {
private fun formatInterval(treatment: Treatment): String = private fun formatInterval(treatment: Treatment): String =
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?" treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
private fun formatDose(d: Double): String = // formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d) // (dédupliquée v1.8.2 — la même règle d'affichage des doses partout)
} }

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
package com.hormonetrack.settings package com.hormonetrack.settings
/** Précompilées (v1.8.2) : appelées à chaque ouverture du dialog « Nouveautés » */
private val VERSION_PATTERN = Regex("^\\d+(\\.\\d+)*$")
private val SECTION_PATTERN = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
/** /**
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour * Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM. * (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
@ -28,7 +32,7 @@ object ChangelogHelper {
private fun parse(version: String): List<Int>? { private fun parse(version: String): List<Int>? {
val cleaned = version.trim().removePrefix("v") val cleaned = version.trim().removePrefix("v")
if (!Regex("^\\d+(\\.\\d+)*$").matches(cleaned)) return null if (!VERSION_PATTERN.matches(cleaned)) return null
return cleaned.split(".").map { it.toInt() } return cleaned.split(".").map { it.toInt() }
} }
@ -43,7 +47,7 @@ object ChangelogHelper {
* @return le markdown concaténé des nouveautés ("" si rien de nouveau). * @return le markdown concaténé des nouveautés ("" si rien de nouveau).
*/ */
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String { fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
val pattern = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL) val pattern = SECTION_PATTERN
val out = StringBuilder() val out = StringBuilder()
for (match in pattern.findAll(markdown)) { for (match in pattern.findAll(markdown)) {
val version = match.groupValues[1] val version = match.groupValues[1]

View File

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

View File

@ -3,6 +3,7 @@ package com.hormonetrack.ui.components
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@ -14,11 +15,10 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.ui.theme.ChartE2
import com.hormonetrack.ui.theme.ChartT
import com.hormonetrack.ui.theme.LabDot import com.hormonetrack.ui.theme.LabDot
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
@ -48,7 +48,15 @@ data class ChartSeries(
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du * ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
* modèle, et `labIsSignificant` a déjà filtré les points aberrants. * modèle, et `labIsSignificant` a déjà filtré les points aberrants.
*/ */
val showExtrema: Boolean = true val showExtrema: Boolean = true,
/**
* v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC — cf
* pk/EstrannaiseCloud) : chaque sous-liste est une polyligne dessinée en
* alpha faible SOUS la courbe principale de la série. null = pas de nuage.
* Hors labels/extrema par construction (c'est de l'incertitude, pas des
* courbes nommées).
*/
val cloud: List<List<LevelPoint>>? = null
) )
data class ChartOptions( data class ChartOptions(
@ -95,12 +103,20 @@ fun xLabelTicks(
cal.set(Calendar.MILLISECOND, 0) cal.set(Calendar.MILLISECOND, 0)
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1) while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
} }
val stepMs = stepHours * 3_600_000L
val out = mutableListOf<Long>() val out = mutableListOf<Long>()
var t = cal.timeInMillis var t = cal.timeInMillis
while (t <= t1) { while (t <= t1) {
out.add(t) out.add(t)
t += stepMs // On avance via le Calendar (et non un +stepMs fixe) : une addition
// en millisecondes dérive d'1 h aux transitions DST, alors que le
// pas « 24 h » doit tomber sur minuit LOCAL tous les jours — y
// compris le jour du changement d'heure.
if (stepHours >= 24) {
cal.add(Calendar.DAY_OF_MONTH, (stepHours / 24L).toInt().coerceAtLeast(1))
} else {
cal.add(Calendar.HOUR_OF_DAY, stepHours.toInt())
}
t = cal.timeInMillis
} }
return out return out
} }
@ -168,12 +184,12 @@ fun niceCeil(value: Double): Double {
*/ */
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> { fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
val toggles = mapOf( val toggles = mapOf(
"ESE" to pkModelsUsed.contains("ESE"), PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE),
"TFS" to pkModelsUsed.contains("TFS"), PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE),
"WHS" to pkModelsUsed.contains("WHS") PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH)
) )
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série) // Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
return if (toggles.values.none { it }) toggles + ("TFS" to true) else toggles return if (toggles.values.none { it }) toggles + (PKModels.TRANSFEM_SCIENCE to true) else toggles
} }
/** /**
@ -304,6 +320,35 @@ fun CurveChart(
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f) val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
// se redessine à chaque geste).
val extremaBySeries = remember(series, options) {
series.map { s ->
Pair(
if (s.showExtrema) {
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
} else emptyList(),
if (s.showExtrema) {
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
} else null
)
}
}
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
// thread-safe — thread UI seul ici — et l'allouer par frame était du
// gaspillage, même standard que le fix des marqueurs v1.8.2).
val markerTimeFmt = remember(zone) {
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
}
val xAxisHourFmt = remember(zone) {
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
}
val xAxisDayFmt = remember(zone) {
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
}
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val allPoints = series.flatMap { it.points } val allPoints = series.flatMap { it.points }
if (allPoints.size < 2) return@Canvas if (allPoints.size < 2) return@Canvas
@ -358,21 +403,19 @@ fun CurveChart(
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(), drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
android.graphics.Paint.Align.LEFT) android.graphics.Paint.Align.LEFT)
} }
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone) drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone, xAxisHourFmt, xAxisDayFmt)
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit // Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée // triangle ▼ en haut de la zone — rend l'instant de la dose simulée
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55). // VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
if (forecastDoses.isNotEmpty()) { if (forecastDoses.isNotEmpty()) {
val markerColor = forecastMarkerColor val markerColor = forecastMarkerColor
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply { val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
color = android.graphics.Color.argb( // SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
(markerColor.alpha * 255).toInt(), // redessine à chaque pan/zoom, une allocation par dose × frame
(markerColor.red * 255).toInt(), // était du gaspillage (la classe n'est de toute façon pas
(markerColor.green * 255).toInt(), // thread-safe — ici on reste dans le DrawScope UI)
(markerColor.blue * 255).toInt() val hourFmt = markerTimeFmt
)
}
for (dose in forecastDoses) { for (dose in forecastDoses) {
if (dose.timestamp !in t0..t1) continue if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp) val x = xOf(dose.timestamp)
@ -392,16 +435,42 @@ fun CurveChart(
} }
drawPath(path, markerColor) drawPath(path, markerColor)
// heure de la dose simulée DANS le fuseau de lecture // heure de la dose simulée DANS le fuseau de lecture
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
hourFmt.timeZone = zone
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint
) )
} }
} }
// Une courbe E2 + une courbe T par série // NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : dessiné EN PREMIER
// (sous toutes les courbes nommées), chaque sous-courbe du posterior
// MCMC en alpha faible. Décisions d'affichage :
// - le nuage ne participe PAS aux échelles (allPoints ne l'inclut
// pas) : c'est une plage d'imprécision, pas des données à cadrer —
// il peut sortir du cadre en haut/bas sans casser la lecture ;
// - ni aux labels ni aux extrema (pas des courbes nommées).
val cloudPaths = mutableListOf<Pair<Color, Path>>()
for (s in series) { for (s in series) {
val cloud = s.cloud ?: continue
for (sub in cloud) {
if (sub.size < 2) continue
val path = Path()
sub.forEachIndexed { i, p ->
val x = xOf(p.timestamp)
val y = yE2(p.e2)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
cloudPaths.add(s.e2Style.color.copy(alpha = 0.15f) to path)
}
}
for ((color, path) in cloudPaths) {
drawPath(
path, color,
style = Stroke(width = 1.5.dp.toPx())
)
}
// Une courbe E2 + une courbe T par série
for ((sIdx, s) in series.withIndex()) {
if (s.points.size < 2) continue if (s.points.size < 2) continue
val e2Path = Path() val e2Path = Path()
s.points.forEachIndexed { i, p -> s.points.forEachIndexed { i, p ->
@ -441,21 +510,24 @@ fun CurveChart(
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée // Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux). // (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false). // v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
// Canvas, recalculés seulement quand séries/options changent) — le
// scan local était refait PAR FRAME, x2 séries.
if (options.showExtrema && s.showExtrema) { if (options.showExtrema && s.showExtrema) {
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0) extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
.forEach { ex -> e2Extrema.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color) drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
} }
if (options.showT) { if (options.showT) {
s.tStyle?.let { tStyle -> s.tStyle?.let { tStyle ->
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) tExtrema.orEmpty().forEach { ex ->
.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color) drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
} }
} }
} }
} }
} }
}
// Labs (points/carrés oranges) // Labs (points/carrés oranges)
if (options.showLabs) { if (options.showLabs) {
@ -516,7 +588,7 @@ fun CurveChart(
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un * estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
* creux — dans la couleur de la série, unité implicite (celle de l'axe). * creux — dans la couleur de la série, unité implicite (celle de l'axe).
*/ */
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetrack.pk.PharmacokineticEngine.Extremum, color: Color) { private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: PharmacokineticEngine.Extremum, color: Color) {
val r = 5.dp.toPx() val r = 5.dp.toPx()
val offset = 6.dp.toPx() val offset = 6.dp.toPx()
val path = Path() val path = Path()
@ -536,19 +608,9 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetr
} }
path.close() path.close()
drawPath(path, color) drawPath(path, color)
// Valeur estimée du pic/creux, dans la couleur de la courbe // Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
val paint = android.graphics.Paint().apply { // en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
this.color = android.graphics.Color.argb( val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
)
textSize = 9.dp.toPx()
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
formatValue(extremum.value), cx, valueY, paint formatValue(extremum.value), cx, valueY, paint
) )
@ -574,27 +636,48 @@ private fun DrawScope.drawAxisUnit(
drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align)) drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align))
} }
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) { private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) {
for (i in 0..rows) {
val y = padTop + h * i / rows val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
} }
} }
/** Paint partagé pour les labels texte (couleur gris neutre fixe). */ /**
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint { * Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
val c = Color(0xFF6B7280) * (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
return android.graphics.Paint().apply { * label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
color = android.graphics.Color.argb( * dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
(c.alpha * 255).toInt(), * Les clés sont des value classes (Color) → pas de fuite.
(c.red * 255).toInt(), */
(c.green * 255).toInt(), private val textPaintCache = HashMap<String, android.graphics.Paint>()
(c.blue * 255).toInt()
private fun DrawScope.textPaint(
color: Color,
textSizeDp: Float,
align: android.graphics.Paint.Align,
fakeBold: Boolean = false
): android.graphics.Paint {
val key = "$color|$textSizeDp|$align|$fakeBold"
return textPaintCache.getOrPut(key) {
android.graphics.Paint().apply {
this.color = android.graphics.Color.argb(
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
) )
textSize = 10.dp.toPx() textSize = textSizeDp.dp.toPx()
textAlign = align textAlign = align
isAntiAlias = true isAntiAlias = true
isFakeBoldText = fakeBold
} }
} }
}
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
textPaint(Color(0xFF6B7280), 10f, align)
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) { private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
val paint = labelPaint(android.graphics.Paint.Align.RIGHT) val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
@ -617,7 +700,11 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
private fun DrawScope.drawXLabels( private fun DrawScope.drawXLabels(
color: Color, t0: Long, t1: Long, color: Color, t0: Long, t1: Long,
padLeft: Float, yBottom: Float, w: Float, padLeft: Float, yBottom: Float, w: Float,
zone: java.util.TimeZone = java.util.TimeZone.getDefault() zone: java.util.TimeZone = java.util.TimeZone.getDefault(),
// v1.9.5 (audit) : formatters mémoïsés du composable (plus de
// SimpleDateFormat alloué par frame)
hourFmt: SimpleDateFormat = SimpleDateFormat("HH'h'", Locale.getDefault()),
dayFmt: SimpleDateFormat = SimpleDateFormat("dd/MM", Locale.getDefault())
) { ) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER) val paint = labelPaint(android.graphics.Paint.Align.CENTER)
val spanH = (t1 - t0) / 3_600_000.0 val spanH = (t1 - t0) / 3_600_000.0
@ -631,28 +718,25 @@ private fun DrawScope.drawXLabels(
} }
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le // v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
// tick « minuit local » serait étiqueté avec l'heure système. // tick « minuit local » serait étiqueté avec l'heure système.
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault()) // v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
else SimpleDateFormat("dd/MM", Locale.getDefault()) val fmt = if (spanH <= 25) hourFmt else dayFmt
fmt.timeZone = zone
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES // v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
// (minuit local pour les pas en jours) — l'ancien alignement sur des // (minuit local pour les pas en jours) — l'ancien alignement sur des
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR. // multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
for (tick in xLabelTicks(t0, t1, stepHours.toLong())) { // v1.9.5 (audit) : la ZONE de lecture est passée à xLabelTicks — l'oubli
// alignait les ticks sur le fuseau de l'appareil pendant que les labels
// sont formatés dans le fuseau choisi (heures décalées dès que les deux
// divergent, contredisant le fix #55).
for (tick in xLabelTicks(t0, t1, stepHours.toLong(), zone)) {
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat()) val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint) drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
} }
} }
private fun DrawScope.drawDotLabel(v: Double, c: Offset) { private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply { // Paint en cache (v1.9.5 : une allocation par lab × frame → une par
color = android.graphics.Color.rgb( // couleur, réutilisée entre frames)
(LabDot.red * 255).toInt(), val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
(LabDot.green * 255).toInt(),
(LabDot.blue * 255).toInt()
)
textSize = 9.dp.toPx()
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint) drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
} }

View File

@ -42,8 +42,9 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.PKModels
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.EstrannaiseCloud
import com.hormonetrack.pk.LabTrajectoryModel import com.hormonetrack.pk.LabTrajectoryModel
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
@ -62,6 +63,12 @@ import com.hormonetrack.ui.theme.TealTertiary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.hormonetrack.ui.components.clampPanHours
import com.hormonetrack.ui.components.defaultModelToggles
import com.hormonetrack.ui.components.forecastExtensionHours
import com.hormonetrack.ui.components.forecastHorizonHours
import com.hormonetrack.ui.components.panDeltaHours
import com.hormonetrack.ui.components.stepForRange
private const val HOUR_MS = 3_600_000L private const val HOUR_MS = 3_600_000L
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j // Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
@ -72,10 +79,12 @@ private const val MAX_RANGE_H = 24L * 300
* Écran Graphiques. * Écran Graphiques.
* *
* Fonctions : * Fonctions :
* - plages 24 h / 7 j / 30 j ; * - plages 6 h → 300 j (zoom v1.2.9, presets 24 h / 7 j / 30 j) ;
* - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé ; * - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé
* - **les deux modèles PK** (Estrannaise / Transfem Science) peuvent être affichés * (le futur avec la Prévision active) ;
* simultanément (override de modèle dans le moteur), avec toggle indépendant ; * - **les modèles PK** (Estrannaise / Transfem Science / WHSAH) peuvent être
* affichés simultanément (override de modèle dans le moteur), toggles
* indépendants ; + Tracé labs (v1.5.0) et sa prolongation (v1.6.0) ;
* - **prévision** : prolonge la courbe avec des doses projetées à partir de la * - **prévision** : prolonge la courbe avec des doses projetées à partir de la
* « fréquence » configurée sur chaque traitement ; * « fréquence » configurée sur chaque traitement ;
* - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T * - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T
@ -118,6 +127,13 @@ fun ChartScreen() {
// revendique « passer par tes labs » et doit rester une référence de // revendique « passer par tes labs » et doit rester une référence de
// comparaison, pas un affichage par défiant // comparaison, pas un affichage par défiant
var showLabTrack by remember { mutableStateOf(false) } var showLabTrack by remember { mutableStateOf(false) }
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC de
// estrannaise.js — cf pk/EstrannaiseCloud) — OFF par défaut, opt-in,
// activable/désactivable à volonté ; ACTIVABLE SEULEMENT quand le
// modèle ESE est affiché (le nuage est exclusif à ce modèle — les
// fits TFS/WHSAH ne publient pas de posterior). Se désactive
// automatiquement si ESE est éteint.
var showCloud by remember { mutableStateOf(false) }
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du // v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
// dernier lab constant × modèle, bornée par l'extinction du modèle — // dernier lab constant × modèle, bornée par l'extinction du modèle —
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la // cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
@ -135,12 +151,12 @@ fun ChartScreen() {
var modelTogglesInitialized by remember { mutableStateOf(false) } var modelTogglesInitialized by remember { mutableStateOf(false) }
LaunchedEffect(treatments) { LaunchedEffect(treatments) {
if (!modelTogglesInitialized && treatments.isNotEmpty()) { if (!modelTogglesInitialized && treatments.isNotEmpty()) {
val toggles = com.hormonetrack.ui.components.defaultModelToggles( val toggles = defaultModelToggles(
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet() treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
) )
showEse = toggles.getValue("ESE") showEse = toggles.getValue(PKModels.ESTRANNAISE)
showTfs = toggles.getValue("TFS") showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
showWhs = toggles.getValue("WHS") showWhs = toggles.getValue(PKModels.WHSAH)
modelTogglesInitialized = true modelTogglesInitialized = true
} }
} }
@ -177,14 +193,14 @@ fun ChartScreen() {
} else { } else {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
mapOf( mapOf(
"ESE" to PharmacokineticEngine.autoCalibrated( PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "ESE" treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
), ),
"TFS" to PharmacokineticEngine.autoCalibrated( PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "TFS" treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
), ),
"WHS" to PharmacokineticEngine.autoCalibrated( PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "WHS" treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
) )
) )
} }
@ -193,8 +209,15 @@ fun ChartScreen() {
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné // Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt). // [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() // ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays) // Posologie d'un traitement archivé ne doit ni activer le chip
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
// créneaux, cf generateForecastDoses).
val maxIntervalDays = treatments
.filter { it.isActive }
.mapNotNull { it.forecastIntervalDays }
.maxOrNull()
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ── // ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
// ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le // ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
@ -220,7 +243,7 @@ fun ChartScreen() {
} }
} }
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) { val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
com.hormonetrack.ui.components.forecastExtensionHours( forecastExtensionHours(
rangeHours, forecastHorizonHours, firstSlotGapH rangeHours, forecastHorizonHours, firstSlotGapH
) )
} else 0L } else 0L
@ -248,6 +271,11 @@ fun ChartScreen() {
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux // exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait // créneaux projetés, fix #55 : sans marqueur, la projection paraissait
// décalée d'un jour). // décalée d'un jour).
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
// recomposition (leçon #65 : un producer doit redériver de ses vraies
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
val forecastDoses by produceState( val forecastDoses by produceState(
emptyList<com.hormonetrack.data.model.DoseLog>(), emptyList<com.hormonetrack.data.model.DoseLog>(),
treatments, doseLogs, showForecast, endMs, forecastHorizonHours treatments, doseLogs, showForecast, endMs, forecastHorizonHours
@ -263,16 +291,30 @@ fun ChartScreen() {
} }
} }
/**
* Une courbe rendue par le producer : clé de modèle ("ESE"/"TFS"/"WHS"/
* "LAB"/"LABX"), ses points, et — v1.9.0 — son NUAGE D'INCERTITUDE
* (uniquement pour "ESE" quand le chip Nuage est actif).
*/
data class ChartCurve(
val model: String,
val points: List<LevelPoint>,
val cloud: List<List<LevelPoint>> = emptyList()
)
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") — // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") —
// v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ; // v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ;
// v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant) // v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant)
val curves by produceState( val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(), emptyList<ChartCurve>(),
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast, autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
showLabTrack, showLabTrackExtend, labResults.size showLabTrack, showLabTrackExtend, showCloud,
// Clé = la liste ENTIÈRE des labs (pas .size, fix #66) : éditer la
// VALEUR d'une analyse doit recalculer les courbes
labResults
) { ) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>() val list = mutableListOf<ChartCurve>()
// ── v1.5.0 : courbe « Tracé labs » ─────────────────────────────── // ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
// Totalement indépendante des toggles de modèles (elle utilise le // Totalement indépendante des toggles de modèles (elle utilise le
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10) // modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
@ -290,7 +332,7 @@ fun ChartScreen() {
// non vides seulement — la légende (plus bas) doit connaître // non vides seulement — la légende (plus bas) doit connaître
// "LABX" AVANT son else (leçon #63). // "LABX" AVANT son else (leçon #63).
if (showLabTrack) { if (showLabTrack) {
val step = com.hormonetrack.ui.components.stepForRange(rangeHours) val step = stepForRange(rangeHours)
val labCurve = LabTrajectoryModel.computeLabAnchoredCurve( val labCurve = LabTrajectoryModel.computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, treatments, doseLogs, labResults, startMs, endMs, step,
extendBeyondLastLab = showLabTrackExtend extendBeyondLastLab = showLabTrackExtend
@ -299,18 +341,18 @@ fun ChartScreen() {
if (showLabTrackExtend && lastAnchor != null) { if (showLabTrackExtend && lastAnchor != null) {
val anchored = labCurve.points.filter { it.timestamp <= lastAnchor } val anchored = labCurve.points.filter { it.timestamp <= lastAnchor }
val extended = labCurve.points.filter { it.timestamp > lastAnchor } val extended = labCurve.points.filter { it.timestamp > lastAnchor }
list.add("LAB" to anchored) list.add(ChartCurve("LAB", anchored))
if (extended.isNotEmpty()) list.add("LABX" to extended) if (extended.isNotEmpty()) list.add(ChartCurve("LABX", extended))
} else { } else {
list.add("LAB" to labCurve.points) list.add(ChartCurve("LAB", labCurve.points))
} }
} }
if (showEse || showTfs || showWhs) { if (showEse || showTfs || showWhs) {
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les // Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
// échelles/k calculés avec SA prédiction. Hors auto-calibration // échelles/k calculés avec SA prédiction. Hors auto-calibration
// → scaleFactor stocké des traitements (comportement historique). // → scaleFactor stocké des traitements (comportement historique).
val effTreatments = autoByModel["TFS"]?.treatments ?: treatments val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
// Doses réelles + doses prévisionnelles éventuelles // Doses réelles + doses prévisionnelles éventuelles
val forecastDoses = if (showForecast) { val forecastDoses = if (showForecast) {
effTreatments.flatMap { effTreatments.flatMap {
@ -319,50 +361,39 @@ fun ChartScreen() {
} else emptyList() } else emptyList()
val allDoses = doseLogs + forecastDoses val allDoses = doseLogs + forecastDoses
// Pas d'échantillonnage adaptatif au zoom (v1.2.9) // Pas d'échantillonnage adaptatif au zoom (v1.2.9)
val step = com.hormonetrack.ui.components.stepForRange(rangeHours) val step = stepForRange(rangeHours)
if (showEse) { // v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
val autoM = autoByModel["ESE"] // le modèle est la seule variable (même calcul, même calibration
list.add( // PAR MODÈLE via autoByModel[model], fix #60)
"ESE" to PharmacokineticEngine.computeCurve( for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
if (!enabled) continue
val autoM = autoByModel[model]
val curve = PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs, effTreatments, allDoses, startMs, endMs,
stepMs = step, stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "ESE", tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}
if (showTfs) {
val autoM = autoByModel["TFS"]
list.add(
"TFS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "TFS",
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}
// v1.4.6 : 3ᵉ modèle WHSAH (même mécanique d'override)
if (showWhs) {
val autoM = autoByModel["WHS"]
list.add(
"WHS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "WHS",
scalePerEster = autoM?.esterScales, scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster tKPerEster = autoM?.tKPerEster
) )
// v1.9.0 : le nuage d'incertitude est attaché à la série
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
// publient pas de posterior MCMC)
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
// MÊME calibration que la courbe ESE (fix v1.9.1 :
// nuage brut ≠ échelle de la courbe calibrée)
EstrannaiseCloud.compute(
treatments, doseLogs, startMs, endMs, step,
scalePerEster = autoM?.esterScales
) )
} else emptyList()
list.add(ChartCurve(model, curve, cloud))
} }
} }
value = list value = list
} }
} }
val allPoints = curves.flatMap { it.second } val allPoints = curves.flatMap { it.points }
val (a, b) = if (allPoints.size >= 2) { val (a, b) = if (allPoints.size >= 2) {
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp } allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
} else 0L to 0L } else 0L to 0L
@ -374,11 +405,11 @@ fun ChartScreen() {
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ; // Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
// T Estrannaise = rose plein, T TFS = rose pointillé. // T Estrannaise = rose plein, T TFS = rose pointillé.
val series = curves.map { (model, points) -> val series = curves.map { (model, points, cloud) ->
when (model) { when (model) {
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT)) PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé // v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true)) PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T // v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné) // (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10) // et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
@ -501,7 +532,12 @@ fun ChartScreen() {
) { ) {
FilterChip( FilterChip(
selected = showEse, selected = showEse,
onClick = { showEse = !showEse }, onClick = {
showEse = !showEse
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE
// éteint le nuage (sinon il flotterait sans sa courbe)
if (!showEse) showCloud = false
},
label = { Text(stringResource(R.string.model_ese)) } label = { Text(stringResource(R.string.model_ese)) }
) )
FilterChip( FilterChip(
@ -530,6 +566,16 @@ fun ChartScreen() {
enabled = showLabTrack, enabled = showLabTrack,
label = { Text(stringResource(R.string.show_lab_track_extend)) } label = { Text(stringResource(R.string.show_lab_track_extend)) }
) )
// v1.9.0 : NUAGE D'INCERTITUDE (exclusif ESE) — off par défaut,
// activable à volonté mais SEULEMENT si ESE est affiché (même
// pattern que Prolonger/Tracé labs) ; éteindre ESE coupe le
// nuage automatiquement (sinon le nuage flotterait sans sa courbe)
FilterChip(
selected = showCloud,
onClick = { showCloud = !showCloud },
enabled = showEse,
label = { Text(stringResource(R.string.show_cloud)) }
)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -550,12 +596,12 @@ fun ChartScreen() {
// v1.4.10 (fix #62) : cumul fractionnaire du pan // v1.4.10 (fix #62) : cumul fractionnaire du pan
// (l'ancienne troncature par delta tuait le pan // (l'ancienne troncature par delta tuait le pan
// sur la vue 24 h) // sur la vue 24 h)
val (hoursDragged, newResidual) = com.hormonetrack.ui.components.panDeltaHours( val (hoursDragged, newResidual) = panDeltaHours(
pan.x, chartWidthPx, rangeHours, panResidualHours pan.x, chartWidthPx, rangeHours, panResidualHours
) )
panResidualHours = newResidual panResidualHours = newResidual
if (hoursDragged != 0L) { if (hoursDragged != 0L) {
panHours = com.hormonetrack.ui.components.clampPanHours( panHours = clampPanHours(
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
) )
} }
@ -573,7 +619,7 @@ fun ChartScreen() {
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong() val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong() val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong() val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
panHours = com.hormonetrack.ui.components.clampPanHours( panHours = clampPanHours(
newPan, gestureFutureHorizon, gestureMaxPan newPan, gestureFutureHorizon, gestureMaxPan
) )
rangeHours = newRange rangeHours = newRange
@ -625,7 +671,7 @@ fun ChartScreen() {
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63). // une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
curves.forEach { (model, _) -> curves.forEach { (model, _) ->
when (model) { when (model) {
"ESE" -> { PKModels.ESTRANNAISE -> {
Text( Text(
stringResource(R.string.legend_ese_e2), stringResource(R.string.legend_ese_e2),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@ -640,7 +686,7 @@ fun ChartScreen() {
} }
} }
// v1.4.6 : légende WHSAH (vert / violet pointillé) // v1.4.6 : légende WHSAH (vert / violet pointillé)
"WHS" -> { PKModels.WHSAH -> {
Text( Text(
stringResource(R.string.legend_whs_e2), stringResource(R.string.legend_whs_e2),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@ -699,18 +745,27 @@ fun ChartScreen() {
} }
// v1.5.0 : légende « Tracé labs » — seulement si la série // v1.5.0 : légende « Tracé labs » — seulement si la série
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon) // a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
if (showLabTrack && curves.any { it.first == "LAB" && it.second.size >= 2 }) { if (showLabTrack && curves.any { it.model == "LAB" && it.points.size >= 2 }) {
Text( Text(
stringResource(R.string.legend_lab_track), stringResource(R.string.legend_lab_track),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = ChartLabTrajectory color = ChartLabTrajectory
) )
} }
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
// tant que le nuage est affiché (série ESE avec cloud non vide)
if (showCloud && showEse && curves.any { it.model == PKModels.ESTRANNAISE && it.cloud.isNotEmpty() }) {
Text(
stringResource(R.string.legend_cloud),
style = MaterialTheme.typography.labelMedium,
color = ChartE2.copy(alpha = 0.55f)
)
}
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT — // v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT —
// seulement si la série "LABX" existe (chip Prolonger actif // seulement si la série "LABX" existe (chip Prolonger actif
// ET horizon de prolongation atteint dans la fenêtre visible) // ET horizon de prolongation atteint dans la fenêtre visible)
val labExtendVisible = showLabTrack && showLabTrackExtend && val labExtendVisible = showLabTrack && showLabTrackExtend &&
curves.any { it.first == "LABX" && it.second.size >= 2 } curves.any { it.model == "LABX" && it.points.size >= 2 }
if (labExtendVisible) { if (labExtendVisible) {
Text( Text(
stringResource(R.string.legend_lab_track_extend), stringResource(R.string.legend_lab_track_extend),

View File

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

View File

@ -72,12 +72,13 @@ import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
private val HOUR_MS = 3_600_000L private const val HOUR_MS = 3_600_000L
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
openLogDoseForTreatmentId: Long?, openLogDoseForTreatmentId: Long?,
onLogDoseConsumed: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit, onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit onOpenTreatment: (Long) -> Unit
@ -113,10 +114,16 @@ fun HomeScreen(
var showLogDialog by remember { mutableStateOf(false) } var showLogDialog by remember { mutableStateOf(false) }
var preselectId by remember { mutableStateOf<Long?>(null) } var preselectId by remember { mutableStateOf<Long?>(null) }
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
LaunchedEffect(openLogDoseForTreatmentId) { LaunchedEffect(openLogDoseForTreatmentId) {
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let { openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
showLogDialog = true showLogDialog = true
preselectId = it preselectId = it
onLogDoseConsumed()
} }
} }
@ -128,7 +135,10 @@ fun HomeScreen(
} }
} }
val curve by produceState<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) { // v1.8.2 : allTreatments et tConfig ajoutés aux clés — éditer un
// scaleFactor ou le modèle T rafraîchit la courbe immédiatement
// (l'ancien trio de clés ratait ces changements jusqu'au tick de 60 s)
val curve by produceState<List<LevelPoint>>(emptyList(), allTreatments, tConfig, effectiveAuto, doseLogs, tick) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val end = System.currentTimeMillis() val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve( value = PharmacokineticEngine.computeCurve(
@ -231,14 +241,20 @@ fun HomeScreen(
// (l'accueil ne fait pas doublon d'explication). // (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState( val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?, initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick allTreatments, doseLogs, labResults, tick, effectiveAuto
) { ) {
value = withContext(Dispatchers.Default) { value = withContext(Dispatchers.Default) {
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
// alimentent la prédiction « ≈ X pg/mL au creux » (null
// si l'option est OFF → carte sans valeur, inchangée)
LabTiming.nextBloodDrawRecommendation( LabTiming.nextBloodDrawRecommendation(
allTreatments, doseLogs, labResults, System.currentTimeMillis() allTreatments, doseLogs, labResults, System.currentTimeMillis(),
effectiveAuto?.esterScales
) )
} }
} }
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
labRecommendation?.let { rec -> labRecommendation?.let { rec ->
val fmtDate = remember { val fmtDate = remember {
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault()) DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
@ -260,6 +276,37 @@ fun HomeScreen(
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))), stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut face à la
// cible de creux personnelle (lecture seule — jamais
// de notification, distinct des seuils d'alerte)
rec.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text( Text(
stringResource( stringResource(
R.string.labrec_before_injection, R.string.labrec_before_injection,
@ -455,49 +502,76 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
} }
} }
/**
* Ligne « prochaine prise » d'un traitement (v1.9.6 — PUR, testable JVM) :
* « Xj Yh · ven. 25 10:00 (nom) » au-delà de 24 h (les heures deviennent peu
* lisibles, v1.4.1), « XhMM · HH:mm (nom) » en dessous, « N min · HH:mm
* (nom) » dans l'heure. Utilisée par chaque ligne de [NextDoseCard].
*/
internal fun nextDoseLine(
nowMs: Long,
fireMs: Long,
name: String,
/** Template localisé du cas ≥ 24 h (string `next_dose_days`, 4 args). */
daysTemplate: String,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault()
): String {
val deltaMs = fireMs - nowMs
val h = deltaMs / HOUR_MS
val m = (deltaMs % HOUR_MS) / 60_000L
return if (h >= 24) {
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
.withZone(zone)
.withLocale(locale)
.format(Instant.ofEpochMilli(fireMs))
String.format(locale, daysTemplate, days, remH, dayTimeStr, name)
} else {
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
.withZone(zone)
.withLocale(locale)
.format(Instant.ofEpochMilli(fireMs))
if (h > 0) {
String.format(locale, "%dh%02d · %s (%s)", h, m, timeStr, name)
} else {
String.format(locale, "%d min · %s (%s)", m, timeStr, name)
}
}
}
@Composable @Composable
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) { private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte // v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes. // suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
// ligne par traitement, TRIÉES par prochaine prise — l'ancienne carte ne
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
// totalement le rappel de l'injection hebdomadaire (le rappel EEn
// « n'arrivait qu'après celui de la CPA »). Le titre passe au pluriel
// dès qu'il y a au moins deux lignes.
val nowMs = System.currentTimeMillis() val nowMs = System.currentTimeMillis()
val fires = treatments val fires = treatments
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } } .mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
.sortedBy { it.second }
if (fires.isEmpty()) return if (fires.isEmpty()) return
val next = fires.minByOrNull { it.second }!!.second
val nextTreatment = fires.minByOrNull { it.second }!!.first
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text( Text(
stringResource(R.string.next_dose), stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
val deltaMs = next - System.currentTimeMillis() // Première ligne (la plus proche) mise en avant, les suivantes en
val h = deltaMs / HOUR_MS // style secondaire — même hiérarchie visuelle que v1.4.x.
val m = (deltaMs % HOUR_MS) / 60_000L fires.forEachIndexed { idx, (treatment, next) ->
val name = nextTreatment?.name ?: ""
// v1.4.1 : au-delà de 24 h, afficher en JOURS (les heures
// deviennent peu lisibles) + le jour de la semaine dans la date.
if (h >= 24) {
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text( Text(
stringResource(R.string.next_dose_days, days, remH, dayTimeStr, name), nextDoseLine(
style = MaterialTheme.typography.titleMedium nowMs, next, treatment?.name ?: "",
) stringResource(R.string.next_dose_days)
} else { ),
val timeStr = DateTimeFormatter.ofPattern("HH:mm") style = if (idx == 0) MaterialTheme.typography.titleMedium
.withZone(ZoneId.systemDefault()) else MaterialTheme.typography.bodyMedium
.format(Instant.ofEpochMilli(next))
Text(
if (h > 0) {
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, name)
} else {
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, name)
},
style = MaterialTheme.typography.titleMedium
) )
} }
} }

View File

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

View File

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

View File

@ -48,11 +48,11 @@ import com.hormonetrack.R
import com.hormonetrack.data.model.AdministrationRoute import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.modelLabelRes
import com.hormonetrack.data.model.PKPresets import com.hormonetrack.data.model.PKPresets
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler
@ -272,10 +272,17 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (PharmacokineticEngine.isInjectionRoute(route)) { if (PharmacokineticEngine.isInjectionRoute(route)) {
// v1.9.4 : la liste des esters suit la couverture RÉELLE du
// modèle choisi (Esters.choicesForModel — miroir du DoseDialog
// et du treatment-editor web) : le modèle ESE ANALYTIQUE offre
// ses 6 esters injectables (EV/EU/EEn/EC/EB/EUCS) directement
// dans l'éditeur, au lieu des seuls EV/EU/EEn de l'époque des
// tables ODS. Chaque ester listé est dispatché par le moteur —
// aucun choix possible ne produit 0 pg/mL.
DropdownField( DropdownField(
label = stringResource(R.string.ester), label = stringResource(R.string.ester),
selectedLabel = ester, selectedLabel = ester,
options = listOf(Esters.EV, Esters.EU, Esters.EEN).map { it to it }, options = Esters.choicesForModel(model).map { it to it },
onSelect = { ester = it } onSelect = { ester = it }
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -283,17 +290,26 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
label = stringResource(R.string.pk_model), label = stringResource(R.string.pk_model),
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona, // v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
// cf pk/WhsahModels.kt) superposable dans le graphique. // cf pk/WhsahModels.kt) superposable dans le graphique.
selectedLabel = when (model) { // Label par le helper UNIQUE (règle anti-bug #59 :
PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs) // jamais de when/ternaire de labels en ligne — l'ancien
PKModels.WHSAH -> stringResource(R.string.model_whs) // à 2 branches affichait « Estrannaise » pour WHSAH).
else -> stringResource(R.string.model_ese) selectedLabel = stringResource(modelLabelRes(model)),
},
options = listOf( options = listOf(
PKModels.ESTRANNAISE to stringResource(R.string.model_ese), PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs), PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
PKModels.WHSAH to stringResource(R.string.model_whs) PKModels.WHSAH to stringResource(R.string.model_whs)
), ),
onSelect = { model = it } onSelect = { model = it
// v1.9.4 : l'ester courant peut sortir de la couverture
// du nouveau modèle (ex. EUCS + TFS : le V3C n'a pas de
// paramètres EUCS → le dispatcher donnerait 0) — on
// rebascule sur le premier ester couvert (EV dans les
// trois listes). Le recompose reconstruit ensuite la
// liste des esters (options = choicesForModel(model)).
if (ester !in Esters.choicesForModel(it)) {
ester = Esters.choicesForModel(it).first()
}
}
) )
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -434,8 +450,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute) String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
) )
} }
val scheduler2 = AlarmScheduler(context) // Le scheduler est déjà créé au niveau de l'écran
if (!scheduler2.canScheduleExact()) { // (var `scheduler`) — pas de recréation par recompose.
if (!scheduler.canScheduleExact()) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
stringResource(R.string.exact_alarm_needed), stringResource(R.string.exact_alarm_needed),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,9 +60,9 @@ object AppLog {
fun log(tag: String, message: String) = synchronized(lock) { fun log(tag: String, message: String) = synchronized(lock) {
val line = formatLine(System.currentTimeMillis(), tag, message) val line = formatLine(System.currentTimeMillis(), tag, message)
buffer.addLast(line) buffer.addLast(line)
val trimmed = trimLines(buffer.toList(), MAX_LINES) // Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le
buffer.clear() // buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières)
buffer.addAll(trimmed) while (buffer.size > MAX_LINES) buffer.removeFirst()
try { try {
file?.appendText(line + "\n") file?.appendText(line + "\n")
} catch (_: Exception) { } catch (_: Exception) {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,84 @@
package com.hormonetrack.data.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests des PRESETS de traitements (v1.9.4 — miroir de `tests/presets.test.js`
* côté web) : le modèle ESE ANALYTIQUE couvre 6 esters injectables — les
* presets de création doivent offrir les 6 (comme TFS offre les 7 du V3C et
* WHSAH les 6 du fit Mona). Avant v1.9.4, les presets ESE n'offraient que
* EV/EU/EEn (l'héritage des tables ODS) — remontée : « les presets n'ont pas
* été mis à jour lors de l'ajout du modèle source ESE ».
*
* Garde centrale : **aucun preset ne peut créer un traitement non couvert par
* son propre modèle** (ester ∈ choicesForModel(pkModel)) — un preset hors
* couverture produirait 0 pg/mL sans aucun avertissement (leçon §12).
*/
class PKPresetsTest {
/** Presets injectables d'un modèle (ester réel — le NONE Bateman sort). */
private fun injectables(model: String) =
PKPresets.all.filter { it.pkModel == model && it.esterType != Esters.NONE }
@Test
fun `les presets ESE couvrent les 6 esters du fit analytique`() {
val esters = injectables(PKModels.ESTRANNAISE).map { it.esterType }.sorted()
// Même ensemble que Esters.choicesForModel(ESE) — l'éditeur et les
// presets doivent proposer exactement la couverture du dispatch.
assertEquals(Esters.choicesForModel(PKModels.ESTRANNAISE).sorted(), esters)
}
@Test
fun `chaque ester couvert a EXACTEMENT un preset ESE (pas de doublon)`() {
val names = injectables(PKModels.ESTRANNAISE).map { it.esterType }
assertEquals(names.size, names.toSet().size)
}
@Test
fun `les presets TFS (7) et WHSAH (6) restent inchangés`() {
assertEquals(7, injectables(PKModels.TRANSFEM_SCIENCE).size)
assertEquals(6, injectables(PKModels.WHSAH).size)
}
@Test
fun `aucun preset injectable ne peut créer un traitement non couvert`() {
// La garde ne vise que les presets à ester RÉEL (profil PK) : un
// preset Bateman (esterType NONE, ex. gel/patch/oral/anti-androgène)
// ne passe pas par le dispatch par ester — sa couverture n'a pas de
// sens. Un preset injectable hors couverture produirait 0 pg/mL sans
// aucun avertissement (leçon §12).
for (preset in PKPresets.all.filter { it.esterType != Esters.NONE }) {
assertTrue(
"preset " + preset.nameRes + " : ester " + preset.esterType +
" hors couverture de " + preset.pkModel + " → 0 pg/mL garanti",
preset.esterType in Esters.choicesForModel(preset.pkModel)
)
}
}
@Test
fun `les presets ESE non injectables (gel patch oral) restent en Bateman`() {
val nonInjectable = PKPresets.all.filter {
it.pkModel == PKModels.ESTRANNAISE && it.esterType == Esters.NONE
}
// 3E2 (gel/patch/oral) + 3 anti-androgènes (CPA/spiro/bica)
assertTrue("gel, patch, oral + anti-androgènes toujours présents", nonInjectable.size >= 6)
assertTrue(nonInjectable.all { it.bioavailabilityFraction in 0.01f..1f })
}
@Test
fun `les champs Bateman informatifs des nouveaux presets ESE restent plausibles`() {
// Les champs Bateman des presets ESE reflètent les t½ terminales des
// fits (informatifs, pas utilisés au calcul) : EC ≈ 202 h (k3 0,669/j),
// EB ≈ 4 h (k3 4,34/j), EUCS ≈ 756 h (k2 0,022/j) — on garde des
// bornes larges pour ne pas figer les valeurs des fits.
val ec = PKPresets.all.first { it.esterType == Esters.EC && it.pkModel == PKModels.ESTRANNAISE }
val eb = PKPresets.all.first { it.esterType == Esters.EB && it.pkModel == PKModels.ESTRANNAISE }
val eucs = PKPresets.all.first { it.esterType == Esters.EUCS && it.pkModel == PKModels.ESTRANNAISE }
assertTrue("t½ EC ESE ≈ 8,4 j (fit 0,669/j)", ec.eliminationHalfLifeHours in 100f..300f)
assertTrue("t½ EB ESE courte (fit 4,34/j)", eb.eliminationHalfLifeHours in 1f..24f)
assertTrue("t½ EUCS ESE ≈ 31,5 j (suspension cristalline)", eucs.eliminationHalfLifeHours in 400f..1200f)
}
}

View File

@ -48,8 +48,9 @@ class CalibrationPerModelTest {
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont // (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
// purs (formes closes) et n'en dépendent pas. // purs (formes closes) et n'en dépendent pas.
if (!PKProfileStore.hasProfile("EEN", "ESE")) { if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = java.io.File("src/main/assets/pk_profiles.json") val file = java.io.File("src/test/assets/pk_profiles.json")
.takeIf { it.exists() } .takeIf { it.exists() }
?: java.io.File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
?: java.io.File("app/src/main/assets/pk_profiles.json") ?: java.io.File("app/src/main/assets/pk_profiles.json")
PKProfileStore.initWithJson(file.readText()) PKProfileStore.initWithJson(file.readText())
} }

View File

@ -0,0 +1,159 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Tests du NUAGE D'INCERTITUDE (v1.9.0 — cf doc §7.12) :
* [EstrannaiseCloud.compute] — feature exclusive au modèle ESE (les fits
* TFS/WHSAH ne publient pas de posterior MCMC).
*
* Épinglés :
* - 32 courbes par défaut, chacune ≥ 2 points, dans la fenêtre demandée ;
* - DISPERSION réelle : les courbes du nuage DIFFÈRENT entre elles (les
* échantillons MCMC couvrent une plage de paramètres — un nuage où toutes
* les courbes coïncideraient serait un bug de chargement) ;
* - non calculable → vide : pas de traitement ESE porteur (TFS/oral),
* MCMC non chargé, nbCurves < 2.
*/
class EstrannaiseCloudTest {
private val DAY_MS = 86_400_000L
private val NOW = 1_790_000_000_000L
/** Traitement EEn ESE actif, 5 mg hebdo sur 4 semaines. */
private fun ese() = Treatment(
id = 1, name = "EEn ESE", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg", esterType = "EEN", pkModel = "ESE",
forecastIntervalDays = 7.0, createdAt = 0
)
private fun doses() = (4 downTo 1).map { k ->
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
}
@Before
fun setup() {
if (EstrannaiseModels.mcmcSamples("EEN").isEmpty()) {
val candidates = listOf(
File("src/main/assets/mcmc_samples.json"),
File("app/src/main/assets/mcmc_samples.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
EstrannaiseModels.initWithJson(file.readText())
}
}
@Test
fun `cloud has 32 curves with real dispersion between them`() {
val cloud = EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
)
assertEquals("32 courbes par défaut", 32, cloud.size)
assertTrue(cloud.all { it.size >= 2 })
// DISPERSION : le max E2 de chaque courbe diffère d'une courbe à
// l'autre (sinon tous les échantillons seraient identiques)
val peaks = cloud.map { c -> c.maxOf { it.e2 } }.distinct()
assertTrue(
"les courbes du nuage divergent (${peaks.size} maxima distincts)",
peaks.size >= 8
)
}
@Test
fun `cloud stays within the requested window`() {
val start = NOW - 10 * DAY_MS
val end = NOW
val cloud = EstrannaiseCloud.compute(
listOf(ese()), doses(), start, end, 3_600_000L
)
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
}
@Test
fun `doses of a TFS-stored treatment ARE covered when ESE is displayed (fix v1_9_2)`() {
// FIX « le nuage ne s'active que autour du tracé émulé, pas autour du
// modèle Estrannaise » : le traitement est STOCKÉ en TFS, mais quand
// ESE est affiché, computeCurve(modelOverride = "ESE") redessine ses
// doses avec le modèle Estrannaise → le nuage doit les couvrir aussi
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
// doses EEn avec les params MCMC de EEN.
val tfsStored = ese().copy(pkModel = "TFS")
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L)
assertEquals(32, cloud.size)
assertTrue(cloud.all { it.size >= 2 })
}
@Test
fun `oral-only treatment produces no cloud (Bateman is not MCMC-sampled)`() {
// L'oral Bateman n'a pas d'ester E2 échantillonnable : le posterior
// MCMC ne s'applique pas → pas de nuage (cohérent : rien n'est tracé
// « en ESE » pour ces doses)
val oral = Treatment(
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
)
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L).isEmpty())
}
@Test
fun `unknown ester doses contribute zero (EUCS-free esters covered by ESE)`() {
// PEP : pas de fit Estrannaise → ses doses contribuent 0 au nuage
// (comme dans la courbe ESE) — le nuage des autres esters reste
val pep = ese().copy(esterType = "PEP")
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L)
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
assertTrue(cloud.isEmpty())
}
@Test
fun `no doses on the carrier produces no cloud`() {
assertTrue(EstrannaiseCloud.compute(
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L
).isEmpty())
}
@Test
fun `nbCurves below 2 produces no cloud`() {
assertTrue(EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, nbCurves = 1
).isEmpty())
}
@Test
fun `cloud follows the calibration like the displayed ESE curve (fix v1_9_1)`() {
// FIX « le nuage ne s'active que autour du tracé, pas autour du
// modèle Estrannaise » : la courbe ESE affichée est CALIBRÉE
// (scalePerEster des labs) mais le nuage était tracé BRUT → les deux
// flottaient à des échelles différentes. Avec le MÊME scalePerEster,
// le nuage ENTOURE la courbe calibrée (×2 ⇒ nuage ×2, écart de
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
val scaled = mapOf("EEN" to 2.0)
val calibrated = EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L,
scalePerEster = scaled
)
val raw = EstrannaiseCloud.compute(
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
)
assertEquals(calibrated.size, raw.size)
assertTrue(calibrated.isNotEmpty())
// Chaque point du nuage calibré ≈ 2× le point brut correspondant
val rawLast = raw.last()
val calLast = calibrated.last()
assertEquals(
calLast.last().e2, rawLast.last().e2 * 2.0, rawLast.last().e2 * 0.01
)
}
}

View File

@ -0,0 +1,161 @@
package com.hormonetrack.pk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Tests du modèle **Estrannaise analytique** (v1.9.0 — cf doc §7.12).
*
* CONTEXTE : depuis v1.9.0, le modèle ESE n'est plus les tables horaires ODS
* mais la **forme close 3C** publiée par estrannaise.js (les mêmes sources
* dont l'ODS n'était que l'échantillonnage horaire). Chaque test épingle :
* - **FIDÉLITÉ** : la forme close reproduit les tables ODS à l'identique
* (garde anti-régression du débranchement — RMS ≈ 0) ;
* - les **cas dégénérés** de taux égaux (solutions limites portées du JS
* source — jamais NaN) ;
* - le **posterior MCMC** chargé (313 échantillons par ester) et sa t½
* analytique.
*/
class EstrannaiseModelsTest {
private val DAY_MS = 86_400_000L
@Before
fun setup() {
// Tables ODS (src/test/assets depuis v1.9.0) : référence de fidélité
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"),
File("app/src/test/assets/pk_profiles.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("pk_profiles.json introuvable pour les tests de fidélité")
PKProfileStore.initWithJson(file.readText())
}
// Posterior MCMC (asset runtime v1.9.0)
if (EstrannaiseModels.mcmcSamples("EV").isEmpty()) {
val candidates = listOf(
File("src/main/assets/mcmc_samples.json"),
File("app/src/main/assets/mcmc_samples.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
EstrannaiseModels.initWithJson(file.readText())
}
}
/** FIDÉLITÉ (le cœur du débranchement v1.9.0) : forme close == tables ODS. */
@Test
fun `analytic shape reproduces the ODS tables exactly`() {
val params = mapOf(
"EV" to EstrannaiseModels.model("EV")!!,
"EU" to EstrannaiseModels.model("EU")!!,
"EEN" to EstrannaiseModels.model("EEN")!!
)
for ((ester, m) in params) {
val profiles = com.google.gson.JsonParser.parseString(
File("src/test/assets/pk_profiles.json").let {
if (!it.exists()) File("app/src/test/assets/pk_profiles.json").readText() else it.readText()
}
).asJsonObject.getAsJsonObject("profiles")
val key = profiles.keySet().firstOrNull {
it.lowercase().startsWith(ester.lowercase() + "_") && it.lowercase().endsWith("ese")
} ?: error("table $ester introuvable")
val arr = profiles.getAsJsonArray(key)
var maxAbs = 0.0
var sumSq = 0.0
for (h in 0..4800) { // 0 → 200 j
val analytic = EstrannaiseModels.sample(ester, h.toDouble())
val table = arr[h].asDouble
val diff = analytic - table
maxAbs = maxOf(maxAbs, kotlin.math.abs(diff))
sumSq += diff * diff
}
val rms = kotlin.math.sqrt(sumSq / 4801)
assertTrue(
"$ester : forme close == table (maxAbs=$maxAbs, rms=$rms)",
maxAbs <= 0.01 && rms <= 0.01
)
}
}
/** Les pics publiés (tables ODS) sont reproduits par la forme close. */
@Test
fun `published peaks match the analytic curve`() {
// (pic table @ arrondi h ; scan analytique au pas de 6 min)
data class Expect(val ester: String, val peak: Double, val tPeakHours: Int)
for (e in listOf(
Expect("EV", 61.12, 45), Expect("EU", 3.44, 55), Expect("EEN", 31.35, 152)
)) {
val m = EstrannaiseModels.model(e.ester)!!
var peak = 0.0
var tPeak = 0.0
var t = 0.0
while (t <= 200.0) {
val v = EstrannaiseModels.sample(e.ester, t * 24.0)
if (v > peak) { peak = v; tPeak = t }
t += 0.25 / 24.0
}
assertEquals("pic ${e.ester}", e.peak, peak, e.peak * 0.002)
// ±3,5 h : le sommet de la table (échantillonnée à l'heure,
// arrondie) est décalé vs le pic continu (EEN : 152 → 154,5 h)
assertEquals("Tmax ${e.ester}", e.tPeakHours.toDouble(), tPeak * 24.0, 3.5)
assertNotNull(m)
}
}
/** Taux dégénérés : les solutions limites ne produisent ni NaN ni négatif. */
@Test
fun `degenerate equal rates use the limit solutions (no NaN)`() {
for (p in listOf(
EstrannaiseModels.Param(478.0, 0.236, 0.236, 1.24), // k1 == k2
EstrannaiseModels.Param(478.0, 0.236, 1.24, 0.236), // k1 == k3
EstrannaiseModels.Param(478.0, 0.236, 1.24, 1.24), // k2 == k3
EstrannaiseModels.Param(478.0, 0.5, 0.5, 0.5) // tous égaux
)) {
for (t in listOf(0.5, 1.0, 24.0, 240.0, 2400.0)) { // heures
val v = EstrannaiseModels.sampleWithParams("EV", t, p)
assertTrue("finie et ≥ 0 (t=$t, k=(${p.k1},${p.k2},${p.k3}))", v >= 0.0 && !v.isNaN())
}
}
}
/** Posterior MCMC : chargé, 313 échantillons par ester, couverture réelle. */
@Test
fun `mcmc posterior is loaded with 313 samples per injectable ester`() {
for (ester in listOf("EV", "EU", "EEN", "EC", "EB", "EUCS")) {
val samples = EstrannaiseModels.mcmcSamples(ester)
assertEquals("313 échantillons ($ester)", 313, samples.size)
// les échantillons couvrent une PLAGE (premier ≠ dernier)
assertTrue(
"le posterior $ester couvre une plage",
samples.first().d != samples.last().d
)
}
}
/** t½ analytique : EV = ln2/min(0,236 ; 4,85 ; 1,24) ≈ 2,94 j. */
@Test
fun `terminal half-life is analytic (ln2 over slowest rate)`() {
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
assertEquals(kotlin.math.ln(2.0) / 0.236, tHalf, 1e-12)
}
/** Normalisation par mg + gardes (dt ≤ 0, ester inconnu). */
@Test
fun `sample is normalized per mg with guards`() {
val one = EstrannaiseModels.sample("EEN", 24.0) // 1 mg
val five = 5.0 * one
assertEquals(5.0 * one, five, 1e-12) // linéarité (trivial mais épinglé)
assertEquals(0.0, EstrannaiseModels.sample("EEN", 0.0), 1e-12)
assertEquals(0.0, EstrannaiseModels.sample("EEN", -1.0), 1e-12)
assertEquals(0.0, EstrannaiseModels.sample("XXX", 24.0), 1e-12)
assertTrue(EstrannaiseModels.sample("EUCS", 24.0) > 0.0) // EUCS couvert
}
}

View File

@ -27,6 +27,7 @@ class ExtremaTest {
fun setup() { fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

View File

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

View File

@ -25,7 +25,8 @@ import java.io.File
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ; * honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un * - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
* creux déjà mesuré) ; * creux déjà mesuré) ;
* - modèle Estrannaise : t½ LUE dans la table (aucun crash, valeur finie). * - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
*/ */
class LabTimingTest { class LabTimingTest {
@ -38,6 +39,7 @@ class LabTimingTest {
fun setup() { fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf( val candidates = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
) )
@ -173,16 +175,55 @@ class LabTimingTest {
@Test @Test
fun `recent interval change resets stabilization`() { fun `recent interval change resets stabilization`() {
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus // 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient // de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime // [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime) // vrai changement de créneau reste un changement — la tolérance ne
// rattrape pas un écart de 2 j).
val doses = weeklyDoses(9) + DoseLog( val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0 id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
) )
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j // régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs) assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
// injection si l'injection n'est pas faite pile à la même heure » —
// l'ancienne comparaison EXACTE cassait le régime pour un log à
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
val doses = listOf(
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
}
@Test
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
val doses = weeklyDoses(9).mapIndexed { idx, d ->
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
}
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(false, rec.wasAlreadyStabilized) assertEquals(false, rec.wasAlreadyStabilized)
} }
@ -199,17 +240,18 @@ class LabTimingTest {
} }
@Test @Test
fun `Estrannaise carrier reads its half-life from the ODS table`() { fun `Estrannaise carrier uses its ANALYTIC half-life (v1_9_0)`() {
// EV / tables ODS : t½ estimée depuis la pente terminale de la table — // v1.9.0 : le modèle ESE est devenu analytique (forme close de
// finie et raisonnable, et la recommandation se calcule sans crash // estrannaise.js) — la t½ = ln2/min(k1,k2,k3) des PKParameters, plus
// d'estimation par pente de table. Finie, raisonnable, et la
// recommandation se calcule sans crash.
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0) val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
val doses = listOf( val doses = listOf(
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0), DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0) DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
) )
val tHalf = PKProfileStore.terminalHalfLifeDays("EV", "ESE") val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
assertNotNull(tHalf) assertTrue("t½ EV analytique plausible ($tHalf j)", tHalf in 0.1..200.0)
assertTrue("t½ EV ODS plausible ($tHalf j)", tHalf!! in 0.1..200.0)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW) val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
assertNotNull(rec) assertNotNull(rec)
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9) assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)

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

@ -40,6 +40,7 @@ class LabTrajectoryModelTest {
// module (les assets Android ne sont pas sur le classpath des tests JVM) // module (les assets Android ne sont pas sur le classpath des tests JVM)
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf( val candidates = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
) )

View File

@ -19,6 +19,7 @@ class PKProfileStoreTest {
fun setup() { fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf( val candidates = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
) )

View File

@ -25,6 +25,7 @@ class PharmacokineticEngineTest {
fun setup() { fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }
@ -159,7 +160,11 @@ class PharmacokineticEngineTest {
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t) LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
} }
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs) val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
assertEquals(0.9, sf!!, 0.01) // v1.8.2 : la médiane est ARRONDIE (Math.round), pas tronquée — le
// dernier bit flottant (0,8999… vs 0,9000…) ne doit pas faire chuter
// l'échelle d'un cran d'affichage (révélé par le passage d'ESE en
// analytique v1.9.0, cf CHANGELOG 1.9.1)
assertEquals(0.9, sf!!, 0.005)
} }
@Test @Test

View File

@ -43,6 +43,7 @@ class RegressionUserCase2Test {
) )
if (!PKProfileStore.hasProfile("EEN", "TFS")) { if (!PKProfileStore.hasProfile("EEN", "TFS")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

View File

@ -42,6 +42,7 @@ class RegressionUserCase3Test {
) )
if (!PKProfileStore.hasProfile("EV", "TFS")) { if (!PKProfileStore.hasProfile("EV", "TFS")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

View File

@ -50,6 +50,7 @@ class RegressionUserCase4Test {
) )
if (!PKProfileStore.hasProfile("EV", "TFS")) { if (!PKProfileStore.hasProfile("EV", "TFS")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

View File

@ -52,6 +52,7 @@ class RegressionUserCase5Test {
) )
if (!PKProfileStore.hasProfile("EV", "TFS")) { if (!PKProfileStore.hasProfile("EV", "TFS")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

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

@ -40,6 +40,7 @@ class RegressionUserCaseTest {
) )
if (!PKProfileStore.hasProfile("EEN", "ESE")) { if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

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

@ -49,8 +49,9 @@ class ScaleFactorWhsahReproTest {
fun setup() { fun setup() {
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists()) assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
if (!PKProfileStore.hasProfile("EEN", "ESE")) { if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = File("src/main/assets/pk_profiles.json") val file = File("src/test/assets/pk_profiles.json")
.takeIf { it.exists() } .takeIf { it.exists() }
?: File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
?: File("app/src/main/assets/pk_profiles.json") ?: File("app/src/main/assets/pk_profiles.json")
PKProfileStore.initWithJson(file.readText()) PKProfileStore.initWithJson(file.readText())
} }

View File

@ -47,6 +47,7 @@ class TransfemScienceEngineTest {
// l'asset pour rester au plus près du comportement réel de l'app. // l'asset pour rester au plus près du comportement réel de l'app.
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }
@ -79,14 +80,24 @@ class TransfemScienceEngineTest {
} }
@Test @Test
fun `EB forced to ESE model yields zero (no ODS table for this ester)`() { fun `EB forced to ESE model yields a REAL curve (analytic since v1_9_0)`() {
// v1.9.0 : l'Estrannaise est devenue ANALYTIQUE (estrannaise.js) et
// couvre désormais EB (fit « EB im ») — l'ancien test épinglait
// « EB+ESE → 0, pas de table ODS pour cet ester » (obsolète)
val tr = treatment(Esters.EB, doseMg = 5.0) val tr = treatment(Esters.EB, doseMg = 5.0)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0) val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
val curve = PharmacokineticEngine.computeCurve( val curve = PharmacokineticEngine.computeCurve(
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS, listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
) )
assertTrue(curve.all { it.e2 == 0.0 }) assertTrue("EB ESE analytique produit une courbe non vide", curve.any { it.e2 > 0.0 })
// et un ester réellement NON couvert par les trois modèles → 0
val unknown = tr.copy(esterType = "XXX")
val curve2 = PharmacokineticEngine.computeCurve(
listOf(unknown), listOf(dose), BASE, BASE + 5 * DAY_MS,
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
)
assertTrue("ester inconnu → 0 dans les trois modèles", curve2.all { it.e2 == 0.0 })
} }
@Test @Test

View File

@ -30,6 +30,7 @@ class V120FeaturesTest {
fun setup() { fun setup() {
if (!PKProfileStore.hasProfile("EEN", "ESE")) { if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }
@ -263,7 +264,9 @@ class V120FeaturesTest {
@Test @Test
fun `scalePerEster scales each dose by its own ester factor`() { fun `scalePerEster scales each dose by its own ester factor`() {
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV) // v1.9.0 : la dose EV doit être DANS le cutoff ESE analytique
// (10 × t½ EV = 29,4 j — l'ancien cutoff ODS était 333 j) : −10 j
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN) val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val scales = mapOf("EV" to 0.5, "EEN" to 2.0) val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
val t = BASE val t = BASE

View File

@ -41,6 +41,7 @@ class WhsahEngineTest {
// les tables ODS servent au chemin ESE (test de contraste) // les tables ODS servent au chemin ESE (test de contraste)
if (!PKProfileStore.hasProfile("EV", "ESE")) { if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf( val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json") File("app/src/main/assets/pk_profiles.json")
).first { it.exists() } ).first { it.exists() }

View File

@ -90,7 +90,10 @@ class WhsahModelsTest {
assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"), assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"),
com.hormonetrack.data.model.Esters.choicesForModel( com.hormonetrack.data.model.Esters.choicesForModel(
com.hormonetrack.data.model.PKModels.WHSAH)) com.hormonetrack.data.model.PKModels.WHSAH))
assertEquals(listOf("EV", "EU", "EEN"), // v1.9.0 : ESE analytique (estrannaise.js) couvre 6 injectables,
// dont EUCS (undécylate suspension cristalline, exclusif à ce fit) ;
// l'ancienne couverture tables ODS n'était que EV/EU/EEn
assertEquals(listOf("EV", "EU", "EEN", "EC", "EB", "EUCS"),
com.hormonetrack.data.model.Esters.choicesForModel( com.hormonetrack.data.model.Esters.choicesForModel(
com.hormonetrack.data.model.PKModels.ESTRANNAISE)) com.hormonetrack.data.model.PKModels.ESTRANNAISE))
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,457 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.13.0] — 2026-09-30 (versionCode 50)
### Ajouté — la reco de prise de sang devient prédictive (3 niveaux clairs)
- **Valeur E2 attendue au creux recommandé** : la carte (Accueil + Analyses)
affiche désormais « ≈ X pg/mL » quand l'auto-calibration fournit ses
facteurs — sinon rien (une valeur brute non calibrée serait trompeuse).
Tu sais AVANT de piquer si le creux tombera dans ta cible.
- **Régime POOLÉ entre traitements identiques** : `pooledRegimeDoses` — les
doses de tous les traitements partageant (ester effectif, mg) forment UNE
séquence. Un re-parenting d'historique (créer un « … - old » et y
déplacer des doses) ne fait plus redémarrer la stabilisation. Garde-fous
épinglés : ester ≠ → jamais poolé (transition EV→EEn) ; dose ≠ → exclue
(le 8 mg casse le régime, trou conservateur).
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle « je
veux être dans [basse, haute] au creux recommandé » — la prédiction est
colorée dans/hors cible sur la carte. ⚠️ **DISTINCT des seuils d'alerte**
(§9.bis) : les seuils surveillent le niveau en continu et notifient ; la
cible est une référence de lab, elle ne notifie JAMAIS. Voyage dans le
backup (champs optionnels rétrocompatibles — un backup ancien n'efface
pas la cible locale).
- **Régression n°7** épinglée sur données réelles (`backup-v1.12.0.json`,
gitigné — l'export avec le re-parenting « 6d-old ») : le régime poolé
traverse la frontière de re-parenting et reste arrêté par le 8 mg.
Vérifié : **284 tests JVM** (250 sans données locales) + **14 tests UI** +
lint verts ; **web 199 tests + E2E verts** (`check.sh`).
## [1.12.0] — 2026-09-29 (versionCode 49)
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
- **Remontée** : « une injection qui n'est plus active est toujours prise en
compte dans le graphique de simulations si elle contient une posologie et
qu'elle a déjà été utilisée — un traitement mis en inactif ne devrait
plus être pris en compte pour les futures prédictions ».
- **Cause** : `generateForecastDoses` (source unique des créneaux
prévisionnels) ne vérifiait pas l'archivage ; de plus l'horizon du chip
Prévision du graphique incluait les Posologies INACTIVES.
- **Fix** : garde `!isActive` dans le moteur (l'historique reste simulé —
§6.bis inchangé — seul le FUTUR s'arrête, comme les rappels depuis
v1.4.0) + horizon/chip Prévision calculés sur les ACTIFS uniquement.
**Miroir web appliqué** (`js/pk/pk-engine.js` + `js/ui/chart.js`).
- Tests : 3 tests JVM (`ForecastDosesGuardTest`) + 3 miroirs web ;
émulateur avec données modifiées (2 inactifs à Posologie 7 j) :
Prévision on/off sans crash.
### Ajouté — icônes de forme de prise dans la page Doses
- Chaque ligne de dose porte désormais l'icône de sa forme : 💊-icône pilule
(oral), seringue (injection IM et SC — teinte distincte pour la SC),
goutte (gel), sparadrap (patch), compte-gouttes (autre). Miroir web :
émojis (`routeGlyph`, même mapping).
### Ajouté — marqueurs « prise de sang le même jour » sur les lignes de doses
- Une analyse de sang effectuée LE MÊME JOUR qu'une dose affiche un petit
encart latéral 🧪↑ (avant la dose) et/ou 🧪↓ (après) — pour visualiser
les prises de sang dans le tableau de dosages (demande utilisatrice).
Fenêtre = jour calendaire LOCAL (au-delà le lien clinique devient ténu).
Helper pur `labMarkersForDose` (Kotlin + miroir web) — 7 tests JVM
(`DosesExtrasTest` : mapping icônes + marqueurs, frontière minuit,
convention « à l'heure de la dose = après ») + 10 miroirs web.
- Vérifié émulateur avec données réelles : glyphes en tête de liste,
marqueur « Blood test later the same day » sur la dose du 10 sept
(analyse 35 min après).
Vérifié : **274 tests JVM** (240 sans données locales) + **14 tests UI** +
lint verts ; **web 194 tests + E2E verts** (`check.sh`).
## [1.11.0] — 2026-09-29 (versionCode 48)
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
- **Demande** : « les traitements inactifs sont peu discernables par rapport
aux traitements actifs — les placer à la fin (tout en bas) de la liste
pour éviter les confusions ».
- **Tri** : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**, sous
un en-tête de section « Traitements inactifs » (n'apparaît que s'il
existe des inactifs). Tri STABLE : l'ordre relatif à l'intérieur de
chaque groupe est conservé — le tri ne réordonne jamais les actifs entre
eux, ni les inactifs entre eux.
- **Distinction visuelle renforcée** : les cartes inactives sont ATTÉNUÉES
(alpha 0,55) en plus du badge « inactif » — second signal demandé
(« peut-être les rendre plus distincts ») ; elles restent lisibles et
tappables (l'édition d'un traitement archivé doit rester possible).
- Helper pur `treatmentsForDisplay` (Kotlin + **miroir web** en
`js/data/models.js`) — 5 tests JVM (`TreatmentsDisplayTest`) + 4 tests
web (`tests/treatments-display.test.js`).
- Vérifié émulateur avec données réelles seedées : les 5 actifs en haut,
en-tête puis les 2 inactifs (EV-old, EEn-old) tout en bas.
Vérifié : **264 tests JVM** (230 sans données locales) + **14 tests UI** +
lint verts ; **web 184 tests + E2E verts** (`check.sh`).
## [1.10.1] — 2026-09-28 (versionCode 47)
### Amélioré — durcissement du système de rappels (revue de code post-v1.10.0)
- **`launchMode="singleTop"` sur MainActivity** : un tap sur la notification
quand l'app est DÉJÀ ouverte délivre désormais `onNewIntent` (dialog de
saisie ouvert, extras traités) **sans détruire/recréer l'activité** —
l'ancien combo `standard` + `CLEAR_TOP` recréait tout et rendait le chemin
`onNewIntent` de la v1.10.0 inopérant (code mort).
- **Instant PLANIFIÉ dans l'alarme (`EXTRA_PLANNED_AT`)** : le garde
« créneau déjà honoré » (v1.10.0) évaluait le jour au moment du FEU — une
alarme inexacte (`setWindow`) ou retardée (Doze) glissant après minuit
évaluait le mauvais jour (23:58 → 00:02). L'instant planifié voyage
désormais dans l'intent de l'alarme, le snooze le FORWARDE tel quel, et
le garde évalue toujours le jour du CRÉNEAU. Vérifié sur émulateur par
broadcast avec `planned_at` daté d'hier → notification correctement
sautée.
- **`BootReceiver` consolidé** (dette §20.bis #2) : `goAsync` + coroutine IO
(pattern ReminderReceiver) — remplace le `Thread` + double `runBlocking`
de la v1.0 ; une seule lecture séquentielle des DAO, `finish()` garanti.
- Audit doc : comptes de tests périmés corrigés (§14 note #64, §16.ter).
Vérifié : 259 tests JVM + 14 tests UI + lint verts ; scénarios émulateur
(singleTop + planned_at). Web non concerné (aucune logique miroir touchée).
## [1.10.0] — 2026-09-27 (versionCode 46)
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
- **Remontée** : « la recommandation suggère une date antérieure à la
stabilisation des taux » (données réelles de la remontée → 6ᵉ régression
épinglée `local-test-data/backup-v1.9.8.json`, gitigné).
- **Cause (double)** : le « creux » était le **minimum de la fenêtre entière**
(créneau N−1 → créneau N). Pour un ester à montée lente (EEn : pic ~J+5 ≈
intervalle 7 j), ce minimum tombe **juste après l'injection PRÉCÉDENTE**
(creux d'absorption, ~3 h) — pas juste avant la suivante : la date proposée
était gouvernée par l'injection N−1, elle-même antérieure à la
stabilisation, et le texte « juste avant ton injection du … » était faux de
plusieurs jours. Balayage sur ±60 jours de `now` (test) : creux 5 oct 02:32
vs stabilisation 7 oct 04:03.
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
courbe avant l'injection) — restaure le principe v1.8.0 documenté (« juste
avant l'injection suivante, le moment le plus comparable »), rend la carte
cohérente (creux 46 min avant le créneau étiqueté) et le garde de
stabilisation s'exprime sur l'instant réellement proposé. Pour les esters à
t½ courte (EV), le point pré-injection EST le minimum de fenêtre :
comportement inchangé. **Miroir web appliqué** (`js/pk/lab-timing.js`).
- Tests : régression n°6 data-driven (4 tests dont un balayage de `now` sur
±60 j — l'invariant « creux ≥ stabilisation » doit tenir À TOUT INSTANT) ;
les 17 LabTimingTest passent inchangés (leur sémantique était celle du fix).
### Corrigé — le dialog de dose se rouvrait à chaque retour sur l'accueil (#69)
- **Remontée** : tap sur la notification de rappel → le dialog de saisie
s'ouvre ; le fermer puis changer d'onglet et revenir sur l'accueil le
ROUVRAIT à chaque fois (reproduit et revérifié sur émulateur).
- **Cause** : la demande vivait au niveau ACTIVITÉ (paramètre d'intent) mais
la consommation vivait dans un `LaunchedEffect` de Home — quitter l'onglet
détruit les `remember` locaux, le paramètre survivait → à chaque
ré-entrée en composition, le dialog se rouvrait.
- **Fix** : la demande est un état `MutableState<Long?>` détenu par
MainActivity (survit aux onglets), consommé UNE fois par HomeScreen ;
les extras sont retirés de l'intent après lecture (un recreate ne doit
jamais rouvrir une notification traitée) ; et un tap notification quand
l'app est déjà ouverte ouvre désormais aussi le dialog (avant :
silencieusement perdu).
- Tests : nouveau test UI instrumenté `LogDoseRequestTest` (ouverture 1× +
plus jamais après aller-retour d'onglets) + vérification émulateur.
### Ajouté — la notification de rappel saute un créneau déjà honoré
- **Demande** : si la dose prévue du jour est saisie AVANT l'heure de
rappel, la notification ne doit pas sonner pour une prise déjà faite ; le
rappel suivant repart au créneau suivant (idem pour le quotidien des
oraux sans Posologie).
- **Implémentation** : garde au moment du déclenchement —
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL,
testable) consultée par `ReminderReceiver` : dose loguée aujourd'hui →
notification sautée (AppLog) + re-programmation du créneau suivant dans
tous les cas. Robuste quel que soit le moment de la saisie.
- Tests : 6 tests JVM (`ReminderLoggedDayTest` : même jour, veille,
frontière minuit, autre traitement, liste vide, mélange) + vérification
A/B sur émulateur (EEn dose-loguée-ce-jour → 0 notif ; Fluoxetine sans
dose → 1 notif).
Vérifié : **259 tests unitaires** (dont régression n°6 sur données réelles)
+ **14 tests UI** + lint vert ; web miroir : **180 tests + E2E verts**
(`check.sh`). Version web alignée au moment de la release (checklist 4.bis).
## [1.9.8] — 2026-09-22 (versionCode 45)
### Ajouté — tests UI Compose, signature release dédiée, README anglais
- **13 tests UI instrumentés** (navigation 5 onglets, dialog « Nouveautés »
— épinglage du fix v1.3.2 « ne réapparaît pas après dismiss » —, sections
Paramètres, version installée = BuildConfig : le bug #46 « versions jamais
bumpées » ne peut plus revenir silencieusement). Déterministes : permission
de notifications pré-grantée, zéro dépendance aux données, indépendants de
la langue FR/EN. Exécution : `./gradlew connectedDebugAndroidTest`.
- **Signature release dédiée, prête à l'emploi** : `scripts/make-release-keystore.sh`
crée le keystore + `keystore.properties` (gitignorés) ; `assembleRelease`
l'utilise dès qu'il existe (sinon clé debug inchangée). Migration
téléphone documentée (export JSON → désinstallation → réinstallation →
réimport). Aucun changement pour l'utilisatrice tant que la bascule n'est
pas faite.
- **README anglais** ([README.en.md](README.en.md)) — lien FR/EN en tête de
chaque fichier ; la doc de développement reste en français.
### Modifié — écran Paramètres découpé (maintenance)
- `SettingsScreen.kt` (840 lignes) devient un orchestrateur de 377 lignes +
11 cartes thématiques dans `ui/screens/settings/` (langue, calibration,
modèle T, seuils, fuseau, rappels, sauvegarde, auto-backup, logs, à propos).
**Comportement et rendu strictement identiques** (déplacement pur) ;
validé par les tests UI + smoke test RELEASE sur émulateur (toutes les
sections, version 1.9.8, 0 crash — anti-bug #64).
- `ChartScreen` reste volontairement entier : sa garde de source (#54)
exige le bloc de gestes dans ce fichier.
Vérifié : 249 tests unitaires + 13 tests UI verts, lint vert, APK debug +
release (2,7 Mo) buildés, smoke test release complet (accueil, 5 onglets,
Paramètres, éditeur de traitement, dialog de dose — 0 crash).
## [1.9.7] — 2026-09-20 (versionCode 44)
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
- **Remontée** : « la suggestion de prochain test sanguin change tout le
temps à chaque injection si l'injection n'est pas faite pile à la même
heure ou si elle est faite 30 minutes plus tard que la précédente ».
- **Cause** : la règle de stabilisation v1.8.1 comparait l'écart inter-doses
**EXACTEMENT** à l'écart précédent (au ms près) — un log à 12:30 au lieu
de 12:00 cassait le régime, repoussant la stabilisation de 5 × t½ à
CHAQUE injection : le creux recommandé fuyait vers l'avant sans jamais
se fixer.
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
Posologie ± 24 h** (`GAP_TOLERANCE_MS` — « je m'injecte le même jour, à
l'heure près »). La fenêtre se réfère à l'INTERVALLE THÉORIQUE, pas à
l'écart précédent : des logs à 6,8 j puis 7,2 j ne se déstabilisent plus
en cascade. Un vrai changement de créneau (2 j au lieu de 7 j) reste
hors fenêtre et réinitialise, comme avant. Le créneau affiché se cale
toujours sur la dernière dose réelle (voulu).
- Tests : le test v1.8.1 « interval change » ré-épinglé (NOW+21 j) + 2
nouveaux (flou d'heure ± 23 h → PAS de reset ; écart 25 h → reset).
249 tests verts + lint. Web miroir v1.9.7 (12 tests lab-timing).
## [1.9.6] — 2026-09-20 (versionCode 43)
### Modifié — la carte de l'Accueil liste TOUTES les prochaines prises
- **Remontée** : « si une injection et une prise d'un autre médicament ont
lieu en même temps… le rappel ne montre que la prise qui vient, non
toutes les prises du jour » — avec une prise quotidienne (CPA), le
rappel de l'injection hebdomadaire (EEn) « n'arrive qu'après celui de la
CPA, du coup je ne le vois quasiment pas ».
- **Fix** : la carte « Prochaine dose » devient « **Prochaines doses** »
(titre pluriel dès 2 lignes) — **une ligne par traitement, TRIÉES par
prochaine prise** ; la 1ʳᵉ ligne (la plus proche) reste mise en avant.
Format de ligne inchangé (`Xj Yh · ven. 25 10:00 (nom)`, `XhMM · HH:mm`,
`N min`). Le CPA se lit désormais AU-DESSUS de l'EEn du samedi, jamais à
sa place.
- Les NOTIFICATIONS de rappel sont inchangées (une alarme par traitement
— le problème était purement l'affichage de l'Accueil).
- Moteur : helper PUR `nextDoseLine` (HomeScreen.kt, locale/zone
paramétrables, template jours localisé) — 3 tests (NextDoseLineTest).
247 tests verts + lint.
## [1.9.5] — 2026-09-20 (versionCode 42)
### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts)
- **#67 — les ticks X ignoraient le fuseau choisi** : `drawXLabels` formatait
les labels DANS le fuseau choisi mais appelait `xLabelTicks` SANS le
passer — les ticks étaient alignés sur le fuseau de l'appareil (heures
décalées dès que les deux divergent, contredisant le fix #55). Fix :
zone passée + **pas avancé par Calendar** (un `+24 h` fixe dérivait d'1 h
aux transitions DST) ; test DST épinglé (Europe/Paris, 29/03/2026).
- **`EstrannaiseCloud.compute` : paramètre `nowMs` mort supprimé** (jamais
lu — le KDoc prétendait une sémantique que le code n'avait pas) ;
appelants + tests mis à jour.
### Amélioré — perf (suite de l'optimisation v1.8.2)
- **Contexte moteur pré-calculé** (`E2Context` + `prepareE2Context`) : le
tracé labs appelle `e2At` par point de grille (720 pts) — chaque appel
reconstruisait les maps internes (groupBy/cutoffs/Bateman), diluant
l'optimisation v1.8.2. Préparé UNE fois par courbe labs ET par
calibration par ester ; **résultat bit-identique** (244 tests verts).
- **Canvas** : Paints texte MÉMOÏSÉS par (couleur, taille, align, gras)
(une alloc par extremum/label × frame → une par clé) ; `detectExtrema`
mémoïsé par série (scan O(n) par frame → par changement de séries) ;
formatters X/marqueurs mémoïsés (SimpleDateFormat par frame → par
fuseau).
### Documenté — commentaires et docs rattrapés après les refontes
- Commentaires à jour avec le code : `PKPresets.kt` (l'ODS n'existe plus
au runtime), `Treatment.kt` (ESE analytique 6 esters),
`LabTimingTest.kt` (t½ analytique), doc §7.2/§7.3 (asset historique,
cutoff = 10 × t½ depuis v1.9.0).
- Règle anti-#59 ré-appliquée : labels de modèle via `modelLabelRes` (le
`when` inline de l'éditeur violait la règle) ; literals « ESE »/« TFS »/
« WHS » remplacés par `PKModels.*` (ChartScreen/CurveChart) ;
`scheduler2` supprimé (le scheduler de l'écran existe déjà).
## [1.9.4] — 2026-09-19 (versionCode 41)
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
- **Remontée** : depuis l'ajout du modèle source Estrannaise (v1.9.0), les
presets de création n'offraient toujours que EV/EU/EEn — « les presets
n'ont pas été mis à jour lors de l'ajout du modèle source pour ESE ».
- **Presets (`PKPresets.kt`, 25 entrées)** : 3 presets ESE ajoutés au
même niveau que les autres — EC huile (5 mg IM), EB (1 mg IM), EUCS
undécylate suspension cristalline (10 mg SC, ester exclusif à ce fit).
Champs Bateman informatifs = t½ terminales des fits (pas utilisés au
calcul — le dispatch passe par la forme close).
- **Éditeur (`TreatmentEditorScreen.kt`)** : la liste des esters suit la
couverture RÉELLE du modèle choisi (`Esters.choicesForModel` — même
liste que l'override du DoseDialog) : ESE liste ses 6 esters
injectables directement à la création. Au changement de modèle, si
l'ester courant sort de la couverture (ex. EUCS + TFS), il rebascule
sur EV — garde anti « 0 pg/mL ».
- **Tests** : `PKPresetsTest` (nouveau, 6 tests — miroir
`tests/presets.test.js` web) : couverture des 6 esters ESE, unicité,
TFS (7) / WHSAH (6) inchangés, garde « aucun preset injectable hors
couverture de son modèle », plausibilité des t½ informatives.
243 tests verts + lint.
- **Doc** : doublon de la section §7.12 supprimé (collé deux fois).
## [1.9.2] — 2026-09-19 (versionCode 40)
### Corrigé — le nuage d'incertitude exigeait un traitement STOCKÉ en ESE
- **Bug remonté** : activer Estrannaise dans le graphique puis le nuage
n'affichait rien tant qu'aucun traitement n'était **stocké** avec le
modèle ESE — « la seule méthode trouvée était de mettre un traitement en
cours sur le modèle ESE, mais ça ne devrait pas être un prérequis ».
- **Cause** : le filtre du nuage testait le `pkModel` **STOCKÉ** du
traitement, alors que la courbe ESE affichée redessine toutes les doses
E2 avec `modelOverride = "ESE"` (peu importe le modèle stocké) — courbe
et nuage n'utilisaient pas la même définition de « quelles doses sont
tracées en ESE ».
- **Fix** : le nuage couvre **toutes les doses E2 à profil injectable dont
l'ester effectif (override compris) est couvert par le fit Estrannaise**
— indépendamment du modèle stocké. L'exclusivité ESE reste portée par le
chip (activable seulement si ESE est affiché). L'oral Bateman reste hors
nuage (pas d'ester échantillonnable). Les traitements inactifs sont
inclus (§6.bis : la courbe ESE les trace aussi).
- 2 tests réécrits (237 au total / 207 sans les données locales) :
« doses d'un traitement TFS couvertes quand ESE est affiché » (le fix,
épinglé des deux plateformes) ; oral seul → vide (conservé).
- Validé émulateur §16.ter sur le profil réel (EEn stocké TFS, ESE
activé) : nuage visible autour de la courbe ESE, 0 crash.
## [1.9.1] — 2026-09-19 (versionCode 39)
### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée
- **Bug remonté** : le nuage ne s'activait « que autour du tracé, pas autour
du modèle Estrannaise » — la courbe ESE affichée est **calibrée**
(scalePerEster des labs) mais le nuage était tracé **brut** : les deux
flottaient à des échelles différentes.
- **Fix** : le nuage reçoit le MÊME `scalePerEster` que la courbe ESE
(autoByModel ESE) — il entoure désormais la courbe calibrée (Android +
web en miroir).
- 1 nouveau test : nuage calibré ×2 = nuage brut ×2 (le nuage suit la
calibration).
### Note — pourquoi le taux affiché a bougé depuis v1.8.2 (+4 pg/mL)
- v1.8.2 a corrigé l'**arrondi de la médiane de calibration** (troncature
flottante → arrondi au plus proche) : une échelle dont la médiane brute
tombait à 0,8999… était tronquée en 0,89 au lieu de 0,90. Avec le modèle
ESE analytique (v1.9.0) déplaçant ce dernier bit, le piège est devenu
visible : les échelles auto-calibrées ont bougé d'un cran d'affichage →
le niveau estimé a monté d'environ 1 % (+4 pg/mL sur ~320).
- **Le niveau actuel est le CORRIGÉ** (l'arrondi est plus juste que la
troncature). Le web applique la même règle depuis v1.9.1 (harmonisation
des niveaux entre plateformes).
## [1.9.0] — 2026-09-18 (versionCode 38)
### Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
- **Le modèle ESE n'est plus les tables horaires ODS** : il est désormais la
**forme close 3C publiée par estrannaise.js** (les sources originelles
dont l'ODS n'était que l'échantillonnage horaire) — fidélité épinglée :
**RMS 0,00** vs les tables sur 0→200 j, écart de pic ≤ 0,1 %.
- **Décision** (utilisatrice) : « tous les modèles ont maintenant leurs
sources originelles — on abandonne complètement les liens avec le fichier
ODS ». Concrètement : plus aucune lecture des tables au runtime
(`concentrationOfDose` dispatche ESE → forme close ; `cutoffHours` ESE →
10 × t½ terminale analytique ; l'asset `pk_profiles.json` quitte l'APK,
−550 Ko, et ne sert plus qu'aux tests de fidélité).
- **6 esters injectables couverts** (EV, EU, EEn, EC, EB + **EUCS**,
undécylate suspension cristalline, exclusif à ce fit) — l'ancien ESE
n'en couvrait que 3. TFS reste à 7 (liste explicite : EUCS n'a pas de
V3C TFS), WHSAH à 6.
- **NUAGE D'INCERTITUDE (exclusif ESE)** : Estrannaise publie le posterior
MCMC de ses paramètres (313 échantillons/ester, asset ≈ 48 Ko) — chip
`Nuage` (off par défaut, activable à volonté, actif SEULEMENT si ESE est
affiché, coupure automatique si ESE est éteint) → nuage diffus de 32
courbes du posterior montrant la plage d'imprécision du modèle, comme
sur le site estrannaise. Dessiné en alpha faible sous les courbes, hors
échelles/labels/extrema.
- t½ terminale ESE désormais **analytique** (ln2/min(k1,k2,k3)) — la
recommandation de prise de sang n'estime plus la t½ par pente de table.
- **11 nouveaux tests** (234 au total / 204 sans les données locales) :
fidélité (RMS, pics ±0,2 %), cas dégénérés (taux égaux → limites sans
NaN), MCMC 313 × 6, nuage (32 courbes à dispersion réelle, fenêtre,
exclusivité ESE, gardes), choicesForModel ESE=6, t½ analytique.
- **Porté web** (web v1.9.0, versions sync) : `estrannaise-models.js` +
`estrannaise-cloud.js` miroirs, nuage canvas, fetch MCMC au démarrage
(l'ODS web quitte aussi le runtime), 11 tests miroirs + E2E nuage.
- Validé émulateur §16.ter (APK release, seed réel v1.7.0) : courbe ESE
analytique affichée, chips Extend/Cloud présents, recommandation
de prise de sang fonctionnelle, 0 crash.
## [1.8.2] — 2026-09-17 (versionCode 37)
### Maintenance — performance du moteur + fraîcheur de l'interface
Aucune nouvelle fonctionnalité : session d'audit complet (code + doc), avec
les optimisations et corrections sûres qui en sont sorties.
### Performance du moteur PK
- `e2At`/`computeCurve` : `cutoffHours` et les paramètres Bateman
(bisection 50 itérations) ne sont plus recalculés à chaque (point ×
dose × traitement) — pré-calculés par traitement, et Bateman devient
**paresseux** (inutile pour les traitements à profil). Le chemin le plus
chaud (tracé labs : ~720 points × N × D appels de cutoff par refresh)
s'allège d'un facteur P × T. Doses pré-groupées par traitement (fin des
scans complets). **Résultat identique** (ordre de sommation préservé —
223 tests verts inchangés).
- `AppLog` : purge du buffer sans recopie à chaque ligne loggée.
- `ChangelogHelper` : Regex précompilées (dialog « Nouveautés »).
- `CurveChart` : SimpleDateFormat créé hors boucle de dessin.
### Corrigé — fraîcheur de l'interface
- **Accueil** : éditer un scaleFactor ou le modèle T rafraîchit la courbe
immédiatement (clés du producteur incomplètes — l'ancien trio attendait
le tick de 60 s).
- **Graphiques** : éditer la VALEUR d'une analyse rafraîchit les courbes
(la clé n'était que le NOMBRE d'analyses).
### Maintenance — code & documentation
- Code mort supprimé (Repository/DAO sans appelants, `currentLevel`,
variable et scheduler dupliqués), imports morts/redondants nettoyés,
médiane de calibration factorisée (4 copies → 1 helper),
qualifications raccourcies, en-têtes/KDoc obsolètes mis à jour.
- Documentation : ~35 corrections (compteurs de tests obsolètes, TOC et
ancres réparées, inventaire des suites de tests à jour et fusionné,
titres CHANGELOG doublés supprimés, versions « à jour v1.2.0 » /
« Gradle 8.9 » / « API 34 » corrigées, fautes).
## [1.8.1] — 2026-09-17 (versionCode 36) ## [1.8.1] — 2026-09-17 (versionCode 36)
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle) ### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
@ -224,8 +675,6 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1
## [1.4.9] — 2026-09-07 (versionCode 29) ## [1.4.9] — 2026-09-07 (versionCode 29)
## [1.4.9] — 2026-09-07 (versionCode 29)
### Corrigé ### Corrigé
- **La calibration tombait à ×2,21 (auto ET manuelle) sur un traitement - **La calibration tombait à ×2,21 (auto ET manuelle) sur un traitement
basculé sous un autre modèle** (#61, remontée : « le bouton Calibrer avec basculé sous un autre modèle** (#61, remontée : « le bouton Calibrer avec
@ -233,7 +682,7 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1
diagnostic par reproduction sur données réelles — l'export contient une diagnostic par reproduction sur données réelles — l'export contient une
dose EEn de test logguée en **janvier** ; les labs de janvier-mars dose EEn de test logguée en **janvier** ; les labs de janvier-mars
tombaient 15–76 j après cette dose, quand la prédiction WHSAH est tombaient 15–76 j après cette dose, quand la prédiction WHSAH est
RÉSIDUELLE (184 → 1 pg/mL) → ratios aberrants (3,3 / 15,9 / 89,6 / 383) RÉSIDUELLE (184 → 1 pg/mL) → ratios aberrants (3,3 / 15,9 / 89,6 / 383 / 391)
→ la MÉDIANE tombait à ×2,21, **dans les deux pipelines** (manuel ET → la MÉDIANE tombait à ×2,21, **dans les deux pipelines** (manuel ET
auto — même défaut de frontière). FIX : **garde de significativité** auto — même défaut de frontière). FIX : **garde de significativité**
(`labIsSignificant`) — un lab ne calibre que si la prédiction reste (`labIsSignificant`) — un lab ne calibre que si la prédiction reste
@ -253,8 +702,6 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1
## [1.4.8] — 2026-09-07 (versionCode 28) ## [1.4.8] — 2026-09-07 (versionCode 28)
## [1.4.8] — 2026-09-07 (versionCode 28)
### Corrigé ### Corrigé
- **La calibration automatique était fausse pour les modèles autres que le - **La calibration automatique était fausse pour les modèles autres que le
modèle stocké du traitement** (#60, remontée : « la calibration auto sur modèle stocké du traitement** (#60, remontée : « la calibration auto sur

File diff suppressed because it is too large Load Diff

View File

@ -45,8 +45,9 @@ installer — pas besoin d'Android Studio ni de faire un build :
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack` 1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
2. Laisse le **Gradle Sync** se terminer (première fois : téléchargements, 5–15 min) 2. Laisse le **Gradle Sync** se terminer (première fois : téléchargements, 5–15 min)
- La barre du bas affiche la progression ; attends « Gradle sync finished ». - La barre du bas affiche la progression ; attends « Gradle sync finished ».
3. Vérifie que l'asset est bien présent : 3. (v1.9.0) Le modèle Estrannaise est **analytique** : l'ancienne table
`app/src/main/assets/pk_profiles.json` (profils Estrannaise / Transfem Science extraits de ton `Estrogen.ods`). `pk_profiles.json` n'est plus embarquée dans l'APK (elle ne sert qu'aux
tests de fidélité, dans `app/src/test/assets/`).
## 3. Préparer ton téléphone ## 3. Préparer ton téléphone
@ -87,7 +88,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
## 6. Premiers pas dans l'app ## 6. Premiers pas dans l'app
1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*) 1. **Traitements → +** → choisis un preset (ex : *Injection EV —
Estrannaise*). Le modèle Estrannaise propose ses 6 esters injectables
(EV, EU, EEn, EC huile, EB, EUCS suspension cristalline) ; le menu
déroulant Ester liste toujours les esters couverts par le modèle choisi.
- Tu as changé d'ester ? Crée (ou garde) le traitement de l'ancien ester et - Tu as changé d'ester ? Crée (ou garde) le traitement de l'ancien ester et
passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes
rappels, mais **son historique reste simulé et calibré** sur les graphiques — rappels, mais **son historique reste simulé et calibré** sur les graphiques —
@ -101,7 +105,11 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods` - Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo) - Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées 2. **Doses → +** → logue tes injections passées
- L'accueil affiche la **prochaine dose en jours** quand elle est à plus de 24 h - L'accueil liste **toutes les prochaines doses** (v1.9.6) : une ligne
par traitement, triées par prochaine prise (la plus proche en premier,
en gras) — une prise quotidienne ne masque plus le rappel de
l'injection hebdomadaire. Affichage en **jours** quand la prochaine
prise est à plus de 24 h
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon (ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
- La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h** - La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h**
(flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation (flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation
@ -122,7 +130,7 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble) - **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la - Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L) calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
- **Prochaine prise de sang (suggestion, v1.8.0)** : si ton traitement E2 - **Prochaine prise de sang (suggestion, v1.8.0 ; v1.8.1 : aussi sur l'Accueil)** : si ton traitement E2
injectable actif a une **Posologie**, la page te suggère quand faire la injectable actif a une **Posologie**, la page te suggère quand faire la
prochaine analyse — **au creux estimé, juste avant l'injection suivante** prochaine analyse — **au creux estimé, juste avant l'injection suivante**
(moment le plus comparable), au premier creux où ton ester est stabilisé (moment le plus comparable), au premier creux où ton ester est stabilisé
@ -157,7 +165,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
## 7. Les modèles mathématiques ## 7. Les modèles mathématiques
- **Estrannaise** : tables horaires issues de ta feuille `Estrogen.ods` (table « Models ») - **Estrannaise (v1.9.0 : analytique)** : forme close 3C publiée par
estrannaise.js — les anciennes tables du tableur en étaient
l'échantillonnage (fidélité vérifiée à l'identique) ; 6 esters injectables
couverts (EV, EU, EEn, EC, EB + EUCS en suspension cristalline)
- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments - **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/), ([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) — [simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
@ -214,6 +225,12 @@ Pics de référence (dose unique de 5 mg, IM) :
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du - **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
du téléphone) du téléphone)
- **Nuage d'incertitude (v1.9.0, exclusif ESE)** : chip `Nuage` (actif
seulement quand Estrannaise est affiché) → nuage diffus de courbes du
posterior MCMC d'Estrannaise montrant la plage d'imprécision du modèle
(comme sur le site estrannaise). v1.9.2 : il couvre toutes les doses E2
injectables quand ESE est affiché — aucun traitement n'a besoin d'être
stocké en ESE
- **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et - **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et
l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de
labels — plus besoin de deviner l'unité des nombres labels — plus besoin de deviner l'unité des nombres
@ -248,7 +265,7 @@ Pics de référence (dose unique de 5 mg, IM) :
| Problème | Solution | | Problème | Solution |
|----------|----------| |----------|----------|
| « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → installer API 34 | | « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → vérifier/installer la plateforme 37 (le build AGP peut la télécharger automatiquement) |
| Pas de téléphone détecté | Réactive le Débogage USB, change de câble (données, pas charge seule) | | Pas de téléphone détecté | Réactive le Débogage USB, change de câble (données, pas charge seule) |
| Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre | | Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre |
| Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app | | Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app |

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