Compare commits

...

80 Commits
v1.2.6 ... main

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

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

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

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

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

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

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

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

Tests : « interval change » ré-épinglé (NOW+21 j) + 2 nouveaux (flou
d'heure ± 23 h → pas de reset ; écart 25 h → reset). 249 verts + lint.
versionCode 44 / 1.9.7 + CHANGELOG/§2/§7.11/README.
2026-09-21 22:19:46 +02:00
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
2ec9f63f93 v1.8.1 : fix stabilisation (changements récents vus) + suggestion sur l'Accueil
Critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app
me disait stabilisée depuis février » — le proxy « 1ʳᵉ dose du traitement »
était aveugle aux changements récents.

- LabTiming.regimeStartMs (nouveau, internal) : le régime courant = la
  séquence terminale de doses à (ester effectif, dose mg, écart
  inter-doses) CONSTANTS — l'écart est comparé exactement (7 j ± 1 h casse
  le régime) : tout changement récent réinitialise la stabilisation et la
  reco saute au premier creux post-stabilisation. KDoc complet (règle +
  justification conservatrice des intervalles irréguliers).
- LabRecommendation.ester = l'ESTER EFFECTIF de la dernière dose (override
  compris) — l'encart/carte racontent le bon ester.
- HomeScreen : carte compacte « Prochaine prise de sang (suggestion) »
  (creux daté + créneau associé + mention de stabilisation SEULEMENT si le
  régime n'était pas déjà stable) — même calcul que la page Analyses
  (demande v1.8.1 : suggestion aussi sur l'Accueil).
- 3 nouveaux tests (223 verts / 193 sans données locales) + lint :
  changement de dose / d'intervalle / d'ester → réinitialisation épinglée.
- Validé émulateur §16.ter (release, seed réel v1.7.0) : la carte passe de
  « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 —
  premier creux fiable après ton dernier changement », creux 27/09 22:43,
  0 crash. versionCode 36 / versionName 1.8.1. Web sync v1.8.1.
2026-09-17 19:48:20 +02:00
8c19c8f295 v1.8.0 : recommandation de prochaine prise de sang (page Analyses)
Moteur pk/LabTiming.kt (PUR, miroir web js/pk/lab-timing.js) :
- traitement porteur = E2 injectable ACTIF à Posologie (sinon null) ;
- stabilisation = 1ʳᵉ dose du régime + 5 × t½ terminale (TFS/WHS
  analytiques ; Estrannaise : NOUVEAU PKProfileStore.terminalHalfLifeDays,
  refactor de la pente d'extrapolation de sample() — zéro duplication) ;
- creux = minimum de la courbe E2 prévisionnelle entre 2 injections
  (forme brute — sans calibration) ; ON SAUTE au premier creux dont le
  créneau est stabilisé (décision v1.8.0) ; filtres : ≥ now+6 h, jamais
  un creux déjà mesuré, horizon borné (ester ultra-long → null).

UI LabsScreen : carte « Prochaine prise de sang (suggestion) » (creux
exact + créneau associé + mention « ou la veille » + statut de
stabilisation + disclaimer) OU invite « renseigne une Posologie » quand
un injectable actif en est dépourvu (demande v1.8.0) — mutuellement
exclusives ; calcul produceState + tick minute.

Tests : LabTimingTest (8) + régression n°5 étendue (1) → 220 verts
(190 sans données locales) + lint. Validé émulateur §16.ter (release,
seed réel v1.7.0) : creux 20/09 10:37, injection EEN 20/09 11:20, EEN
stabilisé depuis le 06/02, 0 crash. versionCode 35 / versionName 1.8.0.
Web sync v1.8.0 (publié séparément).
2026-09-17 18:47:38 +02:00
23c62e85f2 v1.7.1 : fix « une seule note sur deux » dans les Analyses (Android) + régression n°5
- LabsScreen : labNotesForDisplay (helper PUR, testé) — notes DISTINCTES
  d'une prise E2+T → DEUX lignes préfixées du marqueur (« E2 : … » /
  « T : … ») ; note identique (dialog commun) → une seule ligne
  (dédupliquée) ; vides ignorées ; note seule → brute (v1.0 préservé).
  L'ancien code ne prenait que la première note non vide (hypothèse
  « toutes identiques » cassée par l'édition unitaire) : l'autre note
  était conservée mais perdue à l'affichage.
- RegressionUserCase5Test (7 tests) : NOUVEL export réel v1.7.0
  (local-test-data/backup-v1.7.0.json, gitigné) — 3 traitements dont CPA
  oral, 64 doses, 28 labs ; la dernière prise porte EXACTEMENT le cas du
  bug (deux notes distinctes E2/T) : les deux affichées, aucune perdue ;
  plausibilité moteur (CPA oral + EEn actif + EV inactif simulé),
  prévision 7 j, notes de doses préservées. Aucune valeur de santé en dur
  (data-driven, garde §8.bis).
- LabsGroupingTest : +5 tests sur labNotesForDisplay. 211 verts
  (181 sans données locales) + lint vert.
- Validé émulateur §16.ter : APK release seedé avec l'export v1.7.0 →
  écran Analyses affiche « E2 : … » ET « T : … », 0 crash.
- versionCode 34 / versionName 1.7.1. Web sync en miroir (web v1.7.1).
2026-09-17 12:29:44 +02:00
2c58418604 v1.7.0 : sauvegarde automatique quotidienne (opt-in) + unités des axes
AUTO-BACKUP JOURNALIER (en plus de l'export manuel, inchangé) :
- AppSettings : enabled / treeUri (SAF persisté) / keep (1..30, défaut 7) /
  lastRun ; BackupManager.writeAutoBackup : tree URI → createDocument d'un
  fichier horodaté (ExportFileNames.autoBackupFileName — signature
  LocalDateTime, règle anti-#48) → écriture via writeBackup TEL QUEL
  (leçon #45) → purge par AutoBackupRetention (PURE, testée : ne touche
  JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1).
- AutoBackupWorker + AutoBackupScheduler (pattern AlertWorker) : périodique
  24 h (KEEP au démarrage, no-op si désactivé), runNow à l'activation
  (premier backup immédiat), statut dernier run persisté + AppLog ;
  proguard : -keep explicite du worker (réflexion WorkManager, leçon #64).
- Paramètres : carte « Sauvegarde automatique quotidienne » — switch (ON
  sans dossier → picker SAF), dossier persisté (takePersistableUriPermission),
  nom lisible (folderDisplayName), copies conservées, statut OK/ÉCHEC.
- Validé émulateur §16.ter (APK release + données réelles seedées) : dossier
  Download/HormoneTrack choisi via le picker SAF, premier backup v2 écrit
  immédiatement (3 traitements/52 doses/26 labs, importable), rétention
  keep=2 vérifiée (3 vieux autos supprimés, export manuel intact), statut
  « OK (date) », 0 crash.

UNITÉS DES AXES (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2,
gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop
12→26 dp) — validé émulateur (screencap). Portées web (v1.7.0 web sync).

9 nouveaux tests (200 verts / 177 sans données locales) + lint vert.
versionCode 33 / versionName 1.7.0.
2026-09-16 19:52:04 +02:00
76a9c0eb63 Fix #64 : « Tracé labs » vide en release seule (R8 full mode) + étape émulateur obligatoire
- proguard-rules.pro : -keep class LabTrajectoryModel { *; } — le mapping
  R8 montrait R8$$REMOVED$$CLASS (inlining du calcul + du synthétique
  $default dans le producer) : courbe vide en release, debug et 191 tests
  JVM aveugles. Validé émulateur §16.ter sur l'APK release re-buildé
  (données réelles seedées) : ancrée + prolongée + avertissement + 0 crash.
- Checklist §16 : NOUVELLE étape 3.bis — validation émulateur sur l'APK
  RELEASE obligatoire AVANT le tag (leçon #64 : jamais oublier le
  simulateur) + procédure de re-tag si fix avant publication.
- §14 : bug #64 complet (symptôme, diagnostic mapping, fix) ; §2 et
  CHANGELOG mis à jour.
2026-09-13 03:43:11 +02:00
0768b83a88 Docs : v1.6.0 — prolongation tracé labs (§7.10.bis), session §2, compteur 191 tests
- DEVELOPPEMENT : §7.10.bis (recette ρ constant, horizon, split LAB/LABX,
  avertissement, limites), session v1.6.0 §2, §8 191/168 tests, §11
  séries LAB/LABX, §21 checklist manuelle, TOC 7.10/7.10.bis, fix coquilles
  (E3→E2, efecto→effet, trais→traits), checklist §16 compteur à jour.
- CHANGELOG : entrée [1.6.0]. README : statut + puce Tracé labs prolongé.
- GUIDE_INSTALLATION : chip Prolonger + avertissement (§8).
2026-09-12 23:49:24 +02:00
3e2bf3992e v1.6.0 : « Tracé labs » prolongé au-delà du dernier lab (chip Prolonger + avertissement)
- LabTrajectoryModel : extendBeyondLastLab (ρ_last constant au-delà du
  dernier lab significatif), lastAnchorMs, extensionHorizonEndMs
  (horizon = dernière dose E2 + cutoff du traitement — cutoffHours rendu
  public, une seule source de vérité) ; boucle de calcul inchangée.
- ChartScreen : chip « Prolonger » (5ᵉ, off par défaut, désactivé tant
  que Tracé labs est off), split série "LAB" (ancrée) / "LABX"
  (prolongée, rose atténué α 0,55), légendes dédiées (branche explicite
  avant le else — leçon #63), AVERTISSEMENT « simple simulation, sans
  garantie de correspondre au réel, labs potentiellement erronés » sous
  la légende quand la prolongation est visible.
- 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert.
- Bump versionCode 32 / versionName 1.6.0.
2026-09-12 23:49:24 +02:00
dd5a9e3013 Docs : audit doc↔code v1.5.0 — effectifs réels + leçon #63 (§14)
Constats de l'audit systématique (leçon #44 : toute affirmation doit être
vérifiée dans le fichier réel) :
- effectifs de régression RÉELS : 4 classes = 6/6/6/5 = 23 tests (doc
  disait 4 pour la classe 4) → « 160 sans les données locales » (183−23)
  corrigé dans §8, §8.bis, §16, footer et README ; les vieux chiffres
  (44/87 v1.3.x, 150/172 v1.4.x) épinglés comme historiques
- §14 : entrée #63 ajoutée à l'historique des bugs (elle était citée en
  §7.10 mais absente de la liste « à ne pas réintroduire »)
2026-09-11 16:27:46 +02:00
04f6b8bd64 v1.5.0 : « Tracé labs » — courbe hybride ancrée sur les labs (M(t)×ρ(t))
Demande débattue avec l'utilisatrice (hésitation assumée, décision v1.5.0)
: une courbe biologiquement plausible MAIS passant par tous les labs.
- pk/LabTrajectoryModel.kt (pur) : M(t) = modèle PK brut (scaleFactor
  forcé 1) ; ρ_k = lab/M(t_k) interpolé LOG-LINÉAIREMENT entre labs
  consécutifs — passgage exact garanti, morphing d'amplitude continu.
  Garde anti-#61 (labIsSignificant : lab hors fenêtre d'action PAS un
  ancrage). E2 uniquement, fenêtre [1er ; dernier lab], < 2 labs → vide.
  Indépendante de la calibration (elle est SA propre référence — sinon
  double correction, épinglé). JAMAIS dans levelAt/alertes/rappels.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles,
  horizontalScroll CONFINÉ à la rangée (chip 4ᵉ coupé hors fenêtre —
  constat émulateur) ; couleur ChartLabTrajectory #C2185B dashed ;
  ChartSeries.showExtrema (tracé labs hors pics/creux) ; leçon #63 :
  clé 'LAB' exclue de la boucle légende (sinon légende TFS dupliquée).
- 11 tests LabTrajectoryModelTest (183 verts total, 161 sans données
  locales) + lint ; validé émulateur §16.ter (courbe passe exactement
  sur le lab 248 avec seed réel, 0 crash).
- Docs : §7.10 (maths + décisions), §2, §8, §16, §21, CHANGELOG [1.5.0],
  README (status + feature).
2026-09-11 16:11:02 +02:00
e508607919 Docs : portage web migré vers son dépôt séparé HormoneTrack-web (v1.4.10)
Le portage navigateur (ex-web/) vit désormais dans ~/projects/HormoneTrack-web
— dépôt séparé sur les mêmes instances Gitea, historique 100 % propre (la
dette de confidentialité §8.bis reste confinée à ce dépôt), versions
alignées (web v1.4.10 = portage de l'Android v1.4.10), changelogs
totalement séparés.
- §16 : étape 4.bis de la checklist de release = sync web (checklist §10
  du dépôt web) ; publication Gitea web gelée tant que la parité qualité
  n'est pas validée (commit + tag seulement)
- README §Git : note sur le dépôt web apparié
2026-09-08 14:36:30 +02:00
3e4c7c44b3 v1.4.10 : fix #62 — pan mort sur la vue 24 h : les deltas de doigt (< 1 h) étaient tronqués à 0 à chaque événement → cumul fractionnaire (panDeltaHours, pur + 4 tests : cumul 24 h, 7 j, négatifs, dégénérés) ; limitation de test documentée (§16.ter : l'émulateur headless ne délivre pas les moves intermédiaires au chart — pan non testable par adb, validation sur Pixel 9) ; retrait de l'instrumentation de diagnostic ; 172 tests verts + lint 2026-09-07 19:28:41 +02:00
4045614f3b Docs + nettoyage : TOC de la doc de dev complétée (7.8/7.9/9.bis/16.ter), structure des tests dans le README, imports/constantes morts supprimés (Calendar, DAY_MS) — dernier tour de maintenance de la session 2026-09-07 19:07:55 +02:00
9b83533d72 v1.4.9 : fix #61 — calibration tombant à ×2,21 (auto ET manuelle) : garde de significativité labIsSignificant (prédiction ≥ 15 % du max observé, labs par timestamp croissant) sur les 3 pipelines — une dose EEn de test de janvier + des labs de janvier-mars produisaient des ratios aberrants (×3,3 → ×391) ; facteur WHSAH retombe à 0,55 (reproduit et épinglé par ScaleFactorWhsahReproTest) ; hint UX (auto ON → facteur manuel ignoré à l'affichage) ; nettoyage CHANGELOG (4 titres orphelins doublés) ; 168 tests verts + lint 2026-09-07 18:54:43 +02:00
22d4c66d81 v1.4.8 : calibration PAR MODÈLE PK (#60) — le graphique calcule une AutoCalibrated par modèle affiché (modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs (plus de facteur partagé absurde ×2,21 sur WHSAH) ; Home inchangé (modèle stocké) ; CalibrationPerModelTest (5 tests : couverture 3 modèles, délégation unique, k T par modèle, cas dégénérés) ; 164 tests verts + lint 2026-09-07 18:28:15 +02:00
1c9d2e4e6a v1.4.7 : toggles de modèles alignés sur les traitements à profil (#58) + calibration universelle épinglée (par ester, toutes les courbes) + fix #59 : chip de traitement affichait 'Estrannaise' pour WHSAH (ternaire → helper modelLabelRes centralisé) ; 158 tests verts + lint 2026-09-07 17:45:44 +02:00
ca917ba772 v1.4.6 : 3ᵉ modèle PK WHSAH (fit license-free du WHSAH Collective publié dans Mona — crédité) superposable au même niveau qu'Estrannaise/TFS : dispatch parallèle, 6 presets, dropdown 3 choix, chip + courbe verte/violette, choicesForModel (TFS=7/WHS=6/ESE=3) ; #57 : l'écran Graphiques défile (rangée toggles coupée sous le pli) ; 10 tests (155 verts) + lint ; validé émulateur (3 légendes + 3 courbes de couleurs distinctes) 2026-09-07 17:01:28 +02:00
15a7e6614c v1.4.5 : fix #55 — projection paraissant décalée d'un jour (génération exacte confirmée par régression #4 ; illusion = absence de marqueurs + labels à minuit UTC) → marqueurs de doses (prévisionnels pointillés + réels discrets) + xLabelTicks à minuit LOCAL dans le fuseau CHOISI (Paramètres) ; fix #56 — boucle de recomposition (nowMs relu à chaque frame → endMs dérive → main thread saturé, taps perdus) → nowMs mémoïsé sur le tick ; 6 tests (145 verts) + lint ; validé émulateur (chips réactifs, marqueur vertical rendu) 2026-09-07 11:27:38 +02:00
045ab5d060 v1.4.4 : fix #54 — prévision inaccessible depuis v1.4.1 : (a) captures FIGÉES dans pointerInput(Unit) (futurePanHorizon lu à 0 pour toujours → pan futur interdit) → rememberUpdatedState + garde de source ChartScreenSourceGuardTest ; (b) toggle sans effet visible (endMs=now → 0 dose générée) → forecastExtensionHours (extension jusqu'au 1er créneau, sans déplacer le début) ; 3 tests (140 verts) + lint ; validé émulateur (drag gauche → panHours<0, courbe dessinée au-delà de maintenant) 2026-09-07 07:56:32 +02:00
755d8f0007 Docs v1.4.3 : garde-fous de confidentialité renforcés (§8.bis : 5 vérifications à chaque release — gitignore, ls-tree du tag, historique, scan, assertions data-driven ; checklist §16 étape 5) ; guide : delta 6 h de l'accueil + physiologie des esters lents 2026-09-07 07:39:30 +02:00
3fa7cf97ea v1.4.3 : fix #53 — le delta 'vs il y a 6 h' de l'accueil comparait en réalité il y a 24 h (firstOrNull sur la fenêtre 24 h) → pointHoursBefore (point au plus petit écart ≥ 6 h) + 4 tests ; régression n°4 épinglée sur le 4ᵉ export réel (contribution EEn J+1 < 12 %, plateau d'équilibre — comportement pharmacocinétique normal confirmé avec le vrai moteur) ; 137 tests verts + lint 2026-09-07 07:35:54 +02:00
633c6debf4 v1.4.2 : seuils d'alerte configurables E2/T (Paramètres, opt-in, validés haut>bas) — cartes d'avertissement sur l'accueil (niveau ESTIMÉ, calibration incluse) ; notification via worker WorkManager périodique 15 min + check one-time au save, anti-spam par état persisté (Alerts.encodeState/shouldNotify), canal dédié ; backup JSON v2 avec les paramètres utilisateur (langue/auto-cal/seuils, rétrocompat v1, -keep R8) ; 13 nouveaux tests (129 verts) + lint 2026-09-07 00:02:22 +02:00
922d49841f v1.4.1 : prévision étendue et sans saut — activer le chip ne déplace plus la fenêtre (endMs = now − panHours signé, panHours < 0 = futur borné par l'horizon 12×Posologie [30 j ; 1 an], clamp unique clampPanHours) ; delta en jours sur la carte Prochaine dose (> 24 h : '5 j 2 h · sam. 6 18:00') ; 5 tests (112 verts) + lint 2026-09-06 23:00:42 +02:00
c1c2386ab2 Tests + docs v1.4.0 : TransfemScienceEngineTest (câblage moteur↔V3C : EB→pic article, EB/ESE→0, coupure PEP 10×t½, override TFS via computeCurve) ; ReminderScheduleTest : grille sous-quotidienne épinglée (limitation §19) ; compteurs 107/89 + paliers de test §16 2026-09-06 22:32:14 +02:00
208ba56843 v1.4.0 : modèle Transfem Science reconstruit sur la méta-analyse officielle (V3C forme close, 7 esters, pics de l'article reproduits à ~1 % — TransfemScienceModels + 5 tests) ; fix #52 : rappels sur la grille Posologie (nextReminderFireFor, re-programmation après notif et 'Pris', ReminderScheduleTest ×7) ; nouveaux presets EB/EC/ECS/PEP ; 102 tests verts + lint 2026-09-06 22:28:18 +02:00
fb942e4a24 v1.3.5 : agenda réparé de bout en bout (remontée par les logs AppLog exportés) — #50 ensureCalendar : CALLER_IS_SYNCADAPTER exige account+account_type ; #50bis deleteEvent : même défaut, 'supprimé' logué mais event restant ; #51 buildTreatment perdait calendarEventId → événements orphelins ; log agenda véridique (3 messages) ; cycle ON→save→OFF→save validé émulateur ; 90 tests verts + lint 2026-09-06 14:46:50 +02:00
71659b5647 v1.3.4 : 2 crashs v1.3.3 reproduits sur émulateur (données réelles) et corrigés — #47 Doses : stringResource hrt_duration 3 placeholders vs 2 args (né v1.3.1) ; #48 export logs : LocalDate.format('…-HHmm') levait UnsupportedTemporalTypeException au tap → util ExportFileNames + 3 tests ; #49 notif dose StringFormatMatches ; lint filet bloquant (app/lint.xml) ; recette émulateur §16.ter + scripts/seed-emulator.py ; 90 tests verts 2026-09-06 12:38:57 +02:00
e5fefc90df Docs v1.3.3 : dette de scan historique documentée (fragments de labs dans les révisions v1.1.0→v1.2.3 déjà poussées — décision de non-purge consignée §8.bis, leçon (l) §14) ; checklist §21 enrichie (vérifs agenda/version/export logs) 2026-09-06 11:31:15 +02:00
cb64f0e57e v1.3.3 : 3 bugs racines de l'audit de reprise corrigés — permissions agenda absentes du manifest (#44), export logs réutilise BackupManager.writeBackup (+AppLog fail-safe) (#45), bump de version commité avant le tag (#46) ; doc de dev rafraîchie (DB v3, targetSdk 36, Pixel 9 /e/OS, #38/#43, §19-21) 2026-09-06 11:24:12 +02:00
88c365f4d0 Docs §16.bis : publish-release.py référencé (les 2 APK dans UNE invocation) ; gitea-release.py documenté pour le corps seul 2026-09-06 10:52:59 +02:00
f5e58364a3 Docs : publish-release.py documenté (§16.bis + §14 #43 + CHANGELOG), farewell 12 releases complètes, README dépôts jumeaux 2026-09-06 10:51:46 +02:00
af8d40726a v1.3.2 : 3 bugs remontés corrigés (toggle agenda, export logs, dialog changelog récurrent) + données réelles mises à jour
- FIX toggle agenda : Posologie requise (refus + message si absente),
  permission vérifiée RÉELLEMENT via ContextCompat au save (le flag
  remembered pouvait être faux après recomposition), AppLog à chaque étape
- FIX export logs : réutilise le pattern éprouvé de l'export JSON
  (openOutputStream wt), chaque étape journalisée dans AppLog
- FIX dialog changelog : version vue mémorisée AVANT l'affichage (sinon
  réapparaissait à chaque réouverture), titre = BuildConfig.VERSION_NAME
  (documenté : la version de l'APP, pas du contenu)
- RegressionUserCase3Test mis à jour (3 traitements : EV inactif + EEn
  actif + CPA oral avec posologie 1 j, 51 doses, 26 labs)
- versionCode 16, versionName 1.3.2
2026-09-06 10:45:00 +02:00
6c7f2fa2e2 Docs : audit de fraîcheur complet — compteurs de tests (87), TOC (.bis), §3 buildConfig, §16 workflow, §8.bis, README versions, GUIDE logs
- DEVELOPPEMENT : §16 (87 tests), §16.bis étape 2 (87), footer (87/87),
  §8.bis (44 au lieu de 87), §3 (buildFeatures buildConfig), TOC (§6.bis,
  §8.bis, §16.bis ajoutés ; titre §16 harmonisé), README (releases v1.1.0 →
  v1.3.1), GUIDE (logs de diagnostic)
- 87/87 tests verts, scan confidentialité : 0 hit
2026-09-06 10:23:43 +02:00
c15500caeb Docs §16.bis : corrige la référence publish-release.py (n'existe pas) vers la séquence inline validée 2026-09-06 10:20:50 +02:00
58bc55bd0d Docs §16.bis : précise la revérification obligatoire par téléchargement après les deux uploads (pattern v1.2.10/1.3.0/1.3.1 confirmé) 2026-09-06 10:20:25 +02:00
fa5d2df5a5 v1.3.1 : en-tête « temps sous THS » sur Doses, logs de diagnostic exportables, fix toggle agenda (callback async)
- DosesScreen : carte « Sous THS depuis le … » (HrtDuration, mois = 30 j estimés)
- AppLog : journal horodaté buffer circulaire 500 lignes persisté
  filesDir/debug-log.txt ; export SAF + effacement dans Paramètres ;
  alimenté par agenda/permissions/import/export/erreurs
- FIX toggle agenda (bug remonté) : le résultat de permission arrive
  ASYNCHRONE — le test synchrone post-launch() était toujours faux → le
  callback active le switch + journalisation
- AppLogTest (4) + HrtDurationTest (5) → 87 tests verts
- versionCode 15, versionName 1.3.1
2026-09-06 10:16:49 +02:00
d456cfa093 .gitignore : scanner de confidentialité local (énumère les motifs personnels — jamais versionné, cf §16.bis étape 5) 2026-09-06 09:06:18 +02:00
dcf51d1d15 Licence GPL-3.0 + anonymisation docs (surnoms/facteurs ODS, chemin Owncloud, nom de clé SSH) 2026-09-06 08:58:08 +02:00
506b86f5f4 Docs : farewell synchronisé (12 releases avec APK vérifiés) ; piège lexicographique des tags épinglé (§14 #38, §16.bis) ; README/GUIDE deux instances 2026-09-06 08:47:55 +02:00
7e84bc2d19 Docs §16 : farewell repo créé + push initial OK (SSH) ; releases en attente du token trousseau 2026-09-06 08:43:27 +02:00
fb4c477b25 Docs §16.bis : course entre uploads rapprochés documentée (purge + double vérification par téléchargement) — release v1.3.0 réparée et vérifiée 2026-09-06 08:32:26 +02:00
14f15e69cf v1.3.0 : dialog « Nouveautés » post-update, événements d'agenda récurrents, version + lien releases dans Paramètres
- Dialog « Nouveautés » (v1.3.0) : au démarrage, si la version installée est
  plus récente que la dernière vue (DataStore changelog_seen_version), un
  AlertDialog affiche les sections CHANGELOG non vues (ChangelogHelper
  sectionsSince + comparaison SemVer NUMÉRIQUE — 1.2.9 < 1.2.10, lexicographique
  aurait tort) ; asset changelog.md synchronisé à chaque build par la tâche
  Gradle copyChangelog (gitignoré) ; fermable, ne réapparaît pas avant la
  prochaine mise à jour
- Événements d'agenda récurrents (CalendarEvents.kt) : calendrier LOCAL dédié
  « HormoneTrack » (CalendarContract, ACCOUNT_TYPE_LOCAL), événement avec
  RRULE FREQ=DAILY;INTERVAL=N dérivé de la Posologie (arrondi demi-supérieur
  EXPLICITE floor(x+0.5) — kotlin.math.round arrondit les ties vers l'entier
  PAIR : 6,5 → 6, piège épinglé), début = prochaine occurrence à l'heure de
  rappel (ou 12:00) ; switch dans l'éditeur sous « Rappels », permissions
  WRITE_CALENDAR + READ_CALENDAR demandées à l'activation ; suppression/
  recréation au save ; id stocké sur Treatment (Room v3, MIGRATION_2_3)
- Paramètres : version installée (BuildConfig.VERSION_NAME, buildConfig=true)
  + lien cliquable vers les releases Gitea
- Tests : ChangelogHelperTest (8) + CalendarRruleTest (3) → 78 tests verts
- versionCode 14, versionName 1.3.0
2026-09-06 08:30:31 +02:00
d07d668606 Docs §16.bis : releases ≤ v1.2.4 restent à APK debug unique (backfill abandonné — best effort, app-release-unsigned.apk sans signing config d'époque) 2026-09-06 00:10:23 +02:00
7e8a2f6d6f scripts/gitea-release.py : régénération complète — ensure_asset vérifie par TÉLÉCHARGEMENT (le listing a menti pendant les uploads rapprochés) ; retry DELETE+re-upload ×2 ; échec bruyant ; docs/code commentés 2026-09-05 23:51:23 +02:00
f695d3b99e scripts/gitea-release.py : ensure_asset (upload + vérif nom/taille + retry PATCH + échec bruyant) — les ?name=/PATCH pouvaient être ignorés (v1.2.6/v1.2.10) ; docs §16.bis/§14 #37 2026-09-05 23:45:45 +02:00
95ccb1cf9e v1.2.10 : sens des boutons de zoom inversé (+ = zoom avant / fenêtre courte, convention carte — retour utilisateur) ; docs ; versionCode 13 2026-09-05 23:36:30 +02:00
e2218fd3f1 Docs/scripts : correction d'attribution — le nom d'asset générique venait de l'upload interrompu (extinction machine), pas d'un quirk Gitea ; renommage PATCH conservé comme défense 2026-09-05 23:28:02 +02:00
c888a718d5 v1.2.9 : zoom du graphique (pinch + boutons, 6 h → 300 j, focal stable, pas adaptatif) + disclaimer IA dans le README
- ChartScreen : detectTransformGestures unique (pan 1 doigt + pinch 2 doigts,
  pas de conflit de consommation) ; zoom autour du point focal (le temps sous
  le doigt reste fixe) ; plage bornée 6 h → 300 j (MIN/MAX_RANGE_H) ;
  boutons − / + en accessibilité
- CurveChart : stepForRange (15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h) passé à
  computeCurve (courbes lisses à fort zoom) ; labels X adaptatifs 1 h/3 h
- README : disclaimer IA en en-tête (développé avec l'IA, contribution
  humaine essentielle = feedback/retours/suggestions, renvoi §2 doc dev)
  ; DEVELOPPEMENT §1 mention IA-assistée, §11 zoom, §8 67 tests
- ChartZoomTest : 3 tests stepForRange — 67 tests verts
- versionCode 12, versionName 1.2.9
2026-09-05 22:51:27 +02:00
0eaaea40c1 v1.2.8 : « Fréquence d'injection » renommé « Posologie » ; script : renommage d'asset après upload (quirk Gitea)
- strings EN « Dosage » / FR « Posologie » ; hint « dernière prise » (au lieu
  de « dernière injection ») — adapté aux voies non injectables
- quirk Gitea découvert : le nom d'asset passé à l'upload peut être normalisé
  (observé : -release.apk → .apk générique) → le script renomme l'asset via
  l'API d'édition (PATCH …/assets/{id}) après chaque upload
- release v1.2.8 publiée avec les 2 APK (release 2,5 Mo + debug 21 Mo)
- docs : §16.bis quirk, §8 (64 tests), historique, footer, README
- versionCode 11, versionName 1.2.8
2026-09-05 22:29:08 +02:00
7e707b2a39 CHANGELOG : entrée v1.2.8 (renommage Posologie) — étape 3 de la checklist §16.bis, appliquée à la lettre cette fois 2026-09-05 22:25:07 +02:00
55d0e5134f UI : « Fréquence d'injection » renommé « Posologie » (terme inadapté aux voies non injectables) ; hint généralisé « dernière prise » ; docs à jour
- strings EN (Dosage) / FR (Posologie) ; freq_hint : « dernière injection » →
  « dernière prise » (les deux langues)
- README, GUIDE, DEVELOPPEMENT §10/§21 : mentions mises à jour
- versionCode 11, versionName 1.2.8 ; 64 tests verts
2026-09-05 22:24:24 +02:00
b96571856a Docs : guide d'installation — section « télécharger sans compiler » (releases Gitea, différences APK release vs debug) ; README statut v1.2.7/64 tests (replace silencieux raté corrigé) 2026-09-05 22:18:20 +02:00
9c27c4d9e2 v1.2.7 : prévision robuste aux oublis/retards + rattrapage complet de la doc de dev
- generateForecastDoses : les créneaux déjà passés (oubli d'injection) ne sont
  plus simulés — avant, dernier+intervalle tombait dans le passé → faux pic
  dans l'historique + rythme décalé ; la prévision démarre au premier créneau
  strictement futur, au rythme configuré
- un retard décale toute la prévision (part de la dernière prise réelle) —
  comportement voulu, épinglé par 2 tests
- doc §7.3b corrigée (mismatch doc-code détecté par l'utilisatrice)
- docs : §13 écrasement, §19/§20 import, §14 #34/#35, §21, historique
  (lignes v1.2.2/6/7), footer — vérification intégrée dans le script de patch
- versionCode 10, versionName 1.2.7 ; 64 tests verts
2026-09-05 22:14:19 +02:00
1cd3d0f2e4 scripts/gitea-release.py : DELETE renvoie {} (204 sans corps) ; nettoyage des assets v1.2.6 (release 2,4 Mo + debug 20 Mo) 2026-09-05 21:59:18 +02:00
9c6c414d9c scripts/gitea-release.py : gère les réponses 204 sans corps (DELETE d'asset) — crash json.loads sur réponse vide 2026-09-05 21:56:23 +02:00
6e193db969 CHANGELOG : entrée v1.2.6 (extrema avec valeurs + import écrasement) 2026-09-05 21:49:58 +02:00
119 changed files with 17059 additions and 902 deletions

10
.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
@ -29,3 +36,6 @@ Thumbs.db
# Logs # Logs
*.log *.log
local-test-data/ local-test-data/
app/build/generated/changelog/
app/src/main/assets/changelog.md
scripts/confidentiality-scan.py

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

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

204
README.md
View File

@ -1,5 +1,14 @@
# 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.
> La contribution humaine a été **essentielle** : feedback continu, retours
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
> réels), suggestions d'améliorations et validation de chaque release. Le
> détail session par session est documenté dans
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md).
Suivi de thérapie hormonale (THS) sur Android, avec courbes estimées **heure par heure** Suivi de thérapie hormonale (THS) sur Android, avec courbes estimées **heure par heure**
d'estradiol (E2) et de testostérone (T), calibration sur les prises de sang, rappels d'estradiol (E2) et de testostérone (T), calibration sur les prises de sang, rappels
affichés sur smartwatch (Huawei Watch GT 3 via Gadgetbridge ou Huawei Health) et affichés sur smartwatch (Huawei Watch GT 3 via Gadgetbridge ou Huawei Health) et
@ -9,33 +18,74 @@ 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.2.6 — build Android ✅, **62 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.13.0 — build Android ✅, **lint vert** ✅, **284 tests unitaires** ✅ (250 sans les données de test locales ; 7 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) - **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
- **README anglais** : [README.en.md](README.en.md)
## Fonctionnalités ## Fonctionnalités
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j, - **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
**panoramique** (glisser pour remonter dans le passé), **pics & creux** affichables **doses marquées** (prévisionnelles et réelles), **fuseau horaire du graphique
(triangles ▲▼ aux extrema locaux, toggle) configurable** (v1.4.5),
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) : **panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn, − / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
affichés côte à côte avec toggles indépendants leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
- **Deux modèles PK au choix, superposables** : **Estrannaise (EstraNase)** (tables
horaires du `.ods`) et **Transfem Science** — depuis v1.4.0, le modèle TFS est la
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
avec les **7 esters** (EV, EU, EEn, EB, EC huile, EC suspension, PEP) — et WHSAH
en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés
selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré
séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil)
- **« Tracé labs » (v1.5.0, prolongé en v1.6.0)** : la courbe ancrée sur tes
prises de sang (chip `Tracé labs`, off par défaut) — la forme du modèle PK
entre les labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque
lab E2 ; transition log-linéaire d'un lab au suivant, garde des labs hors
fenêtre d'action (#61) ; **chip `Prolonger` (v1.6.0)** : la courbe se
prolonge au-delà du dernier lab (modèle × ratio du dernier lab, horizon =
extinction du modèle, partie estimée dessinée atténuée + légende dédiée) —
la courbe est une référence de COMPARAISON, jamais une estimation d'action
(elle n'entre jamais dans l'accueil/les alertes)
- **Modèle Estrannaise ANALYTIQUE (v1.9.0)** : le modèle ESE utilise désormais
la forme close 3C publiée par estrannaise.js (fidélité aux anciennes tables
épinglée RMS 0) — **nuage d'incertitude MCMC** (chip `Nuage`, exclusif ESE :
32 courbes du posterior montrant la plage d'imprécision, comme sur le site
estrannaise) ; **6 esters injectables** (dont EUCS, exclusif) ; l'ODS
historique n'est plus utilisé au runtime (fidélité = tests uniquement) ;
t½ terminale analytique (recommandation de prise de sang)
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale patch et voie orale
- **Simulation prévisionnelle** : configurer la **fréquence d'injection** (jours) sur un - **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
traitement → projection des doses à venir sur le graphique (jamais sauvegardées) traitement → projection des doses à venir sur le graphique (jamais sauvegardées) ;
**l'activation ne déplace pas le début du graphique** mais ÉTEND la fenêtre
jusqu'à la prochaine dose (v1.4.4) et la projection se parcourt en tirant
vers la gauche, jusqu'à **1 an**
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection** - **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses), (switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché **intervalle en jours entre dosages** affiché ; **icône de forme de prise**
(oral / injection IM-SC / gel / patch, v1.12.0) et **encart « prise de sang
le même jour »** (🧪↑ avant la dose, 🧪↓ après)
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie - **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
ni dans les rappels, mais **tout son historique reste simulé et calibré** — ni dans les rappels, mais **tout son historique reste simulé et calibré** —
utile pour une transition valerate → enanthate utile pour une transition valerate → enanthate ; **regroupés tout en bas** de la
page Traitements sous un en-tête dédié, cartes atténuées (v1.11.0)
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées - **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de **côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Prochaine prise de sang suggérée (v1.8.0, étendu v1.8.1 aux pages Analyses et Accueil)** : la page Analyses recommande le
**creux estimé juste avant l'injection suivante** (moment le plus comparable),
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
estimations, jamais un avis médical ;
**v1.13.0** : la carte affiche la **valeur E2 attendue au creux** (si l'auto-calibration
est ON) et la compare à ta **cible de creux** personnelle (opt-in — distinct des
seuils d'alerte, jamais de notification)
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
@ -45,8 +95,33 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
**k calibré par période d'ester** (la suppression T diffère valerate vs enanthate), **k calibré par période d'ester** (la suppression T diffère valerate vs enanthate),
contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L
- **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) ;
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T) **les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà
loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant
repart au créneau suivant)
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
canal dédié) — évaluées sur le taux **estimé**, opt-in
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2)
- **Sauvegarde automatique quotidienne (v1.7.0, opt-in)** : en PLUS de
l'export manuel — chaque jour, un backup JSON complet (même format,
importable tel quel) est écrit dans le **dossier que tu choisis** (ex.
dossier Owncloud synchronisé). Aucune permission de stockage (SAF, dossier
choisi une fois, permission persistante révocable), rétention configurable
(1–30 copies, défaut 7 — fichier horodaté par run, jamais d'écrasement ;
exports manuels et fichiers étrangers **jamais touchés**), statut du
dernier run dans Paramètres, premier backup immédiat à l'activation
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
**prochaine dose en jours** sur l'accueil quand elle est à plus de 24 h (v1.4.1)
- **Logs de diagnostic** exportables (Paramètres) — utile pour le support
- **Événements d'agenda** : les rappels de prises peuvent créer un événement
récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T + paramètres, v1.4.2)
- **FR + EN** (langue par app, indépendante du système) - **FR + EN** (langue par app, indépendante du système)
- UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte - UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte
@ -59,7 +134,9 @@ 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 # 36 tests (moteur PK, profils, backup, régression) ./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
``` ```
Installation sur un téléphone : mode développeur + Débogage USB, puis Android Studio Installation sur un téléphone : mode développeur + Débogage USB, puis Android Studio
@ -68,10 +145,11 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
## Git ## Git
Dépôt : **https://gitea.cloudyfy.fr/Siphonight/HormoneTrack** (privé), avec Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
**releases taguées** (`v1.1.0` → `v1.2.5`) et **deux APK par release** `Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.10.0`) et **deux APK par release** (depuis v1.2.5)
(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
@ -79,26 +157,62 @@ git log --oneline # historique par couches (toolchain / moteur / UI / docs)
git push -u origin main --tags git push -u origin main --tags
``` ```
**Portage web** : depuis v1.4.10, la version navigateur vit dans son propre
dépôt `Siphonight/HormoneTrack-web` (mêmes instances) — versions **alignées**
(web v1.4.10 = portage de l'Android v1.4.10), releases sync (checklist
§16 étape 4.bis), changelogs séparés.
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md). taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
**Signature release** : l'APK release est signé avec la clé debug tant qu'aucun
`keystore.properties` n'existe à la racine ; dès sa création via
`scripts/make-release-keystore.sh` (fichiers gitignorés), `assembleRelease`
signe avec ce keystore — migration téléphone documentée dans
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
## Les modèles en bref ## Les modèles en bref
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
(pg/mL par mg) issue des tables horaires d'Estrannaise / Transfem Science (8001 h) ; (pg/mL par mg) ; les contributions se superposent. **Trois modèles superposables** :
les contributions se superposent. Pics de référence :
| Profil | Modèle | Pic (pg/mL/mg) | Tmax | - **Estrannaise** = tables horaires du `.ods` (8001 h), EV/EU/EEn
|----------|------------------|----------------|--------| - **Transfem Science** = méta-analyse à 3 compartiments (V3C, forme close,
| EV | Estrannaise | 61,1 | ~45 h | [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
| EU | Estrannaise | 3,4 | ~55 h (plateau long) | 7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
| EEn | Estrannaise | 31,4 | ~152 h | - **WHSAH** (v1.4.6) = fit « license-free » du [WHSAH Collective via
| EV | Transfem Science | 59,0 | ~51 h | Mona](https://github.com/mona-hrt/mona) — même famille mathématique mais
| EU | Transfem Science | 10,1 | ~198 h | paramètres indépendants avec biodisponibilité explicite F < 1 : montée
| EEn | Transfem Science | 32,0 | ~156 h | plus rapide à J+1 (EEn ~70 pg/mL à J+1 pour 5 mg vs ~22 chez TFS) et
décroissance plus longue (t½ EEn 7,3 j vs 4,5 j). 6 esters (sans PEP)
La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle Pics de référence (pg/mL par mg) :
à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine.
| Profil | Modèle | Pic (pg/mL/mg) | Tmax | t½ term. |
|----------|------------------|----------------|--------|----------|
| EV | Estrannaise | 61,1 | ~45 h | — |
| EU | Estrannaise | 3,4 | ~55 h (plateau long) | — |
| EEn | Estrannaise | 31,4 | ~152 h | — |
| EV | Transfem Science | 59,0 | ~51 h | 3,0 j |
| EU | Transfem Science | 10,1 | ~198 h | — |
| EEn | Transfem Science | 32,0 | ~156 h | 4,5 j |
| EB | Transfem Science | 194,2 | ~16 h | 1,2 j |
| EC (huile) | Transfem Science | 31,1 | ~103 h | 6,7 j |
| EC (susp.) | Transfem Science | 48,2 | ~29 h | 5,1 j |
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | 28,4 j |
| EV | WHSAH | 73,5 | ~41 h | 3,1 j |
| EEn | WHSAH | 37,6 | ~120 h | 7,3 j |
| EB | WHSAH | 260,1 | ~12 h | 1,3 j |
| EC (huile) | WHSAH | 25,0 | ~81 h | 7,9 j |
| EC (susp.) | WHSAH | 53,5 | ~16 h | 7,1 j |
| EU | WHSAH | 4,9 | ~67 h | 31,7 j |
**Calibration (v1.4.8)** : le facteur d'échelle s'applique par ESTER et par
PÉRIODE d'injection (médiane des ratios lab ÷ prédiction, comme la colonne
« Scale factor » de la feuille d'origine) — et **par MODÈLE** : chaque courbe
affichée (Estrannaise / Transfem Science / WHSAH) est calibrée avec la
prédiction de SON modèle → toutes collent à tes labs, quelle que soit leur
forme. L'auto-calibration est optionnelle (désactivée par défaut).
## Vie privée ## Vie privée
@ -109,6 +223,9 @@ La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste
depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été
nettoyé avant le premier push, cf [docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md)) nettoyé avant le premier push, cf [docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
- Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.) - Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.)
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
toi-même choisi** (sélecteur système SAF, permission révocable) — rien
ne part ailleurs, aucun serveur
- `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2 - `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2
- Le dépôt est **privé** : les releases APK se téléchargent en étant connecté ; - Le dépôt est **privé** : les releases APK se téléchargent en étant connecté ;
passer le dépôt en public rend les APK téléchargeables sans compte (sans risque passer le dépôt en public rend les APK téléchargeables sans compte (sans risque
@ -118,16 +235,20 @@ La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste
``` ```
HormoneTrack/ HormoneTrack/
├── README.md ← ce fichier ├── README.md ← ce fichier (README.en.md = version anglaise)
├── docs/ ├── docs/
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre) │ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests) │ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
│ ├── CHANGELOG.md journal détaillé des versions │ ├── CHANGELOG.md journal détaillé des versions
│ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites │ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites
├── scripts/ ├── scripts/
│ └── gitea-release.py publie une release (corps = 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)
│ ├── 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
@ -142,27 +263,34 @@ HormoneTrack/
│ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS) │ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS)
│ ├── java/com/hormonetrack/ │ ├── java/com/hormonetrack/
│ │ ├── data/ (Room : models, DAOs, repository, backup) │ │ ├── data/ (Room : models, DAOs, repository, backup)
│ │ ├── pk/ (moteur pharmacocinétique + profils) │ │ ├── pk/ (moteur PK + profils : Estrannaise/TFS/WHSAH, alertes, export)
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot) │ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
│ │ ├── settings/ (DataStore : TConfig, langue) │ │ ├── settings/ (DataStore : TConfig, langue)
│ │ ├── ui/ (Compose : screens, components, theme) │ │ ├── ui/ (Compose : screens, components, theme)
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
│ │ ├── HormoneTrackApp.kt │ │ ├── HormoneTrackApp.kt
│ │ └── MainActivity.kt │ │ └── MainActivity.kt
│ └── res/ (strings FR/EN, thème, icônes) │ └── res/ (strings FR/EN, thème, icônes)
└── test/java/com/hormonetrack/ ← tests unitaires JVM ├── test/java/com/hormonetrack/ ← tests unitaires JVM
├── pk/ (moteur + profils) │ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
└── data/backup/ (round-trip Gson) │ │ par modèle, rappels, régressions data-driven)
│ ├── data/backup/ (round-trip Gson + paramètres)
│ ├── ui/ (fenêtre graphique, garde de source)
│ ├── util/ (noms de fichiers d'export, durées)
│ └── reminder/ + settings/ (RRULE agenda, changelog)
└── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8)
└── uitest/ (navigation, dialog changelog, Paramètres)
``` ```
## Feuille de route ## Feuille de route
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN - [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
- [ ] Tests UI Compose + compilation release signée - [x] Tests UI Compose (13) + infrastructure de compilation release signée (v1.9.8)
- [ ] Verrou biométrique, widget, export CSV - [ ] Verrou biométrique, widget, export CSV
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)) - [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
## Licence ## Licence
À définir avant le premier push public (suggestion : GPL-3.0, cohérent avec l'écosystème **GPL-3.0** — voir [LICENSE](LICENSE). Cohérent avec l'écosystème Gadgetbridge.
Gadgetbridge). Les modèles PK appartiennent à leurs autrices respectives Les modèles PK appartiennent à leurs autrices respectives
([Estrannaise](https://estrannaise.github.io/), [Transfem Science](https://transfemscience.org)). ([Estrannaise](https://estrannaise.github.io/), [Transfem Science](https://transfemscience.org)).

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
@ -12,8 +28,13 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 9 // ⚠️ MÉTADONNÉES DE VERSION — bump obligatoire à CHAQUE release
versionName = "1.2.6" // (checklist §16 étape 1 de docs/DEVELOPPEMENT.md), committé AVANT le
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 50
versionName = "1.13.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -30,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")
}
} }
} }
@ -47,6 +83,8 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// BuildConfig.VERSION_NAME : écran Paramètres + dialog de changelog
buildConfig = true
} }
packaging { packaging {
@ -54,8 +92,27 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
} }
// Lint (v1.3.4) : les checks Compose 1.12+ (NonObservableLocale,
// LocalContextGetResourceValueCall) sont rétrogradés en WARNING via
// app/lint.xml (meilleure compatibilité que la DSL AGP) — cas THÉORIQUES de staleness de
// configuration présents dans tout le code v1, jamais à l'origine des
// crashs remontés. Les vrais problèmes de format de strings
// (StringFormatMatches — aurait attrapé les bugs #47/#48, épinglés §14)
// restent des ERREURS bloquantes. Re-traiter ces warnings lors d'une
// refonte i18n (cf §20).
} }
// Synchronise docs/CHANGELOG.md → src/main/assets/changelog.md (dialog
// « Nouveautés » affiché après chaque mise à jour). Automatique à chaque
// build ; le fichier généré est gitignoré (source de vérité = docs/).
tasks.register<Copy>("copyChangelog") {
from(rootProject.file("docs/CHANGELOG.md"))
into(file("src/main/assets"))
rename { "changelog.md" }
}
tasks.named("preBuild") { dependsOn("copyChangelog") }
dependencies { dependencies {
// BOM Compose : épingle toutes les versions des artefacts androidx.compose.* // BOM Compose : épingle toutes les versions des artefacts androidx.compose.*
// (material3 >= 1.4 = Material 3 Expressive) // (material3 >= 1.4 = Material 3 Expressive)
@ -88,7 +145,7 @@ dependencies {
// DataStore (préférences) // DataStore (préférences)
implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences:1.2.1")
// WorkManager (déclaré, non utilisé — cf docs §19) // WorkManager (UTILISÉ v1.4.2 : worker périodique des seuils d'alerte, cf §9.bis)
implementation("androidx.work:work-runtime-ktx:2.11.2") implementation("androidx.work:work-runtime-ktx:2.11.2")
// Coroutines // Coroutines
@ -103,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")
} }

20
app/lint.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Lint (/v1.3.4). Cf commentaires dans app/build.gradle.kts et §19 de la doc de dev.
Rétrogradation des deux checks Compose 1.12+ : signalements de staleness
de configuration (lecture de resources via LocalContext dans les callbacks
et coroutines, Locale.getDefault() dans un remember{}) — présents dans tout
le code v1, jamais à l'origine des crashs remontés. À re-traiter lors d'une
refonte i18n (cf docs/DEVELOPPEMENT.md §20).
RESTENT EN ERREUR (bloquants, aucun override nécessaire) :
- StringFormatMatches / StringFormatCount / StringFormatInvalid :
auraient attrapé les bugs #47 (hrt_duration : 3 placeholders vs 2 args
passés) et #48 (pattern horaire formaté sur LocalDate) — crashs réels
v1.3.1→v1.3.3 retrouvé au téléphone, cf §14.
-->
<lint>
<issue id="NonObservableLocale" severity="warning" />
<issue id="LocalContextGetResourceValueCall" severity="warning" />
</lint>

View File

@ -10,7 +10,29 @@
-keep class com.hormonetrack.data.model.** { <fields>; } -keep class com.hormonetrack.data.model.** { <fields>; }
-keep class com.hormonetrack.data.backup.BackupData { <fields>; } -keep class com.hormonetrack.data.backup.BackupData { <fields>; }
-keep class com.hormonetrack.data.backup.UserSettings { <fields>; }
-keep class com.hormonetrack.pk.TConfig { <fields>; } -keep class com.hormonetrack.pk.TConfig { <fields>; }
# Énumérations : converties en String par Room et Gson (par nom) # Énumérations : converties en String par Room et Gson (par nom)
-keepclassmembers enum com.hormonetrack.data.model.** { *; } -keepclassmembers enum com.hormonetrack.data.model.** { *; }
# --- Moteur PK : « Tracé labs » (v1.6.0, bug #64 — trouvé en émulateur) ---
# Symptôme : en RELEASE SEULE, la courbe « Tracé labs » restait vide (ni
# dessinée ni légendée) alors que le même calcul fonctionne en debug et
# donne 276 points sur les mêmes données (régression web-miroir). Cause
# Prouvée au mapping R8 : `LabTrajectoryModel -> R8$$REMOVED$$CLASS$$...` —
# R8 full mode a SUPPRIMÉ la classe en inlinant computeLabAnchoredCurve
# (et son synthétique $default des paramètres par défaut) dans le call
# site du producer Compose ; le résultat n'y était plus correct. FIX :
# garder la classe ENTIÈRE — c'est le cœur scientifique de l'app (fonctions
# pures, zéro réflexion) : coût = quelques Ko de dex non compactés.
# ⚠️ Toute nouvelle fonctionnalité du pk/ qui « ne marche qu'en debug » :
# vérifier le mapping (`app/build/outputs/mapping/release/mapping.txt`)
# et étendre cette garde si besoin.
-keep class com.hormonetrack.pk.LabTrajectoryModel { *; }
# --- Auto-backup (v1.7.0) : worker instancié par WorkManager via réflexion ---
# Les règles consumer de WorkManager gardent normalement les sous-classes de
# ListenableWorker, mais le leçon #64 a montré le coût d'une garde absente :
# on l'écrit explicitement (coût = quelques entrées de classe).
-keep class com.hormonetrack.data.backup.AutoBackupWorker { *; }

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

@ -6,6 +6,15 @@
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- Événements d'agenda récurrents (v1.3.0, calendrier local « HormoneTrack »).
⚠️ Ces permissions DANGEREUSES doivent être déclarées ICI en plus d'être
demandées à l'exécution par l'éditeur de traitement : sans cette
déclaration la demande runtime est refusée d'office et CalendarEvents
lève SecurityException → la fonctionnalité agenda était inopérante
depuis v1.3.0 (cause racine manquante, cf §14 #44 de la doc de dev). -->
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<application <application
android:name=".HormoneTrackApp" android:name=".HormoneTrackApp"
android:allowBackup="false" android:allowBackup="false"
@ -17,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,9 +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.reminder.AlertNotifier
import com.hormonetrack.settings.AppSettings import com.hormonetrack.settings.AppSettings
import com.hormonetrack.data.backup.AutoBackupScheduler
class HormoneTrackApp : Application() { class HormoneTrackApp : Application() {
@ -16,8 +19,23 @@ 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)
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
// évalue le niveau estimé toutes les 15 min, notifie au franchissement
// (anti-spam par état persisté). KEEP : replanifier à chaque
// démarrage ne change rien ; WorkManager survit aux reboots.
AlertNotifier.createChannel(this)
AlertNotifier.schedulePeriodic(this)
// v1.7.0 : auto-backup journalier (WorkManager périodique 24 h, KEEP —
// le worker sort immédiatement si l'option est désactivée ; cf
// data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots :
// BootReceiver n'a rien à faire.
AutoBackupScheduler.schedulePeriodic(this)
} }
} }

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

@ -23,7 +23,7 @@ import com.hormonetrack.data.model.Treatment
*/ */
@Database( @Database(
entities = [Treatment::class, DoseLog::class, LabResult::class], entities = [Treatment::class, DoseLog::class, LabResult::class],
version = 2, version = 3,
exportSchema = false exportSchema = false
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -43,6 +43,16 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
/**
* v2 → v3 (2026-09-05) : ajout de calendarEventId (nullable, INTEGER) —
* id de l'événement d'agenda récurrent (CalendarContract) du traitement.
*/
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE treatments ADD COLUMN calendarEventId INTEGER")
}
}
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
@ -53,7 +63,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"hormonetrack.db" "hormonetrack.db"
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

View File

@ -0,0 +1,49 @@
package com.hormonetrack.data.backup
import com.hormonetrack.util.ExportFileNames
/**
* RÉTENTION des auto-backups journaliers (v1.7.0) — décision PURE, testée en
* JVM ([com.hormonetrack.data.backup.AutoBackupRetentionTest]) : elle ne fait
* AUCUN accès disque/SAF, elle ne fait que DÉCIDER quels noms supprimer.
* L'IO (énumération et suppression SAF) vit dans
* [BackupManager.writeAutoBackup] — même séparation décision/IO que le reste
* du moteur.
*
* PRINCIPE : chaque run écrit un NOUVEAU fichier horodaté
* (`hormonetrack-auto-20260913-1830.json`) et supprime les plus vieux
* au-delà de « copies conservées » (défaut 7, borné 1..30 dans les
* Paramètres). Pourquoi ne pas ÉCRASER un fichier fixe : un backup corrompu
* (écriture interrompue, quota Owncloud plein…) écraserait la dernière
* bonne copie — avec la rétention, les N dernières copies saines restent
* disponibles.
*
* GARDES DE VIE PRIVÉE / SÉCURITÉ DE SUPPRESSION (les deux critiques ici) :
* 1. le dossier cible est choisi par l'utilisatrice (SAF, ex. un dossier
* Owncloud) et peut contenir SES propres fichiers → on ne supprime
* JAMAIS un nom qui n'est pas un auto-backup reconnaissable ;
* 2. même parmi les `hormonetrack-auto-…json`, un nom MALFORMÉ (horodatage
* non parsable) est ignoré : jamais supprimé, jamais trié.
*/
object AutoBackupRetention {
/**
* Décide quels fichiers supprimer pour respecter la rétention.
*
* @param existingNames tous les noms vus dans le dossier cible (le tri
* d'entrée est indifférent — la fonction trie elle-même)
* @param keepCount nombre de copies récentes À CONSERVER (borné : tout
* `keepCount < 1` vaut 1 — sans garde, `0` supprimerait TOUT, y
* compris la toute dernière copie)
* @return les noms à SUPPRIMER (sous-ensemble de [existingNames]) —
* vide quand la rétention est déjà respectée.
*/
fun filesToDelete(existingNames: Iterable<String>, keepCount: Int): List<String> {
val keep = keepCount.coerceAtLeast(1)
// Seuls les auto-backups RECONNAISSABLES participent à la rétention
val autos = existingNames
.mapNotNull { name -> ExportFileNames.parseAutoBackupTimestamp(name)?.let { name to it } }
.sortedByDescending { it.second } // plus récent d'abord
return autos.drop(keep).map { it.first }
}
}

View File

@ -0,0 +1,140 @@
package com.hormonetrack.data.backup
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
/**
* SAUVEGARDE AUTOMATIQUE JOURNALIÈRE (v1.7.0) — en PLUS de l'export manuel
* (qui reste inchangé dans Paramètres). Opt-in : désactivée par défaut.
*
* FONCTIONNEMENT (miroir du worker des alertes, AlertNotifier/AlertWorker) :
* - **dossier choisi UNE FOIS** : ACTION_OPEN_DOCUMENT_TREE dans Paramètres
* → tree URI persisté (takePersistableUriPermission → la permission
* survit au reboot, SANS aucune permission de stockage) — l'utilisatrice
* choisit typiquement un dossier Owncloud synchronisé, le backup atterrit
* donc dans son cloud ;
* - **WorkManager périodique 24 h** : survit aux reboots, gère le Doze
* (pas d'alarme exacte nécessaire — un backup journalier n'a pas besoin
* d'une heure à la minute près) ; planifié au démarrage de l'app
* (HormoneTrackApp.onCreate, KEEP) — le worker sort immédiatement si
* l'option est désactivée (même pattern qu'AlertWorker sans seuil) ;
* - **run immédiat à l'activation** (`runNow`) : l'utilisatrice voit le
* fichier apparaître tout de suite — et c'est ce qui rend la validation
* émulateur §16.ter triviale ;
* - **contenu = le backup COMPLET** (`BackupManager.exportJson`, format v2 :
* traitements + doses + labs + réglages T + paramètres) — un auto-backup
* est importable EXACTEMENT comme un export manuel ;
* - **rétention** : les N copies les plus récentes sont conservées (défaut
* 7, borné 1..30) — cf [AutoBackupRetention], les exports manuels ne
* sont JAMAIS supprimés ;
* - **statut visible** : « dernier : réussi/échec + date » dans Paramètres
* (DataStore, alimenté par le worker) + journalisation AppLog complète
* (debug à distance §9.bis — le mécanisme qui a résolu #50).
*
* ⚠️ R8 (leçon #64) : le worker est instancié par WorkManager via réflexion
* → classe couverte par un `-keep` explicite dans proguard-rules.pro.
*/
object AutoBackupScheduler {
/** Nom du travail PÉRIODIQUE (unique) — 24 h. */
private const val PERIODIC_WORK = "auto_backup_daily"
/** Nom du travail one-time (run immédiat à l'activation). */
private const val ONESHOT_WORK = "auto_backup_now"
/** Période : 1 jour (WorkManager lègue au scheduler OS, Doze inclus). */
private const val PERIOD_DAYS = 1L
/**
* Planifie le travail PÉRIODIQUE — à appeler UNE FOIS dans
* Application.onCreate. KEEP : si déjà planifié, on ne repousse pas le
* prochain run (comme AlertNotifier.schedulePeriodic). Le worker lit ses
* options dans DataStore à l'exécution : changer le dossier ou le nombre
* de copies ne requiert AUCUN re-enqueue.
*/
fun schedulePeriodic(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<AutoBackupWorker>(PERIOD_DAYS, TimeUnit.DAYS).build()
)
}
/**
* Run IMMÉDIAT (à l'activation dans Paramètres) : feedback instantané —
* le fichier apparaît dans le dossier choisi sans attendre 24 h.
* REPLACE : plusieurs taps rapides = un seul run en attente.
*/
fun runNow(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
ONESHOT_WORK,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<AutoBackupWorker>().build()
)
}
}
/**
* Worker WorkManager de l'auto-backup : no-op si l'option est désactivée ou
* sans dossier (planifié inconditionnellement au démarrage, comme AlertWorker
* sans seuils) ; sinon export JSON complet + écriture SAF + rétention, et
* statut « dernier run » persisté pour l'affichage Paramètres.
*/
class AutoBackupWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val container = (applicationContext as com.hormonetrack.HormoneTrackApp).container
val settings = container.settings
val enabled = settings.autoBackupEnabled.first()
val treeUri = settings.autoBackupTreeUri.first()
if (!enabled || treeUri == null) {
// Option off (ou dossier jamais choisi) : rien à faire, ce n'est
// pas un échec (Result.success = le périodique continue de tourner)
return Result.success()
}
val now = System.currentTimeMillis()
val ok = try {
// MÊME contenu que l'export manuel (format v2 avec paramètres) :
// un auto-backup est importable tel quel (rétrocompat assurée)
// UNE seule collecte des seuils (même motif que l'export manuel)
val thresholds = settings.alertThresholds.first()
val userSettings = UserSettings(
language = settings.language.first(),
autoCalibrate = settings.autoCalibrate.first(),
alertE2High = thresholds.e2High,
alertE2Low = thresholds.e2Low,
alertTHigh = thresholds.tHigh,
alertTLow = thresholds.tLow
)
val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings)
val keep = settings.autoBackupKeep.first()
BackupManager.writeAutoBackup(applicationContext, treeUri, json, keep)
} catch (e: Exception) {
AppLog.log("autobackup", "ERREUR worker : ${e.message}")
// Statut quand même enregistré (l'UI montrera « échec »)…
settings.setAutoBackupLastRun(false, now)
// …et WorkManager re-tentera avec son backoff (IO transitoire :
// quota cloud, réseau Owncloud absent — un retry a du sens)
return Result.retry()
}
settings.setAutoBackupLastRun(ok, now)
AppLog.log("autobackup", "run terminé : ok=$ok")
// Échec d'écriture (dossier absent, permission révoquée…) : retry
// n'a pas de sens tant que l'utilisatrice ne re-choisit pas le
// dossier → success (le prochain cycle journalier réessaiera).
return Result.success()
}
}

View File

@ -1,7 +1,9 @@
package com.hormonetrack.data.backup package com.hormonetrack.data.backup
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
@ -9,24 +11,67 @@ import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.repository.HormoneRepository import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
import com.hormonetrack.util.AppLog
import com.hormonetrack.util.ExportFileNames
/**
* Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
* transporte désormais les RÉGLAGES en plus des données de santé :
* - `language` : "system" / "fr" / "en" (null = ne pas changer à l'import) ;
* - `autoCalibrate` : option de calibration automatique (null = défaut) ;
* - `alertE2High/Low` (pg/mL) et `alertTHigh/Low` (ng/mL) : seuils d'alerte
* configurés (null = pas de seuil).
*
* ⚠️ Champs PLATS volontairement (pas de nested data class) : Gson lit ces
* champs par RÉFLEXION → chaque classe embarquée doit être couverte par un
* `-keep` explicite dans proguard-rules.pro (sinon l'export/import produit
* des objets vides EN RELEASE SEULE — leçon §16.bis smoke-test R8).
* `changelog_seen_version` est volontairement EXCLU : pas une donnée de
* configuration utile à restaurer (le dialog « Nouveautés » se montre une
* fois de plus après une restauration, ce n'est pas gênant).
*/
data class UserSettings(
val language: String? = null,
val autoCalibrate: Boolean? = null,
val alertE2High: Double? = null,
val alertE2Low: Double? = null,
val alertTHigh: Double? = null,
val alertTLow: Double? = null,
// v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la
// rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson
// les laisse à null → l'import ne touche pas à la cible locale.
val troughTargetLow: Double? = null,
val troughTargetHigh: Double? = null
)
data class BackupData( data class BackupData(
val version: Int = 1, // v2 (v1.4.2) : champ optionnel `settings` ajouté. RÉTROCOMPATIBILITÉ :
// les backups v1 (sans settings) restent parsables (Gson → null) et
// importables — l'import ne vérifie pas strictement la version.
val version: Int = 2,
val exportedAt: Long = System.currentTimeMillis(), val exportedAt: Long = System.currentTimeMillis(),
val treatments: List<Treatment> = emptyList(), val treatments: List<Treatment> = emptyList(),
val doseLogs: List<DoseLog> = emptyList(), val doseLogs: List<DoseLog> = emptyList(),
val labResults: List<LabResult> = emptyList(), val labResults: List<LabResult> = emptyList(),
val tConfig: TConfig = TConfig() val tConfig: TConfig = TConfig(),
/** Paramètres utilisateur (null = backup v1 sans réglages). */
val settings: UserSettings? = null
) )
object BackupManager { object BackupManager {
suspend fun exportJson(repo: HormoneRepository, tConfig: TConfig): String { suspend fun exportJson(
repo: HormoneRepository,
tConfig: TConfig,
/** Réglages utilisateur à embarquer (v1.4.2) ; null = backup minimal. */
settings: UserSettings? = null
): String {
val data = BackupData( val data = BackupData(
treatments = repo.allTreatmentsOnce(), treatments = repo.allTreatmentsOnce(),
doseLogs = repo.allDoseLogsOnce(), doseLogs = repo.allDoseLogsOnce(),
labResults = repo.allLabResultsOnce(), labResults = repo.allLabResultsOnce(),
tConfig = tConfig tConfig = tConfig,
settings = settings
) )
return Gson().toJson(data) return Gson().toJson(data)
} }
@ -36,7 +81,9 @@ object BackupManager {
val doseLogs: Int, val doseLogs: Int,
val labResults: Int, val labResults: Int,
/** Réglages T du backup, à restaurer dans DataStore par l'appelant. */ /** Réglages T du backup, à restaurer dans DataStore par l'appelant. */
val tConfig: TConfig val tConfig: TConfig,
/** Paramètres utilisateur (v1.4.2) ; null = backup v1 sans réglages. */
val settings: UserSettings?
) )
/** /**
@ -48,8 +95,10 @@ object BackupManager {
* Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite * Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite
* (FK CASCADE des doses vers les traitements). * (FK CASCADE des doses vers les traitements).
* *
* @return le nombre d'entrées restaurées par table + le tConfig du backup * @return le nombre d'entrées restaurées par table + le tConfig et les
* (à restaurer dans DataStore par l'appelant). * paramètres utilisateur du backup (à restaurer dans DataStore par
* l'appelant — cf SettingsScreen : langue appliquée sur le thread Main
* car elle recrée l'activité).
*/ */
suspend fun importJson( suspend fun importJson(
repo: HormoneRepository, repo: HormoneRepository,
@ -71,7 +120,8 @@ object BackupManager {
data.treatments.size, data.treatments.size,
data.doseLogs.size, data.doseLogs.size,
data.labResults.size, data.labResults.size,
data.tConfig data.tConfig,
data.settings
) )
} }
@ -92,4 +142,98 @@ object BackupManager {
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
/**
* Écrit UN auto-backup dans le DOSSIER SAF persisté ([treeUriString] = tree
* URI choisi par l'utilisatrice via ACTION_OPEN_DOCUMENT_TREE, ex. un
* dossier Owncloud synchronisé), puis applique la rétention
* ([AutoBackupRetention] : garde les N plus récents, ne supprime QUE les
* fichiers `hormonetrack-auto-*.json` — jamais les exports manuels ni les
* fichiers personnels du dossier).
*
* Étapes :
* 1. tree URI → document URI du dossier (DocumentsContract, SANS la lib
* androidx.documentfile : pas de dépendance ajoutée) ;
* 2. `createDocument` d'un nouveau fichier horodaté
* ([ExportFileNames.autoBackupFileName]) — jamais d'écrasement :
* la rétention garde les N dernières copies saines ;
* 3. écriture du JSON via [writeBackup] TEL QUEL (leçon §14 #45 : même
* IO éprouvée que l'export manuel — openOutputStream "wt") ;
* 4. purge des vieux auto-backups (échec NON bloquant : un refus de
* suppression ne doit pas faire échouer le backup écrit).
*
* @return true si le fichier a été écrit (la purge peut avoir échoué sans
* invalider le succès).
*/
suspend fun writeAutoBackup(
context: Context,
treeUriString: String,
json: String,
keepCount: Int
): Boolean {
return try {
val resolver = context.contentResolver
val treeUri = Uri.parse(treeUriString)
val dirUri = DocumentsContract.buildDocumentUriUsingTree(
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
)
val fileUri = DocumentsContract.createDocument(
resolver, dirUri, "application/json", ExportFileNames.autoBackupFileName()
) ?: return false
val written = writeBackup(context, fileUri, json)
if (written) {
// Purge best-effort : le backup est écrit, l'échec de purge ne
// l'invalide pas (l'utilisatrice le verra dans les AppLog)
try {
purgeOldAutoBackups(resolver, treeUri, keepCount)
} catch (e: Exception) {
AppLog.log("autobackup", "purge impossible : ${e.message}")
}
}
written
} catch (e: Exception) {
AppLog.log("autobackup", "ERREUR écriture : ${e.message}")
false
}
}
/**
* Énumère le dossier cible (SAF children) et supprime les auto-backups au-delà
* de [keepCount] — décision dans [AutoBackupRetention] (pur, testé) : ne
* touche JAMAIS aux exports manuels ni aux fichiers étrangers du dossier.
*/
private fun purgeOldAutoBackups(
resolver: ContentResolver,
treeUri: Uri,
keepCount: Int
) {
val dirId = DocumentsContract.getTreeDocumentId(treeUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirId)
// Une seule requête : (nom affiché, id document) — le delete SAF exige
// l'URI de DOCUMENT, pas le nom.
val byName = LinkedHashMap<String, String>()
resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_DOCUMENT_ID
),
null, null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val name = cursor.getString(0) ?: continue
byName[name] = cursor.getString(1)
}
}
AutoBackupRetention.filesToDelete(byName.keys, keepCount).forEach { name ->
byName[name]?.let { docId ->
DocumentsContract.deleteDocument(
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
)
AppLog.log("autobackup", "rétention : $name supprimé")
}
}
}
} }

View File

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

View File

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

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,
@ -53,7 +87,7 @@ object PKPresets {
route = AdministrationRoute.INJECTION_IM, route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EV, esterType = Esters.EV,
pkModel = PKModels.TRANSFEM_SCIENCE, pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 51f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f, absorptionHours = 50f, eliminationHalfLifeHours = 72f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 4.0 defaultDoseUnit = "mg", defaultDoseAmount = 4.0
), ),
PKPreset( PKPreset(
@ -62,7 +96,7 @@ object PKPresets {
route = AdministrationRoute.INJECTION_IM, route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EU, esterType = Esters.EU,
pkModel = PKModels.TRANSFEM_SCIENCE, pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 198f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f, absorptionHours = 204f, eliminationHalfLifeHours = 528f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0 defaultDoseUnit = "mg", defaultDoseAmount = 10.0
), ),
PKPreset( PKPreset(
@ -71,9 +105,106 @@ object PKPresets {
route = AdministrationRoute.INJECTION_SUBCUT, route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EEN, esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE, pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 156f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f, absorptionHours = 156f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
// --- Nouveaux esters de la méta-analyse TFS (v1.4.0, modèle V3C) :
// EB / EC (huile) / EC suspension / PEP — PAS de tables Estrannaise
// pour ces clés : les presets sont créés en modèle TFS uniquement.
PKPreset(
nameRes = R.string.preset_eb_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EB,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 16f, eliminationHalfLifeHours = 29f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
),
PKPreset(
nameRes = R.string.preset_ec_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EC,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 103f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_ecs_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.ECS,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 30f, eliminationHalfLifeHours = 122f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_pep_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.PEP,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 32.5
),
// --- Nouveaux esters : modèle WHSAH (v1.4.6, fit license-free de
// Mona — cf pk/WhsahModels.kt) : 6 esters, PEP non couvert. Les
// champs Bateman reflètent les t½ WHSAH (pas utilisés au calcul,
// affichés dans l'éditeur).
PKPreset(
nameRes = R.string.preset_ev_whs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EV,
pkModel = PKModels.WHSAH,
absorptionHours = 41f, eliminationHalfLifeHours = 75f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
),
PKPreset(
nameRes = R.string.preset_eu_whs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EU,
pkModel = PKModels.WHSAH,
absorptionHours = 80f, eliminationHalfLifeHours = 722f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0 defaultDoseUnit = "mg", defaultDoseAmount = 10.0
), ),
PKPreset(
nameRes = R.string.preset_een_whs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EEN,
pkModel = PKModels.WHSAH,
absorptionHours = 120f, eliminationHalfLifeHours = 176f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_eb_whs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EB,
pkModel = PKModels.WHSAH,
absorptionHours = 13f, eliminationHalfLifeHours = 25f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
),
PKPreset(
nameRes = R.string.preset_ec_whs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EC,
pkModel = PKModels.WHSAH,
absorptionHours = 91f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_ecs_whs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.ECS,
pkModel = PKModels.WHSAH,
absorptionHours = 60f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
// --- Transdermal / oral (Bateman fallback) --- // --- Transdermal / oral (Bateman fallback) ---
PKPreset( PKPreset(
nameRes = R.string.preset_e2_gel, nameRes = R.string.preset_e2_gel,

View File

@ -8,11 +8,63 @@ object Esters {
const val EV = "EV" const val EV = "EV"
const val EU = "EU" const val EU = "EU"
const val EEN = "EEN" const val EEN = "EEN"
// Esters supplémentaires couverts par la méta-analyse Transfem Science
// (v1.4.0, modèle V3C — cf pk/TransfemScienceModels.kt). Ces clés n'ont
// PAS de tables Estrannaise (ODS) : un traitement ESE + EB/EC/ECS/PEP
// produirait 0 pg/mL (les presets les créent en modèle TFS uniquement).
const val EB = "EB" // benzoate
const val EC = "EC" // cypionate, solution huileuse
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
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). */
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP, EUCS)
/**
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
* (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle —
* TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6
* (PEP non couvert par ce fit) ; ESE (Estrannaise ANALYTIQUE v1.9.0)
* → les 6 injectables du fit estrannaise.js (dont EUCS, exclusif) ;
* les tables ODS historiques n'en couvraient que 3 (EV/EU/EEn).
*/
fun choicesForModel(pkModel: String): List<String> = when (pkModel) {
// ⚠️ TFS = liste EXPLICITE (fix v1.8.2-étendu) : INJECTABLE a gagné
// EUCS (couvert par l'Estrannaise analytique) mais le V3C TFS n'a PAS
// de paramètres pour EUCS → le dispatcher donnerait 0. Chaque liste
// reflète la couverture RÉELLE de son modèle.
PKModels.TRANSFEM_SCIENCE -> listOf(EV, EU, EEN, EB, EC, ECS, PEP)
PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS)
PKModels.ESTRANNAISE -> listOf(EV, EU, EEN, EC, EB, EUCS)
else -> listOf(EV, EU, EEN)
}
} }
object PKModels { object PKModels {
const val ESTRANNAISE = "ESE" const val ESTRANNAISE = "ESE"
const val TRANSFEM_SCIENCE = "TFS" const val TRANSFEM_SCIENCE = "TFS"
// v1.4.6 : 3ᵉ modèle PK — le fit « license-free » du WHSAH Collective
// (source : app Mona, cf pk/WhsahModels.kt). Champ texte → aucune
// migration Room ni changement de backup nécessaires.
const val WHSAH = "WHS"
}
/**
* Ressource de LABEL d'un modèle PK (v1.4.6/1.4.7, fix #59) — UNIQUE source
* de vérité pour les affichages : chips de `TreatmentsScreen`, dropdown de
* `TreatmentEditorScreen`, chip du graphique. ⚠️ L'ancien ternaire à 2
* branches affichait « Estrannaise » pour WHSAH (WHSAH tombait dans le
* `else`), remonté par l'utilisatrice — TOUTE nouvelle utilisation doit
* passer par ce helper, pas par un ternaire (bug #59).
*/
fun modelLabelRes(pkModel: String): Int = when (pkModel) {
PKModels.TRANSFEM_SCIENCE -> com.hormonetrack.R.string.model_tfs
PKModels.WHSAH -> com.hormonetrack.R.string.model_whs
else -> com.hormonetrack.R.string.model_ese
} }
enum class TreatmentType { enum class TreatmentType {
@ -42,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,
@ -64,10 +117,18 @@ data class Treatment(
val reminderMinute: Int? = null, val reminderMinute: Int? = null,
val reminderEnabled: Boolean = false, val reminderEnabled: Boolean = false,
// Événement d'agenda récurrent (CalendarContract) : id de l'event inséré
// dans le calendrier local « HormoneTrack » ; null = pas d'événement.
// Répétition dérivée de la Posologie (forecastIntervalDays).
val calendarEventId: Long? = null,
val createdAt: Long = System.currentTimeMillis() val createdAt: Long = System.currentTimeMillis()
) { ) {
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,127 @@
package com.hormonetrack.pk
/**
* Seuils d'alerte configurables (v1.4.2) : limites HAUTE et BASSE définies
* par l'utilisatrice pour l'estradiol (pg/mL) et la testostérone (ng/mL),
* évaluées contre le TAUX ESTIMÉ ACTUEL (dernier point de courbe, i.e. le
* niveau « ≈ X pg/mL » affiché sur l'accueil — pas une mesure de labo).
*
* Philosophie :
* - **opt-in** : aucun seuil par défaut (l'app n'invente pas de normes
* médicales — tout champ laissé vide désactive l'alerte correspondante) ;
* - l'objet est PUR (aucune dépendance Android) → testable en JVM
* (`AlertsTest`) et réutilisable plus loin (widget, notification…).
* - Les courbes restent des ESTIMATIONS pharmacocinétiques : l'avertissement
* est indicatif, libellé en ce sens dans l'UI (cf strings `alert_*`).
*
* Validation de cohérence : si haut ET bas sont définis, il faut haut > bas
* (sinon l'évaluation serait ambiguë) — vérifié à la saisie dans
* SettingsScreen avec feedback, et défensivement ici (`isCoherent`).
*/
object Alerts {
/**
* Seuils de l'utilisatrice (DataStore). null = pas de limite pour cette
* valeur → jamais d'alerte dessus.
*/
data class Thresholds(
/** Limite HAUTE E2 en pg/mL (null = pas d'alerte haute). */
val e2High: Double? = null,
/** Limite BASSE E2 en pg/mL (null = pas d'alerte basse). */
val e2Low: Double? = null,
/** Limite HAUTE T en ng/mL (null = pas d'alerte haute). */
val tHigh: Double? = null,
/** Limite BASSE T en ng/mL (null = pas d'alerte basse). */
val tLow: Double? = null
) {
/** Cohérence : une limite haute doit être strictement au-dessus de la basse. */
fun isCoherent(): Boolean =
(e2High == null || e2Low == null || e2High > e2Low) &&
(tHigh == null || tLow == null || tHigh > tLow)
}
/** Type de dépassement. */
enum class Level { HIGH, LOW }
/** Une alerte déclenchée : valeur estimée vs la limite franchie. */
data class Alert(
val marker: String, // "E2" ou "T"
val level: Level,
val value: Double, // taux estimé actuel (même unité que la limite)
val limit: Double, // limite franchie
val unit: String // "pg/mL" (E2) ou "ng/mL" (T)
)
/**
* Évalue UNE valeur contre ses limites.
*
* - `value > high` → HIGH ; `value < low` → LOW (strict : la valeur
* exactement à la limite ne déclenche rien — éviter les alertes
* « clignotantes » sur la précision d'affichage) ;
* - limite null → jamais d'alerte sur ce côté ;
* - un seul verdict par appel : HIGH prime LOW si la configuration était
* incohérente (haut < bas) — défensif, la saisie interdit ce cas.
*/
fun evaluate(value: Double, low: Double?, high: Double?): Pair<Level, Double>? = when {
high != null && value > high -> Level.HIGH to high
low != null && value < low -> Level.LOW to low
else -> null
}
/**
* Évalue le niveau actuel (E2 pg/mL + T ng/mL) contre tous les seuils.
* @return la liste des alertes déclenchées (vide = tout va bien / rien
* de configuré). Ordre stable : E2 d'abord, puis T.
*/
fun evaluateAll(currentE2: Double, currentT: Double, t: Thresholds): List<Alert> {
val out = mutableListOf<Alert>()
evaluate(currentE2, t.e2Low, t.e2High)?.let { (level, limit) ->
out += Alert("E2", level, currentE2, limit, "pg/mL")
}
evaluate(currentT, t.tLow, t.tHigh)?.let { (level, limit) ->
out += Alert("T", level, currentT, limit, "ng/mL")
}
return out
}
// ── État de notification (v1.4.2) — anti-spam des vérifications périodiques ──
/**
* Sérialise l'état des alertes déjà NOTIFIÉES pour la persistance
* (DataStore) : `"E2:HIGH;T:LOW"`. Chaîne vide = rien de notifié.
* Pur → testable en JVM (l'encodage est stocké par l'OS entre les
* vérifications du worker, cf reminder/AlertNotifier.kt).
*/
fun encodeState(alerts: List<Alert>): String =
alerts.joinToString(";") { "${it.marker}:${it.level}" }
/** Décodage de l'état persisté (format [encodeState]) → map marqueur → niveau. */
fun parseState(encoded: String?): Map<String, Level> =
encoded.orEmpty().split(";")
.filter { it.contains(":") }
.mapNotNull { part ->
val parts = part.split(":", limit = 2)
val level = Level.entries.firstOrNull { it.name == parts[1] }
if (parts.size == 2 && level != null) parts[0] to level else null
}
.toMap()
/**
* Décide si une NOTIFICATION doit être envoyée pour cette vérification
* (anti-spam du worker périodique, appelé toutes les 15 min) :
*
* - current vide → **false** (pas de notif de « retour à la normale » ;
* l'appelant efface l'état persisté pour permettre la re-notification
* au PROCHAIN franchissement) ;
* - current identique au dernier état notifié → **false** (l'écart
* continue, pas de re-notif toutes les 15 min) ;
* - nouveau franchissement OU changement de niveau (H↔L) → **true**.
*
* @param current état évalué à CETTE vérification (map marqueur → niveau).
* @param lastNotified état persisté de la dernière notification (null = jamais).
*/
fun shouldNotify(current: Map<String, Level>, lastNotified: Map<String, Level>?): Boolean {
if (current.isEmpty()) return false
return current != lastNotified
}
}

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

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

View File

@ -0,0 +1,294 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import kotlin.math.exp
import kotlin.math.ln
/**
* « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les résultats de sang.
*
* DEMANDE (décision v1.5.0, plan validé avec l'utilisatrice après balancement
* variantes A/B — cf doc §7.10) : une courbe « biologiquement plausible MAIS
* passant par tous les points de lab ». Formule :
*
* courbe(t) = M(t) × ρ(t)
*
* où :
* - **M(t)** = niveau du MODÈLE PK brut (superposition des contributions,
* scaleFactor forcé à 1, modèle stocké ou `modelOverride`) — il porte TOUTE
* la physiologie entre les labs (Tmax, queues, accumulation) ;
* - **ρ(t) = lab_k / M(t_k)** à chaque lab — l'amplitude du modèle est
* recalée sur la mesure réelle ; ENTRE deux labs consécutifs, ρ
* interpole LOG-LINÉAIREMENT (morphing d'amplitude multiplicativement
* continu : un passage d'un facteur entre 2 labs ne fait pas de saut
* vertical) ;
* - courbe(t_k) = M(t_k) × (lab_k / M(t_k)) = **lab_k exactement** :
* le passage par chaque point est GARANTI par construction.
*
* GARDE ANTI-#61 (labIsSignificant) : les labs traités par timestamp
* CROISSANT qui ne « gouvernent » plus la prédiction (< 15 % du max déjà
* observé) ne sont PAS des ancrages — la dose de test de janvier + des
* labs tardifs (#61) ne créent pas de zigzag. À la différence des
* pipelines de calibration (#60), un lab rejeté n'est PAS utilisé du tout.
*
* PORTÉE ET LIMITES (essentielles — cf doc §7.10 / décision plan v1.5.0) :
* - **E2 uniquement** (les labs T : 1) sont trop rares, 2) leurs unités
* variées ont causé le bug #26 — hors scope) ;
* - **fenêtre = [1er lab significatif ; dernier lab]** par défaut — rien
* AVANT (M=0 sans dose) ni APRÈS (aucune extrapolation) ;
* - **PROLONGATION (v1.6.0, opt-in `extendBeyondLastLab = true`)** : la
* courbe peut se PROLONGER au-delà du dernier lab — cf détail ci-dessous ;
* - < 2 labs significatifs → courbe vide (un lab seul ne définit pas
* d'intervalle) — et donc jamais de prolongation non plus ;
* - indépendante de la calibration : cette courbe EST SA PROPRE
* calibration continue (ratio brut, scaleFactor des traitements ignoré
* ET scalePerEster ignoré — l'inverse serait une double correction,
* épinglé par test) ;
* - **display-end only** : utilisée UNIQUEMENT par le graphique (chip,
* off par défaut). Jamais branchée sur `levelAt` — l'accueil, les
* seuils d'alerte et les rappels continuent d'utiliser le modèle
* calibré (invariant v1.4.2 §9.bis). La partie PROLONGÉE ne fait pas
* exception : elle est purement visuelle (série "LABX" du chart).
* - courbes T non modélisées ici : la série n'a pas de tStyle (le chart
* ne dessine pas de T pour la série — cf CurveChart).
*
* PUR (aucune dépendance Android) → testable en JVM
* (LabTrajectoryModelTest — l'ancrage exact, la garde #61 exactement,
* les bornes de fenêtre, la prolongation).
*
* ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0) ──────────────────────────
*
* DEMANDE (v1.6.0) : « permettre au tracé labs de s'étendre au-delà du
* dernier lab, en simulant à partir des labs précédents, du dosage et du
* type d'ester injecté, et de l'évolution classique de l'ester ».
*
* Le constat v1.5.0 : la fenêtre s'arrêtait AU dernier lab — entre ce lab
* et « maintenant » (voire au-delà, avec la prévision), la courbe n'existait
* pas, alors que c'est précisément la période la plus récente qu'on veut
* comparer aux courbes des modèles.
*
* PRINCIPE : après le dernier ancrage, ρ cesse d'interpoler et reste CONSTANT :
*
* pour t > t_last_lab : courbe(t) = M(t) × ρ_last
*
* Justification, composant par composant (la demande, point par point) :
* - **« l'évolution classique de l'ester »** = M(t) : la forme du modèle PK
* (montée, Tmax, queues, accumulation) porte toute la dynamique ;
* - **« le dosage et le type d'ester injecté »** = M(t) inclut AUTOMATIQUEMENT
* les doses loguées après le dernier lab : une nouvelle injection (même à
* un autre ester) fait repartir la courbe en pic, à l'amplitude calibrée ;
* - **« les données de labs précédentes »** = ρ_last : le rapport
* corps-réel ÷ modèle observé au dernier lab est supposé rester valable.
*
* POURQUOI ρ CONSTANT et pas une extrapolation de la PENTE de ρ : la pente
* entre deux labs ne mesure pas une tendance du corps, elle morphing deux
* corrections d'amplitude — l'extrapoler divergerait sans aucune base
* physiologique (un ratio de calibration qui monte de 5 %/jour finirait par
* des facteurs ×10 inventés). La calibration observée au dernier lab est le
* meilleur estimateur disponible pour la suite ; c'est exactement ce que
* fait la calibration classique (le facteur stocké s'applique aux doses
* futures — §7.6).
*
* BORNE DE PROLONGATION (horizon) : la courbe prolongée s'arrête quand le
* MODÈLE lui-même s'éteint — `extensionHorizonEndMs` = dernière dose E2 +
* [PharmacokineticEngine.cutoffHours] de son traitement (10 t½ terminales pour V3C/WHS, fin de
* table pour ODS, 30 t½ Bateman). Au-delà, M(t) = 0 : dessiner plus serait
* une ligne à zéro purement inventée. La prolongation est en plus clippée
* par la fenêtre DEMANDÉE (celle du graphique).
*
* LIMITES ASSUMÉES (documentées, pas de bug — cf §7.10.bis) :
* - changement d'ESTER après le dernier lab : ρ_last a été mesuré sous
* l'ancien ester ; l'amplitude du nouveau reste calibrée par ce ρ —
* même limite que la calibration classique (fallback du facteur stocké,
* pas de lab de la nouvelle période) ;
* - les doses PRÉVISIONNELLES ne participent PAS : la prolongation ne
* consomme que les doses RÉELLES (le futur projeté reste la série
* « Prévision », qui superpose déjà les modèles calibrés).
*/
object LabTrajectoryModel {
/**
* Résultat : les points (E2 ancrée, t = 0 — pas de courbe T pour cette
* série) + le nombre de labs réellement utilisés comme ancrages
* (diagnostic dans la doc ; 0 → courbe vide).
*
* [lastAnchorMs] (v1.6.0) : timestamp du DERNIER lab significatif (le
* dernier point « réel » de la courbe). `null` si < 2 ancres (courbe
* vide). L'écran Graphiques l'utilise pour SPLITTER la série en deux
* traits distincts :
* - "LAB" = points ≤ lastAnchorMs (ancré — passe par les labs) ;
* - "LABX" = points > lastAnchorMs (prolongé — ρ constant, estimation).
* La distinction visuelle préserve l'honnêteté de l'affichage : la partie
* inventée ne peut pas se confondre avec la partie mesurée.
*/
data class AnchoredCurve(
val points: List<LevelPoint>,
val anchoredLabs: Int,
val lastAnchorMs: Long? = null
)
// Les ancrages sont des couples (timestamp du lab, ratio lab ÷ prédiction)
// — traités en timeline triée croissante par ratioAt().
/**
* Construit la courbe ancrée sur les labs.
*
* @param treatments TOUS les traitements (actifs ET inactifs — §6.bis :
* l'inactivation ne retire pas l'historique de la
* prédiction M(t))
* @param doseLogs toutes les doses (RÉELLES uniquement — les doses
* prévisionnelles ne participent jamais au tracé labs ;
* les doses loguées APRÈS le dernier lab sont au
* contraire INCLUSES : elles portent la forme de la
* prolongation, cf KDoc de l'objet)
* @param e2Labs labs E2 uniquement (les marqueurs autres sont
* ignorés par `marker == "E2"`) — unité pg/mL
* @param startMs/endMs fenêtre demandée (celle du graphique) — la courbe
* est CLIPPÉE à l'intersection avec [lab 1er ; lab
* dernier], ou avec [lab 1er ; horizon de prolongation]
* quand [extendBeyondLastLab] est actif
* @param stepMs pas d'échantillonnage (stepForRange, v1.2.9)
* @param modelOverride modèle pour la prédiction M(t) (défaut = modèle
* stocké, même sémantique que l'auto-calibration
* Home v1.4.8)
* @param extendBeyondLastLab PROLONGATION (v1.6.0, opt-in) : au-delà du
* dernier lab significatif, ρ reste CONSTANT
* (= ρ du dernier lab) et la courbe continue
* `M(t) × ρ_last` jusqu'à
* [extensionHorizonEndMs] ∩ fenêtre demandée.
* false (défaut) = comportement v1.5.0 exact :
* la fenêtre s'arrête au dernier lab.
*/
fun computeLabAnchoredCurve(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
e2Labs: List<LabResult>,
startMs: Long,
endMs: Long,
stepMs: Long = 3_600_000L,
modelOverride: String? = null,
extendBeyondLastLab: Boolean = false
): AnchoredCurve {
if (startMs >= endMs) return AnchoredCurve(emptyList(), 0)
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
// facteur — combiner avec le facteur stocké = double correction).
// Contexte moteur pré-calculé UNE fois (v1.9.5, audit) : la boucle
// de grille appelle e2At par point (720 pts) — sans contexte
// partagé, chaque appel reconstruisait les maps internes.
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
val e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs)
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
val anchors = mutableListOf<Pair<Long, Double>>()
var maxPredictionSoFar = 0.0
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride)
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
anchors.add(lab.timestamp to (lab.value / predicted))
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
}
if (anchors.size < 2) return AnchoredCurve(emptyList(), anchors.size)
val firstLabMs = anchors.first().first
val lastLabMs = anchors.last().first
// ── 2) Fenêtre effective ─────────────────────────────────────────────
// v1.5.0 : [1er lab ; dernier lab] ∩ fenêtre demandée.
// v1.6.0 (extendBeyondLastLab) : la borne droite devient le min de la
// fenêtre demandée et de l'HORIZON DE PROLONGATION (dernière dose E2 +
// cutoff de son traitement). Au-delà du dernier lab, ratioAt() retourne
// déjà ρ_last (garde défensive aux bornes) : la boucle de calcul est
// INCHANGÉE — élargir la fenêtre EST la prolongation, et la continuité
// au point de suture est garantie par construction (ρ(t_last) = ρ_last
// exactement, dérivable à gauche comme à droite puisque ρ est constant
// après).
val t0 = maxOf(startMs, firstLabMs)
val t1 = if (extendBeyondLastLab) {
minOf(endMs, extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) ?: lastLabMs)
} else {
minOf(endMs, lastLabMs)
}
if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size)
// ── 3) Courbe = M(t) × ρ(t) sur la grille ─────────────────────────────
val points = mutableListOf<LevelPoint>()
var t = t0
while (t <= t1) {
val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
val rho = ratioAt(anchors, t)
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
t += stepMs
}
return AnchoredCurve(points, anchors.size, lastLabMs)
}
/**
* BORNE DE PROLONGATION (v1.6.0) : timestamp jusqu'où la partie extrapolée
* a un sens. Défini par le MODÈLE, pas par un cap arbitraire :
*
* horizon = (timestamp de la dernière dose E2) + cutoffHours(son traitement)
*
* où cutoffHours = 10 t½ terminales (V3C/WHS), fin de table (ODS) ou
* 30 t½ (Bateman) — cf [PharmacokineticEngine.cutoffHours]. Après cette
* borne, M(t) = 0 : la courbe prolongée serait une ligne à zéro inventée.
*
* @return le timestamp de fin de prolongation, ou `null` quand il n'y a
* RIEN à prolonger : aucune dose E2, ou modèle déjà éteint au dernier
* lab (le caller retombe alors sur la fenêtre v1.5.0).
*/
internal fun extensionHorizonEndMs(
estrogenTreatments: List<Treatment>,
doseLogs: List<DoseLog>,
lastLabMs: Long
): Long? {
val e2Doses = doseLogs.filter { d -> estrogenTreatments.any { it.id == d.treatmentId } }
val lastDose = e2Doses.maxByOrNull { it.timestamp } ?: return null
val tr = estrogenTreatments.first { it.id == lastDose.treatmentId }
val cutoffMs = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong()
val horizonEnd = lastDose.timestamp + cutoffMs
return if (horizonEnd > lastLabMs) horizonEnd else null
}
/**
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
* (`anchors` trié croissant). Pour t ∈ [t_k ; t_(k+1)] :
*
* ρ(t) = exp( lnρ_k + (t − t_k)/(t_(k+1) − t_k) · (lnρ_(k+1) − lnρ_k) )
*
* Garanties : ρ(t_k) = ρ_k exactement (passage par le lab), continuité
* multiplicative (pas de saut), monotone entre deux références.
* Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne
* (garde division par zéro).
*
* HORS bornes : avant le 1ᵉʳ / après le DERNIER ancre → ρ de borne.
* v1.6.0 : la branche « après le dernier » (return ρ_last) est devenue un
* COMPORTEMENT, pas une simple garde — c'est elle qui fonde la
* prolongation (ρ constant au-delà du dernier lab, cf KDoc de l'objet) ;
* en v1.5.0 la fenêtre du caller n'y entrait jamais.
*/
internal fun ratioAt(anchors: List<Pair<Long, Double>>, tMs: Long): Double {
if (tMs <= anchors.first().first) return anchors.first().second
if (tMs >= anchors.last().first) return anchors.last().second
for (i in 0 until anchors.size - 1) {
val (t0, r0) = anchors[i]
val (t1, r1) = anchors[i + 1]
if (tMs in t0..t1) {
val span = t1 - t0
if (span <= 0L) return r1 // doublon de timestamp : 2ᵉ gagne
val f = (tMs - t0).toDouble() / span.toDouble()
return exp(ln(r0) + f * (ln(r1) - ln(r0)))
}
}
// inatteignable si anchors croissant et bornes cohérentes — garde
return anchors.last().second
}
}

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,6 +61,62 @@ object PKProfileStore {
fun profileLength(ester: String, model: String): Int = fun profileLength(ester: String, model: String): Int =
lookup(profileKey(ester, model))?.size ?: 0 lookup(profileKey(ester, model))?.size ?: 0
/**
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) :
* même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du
* pic, pente = décroissance moyenne sur les 48 h précédentes, jamais
* avant le pic) — t½ = ln2 ÷ |pente|.
*
* Pourquoi ce helper : la recommandation de prochaine prise de sang
* ([LabTiming]) a besoin des 5 × t½ terminales de l'ester pour juger de
* la STABILISATION du régime — et le modèle Estrannaise (tables ODS)
* n'a pas de forme close : sa t½ se LIT dans la table. Les modèles TFS/
* WHSAH, eux, exposent leur t½ analytiquement (`terminalHalfLifeDays`).
*
* @return t½ en jours (≥ 0), ou `null` si la table ne permet pas
* d'estimer une décroissance terminale exploitable (ester inconnu,
* pic nul, pas de queue décroissante).
*/
fun terminalHalfLifeDays(ester: String, model: String): Double? {
if (!initialized) return null
val suffix = when (model) {
PKModels.TRANSFEM_SCIENCE -> "tfs"
PKModels.ESTRANNAISE -> "ese"
else -> return 0.0
}
val arr = lookup("${ester}_$suffix") ?: return null
val (j, rate) = terminalDecayParameters(arr) ?: return null
if (rate >= 0.0) return null
return ln(2.0) / -rate / 24.0 // pente /h → t½ en jours
}
/**
* Paramètres de DÉCROISSANCE TERMINALE d'une table (partagés par
* [sample] — extrapolation au-delà de la fin — et [terminalHalfLifeDays])
* : `(index du dernier point ≥ 1 % du pic, pente log-linéaire /h)` sur
* les 48 h précédentes, jamais avant le pic. `null` si la table n'a ni
* pic ni queue exploitable.
*/
private fun terminalDecayParameters(arr: FloatArray): Pair<Int, Double>? {
var peakIdx = 0
var peakV = 0f
for (idx in arr.indices) {
if (arr[idx] > peakV) {
peakV = arr[idx]
peakIdx = idx
}
}
if (peakV <= 0f) return null
var j = arr.size - 1
while (j > 0 && arr[j] < peakV * 0.01f) j--
if (j <= 0) return null
val window = 48
val j0 = maxOf(peakIdx, j - window)
if (j <= j0) return null
val rate = kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
return j to rate
}
/** /**
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection, * Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
* linearly interpolated between hourly points; beyond the table, extrapolated * linearly interpolated between hourly points; beyond the table, extrapolated
@ -64,8 +125,8 @@ object PKProfileStore {
fun sample(ester: String, model: String, dtHours: Double): Double { fun sample(ester: String, model: String, dtHours: Double): Double {
if (!initialized || dtHours <= 0.0) return 0.0 if (!initialized || dtHours <= 0.0) return 0.0
val suffix = when (model) { val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" PKModels.ESTRANNAISE -> "ese"
else -> return 0.0 else -> return 0.0
} }
val arr = lookup("${ester}_$suffix") ?: return 0.0 val arr = lookup("${ester}_$suffix") ?: return 0.0
@ -77,23 +138,7 @@ object PKProfileStore {
// display floor long before the true value vanishes. Extrapolate from the // display floor long before the true value vanishes. Extrapolate from the
// last point still >= 1% of the peak, using the average hourly decay of // last point still >= 1% of the peak, using the average hourly decay of
// the previous 48 h (never sampling before the peak). // the previous 48 h (never sampling before the peak).
var peakIdx = 0 val (j, rate) = terminalDecayParameters(arr) ?: return 0.0
var peakV = 0f
for (idx in arr.indices) {
if (arr[idx] > peakV) {
peakV = arr[idx]
peakIdx = idx
}
}
if (peakV <= 0f) return 0.0
var j = lastIdx
while (j > 0 && arr[j] < peakV * 0.01f) j--
if (j <= 0) return 0.0
val window = 48
val j0 = maxOf(peakIdx, j - window)
val rate = if (j > j0) {
kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
} else 0.0
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j)) return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
} }

View File

@ -4,6 +4,7 @@ import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.Esters
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.abs import kotlin.math.abs
@ -84,7 +85,26 @@ object PharmacokineticEngine {
if (treatment.usesProfileModel) { if (treatment.usesProfileModel) {
val ester = doseEster(treatment, dose) val ester = doseEster(treatment, dose)
if (ester != Esters.NONE) { if (ester != Esters.NONE) {
return PKProfileStore.sample(ester, modelOverride ?: treatment.pkModel, dtH) * mg val model = modelOverride ?: treatment.pkModel
// v1.4.0 : le modèle Transfem Science utilise les paramètres
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
// forme close.
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
// 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 {
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) ->
TransfemScienceModels.sample(ester, dtH) * mg
model == PKModels.WHSAH && WhsahModels.hasModel(ester) ->
WhsahModels.sample(ester, dtH) * mg
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(ester) ->
EstrannaiseModels.sample(ester, dtH) * mg
else -> 0.0 // ester sans modèle (documenté : ni TFS ni WHSAH ni ESE)
}
} }
} }
@ -100,9 +120,40 @@ object PharmacokineticEngine {
return max(0.0, c) return max(0.0, c)
} }
private fun cutoffHours(treatment: Treatment): Double { /**
* Coupure temporelle (HEURES) au-delà de laquelle une dose de ce traitement
* ne contribue plus à la prédiction :
* - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit
* (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ;
* - profil Estrannaise → 10 demi-vies TERMINALES analytiques (v1.9.0 ;
* l'ancienne coupure « longueur de table ODS 8001 h » a disparu avec
* les tables du runtime) ;
* - Bateman (gel/patch/oral) → 30 demi-vies d'élimination.
*
* Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs]
* pour borner la PROLONGATION du tracé labs — la partie extrapolée s'éteint
* exactement quand le modèle lui-même s'éteint (même sémantique de coupure,
* une seule source de vérité).
*/
fun cutoffHours(treatment: Treatment): Double {
val profileH = if (treatment.usesProfileModel) { val profileH = if (treatment.usesProfileModel) {
PKProfileStore.profileLength(treatment.esterType, treatment.pkModel).toDouble() val model = treatment.pkModel
when {
// v1.4.6 : modèle WHSAH → coupure à 10 demi-vies terminales
// du fit (ex. EEn t½ 7,3 j → 73 j), comme pour le V3C TFS.
model == PKModels.WHSAH && WhsahModels.hasModel(treatment.esterType) ->
WhsahModels.model(treatment.esterType)!!
.terminalHalfLifeDays * 24.0 * 10.0
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
TransfemScienceModels.model(treatment.esterType)!!
.terminalHalfLifeDays * 24.0 * 10.0
// v1.9.0 : ESE analytique → t½ terminale ANALYTIQUE (comme
// TFS/WHS) au lieu de la longueur de table ODS
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(treatment.esterType) ->
EstrannaiseModels.model(treatment.esterType)!!
.terminalHalfLifeDays * 24.0 * 10.0
else -> 0.0
}
} else 0.0 } else 0.0
val batemanH = if (treatment.usesProfileModel) 0.0 val batemanH = if (treatment.usesProfileModel) 0.0
else 30.0 * treatment.eliminationHalfLifeHours.toDouble() else 30.0 * treatment.eliminationHalfLifeHours.toDouble()
@ -113,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>,
@ -126,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 {
@ -174,14 +263,22 @@ object PharmacokineticEngine {
} }
} }
/**
* Niveau combiné à un instant donné. [scalePerEster] (v1.4.2, optionnel)
* applique la calibration par période d'ester EXACTEMENT comme
* [computeCurve] — la notification d'alerte (AlertNotifier) et la carte
* « niveau actuel » de l'accueil partagent ainsi le même calcul.
* null = scaleFactor stocké des traitements (comportement historique).
*/
fun levelAt( fun levelAt(
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tMs: Long, tMs: Long,
tConfig: TConfig, tConfig: TConfig,
tKPerEster: Map<String, Double>? = null tKPerEster: Map<String, Double>? = null,
scalePerEster: Map<String, Double>? = null
): LevelPoint { ): LevelPoint {
val e2 = e2At(treatments, doseLogs, tMs) val e2 = e2At(treatments, doseLogs, tMs, scalePerEster = scalePerEster)
val k = activeEsterAt(treatments, doseLogs, tMs) val k = activeEsterAt(treatments, doseLogs, tMs)
?.let { tKPerEster?.get(it) } ?: tConfig.k ?.let { tKPerEster?.get(it) } ?: tConfig.k
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k))) return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
@ -212,8 +309,9 @@ object PharmacokineticEngine {
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tConfig: TConfig, tConfig: TConfig,
tKPerEster: Map<String, Double>? = null, tKPerEster: Map<String, Double>? = null,
nowMs: Long = System.currentTimeMillis() nowMs: Long = System.currentTimeMillis(),
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster) scalePerEster: Map<String, Double>? = null
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster)
fun computeCurve( fun computeCurve(
treatments: List<Treatment>, treatments: List<Treatment>,
@ -244,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
@ -266,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)
@ -310,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()
@ -319,6 +433,15 @@ object PharmacokineticEngine {
val forecast = mutableListOf<DoseLog>() val forecast = mutableListOf<DoseLog>()
var t = last.timestamp + intervalMs var t = last.timestamp + intervalMs
// ⚠️ Oubli d'une injection : le premier créneau théorique tombe dans le
// PASSÉ (dernière prise + intervalle = jour de l'injection manquée) —
// on ne simule JAMAIS un créneau passé (faux pic dans l'historique) :
// on avance au premier créneau STRICTEMENT FUTUR, au rythme configuré.
// Un retard, lui, décale naturellement toute la prévision (elle part de
// la dernière prise réelle).
while (t <= nowMs) {
t += intervalMs
}
while (t <= toMs) { while (t <= toMs) {
forecast.add( forecast.add(
DoseLog( DoseLog(
@ -353,7 +476,8 @@ object PharmacokineticEngine {
fun computeEsterScaleFactors( fun computeEsterScaleFactors(
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
e2Labs: List<LabResult> e2Labs: List<LabResult>,
modelOverride: String? = null
): Map<String, Double> { ): Map<String, Double> {
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL } val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
val estrogenDoses = doseLogs.filter { d -> val estrogenDoses = doseLogs.filter { d ->
@ -361,12 +485,23 @@ object PharmacokineticEngine {
} }
if (estrogenDoses.isEmpty()) return emptyMap() if (estrogenDoses.isEmpty()) return emptyMap()
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab // Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab,
// avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec
// SA propre prédiction — v1.4.7 calculait tout depuis le modèle
// stocké du traitement, ce qui produisait des facteurs absurdes dès
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) } val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
val ctx = prepareE2Context(unscaled, doseLogs)
val ratiosByEster = HashMap<String, MutableList<Double>>() val ratiosByEster = HashMap<String, MutableList<Double>>()
for (lab in e2Labs) { var maxPredictionSoFar = 0.0
val predicted = e2At(unscaled, doseLogs, lab.timestamp) // ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
if (predicted <= 0.5) continue // les labs hors de la fenêtre d'action du modèle (prédiction
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
// tombait à ×2,21
for (lab in e2Labs.sortedBy { it.timestamp }) {
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris) // Attribution : dernière dose E2 ≤ lab → son ester (override compris)
val attributedDose = estrogenDoses val attributedDose = estrogenDoses
@ -379,12 +514,10 @@ object PharmacokineticEngine {
ratiosByEster.getOrPut(ester) { mutableListOf() } ratiosByEster.getOrPut(ester) { mutableListOf() }
.add(lab.value / predicted) .add(lab.value / predicted)
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
} }
return ratiosByEster.mapValues { (_, ratios) -> return ratiosByEster.mapValues { (_, ratios) ->
val sorted = ratios.sorted() medianRounded(ratios, 2)
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 100).toInt() / 100.0
} }
} }
@ -407,7 +540,8 @@ object PharmacokineticEngine {
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tLabs: List<LabResult>, tLabs: List<LabResult>,
current: TConfig, current: TConfig,
scalePerEster: Map<String, Double>? = null scalePerEster: Map<String, Double>? = null,
modelOverride: String? = null
): Map<String, Double> { ): Map<String, Double> {
val estrogenDoses = doseLogs.filter { d -> val estrogenDoses = doseLogs.filter { d ->
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL } treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
@ -415,7 +549,10 @@ object PharmacokineticEngine {
if (estrogenDoses.isEmpty()) return emptyMap() if (estrogenDoses.isEmpty()) return emptyMap()
val ksByEster = HashMap<String, MutableList<Double>>() val ksByEster = HashMap<String, MutableList<Double>>()
for (lab in tLabs) { var maxE2SoFar = 0.0
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant
// (l'E2 prédite doit rester ≥ 15 % du max observé — cf labIsSignificant)
for (lab in tLabs.sortedBy { it.timestamp }) {
val tNgMl = convertTToNgMl(lab.value, lab.unit) val tNgMl = convertTToNgMl(lab.value, lab.unit)
if (tNgMl <= current.floor + 0.02) continue if (tNgMl <= current.floor + 0.02) continue
val attributed = estrogenDoses val attributed = estrogenDoses
@ -425,19 +562,18 @@ object PharmacokineticEngine {
val ester = doseEster(tr, attributed) val ester = doseEster(tr, attributed)
if (ester == Esters.NONE) continue if (ester == Esters.NONE) continue
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster) // v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60)
if (e2 <= 1.0) continue val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster,
modelOverride = modelOverride)
if (!labIsSignificant(e2, maxE2SoFar)) continue
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2 val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
if (k > 1e-4 && k < 10.0) { if (k > 1e-4 && k < 10.0) {
ksByEster.getOrPut(ester) { mutableListOf() }.add(k) ksByEster.getOrPut(ester) { mutableListOf() }.add(k)
} }
if (e2 > maxE2SoFar) maxE2SoFar = e2
} }
return ksByEster.mapValues { (_, ks) -> return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) }
val sorted = ks.sorted()
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 1000).toInt() / 1000.0
}
} }
/** /**
@ -467,14 +603,23 @@ object PharmacokineticEngine {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
labs: List<LabResult>, labs: List<LabResult>,
tConfig: TConfig tConfig: TConfig,
/**
* Modèle PK pour les PRÉDICTIONS de calibration (v1.4.8, fix #60) :
* null = modèle stocké de chaque traitement (Home). Le graphique
* appelle une fois PAR MODÈLE affiché (ESE/TFS/WHS) — chaque courbe
* reçoit ainsi SES échelles et SON k.
*/
modelOverride: String? = null
): AutoCalibrated { ): AutoCalibrated {
val e2Labs = labs.filter { it.marker.equals("E2", true) } val e2Labs = labs.filter { it.marker.equals("E2", true) }
val tLabs = labs.filter { it.marker.equals("T", true) } val tLabs = labs.filter { it.marker.equals("T", true) }
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs) val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs,
modelOverride = modelOverride)
val tKPerEster = computeTKPerEster( val tKPerEster = computeTKPerEster(
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales,
modelOverride = modelOverride
) )
return AutoCalibrated( return AutoCalibrated(
treatments = treatments, treatments = treatments,
@ -490,6 +635,57 @@ object PharmacokineticEngine {
// Calibration from lab results // Calibration from lab results
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/**
* Garde de SIGNIFICATIVITÉ d'un lab (v1.4.9, fix #61) : le lab ne
* calibre que si la prédiction du modèle à son instant reste ≥ 15 %
* du maximum de prédiction déjà observé — c'est-à-dire tant que
* l'injection « gouverne » encore le taux. Au-delà, le lab reflète
* AUTRE CHOSE (temps mort, début de traitement, autre ester) et son
* ratio lab ÷ prédiction devient aberrant (ex. lab 373 pg/mL contre
* une prédiction résiduelle de 23 pg/mL → ratio 15,9). Remontée
* v1.4.8 : une dose EEn de test logguée en janvier, des labs de
* janvier-mars à 15–76 j → ratios aberrants dont la MÉDIANE tombait
* à ×2,21 — dans les DEUX pipelines (manuel et auto, même défaut).
*
* Pourquoi une garde RELATIVE (et pas temporelle en jours) : la
* fenêtre d'action dépend du profil (l'injection gouverne ~2 j pour
* le benzoate, des mois pour l'undécylate) — une fenêtre fixe
* casserait autant les labs réels que les tests. La PRÉDICTION,
* elle, contient déjà toute la physiologie du modèle.
*/
const val LAB_MIN_PREDICTION_FRACTION = 0.15
/**
* Le lab est SIGNIFICATIF pour la calibration : prédiction > 0,5 pg/mL
* (pas du bruit) ET ≥ [LAB_MIN_PREDICTION_FRACTION] du maximum de
* prédiction déjà observé. [maxSoFar] = max des prédictions calculées
* jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ;
* 0 = premier lab → seule la garde > 0,5 s'applique.
*/
/**
* MÉDIANE arrondie d'une liste de ratios (helper partagé par les 4
* pipelines de calibration — était dupliqué 4×). Liste triée en place.
* @param decimals nombre de décimales après arrondi (via troncature ×10^n)
*/
private fun medianRounded(values: MutableList<Double>, decimals: Int): Double {
values.sort()
val median = if (values.size % 2 == 1) values[values.size / 2]
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
val factor = Math.pow(10.0, decimals.toDouble())
// ⚠️ ARRONDI (v1.8.2), pas troncature : l'ancien motif
// `(median * factor).toInt() / factor` tronquait au dernier bit
// flottant — 0.8999999999999999 × 100 → toInt() = 89 → « 0,89 »
// (le modèle ESE analytique a déplacé ce bit, révélant le piège ;
// le bug #61 historique du même genre : 6,5 → 6 avec
// kotlin.math.round — là, Math.round est l'arrondi au plus proche).
return Math.round(median * factor) / factor
}
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
predicted > 0.5 &&
(maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar)
fun computeScaleFactor( fun computeScaleFactor(
treatment: Treatment, treatment: Treatment,
allDoseLogs: List<DoseLog>, allDoseLogs: List<DoseLog>,
@ -501,7 +697,12 @@ object PharmacokineticEngine {
val p = batemanParams(treatment) val p = batemanParams(treatment)
val ratios = mutableListOf<Double>() val ratios = mutableListOf<Double>()
for (lab in e2Labs) { var maxPredictionSoFar = 0.0
// ⚠️ fix #61 : labs par timestamp CROISSANT — le max de prédiction se
// construit au fil de l'eau et la garde labIsSignificant exclut les
// labs qui n'atteignent plus 15 % du max (l'injection ne gouverne
// plus : le lab reflète autre chose — ratios aberrants → ×2,21)
for (lab in e2Labs.sortedBy { it.timestamp }) {
var predicted = 0.0 var predicted = 0.0
for (dose in myDoses) { for (dose in myDoses) {
if (dose.timestamp > lab.timestamp) continue if (dose.timestamp > lab.timestamp) continue
@ -509,18 +710,13 @@ object PharmacokineticEngine {
if (dtH > cutoffHours(treatment)) continue if (dtH > cutoffHours(treatment)) continue
predicted += concentrationOfDose(treatment, dose, lab.timestamp, p) predicted += concentrationOfDose(treatment, dose, lab.timestamp, p)
} }
if (predicted > 0.5) { if (labIsSignificant(predicted, maxPredictionSoFar)) {
ratios.add(lab.value / predicted) ratios.add(lab.value / predicted)
} }
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
} }
if (ratios.isEmpty()) return null if (ratios.isEmpty()) return null
ratios.sort() return medianRounded(ratios, 2)
val median = if (ratios.size % 2 == 1) {
ratios[ratios.size / 2]
} else {
(ratios[ratios.size / 2 - 1] + ratios[ratios.size / 2]) / 2.0
}
return (median * 100).toInt() / 100.0
} }
fun computeTConfigCalibration( fun computeTConfigCalibration(
@ -529,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
@ -543,31 +741,104 @@ 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
}
return current.copy(k = (median * 1000).toInt() / 1000.0)
} }
fun nextReminderFireMs(treatments: List<Treatment>, nowMs: Long = System.currentTimeMillis()): Long? { /**
var next: Long? = null * Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
*
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
* pour une prise déjà faite — le rappel suivant repart au créneau
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
* S'applique identiquement au fallback quotidien (oral sans Posologie :
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
*
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
*/
fun hasDoseLoggedOnDay(
treatmentId: Long,
doseLogs: List<DoseLog>,
atMs: Long,
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
): Boolean {
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
return doseLogs.any {
it.treatmentId == treatmentId &&
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
}
}
/**
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
*
* ⚠️ FIX du bug « rappels quotidiens » (#52) : si le traitement a une
* **Posologie** (forecastIntervalDays) ET un historique de doses, le
* rappel suit la GRILLE des prises (dernière dose réelle + k × intervalle,
* créneaux passés sautés — même sémantique que [generateForecastDoses])
* et sonne le jour du créneau à l'heure de rappel choisie : une injection
* tous les samedis à 18 h → rappel uniquement le samedi à 18 h.
* Sinon (gel/oral, ou aucune dose pour cadrer la grille) → comportement
* QUOTIDIEN historique (prochaine occurrence HH:mm).
*/
fun nextReminderFireFor(treatment: Treatment, doseLogs: List<DoseLog>, nowMs: Long): Long? {
if (!treatment.reminderEnabled || !treatment.isActive) return null
val h = treatment.reminderHour ?: return null
val m = treatment.reminderMinute ?: return null
val intervalDays = treatment.forecastIntervalDays
val myDoses = doseLogs.filter { it.treatmentId == treatment.id && it.timestamp <= nowMs }
if (intervalDays != null && intervalDays > 0.0 && myDoses.isNotEmpty()) {
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs > 0L) {
var slot = myDoses.maxByOrNull { it.timestamp }!!.timestamp
// Le premier créneau STRICTEMENT futur (un créneau passé = oubli :
// on avance au rythme configuré, cf generateForecastDoses).
while (slot <= nowMs) slot += intervalMs
val cal = java.util.Calendar.getInstance()
var fireMs = 0L
var candidate = slot
// L'alarme sonne le jour du créneau, à l'heure de rappel choisie.
while (true) {
cal.timeInMillis = candidate
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
fireMs = cal.timeInMillis
if (fireMs > nowMs) return fireMs
// L'heure de rappel du jour du créneau est déjà passée → créneau suivant
candidate += intervalMs
}
}
}
// Fallback QUOTIDIEN (comportement historique) : prochaine occurrence HH:mm
val cal = java.util.Calendar.getInstance() val cal = java.util.Calendar.getInstance()
cal.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
return cal.timeInMillis
}
/**
* Prochain rappel parmi tous les traitements (min). [doseLogs] active la
* planification par Posologie (cf [nextReminderFireFor]) ; vide = fallback
* quotidien pur (compatibilité avec les appels historiques).
*/
fun nextReminderFireMs(
treatments: List<Treatment>,
doseLogs: List<DoseLog> = emptyList(),
nowMs: Long = System.currentTimeMillis()
): Long? {
var next: Long? = null
for (tr in treatments) { for (tr in treatments) {
// Un traitement inactif ne génère plus de rappels val fire = nextReminderFireFor(tr, doseLogs, nowMs)
if (!tr.reminderEnabled || !tr.isActive) continue if (fire != null && (next == null || fire < next)) next = fire
val h = tr.reminderHour ?: continue
val m = tr.reminderMinute ?: continue
cal.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
val fire = cal.timeInMillis
if (next == null || fire < next) next = fire
} }
return next return next
} }

View File

@ -0,0 +1,103 @@
package com.hormonetrack.pk
/**
* Modèles pharmacocinétiques « Transfem Science » — méta-analyse des esters
* d'estradiol injectables (transfemscience.org/articles/injectable-e2-meta-analysis/).
*
* SOURCE DES PARAMÈTRES (v1.4.0) : les coefficients du modèle à 3 compartiments
* publiés par le simulateur officiel de Transfem Science
* (https://transfemscience.org/misc/injectable-e2-simulator/ — les valeurs sont
* des DONNÉES SCIENTIFIQUES issues des études agrégées, Tableaux 9–10 de
* l'article). Avant v1.4.0, le modèle TFS de l'app utilisait des tables
* horaires dérivées du tableur de l'utilisatrice (ODS) ; désormais la courbe
* TFS est calculée EXACTEMENT comme le simulateur :
*
* Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
* + e^(−k3·t)/((k1−k3)(k2−k3))
* + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ]
*
* avec t en JOURS, Cp en pg/mL pour la dose de calibration fitDose (5 mg).
* La courbe est NORMALISÉE PAR MG : `sample()` retourne Cp(t) / fitDose —
* le moteur PK multiplie déjà par la dose réelle (dose_mg), exactement comme
* avec les tables ODS. Sanity checks épinglés par `TransfemScienceModelsTest`
* (Cmax/Tmax/t½ de l'article reproduits à ~1 %) :
*
* EV : Cmax 295 pg/mL @ 2,1 j, t½ 3,0 j | EEn : 160 @ 6,5 j, t½ 4,6 j
* EB : 971 @ 0,65 j, t½ 1,2 j | EC (huile) : 155 @ 4,3 j, t½ 6,7 j
* EC susp. : 241 @ 1,2 j, t½ 5,1 j | PEP : 34 pg/mL @ 32,5 mg @ 18 j, t½ 28,4 j
*
* Remarques de l'article conservées dans la doc de dev (§7) : l'undécylate
* (EU) a des données d'étude limitées (ajustement moins fiable) ; PEP a une
* biodisponibilité réduite (~15–20 % — d'où des doses ~6,5× plus élevées).
*/
object TransfemScienceModels {
/** Modèle à 3 compartiments (V3C) d'un ester, tel que publié par TFS. */
data class V3C(
/** Clé ester de l'app ("EV", "EB", …). */
val ester: String,
/** Constante d'amplitude (pg/mL pour la dose de fit, unités TFS). */
val D: Double,
/** Constantes de vitesse en j⁻¹ (k1 = absorption dépôt, k2/k3 phases). */
val k1: Double,
val k2: Double,
val k3: Double,
/** Dose à laquelle les paramètres ont été ajustés (5 mg dans l'article). */
val fitDoseMg: Double = 5.0
) {
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
val terminalHalfLifeDays: Double
get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3)
}
private val MODELS: Map<String, V3C> = mapOf(
// estradiol valerate (solution huileuse)
"EV" to V3C("EV", D = 2596.05956, k1 = 2.38229125, k2 = 0.23345814, k3 = 1.37642769),
// estradiol enanthate (solution huileuse)
"EEN" to V3C("EEN", D = 333.874181, k1 = 0.42412968, k2 = 0.43452980, k3 = 0.15291485),
// estradiol undecylate — ⚠️ données d'étude limitées (cf article, §Limites)
"EU" to V3C("EU", D = 65.9493374, k1 = 0.29634323, k2 = 4799337.57, k3 = 0.03141554),
// estradiol benzoate (solution huileuse)
"EB" to V3C("EB", D = 1.7050e+08, k1 = 3.22397192, k2 = 0.58870148, k3 = 70721.4018),
// estradiol cypionate — SOLUTION HUILEUSE
"EC" to V3C("EC", D = 1920.89671, k1 = 0.10321089, k2 = 0.89854779, k3 = 0.89359759),
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux) ; clé dédiée
"ECS" to V3C("ECS", D = 1.5669e+08, k1 = 0.13586726, k2 = 2.51772731, k3 = 74768.1493),
// polyestradiol phosphate — biodispo réduite, doses ~6,5× (cf article)
"PEP" to V3C("PEP", D = 34.46836875, k1 = 0.02456035, k2 = 135643.711, k3 = 0.10582368)
)
fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase())
/** Le modèle V3C de l'ester, ou null (lookup insensible à la casse). */
fun model(ester: String): V3C? = MODELS[ester.uppercase()]
/**
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
* modèle V3C de la méta-analyse TFS. dtHours ≤ 0 → 0 (rien avant la dose).
*/
fun sample(ester: String, dtHours: Double): Double {
if (dtHours <= 0.0) return 0.0
val m = model(ester) ?: return 0.0
val t = dtHours / 24.0 // la formule TFS travaille en jours
return rawConcentrationDays(m, t) / m.fitDoseMg
}
/**
* Cp(t) en pg/mL pour la dose de fit (formule V3C exacte du simulateur).
* Le compartiment k2 peut être « ultra-rapide » (EU, PEP : k2 ~10⁵–10⁶ j⁻¹) :
* son terme décroît instantanément et n'affecte que t≈0 — double précision
* suffisante (vérifié par les tests de pics de l'article).
*/
private fun rawConcentrationDays(m: V3C, tDays: Double): Double {
val D = m.D
val k1 = m.k1
val k2 = m.k2
val k3 = m.k3
return D * k1 * k2 * (
kotlin.math.exp(-k1 * tDays) / ((k1 - k2) * (k1 - k3)) +
kotlin.math.exp(-k3 * tDays) / ((k1 - k3) * (k2 - k3)) +
kotlin.math.exp(-k2 * tDays) * (k3 - k1) / ((k1 - k2) * (k1 - k3) * (k2 - k3))
)
}
}

View File

@ -0,0 +1,102 @@
package com.hormonetrack.pk
/**
* Modèle PK « WHSAH » (v1.4.6) — 3ᵉ modèle au niveau d'Estrannaise et
* Transfem Science, superposable sur le graphique.
*
* SOURCE : le fit « license-free » publié par le WHSAH Collective dans
* l'app open-source Mona (projet Flutter — lib/data/model/graph_calculator.dart,
* commit « new license-free pk params » ; leurs auteurs déclarent explicitement
* les coefficients libres d'usage, « Inferred with love exclusively for Mona »).
* Crédit : alix / WHSAH Collective via Mona.
*
* POURQUOI CE MODÈLE EXISTE (cf diagnostic comparatif du 7 sept. 2026) :
* Mona a d'abord utilisé les paramètres Transfem Science (identiques aux
* nôtres, commit du 6 mars 2026) PUIS les a remplacés par ce fit indépendant.
* Les deux sont des tri-exponentielles en forme close ajustées sur les mêmes
* études, avec des conventions différentes :
*
* - WHSAH paramètre une BIODISPONIBILITÉ explicite F < 1 (0,62–0,76) et
* un facteur d'échelle `auc` intégré — les courbes « brutes » sont ~24 %
* plus basses que TFS non calibrée (la calibration de l'app absorbe ça) ;
* - la MONTÉE est plus rapide (compartiment rapide plus vif) et la t½
* terminale plus longue sur certains esters : EV Cmax 367 pg/mL @ 1,7 j
* (TFS : 295 @ 2,1 j), EEn Cmax 188 @ 5,0 j (TFS : 160 @ 6,5 j) et
* t½ EEn 7,3 j (TFS : 4,5 j) — à J+1 l'EEn WHSAH est ~3× plus haut
* (70 vs 22 pg/mL) ;
* - PEP NON COUVERT (retiré du jeu WHSAH) → `sample("PEP", …) = 0`.
*
* FORMULE (telle qu'implémentée dans Mona, normalisée PAR MG pour le moteur
* — le multiplicateur de dose est appliqué par `PharmacokineticEngine`) :
*
* sample = F · auc · k1·k2·k3 · [ e^(−k1·t)/((k1−k2)(k1−k3))
* − e^(−k2·t)/((k1−k2)(k2−k3))
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
*
* avec t en JOURS, résultat en pg/mL pour 1 mg. Sanity checks épinglés par
* `WhsahModelsTest` (fidélité au comportement de Mona, ±2 %) :
* EV Cmax 367,5 @ 1,69 j · EEn 187,9 @ 4,99 j · t½ EEn 7,34 j.
*/
object WhsahModels {
/** Modèle WHSAH d'un ester (fit F/auc/k1/k2/k3). */
data class WHS(
/** Clé ester de l'app ("EV", "EB", …). */
val ester: String,
/** Biodisponibilité explicite (< 1 dans le fit WHSAH). */
val F: Double,
/** Constante d'échelle du fit (pg/mL intégrés). */
val auc: Double,
/** Constantes de vitesse en j⁻¹ (k1 = absorption, k2/k3 phases). */
val k1: Double,
val k2: Double,
val k3: Double
) {
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
val terminalHalfLifeDays: Double
get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3)
}
private val MODELS: Map<String, WHS> = mapOf(
// estradiol enanthate (solution huileuse)
"EEN" to WHS("EEN", F = 0.708, auc = 875.4, k1 = 0.09441, k2 = 3.354, k3 = 0.4078),
// estradiol valerate (solution huileuse)
"EV" to WHS("EV", F = 0.764, auc = 621.3, k1 = 0.2230, k2 = 17.62, k3 = 1.305),
// estradiol benzoate (solution huileuse)
"EB" to WHS("EB", F = 0.723, auc = 889.9, k1 = 0.5220, k2 = 521.9, k3 = 5.223),
// estradiol cypionate — SOLUTION HUILEUSE
"EC" to WHS("EC", F = 0.687, auc = 554.5, k1 = 0.0880, k2 = 17.95, k3 = 0.7177),
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux)
"ECS" to WHS("ECS", F = 0.687, auc = 852.6, k1 = 0.0973, k2 = 218.67, k3 = 6.624),
// estradiol undecylate — données d'étude limitées (comme chez TFS)
"EU" to WHS("EU", F = 0.618, auc = 385.8, k1 = 0.02189, k2 = 183.4, k3 = 1.564)
)
fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase())
/** Le modèle WHSAH de l'ester, ou null (lookup insensible à la casse). */
fun model(ester: String): WHS? = MODELS[ester.uppercase()]
/**
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
* modèle WHSAH. dtHours ≤ 0 → 0 (rien avant la dose).
*/
fun sample(ester: String, dtHours: Double): Double {
if (dtHours <= 0.0) return 0.0
val m = model(ester) ?: return 0.0
return rawConcentrationDays(m, dtHours / 24.0) // t en jours
}
/**
* Cp(t) en pg/mL pour 1 mg (forme Mona exacte — voir KDoc de l'objet).
* Les compartiments « ultra-rapides » (k2 jusqu'à ~520 j⁻¹ : EB) décroissent
* instantanément et n'affectent que t≈0 — double précision suffisante
* (vérifié par les tests de pics).
*/
private fun rawConcentrationDays(m: WHS, tDays: Double): Double {
val part1 = kotlin.math.exp(-m.k1 * tDays) / ((m.k1 - m.k2) * (m.k1 - m.k3))
val part2 = kotlin.math.exp(-m.k2 * tDays) / ((m.k1 - m.k2) * (m.k2 - m.k3))
val part3 = kotlin.math.exp(-m.k3 * tDays) / ((m.k1 - m.k3) * (m.k2 - m.k3))
return m.F * m.auc * m.k1 * m.k2 * m.k3 * (part1 - part2 + part3)
}
}

View File

@ -0,0 +1,214 @@
package com.hormonetrack.reminder
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase
import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
/**
* Notification d'alerte de seuil (v1.4.2) — complète la carte d'avertissement
* de l'accueil : l'évaluation tourne MÊME app fermée via **WorkManager**
* (dépendance déclarée depuis v1.0, enfin utilisée).
*
* Fonctionnement :
* - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le
* niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil :
* calibration par période d'ester si l'option est active) contre les
* seuils configurés ;
* - **anti-spam** : l'état des alertes déjà notifiées est persisté en
* DataStore (`Alerts.encodeState`) → pas de re-notification du même
* franchissement ; le retour à la normale ré-arme la prochaine notif
* (cf `Alerts.shouldNotify`) ;
* - au SAVE des seuils dans Paramètres, un check **one-time immédiat** est
* déclenché (feedback quasi instantané sans attendre les 15 min) ;
* - canal de notification DÉDIÉ (`hormonetrack_alerts`) : l'utilisatrice
* règle les avertissements indépendamment des rappels (montre GT 3).
*
* ⚠️ WorkManager persiste les travaux entre reboots — BootReceiver n'a
* rien à faire pour ce worker.
*/
object AlertNotifier {
const val CHANNEL_ID = "hormonetrack_alerts"
/** Id du travail périodique (unique) et nom du travail one-time. */
private const val PERIODIC_WORK = "alert_threshold_periodic"
private const val ONESHOT_WORK = "alert_threshold_oneshot"
/** Période de vérification : minimum autorisé par WorkManager. */
private const val PERIOD_MINUTES = 15L
/** Notification id — une seule notification d'alerte à la fois (update). */
private const val NOTIFICATION_ID = 4_002
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.alert_channel_name),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.alert_channel_desc)
}
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
/**
* Planifie le worker PÉRIODIQUE (à appeler une fois dans Application
*.onCreate). KEEP : si le travail existe déjà, on ne le replanifie pas.
*/
fun schedulePeriodic(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<AlertWorker>(PERIOD_MINUTES, TimeUnit.MINUTES).build()
)
}
/** Check IMMÉDIAT (au save des seuils) — remplace le travail one-time en attente. */
fun checkNow(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
ONESHOT_WORK,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<AlertWorker>().build()
)
}
/**
* Évalue le niveau actuel et notifie si nécessaire. Retourne l'état à
* persister (encodé, cf [Alerts.encodeState]) — l'appelant le stocke.
* Séparé du worker pour rester lisible ; le worker ne fait que l'IO.
*/
suspend fun evaluateAndNotify(context: Context): String {
val db = AppDatabase.getInstance(context)
val container = (context.applicationContext as com.hormonetrack.HormoneTrackApp).container
val settings = container.settings
val thresholds = settings.alertThresholds.first()
if (thresholds == Alerts.Thresholds()) {
// Aucun seuil configuré → nettoyer l'état et sortir (pas de notif)
settings.setAlertNotifiedState(null)
return ""
}
// Même calcul que la carte accueil : calibration auto si activée
val treatments = db.treatmentDao().getAllOnce()
val doseLogs = db.doseLogDao().getAllOnce()
val labs = db.labResultDao().getAllOnce()
val tConfig = settings.tConfig.first()
val autoCalibrate = settings.autoCalibrate.first()
val auto = if (autoCalibrate && treatments.isNotEmpty()) {
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
} else null
val now = System.currentTimeMillis()
val level = PharmacokineticEngine.levelAt(
treatments, doseLogs, now,
tConfig = auto?.tConfig ?: tConfig,
tKPerEster = auto?.tKPerEster,
scalePerEster = auto?.esterScales
)
val alerts = Alerts.evaluateAll(level.e2, level.t, thresholds)
val lastNotified = Alerts.parseState(settings.alertNotifiedState.first())
val current = alerts.associate { it.marker to it.level }
when {
alerts.isEmpty() -> {
// Retour à la normale : on efface l'état (prochain écart re-notifiera)
if (lastNotified.isNotEmpty()) {
settings.setAlertNotifiedState(null)
AppLog.log("alerte", "seuils : retour à la normale (E2≈${"%.0f".format(level.e2)}, T≈${"%.2f".format(level.t)})")
}
}
Alerts.shouldNotify(current, lastNotified) -> {
notify(context, alerts)
settings.setAlertNotifiedState(Alerts.encodeState(alerts))
AppLog.log("alerte", "notification : ${alerts.joinToString { "${it.marker}:${it.level}" }} (E2≈${"%.0f".format(level.e2)}, T≈${"%.2f".format(level.t)})")
}
else -> {
AppLog.log("alerte", "seuils : franchissement inchangé (${current}) — pas de re-notification")
}
}
return Alerts.encodeState(alerts)
}
/** Construit et poste la notification d'alerte (canal dédié). */
private fun notify(context: Context, alerts: List<Alerts.Alert>) {
// POST_NOTIFICATIONS runtime (API 33+) : sans permission, ne pas
// poster (l'app ne crashe pas — NotificationManager ignore silencieusement
// sauf SecurityException selon les builds ; garde défensive).
if (android.os.Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
AppLog.log("alerte", "notification impossible : POST_NOTIFICATIONS refusée")
return
}
val title = context.getString(R.string.alert_notif_title)
val text = alerts.joinToString("\n") { a ->
val valueStr = if (a.value >= 10) "%.0f".format(a.value) else "%.2f".format(a.value)
val limitStr = if (a.limit >= 10) "%.0f".format(a.limit) else "%.2f".format(a.limit)
if (a.level == Alerts.Level.HIGH) {
context.getString(R.string.alert_high, a.marker, valueStr, a.unit, limitStr)
} else {
context.getString(R.string.alert_low, a.marker, valueStr, a.unit, limitStr)
}
}
val openIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val openPi = PendingIntent.getActivity(
context, NOTIFICATION_ID, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setContentIntent(openPi)
.setAutoCancel(true)
.build()
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIFICATION_ID, notification)
}
}
/**
* Worker WorkManager : appelle [AlertNotifier.evaluateAndNotify] en coroutine
* (CoroutineWorker → dispatchers WorkManager, IO-friendly). Toute exception
* est journalisée puis renvoyée en échec RETRYABLE (WorkManager re-tentera).
*/
class AlertWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
AlertNotifier.evaluateAndNotify(applicationContext)
Result.success()
} catch (e: Exception) {
AppLog.log("alerte", "ERREUR worker : ${e.message}")
Result.retry()
}
}

View File

@ -0,0 +1,194 @@
package com.hormonetrack.reminder
import android.content.ContentUris
import android.content.Context
import android.content.ContentValues
import android.provider.CalendarContract
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.components.formatDose
import java.util.Calendar
import java.util.TimeZone
/**
* Gestion des événements d'agenda récurrents pour les prises hormonales
* (v1.3.0) : un événement est inséré dans un calendrier LOCAL « HormoneTrack »
* (visible dans l'agenda du téléphone) avec une répétition RRULE dérivée de la
* **Posologie** du traitement (forecastIntervalDays).
*
* Cycle de vie :
* - activation (switch « Événement d'agenda » dans l'éditeur de traitement,
* sous « Rappels ») → [upsertEvent] crée/met à jour l'événement et l'id est
* stocké sur le traitement ;
* - désactivation / suppression / changement de posologie → l'ancien
* événement est supprimé puis recréé au save.
*
* Permission : WRITE_CALENDAR (+ READ_CALENDAR pour les requêtes), demandée à
* l'exécution par l'éditeur au moment de l'activation du switch.
*/
object CalendarEvents {
const val CALENDAR_NAME = "HormoneTrack"
/**
* RRULE de répétition dérivé de la posologie (jours) : répétition DAILY
* tous les N jours (INTERVAL = N). L'intervalle est arrondi à l'entier
* (RRULE n'accepte pas de décimales) — documenté dans l'UI.
* ⚠️ Arrondi demi-supérieur EXPLICITE via floor(x + 0.5) : kotlin.math.round
* arrondit les ties vers l'entier PAIR (6,5 → 6 !) — piégé en v1.3.0.
*/
fun rruleForIntervalDays(intervalDays: Double): String =
"FREQ=DAILY;INTERVAL=${maxOf(1, kotlin.math.floor(intervalDays + 0.5).toInt())}"
/**
* Prochaine occurrence (ms) d'une prise à hh:mm : aujourd'hui si pas encore
* passé, sinon demain. Heure par défaut 12:00 si aucun rappel configuré.
*/
fun nextOccurrenceMs(nowMs: Long, hour: Int, minute: Int): Long {
val cal = Calendar.getInstance(TimeZone.getDefault()).apply {
timeInMillis = nowMs
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (cal.timeInMillis <= nowMs) cal.add(Calendar.DAY_OF_MONTH, 1)
return cal.timeInMillis
}
fun defaultStartMs(treatment: Treatment, nowMs: Long): Long {
val hour = treatment.reminderHour ?: 12
val minute = treatment.reminderMinute ?: 0
return nextOccurrenceMs(nowMs, hour, minute)
}
/**
* Retourne l'id du calendrier local « HormoneTrack » (le crée s'il manque).
* Nécessite WRITE_CALENDAR (l'insertion d'un calendrier local se fait avec
* CALLER_IS_SYNCADAPTER=true).
*
* ⚠️ BUG fixé (v1.3.5, #50) : un URI avec CALLER_IS_SYNCADAPTER DOIT aussi
* embarquer ACCOUNT_NAME + ACCOUNT_TYPE — sinon le CalendarProvider lève
* `IllegalArgumentException: Sync adapters must specify an account and
* account type` sur la requête ou l'insertion. Vu dans les AppLog exportés
* du Pixel 9 /e/OS v1.3.4 : la permission agenda était accordée (fix
* manifest v1.3.3 ✓) et ensureCalendar plantait juste après.
*/
fun ensureCalendar(context: Context): Long {
val calUri = CalendarContract.Calendars.CONTENT_URI
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(
CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME
)
.appendQueryParameter(
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.ACCOUNT_TYPE_LOCAL
)
.build()
context.contentResolver.query(
calUri,
arrayOf(CalendarContract.Calendars._ID, CalendarContract.Calendars.NAME),
"${CalendarContract.Calendars.NAME} = ?",
arrayOf(CALENDAR_NAME),
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getLong(0)
}
}
val values = ContentValues().apply {
put(CalendarContract.Calendars.NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, CALENDAR_NAME)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF4F5BD5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val newUri = context.contentResolver.insert(calUri, values)!!
return ContentUris.parseId(newUri)
}
/**
* Crée OU met à jour l'événement récurrent du traitement :
* - début = prochaine occurrence à l'heure de rappel (ou 12:00) ;
* - répétition = posologie (RRULE FREQ=DAILY;INTERVAL=N) ;
* - si l'événement précédent existe toujours, il est supprimé puis recréé
* (la modification d'un RRULE via l'API étant capricieuse selon les
* fournisseurs d'agenda — supprimer/recréer est fiable).
*
* @return le nouvel id d'événement (à stocker sur le traitement).
*/
fun upsertEvent(context: Context, calendarId: Long, treatment: Treatment): Long {
treatment.calendarEventId?.let { deleteEvent(context, it) }
val start = defaultStartMs(treatment, System.currentTimeMillis())
val end = start + 30 * 60_000L // 30 min de bloc
val tz = TimeZone.getDefault().id
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "${treatment.name} · ${formatDoseLine(treatment)}")
put(
CalendarContract.Events.DESCRIPTION,
"Prise hormonale récurrente (Posologie : tous les " +
"${formatInterval(treatment)} j). Généré par HormoneTrack."
)
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, end)
put(CalendarContract.Events.EVENT_TIMEZONE, tz)
put(
CalendarContract.Events.RRULE,
rruleForIntervalDays(treatment.forecastIntervalDays ?: 1.0)
)
}
val eventUri = CalendarContract.Events.CONTENT_URI
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val eventUriInserted = context.contentResolver.insert(eventUri, values)!!
return ContentUris.parseId(eventUriInserted)
}
/**
* Supprime l'événement d'agenda du traitement (si présent).
*
* ⚠️ #50 bis (v1.3.5) : CALLER_IS_SYNCADAPTER exige AUSSI les paramètres
* ACCOUNT_NAME/ACCOUNT_TYPE ici — sans eux le provider lève
* IllegalArgumentException (avalée par le catch), et l'événement restait
* dans le provider après « switch OFF » + save. Validé émulateur : la
* suppression échouait silencieusement avant ce fix.
*/
fun deleteEvent(context: Context, eventId: Long) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(
CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME
)
.appendQueryParameter(
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.ACCOUNT_TYPE_LOCAL
)
.build()
try {
context.contentResolver.delete(uri, null, null)
} catch (_: Exception) {
// L'événement peut déjà avoir été supprimé côté agenda — ignorer.
}
}
private fun formatDoseLine(treatment: Treatment): String =
"${formatDose(treatment.doseAmount)} ${treatment.doseUnit}"
private fun formatInterval(treatment: Treatment): String =
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
// formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt
// (dédupliquée v1.8.2 — la même règle d'affichage des doses partout)
}

View File

@ -31,6 +31,10 @@ class DoseActionReceiver : BroadcastReceiver() {
val treatment = db.treatmentDao().getById(treatmentId) ?: return@launch val treatment = db.treatmentDao().getById(treatmentId) ?: return@launch
val amount = if (dose > 0.0) dose else treatment.doseAmount val amount = if (dose > 0.0) dose else treatment.doseAmount
db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount)) db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount))
// ⚠️ v1.4.0 (fix #52) : la dose loguée AVANCE la grille —
// reprogrammer immédiatement le rappel au créneau suivant
// (Posologie si configurée, sinon demain à la même heure).
AlarmScheduler(context).scheduleFor(treatment, db.doseLogDao().getAllOnce())
} finally { } finally {
pending.finish() pending.finish()
} }
@ -39,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

@ -12,8 +12,14 @@ import androidx.core.app.NotificationCompat
import com.hormonetrack.MainActivity import com.hormonetrack.MainActivity
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import java.util.Calendar import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.util.AppLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
object ReminderContract { object ReminderContract {
const val CHANNEL_ID = "hormonetrack_reminders" const val CHANNEL_ID = "hormonetrack_reminders"
@ -24,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
@ -47,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(),
@ -59,32 +82,42 @@ class AlarmScheduler(private val context: Context) {
) )
} }
private fun nextOccurrence(hour: Int, minute: Int): Long {
val cal = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (cal.timeInMillis <= System.currentTimeMillis()) {
cal.add(Calendar.DAY_OF_MONTH, 1)
}
return cal.timeInMillis
}
fun canScheduleExact(): Boolean = fun canScheduleExact(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms() Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
fun scheduleDaily(treatment: Treatment): Boolean { /**
val hour = treatment.reminderHour ?: return false * Programme la PROCHAINE alarme du traitement (v1.4.0, fix #52) :
val minute = treatment.reminderMinute ?: return false * la date vient du MOTEUR (`nextReminderFireFor`) — unique source de
val triggerAt = nextOccurrence(hour, minute) * vérité — donc suit la Posologie quand elle existe (rappel le jour du
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount) * créneau uniquement), sinon quotidien. One-shot : l'alarme suivante est
return scheduleAt(triggerAt, pi) * reprogrammée par ReminderReceiver (après notif) et DoseActionReceiver
* (après « Pris »).
*/
fun scheduleFor(treatment: Treatment, doseLogs: List<DoseLog>): Boolean {
val fireAt = PharmacokineticEngine.nextReminderFireFor(
treatment, doseLogs, System.currentTimeMillis()
) ?: return false
// L'instant planifié voyage dans l'intent : le garde « créneau déjà
// honoré » (v1.10.1) évalue le jour PLANIFIÉ, pas celui du feu réel
// (une alarme inexacte peut glisser après minuit — cf
// ReminderContract.EXTRA_PLANNED_AT).
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount, fireAt)
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)
} }
@ -112,11 +145,15 @@ class AlarmScheduler(private val context: Context) {
alarmManager.cancel(pi) alarmManager.cancel(pi)
} }
fun rescheduleAll(treatments: List<Treatment>) { /**
* Reprogramme/annule tous les rappels. [doseLogs] alimente la grille
* Posologie (fix #52) ; vide = fallback quotidien (compat. appelants).
*/
fun rescheduleAll(treatments: List<Treatment>, doseLogs: List<DoseLog> = emptyList()) {
treatments.forEach { tr -> treatments.forEach { tr ->
// Un traitement inactif ne rappelle plus : son alarme est annulée // Un traitement inactif ne rappelle plus : son alarme est annulée
if (tr.reminderEnabled && tr.isActive) { if (tr.reminderEnabled && tr.isActive) {
scheduleDaily(tr) scheduleFor(tr, doseLogs)
} else { } else {
cancel(tr.id) cancel(tr.id)
} }
@ -182,8 +219,11 @@ class ReminderReceiver : BroadcastReceiver() {
} else { } else {
context.getString(R.string.reminder_title_plain) context.getString(R.string.reminder_title_plain)
} }
// ⚠️ reminder_text_with_dose attend un %s : passer le Double brut
// déclenche un warning lint StringFormatMatches (« suspicious type ») —
// formater explicitement en dose humaine (ex. « 4,0 »).
val text = if (dose != null && dose > 0.0) { val text = if (dose != null && dose > 0.0) {
context.getString(R.string.reminder_text_with_dose, dose) context.getString(R.string.reminder_text_with_dose, dose.toString())
} else { } else {
context.getString(R.string.reminder_text) context.getString(R.string.reminder_text)
} }
@ -200,26 +240,69 @@ class ReminderReceiver : BroadcastReceiver() {
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
// (grille Posologie si elle existe, sinon quotidien).
// v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée
// (prise saisie avant l'heure de rappel), la notification est SAUTÉE —
// elle ne doit pas sonner pour une prise déjà faite — et le rappel
// suivant repart au créneau suivant (re-schedule dans tous les cas).
// v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ
// (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte
// peut glisser après minuit sans changer de créneau.
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(treatmentId.toInt(), notification) val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
} else {
System.currentTimeMillis()
}
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val db = AppDatabase.getInstance(context)
val doses = db.doseLogDao().getAllOnce()
if (PharmacokineticEngine.hasDoseLoggedOnDay(
treatmentId, doses, plannedAtMs
)
) {
AppLog.log(
"reminder",
"créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)"
)
} else {
manager.notify(treatmentId.toInt(), notification)
}
db.treatmentDao().getById(treatmentId)?.let { tr ->
AlarmScheduler(context).scheduleFor(tr, doses)
}
} finally {
pending.finish()
}
}
} }
} }
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 treatments = kotlinx.coroutines.runBlocking { 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) ;
AppDatabase.getInstance(context).treatmentDao().getAllOnce() // les doses alimentent la grille Posologie (fix #52)
} val treatments = db.treatmentDao().getAllOnce()
AlarmScheduler(context).rescheduleAll(treatments) val doses = db.doseLogDao().getAllOnce()
AlarmScheduler(context).rescheduleAll(treatments, doses)
} finally { } finally {
result.finish() pending.finish()
} }
}.start() }
} }
} }

View File

@ -6,8 +6,11 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -15,7 +18,8 @@ import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
/** /**
* Préférences persistées (DataStore) : modèle T, langue, options d'affichage. * Préférences persistées (DataStore) : modèle T, langue, options d'affichage,
* seuils d'alerte (v1.4.2).
*/ */
class AppSettings(private val context: Context) { class AppSettings(private val context: Context) {
@ -27,6 +31,63 @@ class AppSettings(private val context: Context) {
/** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */ /** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */
val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate") val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate")
/** Dernière version pour laquelle le dialog « Nouveautés » a été montré. */
val CHANGELOG_SEEN = stringPreferencesKey("changelog_seen_version")
// ── Seuils d'alerte (v1.4.2) : limites E2 (pg/mL) et T (ng/mL). ──
// Clé absente = pas de seuil → pas d'alerte sur ce côté (opt-in pur).
val ALERT_E2_HIGH = doublePreferencesKey("alert_e2_high")
val ALERT_E2_LOW = doublePreferencesKey("alert_e2_low")
val ALERT_T_HIGH = doublePreferencesKey("alert_t_high")
val ALERT_T_LOW = doublePreferencesKey("alert_t_low")
/**
* État des alertes DÉJÀ NOTIFIÉES (encodage [Alerts.encodeState],
* ex. "E2:HIGH;T:LOW") — anti-spam du worker périodique : on ne
* re-notify pas le même franchissement toutes les 15 min.
*/
val ALERT_NOTIFIED_STATE = stringPreferencesKey("alert_notified_state")
/**
* Fuseau de LECTURE du graphique (v1.4.5) : ID java.util.TimeZone
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
*/
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
// ── Auto-backup journalier (v1.7.0) ────────────────────────────────
/** Activer/désactiver la sauvegarde automatique (défaut : false). */
val AUTO_BACKUP_ENABLED = booleanPreferencesKey("auto_backup_enabled")
/**
* Dossier cible SAF (v1.7.0) : tree URI issu d'ACTION_OPEN_DOCUMENT_TREE,
* persisté via takePersistableUriPermission (la permission SURVIT au
* reboot — le worker peut écrire sans intervention). null = pas de
* dossier choisi → l'auto-backup ne peut pas tourner (worker no-op).
*/
val AUTO_BACKUP_TREE_URI = stringPreferencesKey("auto_backup_tree_uri")
/**
* Nombre de copies d'auto-backup conservées dans le dossier (1..30,
* défaut 7) — les plus vieilles sont supprimées après chaque run
* (cf AutoBackupRetention ; l'export MANUEL n'est jamais touché).
*/
val AUTO_BACKUP_KEEP = intPreferencesKey("auto_backup_keep")
/** Résultat du DERNIER run auto : "ok" / "error" (null = jamais tourné). */
val AUTO_BACKUP_LAST_RESULT = stringPreferencesKey("auto_backup_last_result")
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
// les deux bandes sont volontairement indépendantes. Champ absent =
// cible désactivée (comportement v1.12).
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
} }
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs -> val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -46,6 +107,25 @@ class AppSettings(private val context: Context) {
prefs[Keys.AUTO_CALIBRATE] ?: false prefs[Keys.AUTO_CALIBRATE] ?: false
} }
/** Version dont les nouveautés ont déjà été montrées (null = jamais). */
val changelogSeenVersion: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.CHANGELOG_SEEN]
}
/**
* Seuils d'alerte (v1.4.2) : null = pas de limite pour la valeur
* (les champs laissés vides dans Paramètres). Évalués contre le taux
* ESTIMÉ actuel sur l'accueil (cf pk/Alerts.kt).
*/
val alertThresholds: Flow<Alerts.Thresholds> = context.dataStore.data.map { prefs ->
Alerts.Thresholds(
e2High = prefs[Keys.ALERT_E2_HIGH],
e2Low = prefs[Keys.ALERT_E2_LOW],
tHigh = prefs[Keys.ALERT_T_HIGH],
tLow = prefs[Keys.ALERT_T_LOW]
)
}
suspend fun setTConfig(config: TConfig) { suspend fun setTConfig(config: TConfig) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[Keys.T_BASE] = config.base prefs[Keys.T_BASE] = config.base
@ -65,4 +145,125 @@ class AppSettings(private val context: Context) {
prefs[Keys.AUTO_CALIBRATE] = enabled prefs[Keys.AUTO_CALIBRATE] = enabled
} }
} }
suspend fun setChangelogSeenVersion(version: String) {
context.dataStore.edit { prefs ->
prefs[Keys.CHANGELOG_SEEN] = version
}
}
/** État des alertes déjà notifiées (cf [Keys.ALERT_NOTIFIED_STATE]). */
val alertNotifiedState: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.ALERT_NOTIFIED_STATE]
}
/**
* Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
* (auto-détecté). Un ID invalide est impossible (validé à la saisie).
*/
val chartTimezone: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.CHART_TIMEZONE]
}
suspend fun setChartTimezone(timezoneId: String?) {
context.dataStore.edit { prefs ->
if (timezoneId.isNullOrBlank()) prefs.remove(Keys.CHART_TIMEZONE)
else prefs[Keys.CHART_TIMEZONE] = timezoneId
}
}
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
/** Auto-backup activé (défaut DÉSACTIVÉ — opt-in, comme tout ce qui écrit). */
val autoBackupEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_ENABLED] ?: false
}
/** Dossier cible SAF persisté (null = pas de dossier → worker no-op). */
val autoBackupTreeUri: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_TREE_URI]
}
/** Copies conservées (1..30, défaut 7 — borné au SET, cf setAutoBackupKeep). */
val autoBackupKeep: Flow<Int> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_KEEP] ?: 7
}
/** Résultat du dernier run : Pair(résultat, horodatage) ; null = jamais tourné. */
val autoBackupLastRun: Flow<Pair<String?, Long?>> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_LAST_RESULT] to prefs[Keys.AUTO_BACKUP_LAST_AT]
}
suspend fun setAutoBackupEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[Keys.AUTO_BACKUP_ENABLED] = enabled }
}
suspend fun setAutoBackupTreeUri(uri: String?) {
context.dataStore.edit { prefs ->
if (uri.isNullOrBlank()) prefs.remove(Keys.AUTO_BACKUP_TREE_URI)
else prefs[Keys.AUTO_BACKUP_TREE_URI] = uri
}
}
/** Copies conservées — borné [1 ; 30] (un `0` supprimerait la dernière copie). */
suspend fun setAutoBackupKeep(keep: Int) {
context.dataStore.edit { prefs ->
prefs[Keys.AUTO_BACKUP_KEEP] = keep.coerceIn(1, 30)
}
}
/**
* Persiste le résultat du dernier run (appelé par [com.hormonetrack.data.backup.AutoBackupWorker])
* — affiché dans Paramètres (« dernier : réussi/échec, date »).
*/
suspend fun setAutoBackupLastRun(ok: Boolean, atMs: Long) {
context.dataStore.edit { prefs ->
prefs[Keys.AUTO_BACKUP_LAST_RESULT] = if (ok) "ok" else "error"
prefs[Keys.AUTO_BACKUP_LAST_AT] = atMs
}
}
suspend fun setAlertNotifiedState(encoded: String?) {
context.dataStore.edit { prefs ->
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)
else prefs[Keys.ALERT_NOTIFIED_STATE] = encoded
}
}
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
val low = prefs[Keys.TROUGH_TARGET_LOW]
val high = prefs[Keys.TROUGH_TARGET_HIGH]
if (low != null && high != null) low to high else null
}
/**
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
* « champs vides = cible désactivée ». La cohérence high > low est
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
*/
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
context.dataStore.edit { prefs ->
if (target == null) {
prefs.remove(Keys.TROUGH_TARGET_LOW)
prefs.remove(Keys.TROUGH_TARGET_HIGH)
} else {
prefs[Keys.TROUGH_TARGET_LOW] = target.first
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
}
}
}
/**
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
* (DataStore : remove) — « champ vide = pas d'alerte ».
*/
suspend fun setAlertThresholds(t: Alerts.Thresholds) {
context.dataStore.edit { prefs ->
if (t.e2High != null) prefs[Keys.ALERT_E2_HIGH] = t.e2High else prefs.remove(Keys.ALERT_E2_HIGH)
if (t.e2Low != null) prefs[Keys.ALERT_E2_LOW] = t.e2Low else prefs.remove(Keys.ALERT_E2_LOW)
if (t.tHigh != null) prefs[Keys.ALERT_T_HIGH] = t.tHigh else prefs.remove(Keys.ALERT_T_HIGH)
if (t.tLow != null) prefs[Keys.ALERT_T_LOW] = t.tLow else prefs.remove(Keys.ALERT_T_LOW)
}
}
} }

View File

@ -0,0 +1,66 @@
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
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
*
* Le contenu vient de `docs/CHANGELOG.md` embarqué comme asset (synchronisé
* automatiquement à chaque build par la tâche Gradle `copyChangelog`).
*/
object ChangelogHelper {
/**
* Compare deux versions SemVer « X.Y.Z » NUMÉRIQUEMENT (composant par
* composant) — une comparaison lexicographique considérerait à tort
* « 1.2.9 > 1.2.10 ».
*/
fun isVersionNewer(current: String, reference: String?): Boolean {
if (reference.isNullOrBlank()) return true // première installation
val c = parse(current) ?: return true
val r = parse(reference) ?: return true
for (i in 0 until maxOf(c.size, r.size)) {
val a = c.getOrElse(i) { 0 }
val b = r.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false // identiques
}
private fun parse(version: String): List<Int>? {
val cleaned = version.trim().removePrefix("v")
if (!VERSION_PATTERN.matches(cleaned)) return null
return cleaned.split(".").map { it.toInt() }
}
/**
* Extrait de la CHANGELOG (markdown) les sections des versions STRICTEMENT
* plus récentes que `lastSeenVersion` (null = première installation → la
* version courante seule), jusqu'à `currentVersion` incluse.
*
* Format attendu : des sections « ## [X.Y.Z] — … » avec leur corps, la plus
* récente EN PREMIER (convention Keep a Changelog du dépôt).
*
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
*/
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
val pattern = SECTION_PATTERN
val out = StringBuilder()
for (match in pattern.findAll(markdown)) {
val version = match.groupValues[1]
// Sections triées de la plus récente à la plus ancienne :
// - inclure ce qui est STRICTEMENT plus récent que la dernière version
// vue (lastSeen null = première installation → section courante seule) ;
// - s'arrêter dès qu'on descend sous lastSeen (déjà vu).
if (lastSeenVersion != null && !isVersionNewer(version, lastSeenVersion)) break
if (lastSeenVersion == null && version != currentVersion) continue
if (out.isNotEmpty()) out.append("\n\n---\n\n")
out.append("## Version ").append(version).append("\n\n")
out.append(match.groupValues[2].trim())
}
return out.toString().trim()
}
}

View File

@ -15,8 +15,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.first
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
@ -41,8 +46,45 @@ 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
// Dialog « Nouveautés » (v1.3.0) : affiché après une mise à jour (ou à la
// première installation) avec le changelog des versions non vues ; la
// version vue est mémorisée dans DataStore → ne réapparaît pas avant la
// prochaine mise à jour.
var changelogText by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
val current = com.hormonetrack.BuildConfig.VERSION_NAME
val seen = app.container.settings.changelogSeenVersion.first()
com.hormonetrack.util.AppLog.log("changelog", "démarrage : app=$current, dernière vue=$seen")
val markdown = try {
context.assets.open("changelog.md").bufferedReader().use { it.readText() }
} catch (_: Exception) {
"" // asset manquant (build sans copyChangelog) → pas de dialog
}
val text = com.hormonetrack.settings.ChangelogHelper.sectionsSince(
markdown, seen, current
)
// ⚠️ FIX v1.3.2 : MÉMORISER LA VERSION VUE AVANT D'AFFICHER — sinon,
// si l'app est fermée pendant que le dialog est ouvert (ou que
// DataStore n'a pas eu le temps de flusher), le même changelog
// ressort à chaque réouverture.
app.container.settings.setChangelogSeenVersion(current)
if (text.isNotBlank()) {
changelogText = text
com.hormonetrack.util.AppLog.log("changelog", "nouveautés affichées (${text.length} car.)")
}
}
changelogText?.let { md ->
com.hormonetrack.ui.components.ChangelogDialog(
changelogMarkdown = md,
version = com.hormonetrack.BuildConfig.VERSION_NAME,
onDismiss = { changelogText = null }
)
}
CompositionLocalProvider(LocalAppContainer provides app.container) { CompositionLocalProvider(LocalAppContainer provides app.container) {
val navController = rememberNavController() val navController = rememberNavController()
val tabs = listOf( val tabs = listOf(
@ -90,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

@ -0,0 +1,70 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
/**
* Dialog « Nouveautés » (v1.3.0) : affiché au démarrage après une mise à jour
* (ou à la première installation) avec le changelog des versions depuis la
* dernière vue ; fermable, et ne réapparaît pas jusqu'à la prochaine mise à
* jour (la version vue est mémorisée dans DataStore par l'appelant).
*/
@Composable
fun ChangelogDialog(changelogMarkdown: String, version: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) }
},
title = { Text(stringResource(R.string.changelog_title, version)) },
text = {
Column(
Modifier
.fillMaxWidth()
.height(360.dp)
.verticalScroll(rememberScrollState())
) {
// Rendu simple du markdown : les lignes de titre ressortent en gras,
// le reste en texte courant (pas de lib de rendu markdown)
changelogMarkdown.lines().forEach { line ->
val trimmed = line.trim()
when {
trimmed.startsWith("###") -> Text(
trimmed.removePrefix("###").trim(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
trimmed.startsWith("##") -> Text(
trimmed.removePrefix("##").trim(),
style = MaterialTheme.typography.titleLarge
)
trimmed.startsWith("-") -> Text(
"• " + trimmed.removePrefix("-").trim(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
)
trimmed.isEmpty() -> Text(" ")
else -> Text(
trimmed,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
)
}

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
@ -13,16 +14,16 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke 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.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.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.ceil
/** /**
* Style d'une courbe dessinée sur le graphique. * Style d'une courbe dessinée sur le graphique.
@ -40,7 +41,22 @@ data class ChartSeries(
val points: List<LevelPoint>, val points: List<LevelPoint>,
val e2Style: CurveStyle, val e2Style: CurveStyle,
/** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */ /** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */
val tStyle: CurveStyle? = null val tStyle: CurveStyle? = null,
/**
* v1.5.0 : cette série participe-t-elle à la détection des pics/creux ?
* Défaut true (toutes les séries modèle) ; « Tracé labs » est FALSE —
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
* modèle, et `labIsSignificant` a déjà filtré les points aberrants.
*/
val showExtrema: Boolean = true,
/**
* v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC — cf
* pk/EstrannaiseCloud) : chaque sous-liste est une polyligne dessinée en
* alpha faible SOUS la courbe principale de la série. null = pas de nuage.
* Hors labels/extrema par construction (c'est de l'incertitude, pas des
* courbes nommées).
*/
val cloud: List<List<LevelPoint>>? = null
) )
data class ChartOptions( data class ChartOptions(
@ -51,6 +67,92 @@ data class ChartOptions(
val nowMs: Long = System.currentTimeMillis() val nowMs: Long = System.currentTimeMillis()
) )
/**
* Instants des LABELS X (v1.4.5, fix #55) : alignés sur les frontières LOCALES
* de [zone] (minuit local pour les pas en jours, heures rondes locales pour
* les pas horaires) et NON sur des multiples UTC — l'ancien alignement
* plaçait le label « 13/09 » à 02:00 du matin FR, ce qui décalait la lecture
* visuelle des événements (remontée : « la prévision simule mon injection le
* 13 au lieu du 12 »). [zone] = fuseau de LECTURE du graphique : celui du
* téléphone par défaut, ou celui choisi dans Paramètres (v1.4.5). Pur
* (Calendar + TimeZone) → testable en JVM.
*
* @return la liste croissante des instants de tick dans [t0 ; t1].
*/
fun xLabelTicks(
t0: Long,
t1: Long,
stepHours: Long,
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
): List<Long> {
if (t1 <= t0 || stepHours <= 0) return emptyList()
val cal = Calendar.getInstance(zone)
cal.timeInMillis = t0
if (stepHours >= 24) {
// pas en JOURS : frontières = MINUIT LOCAL (dans [zone])
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
val days = (stepHours / 24L).toInt().coerceAtLeast(1)
while (cal.timeInMillis <= t0) cal.add(Calendar.DAY_OF_MONTH, days)
} else {
// pas horaire : heures rondes LOCALES (dans [zone])
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
}
val out = mutableListOf<Long>()
var t = cal.timeInMillis
while (t <= t1) {
out.add(t)
// On avance via le Calendar (et non un +stepMs fixe) : une addition
// en millisecondes dérive d'1 h aux transitions DST, alors que le
// pas « 24 h » doit tomber sur minuit LOCAL tous les jours — y
// compris le jour du changement d'heure.
if (stepHours >= 24) {
cal.add(Calendar.DAY_OF_MONTH, (stepHours / 24L).toInt().coerceAtLeast(1))
} else {
cal.add(Calendar.HOUR_OF_DAY, stepHours.toInt())
}
t = cal.timeInMillis
}
return out
}
/**
* Convertit un delta de drag en heures de pan, en CUMULANT le résiduel
* fractionnaire (v1.4.10, fix #62).
*
* ⚠️ BUG #62 : l'ancien code tronquait CHAQUE delta incrémental en heures
* entières (`(pan.x / largeur * plage).toLong()`) — à la plage 24 h, le
* delta d'un doigt réel (~30 px ≈ 0,67 h) était tronqué à 0 → le pan ne
* bougeait JAMAIS sur la vue 24 h (alors qu'il marchait sur 7 j+ : le même
* delta ≈ 4,7 h). Le fix : le résiduel fractionnaire (la part < 1 h) est
* conservé d'un événement de mouvement au suivant — le pan se déclenche dès
* que le CUMUL franchit une heure entière.
*
* Pur → testable en JVM (`ChartZoomTest`).
*
* @param panPx delta horizontal du geste (px, signé).
* @param chartWidthPx largeur du canvas en px.
* @param rangeHours plage temporelle courante (24 h / 7 j / 30 j…).
* @param residualHours résiduel fractionnaire précédent (à ré-injecter).
* @return Pair(heures entières à appliquer au pan, nouveau résiduel).
*/
fun panDeltaHours(
panPx: Float,
chartWidthPx: Int,
rangeHours: Long,
residualHours: Double
): Pair<Long, Double> {
if (chartWidthPx <= 0 || rangeHours <= 0) return 0L to residualHours
val total = panPx / chartWidthPx * rangeHours + residualHours
val whole = total.toLong() // troncature vers ZÉRO (gère les deux signes)
return whole to (total - whole)
}
/** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */ /** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */
fun niceCeil(value: Double): Double { fun niceCeil(value: Double): Double {
if (value <= 0) return 100.0 if (value <= 0) return 100.0
@ -66,6 +168,123 @@ fun niceCeil(value: Double): Double {
return nice * exp return nice * exp
} }
/**
* Toggles de MODÈLES PK par défaut (v1.4.7, fix #58) : au chargement du
* graphique, seuls les modèles RÉELLEMENT utilisés par les traitements (en
* cours ou passés — `isActive` n'est pas un filtre de simulation, cf §6.bis)
* sont affichés ; les autres restent disponibles (chip off, activables au
* tap). Ex. EV historique sous TFS + EEn actuel sous WHSAH → TFS ✓ WHSAH ✓
* Estrannaise ✗.
*
* @param pkModelsUsed ensemble des `pkModel` des traitements de l'utilisatrice.
* @return map modèle → toggle initial. GARDE-FOU : si AUCUN modèle profil
* n'est utilisé (traitements gel/oral uniquement), TFS est forcé à ON —
* les traitements Bateman sont tracés IDENTIQUEMENT dans chaque série
* (cf §7.3b) : sans garde-fou, le graphique serait vide.
*/
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
val toggles = mapOf(
PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE),
PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE),
PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH)
)
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
return if (toggles.values.none { it }) toggles + (PKModels.TRANSFEM_SCIENCE to true) else toggles
}
/**
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
* plus on zoome, plus le pas finit pour des courbes lisses
* (≤ 12 h → 15 min ; ≤ 24 h → 30 min ; sinon 1 h).
*/
fun stepForRange(rangeHours: Long): Long = when {
rangeHours <= 12L -> 900_000L // 15 min
rangeHours <= 24L -> 1_800_000L // 30 min
else -> 3_600_000L // 1 h
}
/**
* Horizon de PRÉVISION en heures (v1.4.1) : quantité de doses projetées
* générées au-delà de « maintenant » quand le chip Prévision est actif.
* 12 × la plus grande Posologie, borné [30 j ; 365 j] — « scroller à
* l'infini » s'arrête à 1 an (compromis calcul : 365 j / 6 j ≈ 61 doses
* projetées superposées, coût O(points × doses) maîtrisé).
* @return null si aucun traitement n'a de Posologie (chip désactivé).
*/
fun forecastHorizonHours(maxIntervalDays: Double?): Long? {
if (maxIntervalDays == null || maxIntervalDays <= 0.0) return null
return (maxIntervalDays * 24.0 * 12.0).toLong().coerceIn(24L * 30, 24L * 365)
}
/**
* EXTENSION de fenêtre à droite quand le chip Prévision est activé
* (v1.4.4, fix #54) : le toggle doit avoir un effet IMMÉDIAT et visible.
*
* ⚠️ Historique : en v1.4.1, activer la prévision ne déplaçait plus la
* fenêtre (le saut « tout à droite » de v1.4.0 avait été rejeté) MAIS la
* projection ne devenait visible qu'en tirant vers la gauche — car
* `endMs = now` faisait que `generateForecastDoses(..., toMs = now, ...)`
* ne générait AUCUNE dose : le chip semblait inerte (« le toggle ne fait
* plus rien », remontée v1.4.3). La solution : étendre la fenêtre à droite
* SANS déplacer son début (l'historique visible reste à sa place, seule la
* largeur augmente) jusqu'à couvrir au moins la prochaine dose projetée,
* sinon d'un tiers de la plage actuelle.
*
* @param rangeHours plage courante (24 h / 7 j / 30 j…)
* @param horizonHours horizon de prévision ([forecastHorizonHours], ≤ 0 = pas de prévision)
* @param firstSlotGapHours écart « maintenant → premier créneau projeté »
* (null = aucun traitement avec Posologie)
* @return heures à ajouter à `endMs` (0 = rien).
*/
fun forecastExtensionHours(
rangeHours: Long,
horizonHours: Long,
firstSlotGapHours: Long?
): Long {
if (horizonHours <= 0L) return 0L
// Montrer AU MOINS la prochaine dose (+12 h de contexte), sinon 1/3 de
// la plage actuelle — jamais au-delà de l'horizon.
val toFirstSlot = (firstSlotGapHours ?: 0L) + 12L
return maxOf(rangeHours / 3, toFirstSlot).coerceIn(0L, horizonHours)
}
/**
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
* dans le FUTUR jusqu'à [-futureHorizonHours ; …] (panHours négatif =
* fenêtre décalée vers le futur). Unique point de clamp partagé par le
* drag et le zoom pour éviter les divergences.
*/
fun clampPanHours(panHours: Long, futureHorizonHours: Long, maxPastHours: Long): Long =
panHours.coerceIn(-futureHorizonHours.coerceAtLeast(0L), maxPastHours.coerceAtLeast(0L))
/**
* Point le plus PROCHE de `hours` heures AVANT le dernier point de la série
* (v1.4.3, fix #53) : utilisé par le delta « vs il y a 6 h » de la carte
* d'accueil.
*
* ⚠️ BUG corrigé ici : l'ancien code faisait `curve.firstOrNull { écart >= 6 h }`
* sur une fenêtre de 24 h → le PREMIER point de la liste (il y a ~24 h !)
* satisfaisait la condition immédiatement, et le delta affiché « vs 6 h »
* comparait en réalité à il y a 24 h (remontée : « −11 pg/mL » le lendemain
* d'une injection EEn, cf régression #4). Cette fonction prend le point dont
* l'écart au dernier est le plus PETIT au-dessus de `hours` (par excès —
* avec une grille régulière, c'est le point à ~6 h exactement).
*
* @return null si aucun point n'est au-delà de `hours` (courbe trop courte).
*/
fun pointHoursBefore(points: List<LevelPoint>, hours: Double): LevelPoint? {
val last = points.lastOrNull() ?: return null
var best: LevelPoint? = null
var bestGap = Double.MAX_VALUE
for (p in points) {
val gap = (last.timestamp - p.timestamp) / 3_600_000.0
if (gap < hours) continue // seulement AVANT le point de référence
if (gap < bestGap) { bestGap = gap; best = p }
}
return best
}
/** /**
* Graphique Canvas pur, multi-séries : * Graphique Canvas pur, multi-séries :
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ; * - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;
@ -73,7 +292,10 @@ fun niceCeil(value: Double): Double {
* - les labs E2 sont des points orange ronds, les labs T des carrés orange * - les labs E2 sont des points orange ronds, les labs T des carrés orange
* (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs * (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs
* peuvent être saisis en ng/dL ou nmol/L) ; * peuvent être saisis en ng/dL ou nmol/L) ;
* - ligne verticale « maintenant ». * - ligne verticale « maintenant » ;
* - [forecastDoses] (v1.4.5) : les doses PRÉVISIONNELLES sont marquées par une
* ligne verticale pointillée + un triangle ▼ en haut — sinon elles sont
* invisibles et la projection paraît décalée d'un jour (bug #55).
*/ */
@Composable @Composable
fun CurveChart( fun CurveChart(
@ -81,18 +303,61 @@ fun CurveChart(
e2Labs: List<LabResult>, e2Labs: List<LabResult>,
tLabs: List<LabResult>, tLabs: List<LabResult>,
options: ChartOptions, options: ChartOptions,
modifier: Modifier modifier: Modifier,
/** Doses PRÉVISIONNELLES (jamais persistées) à marquer sur le graphique. */
forecastDoses: List<DoseLog> = emptyList(),
/** Doses RÉELLES enregistrées : marqueurs discrets (triangles bas) pour
* la lisibilité des prises passées (v1.4.5). */
realDoses: List<DoseLog> = emptyList(),
/** Fuseau de LECTURE du graphique (téléphone par défaut, choix Paramètres). */
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
) { ) {
val gridColor = MaterialTheme.colorScheme.outlineVariant val gridColor = MaterialTheme.colorScheme.outlineVariant
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val nowLineColor = MaterialTheme.colorScheme.tertiary val nowLineColor = MaterialTheme.colorScheme.tertiary
// Couleurs des marqueurs (lues hors Canvas : MaterialTheme n'est pas
// appelable dans le DrawScope)
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
// se redessine à chaque geste).
val extremaBySeries = remember(series, options) {
series.map { s ->
Pair(
if (s.showExtrema) {
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
} else emptyList(),
if (s.showExtrema) {
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
} else null
)
}
}
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
// thread-safe — thread UI seul ici — et l'allouer par frame était du
// gaspillage, même standard que le fix des marqueurs v1.8.2).
val markerTimeFmt = remember(zone) {
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
}
val xAxisHourFmt = remember(zone) {
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
}
val xAxisDayFmt = remember(zone) {
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
}
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val allPoints = series.flatMap { it.points } val allPoints = series.flatMap { it.points }
if (allPoints.size < 2) return@Canvas if (allPoints.size < 2) return@Canvas
val padLeft = 42.dp.toPx() val padLeft = 42.dp.toPx()
val padRight = 42.dp.toPx() val padRight = 42.dp.toPx()
val padTop = 12.dp.toPx() // v1.7.0 : padTop élargi (12 → 26 dp) pour loger les UNITÉS d'axe au
// sommet des colonnes de labels (remontée « on n'a jamais ajouté les
// unités » — l'axe gauche était des nombres nus depuis la v1.0).
val padTop = 26.dp.toPx()
val padBottom = 26.dp.toPx() val padBottom = 26.dp.toPx()
val w = size.width - padLeft - padRight val w = size.width - padLeft - padRight
@ -125,13 +390,87 @@ fun CurveChart(
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4) drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h) drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
// Unités des axes (v1.7.0 — remontée : « on n'a jamais ajouté les
// unités des mesures dans les axes ») : pg/mL pour l'E2 (axe gauche),
// ng/mL pour la T (axe droit) — dessinées au SOMMET de chaque colonne
// de labels, au-dessus du premier tick (padTop élargi pour ça).
// ⚠️ Ce ne sont PAS des strings i18n : pg/mL et ng/mL sont des unités
// universelles, déjà en dur dans les légendes du chart.
drawAxisUnit(E2_UNIT, padLeft - 6.dp.toPx(), padTop - 6.dp.toPx(),
android.graphics.Paint.Align.RIGHT)
if (options.showT && series.any { it.tStyle != null }) { if (options.showT && series.any { it.tStyle != null }) {
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h) drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
android.graphics.Paint.Align.LEFT)
}
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone, xAxisHourFmt, xAxisDayFmt)
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
if (forecastDoses.isNotEmpty()) {
val markerColor = forecastMarkerColor
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
// redessine à chaque pan/zoom, une allocation par dose × frame
// était du gaspillage (la classe n'est de toute façon pas
// thread-safe — ici on reste dans le DrawScope UI)
val hourFmt = markerTimeFmt
for (dose in forecastDoses) {
if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp)
drawLine(
markerColor,
Offset(x, padTop + 14.dp.toPx()),
Offset(x, padTop + h),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))
)
val r = 4.dp.toPx()
val path = Path().apply {
moveTo(x, padTop + 14.dp.toPx())
lineTo(x - r, padTop + 6.dp.toPx())
lineTo(x + r, padTop + 6.dp.toPx())
close()
}
drawPath(path, markerColor)
// heure de la dose simulée DANS le fuseau de lecture
drawContext.canvas.nativeCanvas.drawText(
hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint
)
}
}
// 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) {
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())
)
} }
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w)
// Une courbe E2 + une courbe T par série // Une courbe E2 + une courbe T par série
for (s in series) { for ((sIdx, s) in series.withIndex()) {
if (s.points.size < 2) continue if (s.points.size < 2) continue
val e2Path = Path() val e2Path = Path()
s.points.forEachIndexed { i, p -> s.points.forEachIndexed { i, p ->
@ -169,18 +508,22 @@ fun CurveChart(
} }
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée // Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
// (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux) // (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
if (options.showExtrema) { // v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0) // v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
.forEach { ex -> // Canvas, recalculés seulement quand séries/options changent) — le
// scan local était refait PAR FRAME, x2 séries.
if (options.showExtrema && s.showExtrema) {
extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
e2Extrema.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color) drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
} }
if (options.showT) { if (options.showT) {
s.tStyle?.let { tStyle -> s.tStyle?.let { tStyle ->
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) tExtrema.orEmpty().forEach { ex ->
.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color) drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
} }
}
} }
} }
} }
@ -208,6 +551,26 @@ fun CurveChart(
} }
} }
// Doses RÉELLES (v1.4.5) : petits triangles ▲ discrets au bas de la
// zone — un « peigne » des prises passées, lisible sans surcharger
// (pas de ligne verticale : les injections sont fréquentes).
if (realDoses.isNotEmpty()) {
val realColor = realDoseMarkerColor
for (dose in realDoses) {
if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp)
val r = 3.5.dp.toPx()
val base = padTop + h
val path = Path().apply {
moveTo(x, base - 8.dp.toPx())
lineTo(x - r, base)
lineTo(x + r, base)
close()
}
drawPath(path, realColor)
}
}
// Repère « maintenant » // Repère « maintenant »
if (options.nowMs in t0..t1) { if (options.nowMs in t0..t1) {
drawLine( drawLine(
@ -225,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()
@ -245,46 +608,77 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetr
} }
path.close() path.close()
drawPath(path, color) drawPath(path, color)
// Valeur estimée du pic/creux, dans la couleur de la courbe // Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
val paint = android.graphics.Paint().apply { // en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
this.color = android.graphics.Color.argb( val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
)
textSize = 9.dp.toPx()
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
formatValue(extremum.value), cx, valueY, paint formatValue(extremum.value), cx, valueY, paint
) )
} }
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) { /** Unité de l'axe E2 (gauche) — universelle, pas une string i18n (cf drawAxisUnit). */
private const val E2_UNIT = "pg/mL"
/** Unité de l'axe T (droite) — idem. */
private const val T_UNIT = "ng/mL"
/**
* Libellé d'UNITÉ d'axe (v1.7.0) : petit texte gris au sommet d'une colonne
* de labels Y, aligné comme les nombres de sa colonne ([align] RIGHT pour
* l'axe gauche, LEFT pour l'axe droit).
*/
private fun DrawScope.drawAxisUnit(
text: String,
x: Float,
baseline: Float,
align: android.graphics.Paint.Align
) {
drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align))
}
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) {
for (i in 0..rows) {
val y = padTop + h * i / rows val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
} }
} }
/** Paint partagé pour les labels texte (couleur gris neutre fixe). */ /**
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint { * Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
val c = Color(0xFF6B7280) * (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
return android.graphics.Paint().apply { * label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
color = android.graphics.Color.argb( * dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
(c.alpha * 255).toInt(), * Les clés sont des value classes (Color) → pas de fuite.
(c.red * 255).toInt(), */
(c.green * 255).toInt(), private val textPaintCache = HashMap<String, android.graphics.Paint>()
(c.blue * 255).toInt()
) private fun DrawScope.textPaint(
textSize = 10.dp.toPx() color: Color,
textAlign = align textSizeDp: Float,
isAntiAlias = true align: android.graphics.Paint.Align,
fakeBold: Boolean = false
): android.graphics.Paint {
val key = "$color|$textSizeDp|$align|$fakeBold"
return textPaintCache.getOrPut(key) {
android.graphics.Paint().apply {
this.color = android.graphics.Color.argb(
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
)
textSize = textSizeDp.dp.toPx()
textAlign = align
isAntiAlias = true
isFakeBoldText = fakeBold
}
} }
} }
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
textPaint(Color(0xFF6B7280), 10f, align)
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) { private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
val paint = labelPaint(android.graphics.Paint.Align.RIGHT) val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
for (i in 0..4) { for (i in 0..4) {
@ -305,35 +699,44 @@ 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(),
// 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
// Pas des labels X adaptatif — le zoom (v1.2.9) descend jusqu'à quelques heures
val stepHours = when { val stepHours = when {
spanH <= 8 -> 1
spanH <= 18 -> 3
spanH <= 25 -> 6 spanH <= 25 -> 6
spanH <= 25 * 7 -> 24 spanH <= 25 * 7 -> 24
else -> 24 * 5 else -> 24 * 5
} }
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault()) // v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
else SimpleDateFormat("dd/MM", Locale.getDefault()) // tick « minuit local » serait étiqueté avec l'heure système.
var tick = ceil(t0 / (stepHours * 3_600_000.0)).toLong() * stepHours * 3_600_000L // v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
while (tick <= t1) { val fmt = if (spanH <= 25) hourFmt else dayFmt
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
// (minuit local pour les pas en jours) — l'ancien alignement sur des
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
// v1.9.5 (audit) : la ZONE de lecture est passée à xLabelTicks — l'oubli
// alignait les ticks sur le fuseau de l'appareil pendant que les labels
// sont formatés dans le fuseau choisi (heures décalées dès que les deux
// divergent, contredisant le fix #55).
for (tick in xLabelTicks(t0, t1, stepHours.toLong(), zone)) {
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)
tick += stepHours * 3_600_000L
} }
} }
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

@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
/** /**
@ -139,7 +140,12 @@ fun DoseDialog(
text = { Text(stringResource(R.string.ester_default, selected.esterType)) }, text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
onClick = { esterOverride = null; esterExpanded = false } onClick = { esterOverride = null; esterExpanded = false }
) )
listOf(Esters.EV, Esters.EU, Esters.EEN).forEach { e -> // v1.4.0/v1.4.6 : la liste d'esters suit le MODÈLE
// du traitement (helper pur Esters.choicesForModel,
// testé) — TFS = 7 esters (V3C), WHSAH = 6 (PEP non
// couvert), Estrannaise (ODS) = EV/EU/EEn seulement.
val choices = Esters.choicesForModel(selected.pkModel)
choices.forEach { e ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(esterLabel(e)) }, text = { Text(esterLabel(e)) },
onClick = { esterOverride = e; esterExpanded = false } onClick = { esterOverride = e; esterExpanded = false }
@ -175,6 +181,10 @@ private fun esterLabel(ester: String): String = when (ester) {
Esters.EV -> "EV — valerate" Esters.EV -> "EV — valerate"
Esters.EU -> "EU — undecylate" Esters.EU -> "EU — undecylate"
Esters.EEN -> "EEn — enanthate" Esters.EEN -> "EEn — enanthate"
Esters.EB -> "EB — benzoate"
Esters.EC -> "EC — cypionate (oil)"
Esters.ECS -> "EC — cypionate (suspension)"
Esters.PEP -> "PEP — polyestradiol phosphate"
else -> ester else -> ester
} }

View File

@ -1,6 +1,9 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -9,12 +12,18 @@ 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -25,6 +34,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -32,8 +42,10 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.PKModels
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.EstrannaiseCloud
import com.hormonetrack.pk.LabTrajectoryModel
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
@ -42,24 +54,37 @@ import com.hormonetrack.ui.components.ChartSeries
import com.hormonetrack.ui.components.CurveChart import com.hormonetrack.ui.components.CurveChart
import com.hormonetrack.ui.components.CurveStyle import com.hormonetrack.ui.components.CurveStyle
import com.hormonetrack.ui.theme.ChartE2 import com.hormonetrack.ui.theme.ChartE2
import com.hormonetrack.ui.theme.ChartLabTrajectory
import com.hormonetrack.ui.theme.ChartT import com.hormonetrack.ui.theme.ChartT
import com.hormonetrack.ui.theme.ChartTWhs
import com.hormonetrack.ui.theme.ChartWhs
import com.hormonetrack.ui.theme.LabDot import com.hormonetrack.ui.theme.LabDot
import com.hormonetrack.ui.theme.TealTertiary import com.hormonetrack.ui.theme.TealTertiary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.hormonetrack.ui.components.clampPanHours
import com.hormonetrack.ui.components.defaultModelToggles
import com.hormonetrack.ui.components.forecastExtensionHours
import com.hormonetrack.ui.components.forecastHorizonHours
import com.hormonetrack.ui.components.panDeltaHours
import com.hormonetrack.ui.components.stepForRange
private const val HOUR_MS = 3_600_000L private const val HOUR_MS = 3_600_000L
private const val DAY_MS = 24 * HOUR_MS // Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
private const val MIN_RANGE_H = 6L
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
@ -78,13 +103,63 @@ fun ChartScreen() {
val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val labResults by repo.allLabResults.collectAsState(initial = emptyList())
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Fuseau de LECTURE du graphique (v1.4.5) : choisi dans Paramètres ou
// fuseau du téléphone (auto). Sert à l'alignement minuit des labels X et
// à l'heure affichée des marqueurs de doses.
val chartTimezoneId by container.settings.chartTimezone.collectAsState(initial = null as String?)
val chartZone = remember(chartTimezoneId) {
chartTimezoneId?.let { id ->
runCatching { java.util.TimeZone.getTimeZone(id) }.getOrNull()
} ?: java.util.TimeZone.getDefault()
}
// Zoom temporel (v1.2.9) : plage bornée 6 h → 300 j, pinch + boutons
var rangeHours by remember { mutableLongStateOf(24L) } var rangeHours by remember { mutableLongStateOf(24L) }
var panHours by remember { mutableLongStateOf(0L) } var panHours by remember { mutableLongStateOf(0L) }
var showT by remember { mutableStateOf(true) } var showT by remember { mutableStateOf(true) }
var showLabs by remember { mutableStateOf(true) } var showLabs by remember { mutableStateOf(true) }
var showEse by remember { mutableStateOf(true) } var showEse by remember { mutableStateOf(true) }
var showTfs by remember { mutableStateOf(true) } var showTfs by remember { mutableStateOf(true) }
// v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona)
var showWhs by remember { mutableStateOf(false) }
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, cf
// pk/LabTrajectoryModel.kt) — OFF par défaut : opt-in, la courbe
// revendique « passer par tes labs » et doit rester une référence de
// comparaison, pas un affichage par défiant
var showLabTrack by remember { mutableStateOf(false) }
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC de
// estrannaise.js — cf pk/EstrannaiseCloud) — OFF par défaut, opt-in,
// activable/désactivable à volonté ; ACTIVABLE SEULEMENT quand le
// modèle ESE est affiché (le nuage est exclusif à ce modèle — les
// fits TFS/WHSAH ne publient pas de posterior). Se désactive
// automatiquement si ESE est éteint.
var showCloud by remember { mutableStateOf(false) }
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
// dernier lab constant × modèle, bornée par l'extinction du modèle —
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
// partie prolongée est une ESTIMATION non ancrée, elle doit être
// demandée explicitement ; le chip n'est actif que si Tracé labs est ON.
var showLabTrackExtend by remember { mutableStateOf(false) }
// v1.4.7 (fix #58) : au CHARGEMENT des traitements, les toggles sont
// alignés sur les modèles réellement UTILISÉS par des traitements à
// PROFIL PK (injection + ester, `usesProfileModel`) — en cours ou passés
// (cf §6.bis : EV inactif reste simulé) ; les autres modèles restent
// activables au tap. ⚠️ FILTRER par usesProfileModel : sinon un
// traitement oral/anti-androgène (pkModel stocké = ESE par défaut, sans
// courbe profil) activerait Estrannaise à tort (vu sur l'émulateur avec
// le CPA seedé). Une seule initialisation.
var modelTogglesInitialized by remember { mutableStateOf(false) }
LaunchedEffect(treatments) {
if (!modelTogglesInitialized && treatments.isNotEmpty()) {
val toggles = defaultModelToggles(
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
)
showEse = toggles.getValue(PKModels.ESTRANNAISE)
showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
showWhs = toggles.getValue(PKModels.WHSAH)
modelTogglesInitialized = true
}
}
var showForecast by remember { mutableStateOf(false) } var showForecast by remember { mutableStateOf(false) }
var showExtrema by remember { mutableStateOf(false) } var showExtrema by remember { mutableStateOf(false) }
var chartWidthPx by remember { mutableIntStateOf(0) } var chartWidthPx by remember { mutableIntStateOf(0) }
@ -100,43 +175,184 @@ fun ChartScreen() {
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T, // Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// recalculés à la volée depuis les labs. null = option désactivée. // recalculés à la volée depuis les labs. null = option désactivée.
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>( // ── Calibration PAR MODÈLE (v1.4.8, fix #60) ──────────────────────────────
null, treatments, doseLogs, labResults, tConfig, autoCalibrate // Demande : « la calibration doit être active sur tous les modèles si elle
// est activée, mais les facteurs ne doivent pas être identiques entre les
// profils PK ». AVANT : un SEUL jeu de facteurs calculé depuis le modèle
// STOCKÉ des traitements puis partagé par toutes les courbes → en
// basculant un traitement (ex. EEn TFS → WHSAH), le facteur devenait
// absurde (×2,21, remontée). DÉSORMAIS : une AutoCalibrated PAR modèle
// (ESE/TFS/WHS), chacune calibrée avec la prédiction DE SON modèle →
// chaque courbe affichée colle à ses labs. Home continue d'utiliser le
// modèle stocké (calcul sans override).
val autoByModel by produceState<Map<String, PharmacokineticEngine.AutoCalibrated>>(
emptyMap(), treatments, doseLogs, labResults, tConfig, autoCalibrate
) { ) {
value = if (!autoCalibrate) { value = if (!autoCalibrate) {
null emptyMap()
} else { } else {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) mapOf(
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
),
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
),
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
)
)
} }
} }
} }
// Horizon de prévision : 2 × le plus grand intervalle configuré (borné 7–30 jours) // Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() // [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
val forecastHorizonHours = maxIntervalDays?.let { (it * 24 * 2).toLong().coerceIn(DAY_MS / HOUR_MS * 7, DAY_MS / HOUR_MS * 30) } // ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
// Posologie d'un traitement archivé ne doit ni activer le chip
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
// créneaux, cf generateForecastDoses).
val maxIntervalDays = treatments
.filter { it.isActive }
.mapNotNull { it.forecastIntervalDays }
.maxOrNull()
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
// Fenêtre temporelle courante : endMs recule quand on tire vers la droite // ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
val nowMs = System.currentTimeMillis() // ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
// tick minute — relu frais à chaque recomposition, il faisait dériver
// endMs → les keys du producer (endMs) changeaient à chaque frame →
// re-calcul perpétuel → main thread saturé (« Skipped 52 frames ») → les
// taps sur les chips étaient perdus. Les vals dérivées de nowMs sont
// donc stables entre recompositions et ne bougent qu'au tick (60 s).
val nowMs = remember(tick) { System.currentTimeMillis() }
val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs
val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L) val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L)
val endMs = nowMs - panHours * HOUR_MS + val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L
(if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L) // Gap jusqu'au 1ᵉʳ créneau projeté : MÉMOÏSÉ (le calcul de créneaux dans
// le corps du composable tournerait à chaque recomposition — cf #56).
val firstSlotGapH = remember(treatments, doseLogs, forecastHorizonHours) {
if (forecastHorizonHours == null) null
else {
val horizonMs = forecastHorizonHours * HOUR_MS
val at = System.currentTimeMillis()
treatments.flatMap { tr ->
PharmacokineticEngine.generateForecastDoses(tr, doseLogs, at + horizonMs, at)
}.minOfOrNull { it.timestamp }?.let { (it - at) / HOUR_MS }
}
}
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
forecastExtensionHours(
rangeHours, forecastHorizonHours, firstSlotGapH
)
} else 0L
val endMs = nowMs - panHours * HOUR_MS + forecastExtensionH * HOUR_MS
val startMs = endMs - rangeHours * HOUR_MS val startMs = endMs - rangeHours * HOUR_MS
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") // ⚠️ FIX #54 (v1.4.4) — captures FIGÉES dans pointerInput(Unit) : la
val curves by produceState( // closure du gestionnaire de gestes est créée UNE SEULE FOIS ; les vals
emptyList<Pair<String, List<LevelPoint>>>(), // calculées ci-dessus y étaient capturées à leur valeur de la PREMIÈRE
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast // composition (showForecast = false au lancement → futurePanHorizon = 0)
// → le clamp `coerceIn(0, maxPan)` INTERDISAIT le pan vers le futur pour
// toujours : le chip activé + le drag vers la gauche ne généraient JAMAIS
// la projection (« le toggle ne fait rien »). rememberUpdatedState expose
// ces valeurs comme des états lus À JOUR par la closure (les MutableState
// — panHours, rangeHours, showForecast — n'ont pas ce problème).
val gestureFutureHorizon by rememberUpdatedState(futurePanHorizon)
val gestureMaxPan by rememberUpdatedState(maxPanHours)
val gestureExtension by rememberUpdatedState(forecastExtensionH)
// Résiduel fractionnaire du pan (fix #62) : conserve la part < 1 h des
// deltas pour que le pan fonctionne sur la vue 24 h (les deltas de doigt
// y sont inférieurs à une heure entière)
var panResidualHours by remember { mutableStateOf(0.0) }
// Doses PRÉVISIONNELLES (v1.4.5) : les mêmes que la courbe utilise —
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
// décalée d'un jour).
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
// recomposition (leçon #65 : un producer doit redériver de ses vraies
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
val forecastDoses by produceState(
emptyList<com.hormonetrack.data.model.DoseLog>(),
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
) { ) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>() value = if (showForecast && forecastHorizonHours != null) {
if (showEse || showTfs) { treatments.flatMap {
// Valeurs actuelles : échelles par ester si auto-calibration active, PharmacokineticEngine.generateForecastDoses(
// sinon le scaleFactor stocké de chaque traitement it, doseLogs, endMs, System.currentTimeMillis()
val effTreatments = effectiveAuto?.treatments ?: treatments )
val effTConfig = effectiveAuto?.tConfig ?: tConfig }
val effScales = effectiveAuto?.esterScales } else emptyList()
}
}
/**
* Une courbe rendue par le producer : clé de modèle ("ESE"/"TFS"/"WHS"/
* "LAB"/"LABX"), ses points, et — v1.9.0 — son NUAGE D'INCERTITUDE
* (uniquement pour "ESE" quand le chip Nuage est actif).
*/
data class ChartCurve(
val model: String,
val points: List<LevelPoint>,
val cloud: List<List<LevelPoint>> = emptyList()
)
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") —
// v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ;
// v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant)
val curves by produceState(
emptyList<ChartCurve>(),
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
showLabTrack, showLabTrackExtend, showCloud,
// Clé = la liste ENTIÈRE des labs (pas .size, fix #66) : éditer la
// VALEUR d'une analyse doit recalculer les courbes
labResults
) {
withContext(Dispatchers.Default) {
val list = mutableListOf<ChartCurve>()
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
// Totalement indépendante des toggles de modèles (elle utilise le
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
// et de la calibration (elle EST sa propre calibration continue —
// scalePerEster ignoré, épinglé par LabTrajectoryModelTest).
// showLabTrack + série visible UNIQUEMENT si ≥ 2 labs qui ancrent.
//
// ── v1.6.0 : split ancré / prolongé ──────────────────────────────
// Avec « Prolonger », la série est SPLITTÉE au dernier lab
// significatif (lastAnchorMs) : "LAB" = partie ancrée (passe par
// les labs), "LABX" = partie prolongée (M(t) × ρ_last — une
// estimation). Deux traits distincts (LABX en atténué) préservent
// l'honnêteté de l'affichage : la partie inventée ne peut pas se
// confondre avec la partie mesurée. ⚠️ Deux clés DISTINCTES et
// non vides seulement — la légende (plus bas) doit connaître
// "LABX" AVANT son else (leçon #63).
if (showLabTrack) {
val step = stepForRange(rangeHours)
val labCurve = LabTrajectoryModel.computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step,
extendBeyondLastLab = showLabTrackExtend
)
val lastAnchor = labCurve.lastAnchorMs
if (showLabTrackExtend && lastAnchor != null) {
val anchored = labCurve.points.filter { it.timestamp <= lastAnchor }
val extended = labCurve.points.filter { it.timestamp > lastAnchor }
list.add(ChartCurve("LAB", anchored))
if (extended.isNotEmpty()) list.add(ChartCurve("LABX", extended))
} else {
list.add(ChartCurve("LAB", labCurve.points))
}
}
if (showEse || showTfs || showWhs) {
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
// échelles/k calculés avec SA prédiction. Hors auto-calibration
// → scaleFactor stocké des traitements (comportement historique).
val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
// Doses réelles + doses prévisionnelles éventuelles // Doses réelles + doses prévisionnelles éventuelles
val forecastDoses = if (showForecast) { val forecastDoses = if (showForecast) {
effTreatments.flatMap { effTreatments.flatMap {
@ -144,32 +360,40 @@ fun ChartScreen() {
} }
} else emptyList() } else emptyList()
val allDoses = doseLogs + forecastDoses val allDoses = doseLogs + forecastDoses
if (showEse) { // Pas d'échantillonnage adaptatif au zoom (v1.2.9)
list.add( val step = stepForRange(rangeHours)
"ESE" to PharmacokineticEngine.computeCurve( // v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
effTreatments, allDoses, startMs, endMs, // le modèle est la seule variable (même calcul, même calibration
tConfig = effTConfig, modelOverride = "ESE", // PAR MODÈLE via autoByModel[model], fix #60)
scalePerEster = effScales, for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
tKPerEster = effectiveAuto?.tKPerEster if (!enabled) continue
) val autoM = autoByModel[model]
val curve = PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
) )
} // v1.9.0 : le nuage d'incertitude est attaché à la série
if (showTfs) { // ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
list.add( // publient pas de posterior MCMC)
"TFS" to PharmacokineticEngine.computeCurve( val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
effTreatments, allDoses, startMs, endMs, // MÊME calibration que la courbe ESE (fix v1.9.1 :
tConfig = effTConfig, modelOverride = "TFS", // nuage brut ≠ échelle de la courbe calibrée)
scalePerEster = effScales, EstrannaiseCloud.compute(
tKPerEster = effectiveAuto?.tKPerEster 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
@ -181,14 +405,37 @@ fun ChartScreen() {
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ; // Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
// T Estrannaise = rose plein, T TFS = rose pointillé. // T Estrannaise = rose plein, T TFS = rose pointillé.
val series = curves.map { (model, points) -> val series = curves.map { (model, points, cloud) ->
if (model == "ESE") { when (model) {
ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT)) PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
} else { // v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true)) PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
"LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null,
showExtrema = false)
// v1.6.0 : partie PROLONGÉE du tracé labs — même couleur ATTÉNUÉE
// (alpha 0,55) pour distinguer visuellement l'estimation non
// ancrée de la partie qui passe par les labs. Même exclusions
// (pas de T, pas d'extrema — les extrema de l'interp ρ constant
// ne sont que ceux du modèle brut).
"LABX" -> ChartSeries(
points,
CurveStyle(ChartLabTrajectory.copy(alpha = 0.55f), dashed = true),
null,
showExtrema = false
)
else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
} }
} }
// ⚠️ PAS de verticalScroll ici (fix #62) : le scroll parent AVALE le
// drag horizontal du graphique (pan + pinch, cf §11) — le pan était
// totalement mort avec lui (vu en émulateur v1.4.9). Le contenu tient
// sur les écrans standards (chips en 3 rangées compactes + carte 320 dp,
// vérifié par dump §16.ter) ; si un futur ajout repousse la carte sous
// le pli : compacter les rangées plutôt que re-rajouter un scroll.
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -217,6 +464,23 @@ fun ChartScreen() {
onClick = { rangeHours = 24L * 30 }, onClick = { rangeHours = 24L * 30 },
label = { Text(stringResource(R.string.chart_30j)) } label = { Text(stringResource(R.string.chart_30j)) }
) )
Spacer(Modifier.width(4.dp))
// Zoom manuel (accessibilité — le pinch fonctionne aussi) — convention
// carte : « + » = zoom AVANT (fenêtre plus courte, plus de détail),
// « − » = zoom ARRIÈRE (fenêtre plus longue). Inversé en v1.2.10 :
// l'ancien sens (plus de temps avec « + ») n'était pas intuitif.
FilledTonalIconButton(
onClick = { rangeHours = (rangeHours / 2).coerceIn(MIN_RANGE_H, MAX_RANGE_H) },
enabled = rangeHours > MIN_RANGE_H
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.zoom_in))
}
FilledTonalIconButton(
onClick = { rangeHours = (rangeHours * 2).coerceAtMost(MAX_RANGE_H) },
enabled = rangeHours < MAX_RANGE_H
) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.zoom_out))
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -235,7 +499,13 @@ fun ChartScreen() {
) )
FilterChip( FilterChip(
selected = showForecast, selected = showForecast,
onClick = { showForecast = !showForecast }, onClick = {
showForecast = !showForecast
// v1.4.1 : désactiver la prévision pendant qu'on est dans le
// futur → revenir à maintenant (sinon fenêtre vide au-delà
// de « maintenant » sans doses projetées).
if (!showForecast) panHours = maxOf(panHours, 0L)
},
enabled = forecastHorizonHours != null, enabled = forecastHorizonHours != null,
label = { Text(stringResource(R.string.show_forecast)) } label = { Text(stringResource(R.string.show_forecast)) }
) )
@ -248,11 +518,26 @@ fun ChartScreen() {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Modèles PK superposables // Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) + v1.5.0 : le chip
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // « Tracé labs » vit ICI (4ᵉ chip de la rangée). ⚠️ SCROLL HORIZONTAL
// CONFINÉ à la rangée : 4 chips débordent sur écrans étroits (constat
// émulateur v1.5.0 — le 4ᵉ chip était hors fenêtre, absent du dump
// uiautomator). AUCUN conflit avec le pan du graphique : le scroll
// portre sur CETTE rangée uniquement, le pan/gesture reste sur le
// Card du chart (le fix #62 portait sur un scroll VERTICAL parent ;
// ici le libellé est différent et localisé).
Row(
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip( FilterChip(
selected = showEse, selected = showEse,
onClick = { showEse = !showEse }, onClick = {
showEse = !showEse
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE
// éteint le nuage (sinon il flotterait sans sa courbe)
if (!showEse) showCloud = false
},
label = { Text(stringResource(R.string.model_ese)) } label = { Text(stringResource(R.string.model_ese)) }
) )
FilterChip( FilterChip(
@ -260,6 +545,37 @@ fun ChartScreen() {
onClick = { showTfs = !showTfs }, onClick = { showTfs = !showTfs },
label = { Text(stringResource(R.string.model_tfs)) } label = { Text(stringResource(R.string.model_tfs)) }
) )
FilterChip(
selected = showWhs,
onClick = { showWhs = !showWhs },
label = { Text(stringResource(R.string.model_whs)) }
)
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
FilterChip(
selected = showLabTrack,
onClick = { showLabTrack = !showLabTrack },
label = { Text(stringResource(R.string.show_lab_track)) }
)
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab
// (off par défaut, sans effet — donc désactivé — si Tracé labs
// est off : la prolongation n'existe que comme extension de CETTE
// courbe, pas comme série autonome)
FilterChip(
selected = showLabTrackExtend,
onClick = { showLabTrackExtend = !showLabTrackExtend },
enabled = showLabTrack,
label = { Text(stringResource(R.string.show_lab_track_extend)) }
)
// v1.9.0 : NUAGE D'INCERTITUDE (exclusif ESE) — off par défaut,
// activable à volonté mais SEULEMENT si ESE est affiché (même
// pattern que Prolonger/Tracé labs) ; éteindre ESE coupe le
// nuage automatiquement (sinon le nuage flotterait sans sa courbe)
FilterChip(
selected = showCloud,
onClick = { showCloud = !showCloud },
enabled = showEse,
label = { Text(stringResource(R.string.show_cloud)) }
)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -268,14 +584,46 @@ fun ChartScreen() {
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { chartWidthPx = it.width } .onSizeChanged { chartWidthPx = it.width }
.pointerInput(rangeHours, maxPanHours) { .pointerInput(Unit) {
// Glisser vers la droite = remonter dans le passé // Pan (1 doigt) + pinch-zoom (2 doigts, v1.2.9) — un seul
detectHorizontalDragGestures { change, dragAmount -> // handler pour éviter les conflits de consommation
change.consume() detectTransformGestures { centroid, pan, zoom, _ ->
if (chartWidthPx > 0 && maxPanHours > 0) { if (chartWidthPx > 0) {
val hoursDragged = (dragAmount / chartWidthPx * rangeHours).toLong() // Pan horizontal : glisser à DROITE = remonter le
// temps (panHours croît) ; glisser à GAUCHE =
// avancer dans le FUTUR (panHours décroît, borné
// par l'horizon de prévision — v1.4.1)
// v1.4.10 (fix #62) : cumul fractionnaire du pan
// (l'ancienne troncature par delta tuait le pan
// sur la vue 24 h)
val (hoursDragged, newResidual) = panDeltaHours(
pan.x, chartWidthPx, rangeHours, panResidualHours
)
panResidualHours = newResidual
if (hoursDragged != 0L) { if (hoursDragged != 0L) {
panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours) panHours = clampPanHours(
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
)
}
// Zoom autour du point focal : le temps sous le doigt
// reste fixe pendant le pinch
if (zoom != 1f && zoom > 0f) {
val f = (centroid.x / chartWidthPx.toDouble()).coerceIn(0.0, 1.0)
val newRange = (rangeHours / zoom).toLong()
.coerceIn(MIN_RANGE_H, MAX_RANGE_H)
if (newRange != rangeHours) {
// L'ancre de droite inclut l'extension de
// prévision (v1.4.4) : cohérent avec endMs
val endAnchor = nowMs - panHours * HOUR_MS +
gestureExtension * HOUR_MS
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
panHours = clampPanHours(
newPan, gestureFutureHorizon, gestureMaxPan
)
rangeHours = newRange
}
} }
} }
} }
@ -299,46 +647,78 @@ fun ChartScreen() {
showExtrema = showExtrema, showExtrema = showExtrema,
nowMs = nowMs nowMs = nowMs
), ),
forecastDoses = if (showForecast) forecastDoses else emptyList(),
realDoses = doseLogs,
zone = chartZone,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(320.dp) .height(320.dp)
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Retour au présent dès qu'on a voyagé dans le passé // Retour au présent dès qu'on a voyagé (passé OU futur —
if (panHours > 0L) { // v1.4.1 : le futur est atteint en tirant vers la gauche
// avec la prévision active)
if (panHours != 0L) {
TextButton(onClick = { panHours = 0L }) { TextButton(onClick = { panHours = 0L }) {
Text(stringResource(R.string.back_to_present)) Text(stringResource(R.string.back_to_present))
} }
} }
// Légende dynamique // Légende dynamique — ⚠️ v1.5.0 : la clé "LAB" (Tracé labs)
// est SKIPPÉE ici : elle a sa propre légende dédiée plus
// bas, sinon le `else` (TFS) imprimerait la légende TFS
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
curves.forEach { (model, _) -> curves.forEach { (model, _) ->
if (model == "ESE") { when (model) {
Text( PKModels.ESTRANNAISE -> {
stringResource(R.string.legend_ese_e2),
style = MaterialTheme.typography.labelMedium,
color = ChartE2
)
if (showT) {
Text( Text(
stringResource(R.string.legend_ese_t), stringResource(R.string.legend_ese_e2),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = ChartT color = ChartE2
) )
if (showT) {
Text(
stringResource(R.string.legend_ese_t),
style = MaterialTheme.typography.labelMedium,
color = ChartT
)
}
} }
} else { // v1.4.6 : légende WHSAH (vert / violet pointillé)
Text( PKModels.WHSAH -> {
stringResource(R.string.legend_tfs_e2),
style = MaterialTheme.typography.labelMedium,
color = TealTertiary
)
if (showT) {
Text( Text(
stringResource(R.string.legend_tfs_t), stringResource(R.string.legend_whs_e2),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = ChartT color = ChartWhs
) )
if (showT) {
Text(
stringResource(R.string.legend_whs_t),
style = MaterialTheme.typography.labelMedium,
color = ChartTWhs
)
}
}
"LAB" -> { /* légende dédiée, cf plus bas */ }
// v1.6.0 : idem — la partie prolongée a sa légende
// dédiée plus bas. ⚠️ branche EXPLICITE requise :
// sinon le `else` (= TFS) imprimerait la légende
// TFS une 2ᵉ fois (leçon #63, v1.5.0)
"LABX" -> { /* légende dédiée, cf plus bas */ }
else -> {
Text(
stringResource(R.string.legend_tfs_e2),
style = MaterialTheme.typography.labelMedium,
color = TealTertiary
)
if (showT) {
Text(
stringResource(R.string.legend_tfs_t),
style = MaterialTheme.typography.labelMedium,
color = ChartT
)
}
} }
} }
} }
@ -349,6 +729,13 @@ fun ChartScreen() {
color = LabDot color = LabDot
) )
} }
if (showForecast && forecastDoses.isNotEmpty()) {
Text(
stringResource(R.string.legend_forecast_dose),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary
)
}
if (showExtrema) { if (showExtrema) {
Text( Text(
stringResource(R.string.legend_extrema), stringResource(R.string.legend_extrema),
@ -356,6 +743,48 @@ fun ChartScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
// v1.5.0 : légende « Tracé labs » — seulement si la série
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
if (showLabTrack && curves.any { it.model == "LAB" && it.points.size >= 2 }) {
Text(
stringResource(R.string.legend_lab_track),
style = MaterialTheme.typography.labelMedium,
color = ChartLabTrajectory
)
}
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
// tant que le nuage est affiché (série ESE avec cloud non vide)
if (showCloud && showEse && curves.any { it.model == PKModels.ESTRANNAISE && it.cloud.isNotEmpty() }) {
Text(
stringResource(R.string.legend_cloud),
style = MaterialTheme.typography.labelMedium,
color = ChartE2.copy(alpha = 0.55f)
)
}
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT —
// seulement si la série "LABX" existe (chip Prolonger actif
// ET horizon de prolongation atteint dans la fenêtre visible)
val labExtendVisible = showLabTrack && showLabTrackExtend &&
curves.any { it.model == "LABX" && it.points.size >= 2 }
if (labExtendVisible) {
Text(
stringResource(R.string.legend_lab_track_extend),
style = MaterialTheme.typography.labelMedium,
color = ChartLabTrajectory.copy(alpha = 0.55f)
)
// Avertissement (demande v1.6.0) : la partie prolongée
// est une EXTRAPOLATION sans garantie — elle repose sur
// des mesures de labs elles-mêmes faillibles. Affiché
// TANT QUE la prolongation est visible (sous la
// légende), pour qu'elle ne puisse pas être lue comme
// une prédiction fiable de l'avenir.
Text(
stringResource(R.string.lab_track_extend_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
Text( Text(
stringResource(R.string.drag_hint), stringResource(R.string.drag_hint),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,

View File

@ -2,27 +2,34 @@ 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
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
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
@ -36,13 +43,19 @@ 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.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -51,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() {
@ -58,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) }
@ -100,6 +163,41 @@ fun DosesScreen() {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.nav_doses)) } title = { Text(stringResource(R.string.nav_doses)) }
) )
// En-tête « temps sous THS » : de la 1re prise enregistrée à maintenant
val earliest = remember(doseLogs) { doseLogs.minOfOrNull { it.timestamp } }
earliest?.let { first ->
Card(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Column(Modifier.padding(12.dp)) {
val dateStr = DateTimeFormatter.ofPattern("d MMMM yyyy")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(first))
Text(
stringResource(R.string.hrt_since, dateStr),
style = MaterialTheme.typography.titleMedium
)
val totalDays = HrtDuration.daysSince(first, System.currentTimeMillis())
if (totalDays > 0) {
val (months, days) = HrtDuration.monthsAndDays(totalDays)
// ⚠️ hrt_duration a TROIS placeholders (%1$d mois, %2$d jours,
// %3$d total) : passer aussi totalDays, sinon
// MissingFormatArgumentException = crash de l'écran Doses
// dès que totalDays > 0 (donc à chaque affichage avec des
// données réelles). Testé au bug réel v1.3.3 (cf §14 #47).
Text(
if (months > 0) stringResource(R.string.hrt_duration, months, days, totalDays)
else stringResource(R.string.hrt_duration_days_only, days),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
if (doseLogs.isEmpty()) { if (doseLogs.isEmpty()) {
Text( Text(
stringResource(R.string.no_data), stringResource(R.string.no_data),
@ -121,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 ?: ""}")
}, },
@ -145,8 +261,16 @@ fun DosesScreen() {
} }
}, },
trailingContent = { trailingContent = {
IconButton(onClick = { toDelete = log }) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) // v1.12.0 : encart latéral « prise de sang
// le même jour » — ↑ = lab AVANT la dose,
// ↓ = lab APRÈS (cf labMarkersForDose)
val (labBefore, labAfter) = labMarkersForDose(labResults, log.timestamp, zone)
if (labBefore) LabSideMarker(up = true)
if (labAfter) LabSideMarker(up = false)
IconButton(onClick = { toDelete = log }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
} }
} }
) )
@ -201,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

@ -43,10 +43,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.DoseLog
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.ChartOptions import com.hormonetrack.ui.components.ChartOptions
@ -70,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
@ -111,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()
} }
} }
@ -126,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(
@ -172,7 +184,154 @@ fun HomeScreen(
NowLevelCard(curve) NowLevelCard(curve)
NextDoseCard(activeTreatments) // Seuils d'alerte (v1.4.2) : évaluation du TAUX ESTIMÉ ACTUEL
// (dernier point de la courbe = même valeur que la carte
// « niveau actuel ») contre les limites configurées dans
// Paramètres (pk/Alerts.kt — opt-in, champ vide = pas d'alerte).
val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = com.hormonetrack.pk.Alerts.Thresholds()
)
curve.lastOrNull()?.let { now ->
val alerts = com.hormonetrack.pk.Alerts.evaluateAll(now.e2, now.t, alertThresholds)
if (alerts.isNotEmpty()) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.alert_card_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(Modifier.height(4.dp))
alerts.forEach { a ->
val formatted = formatDose(a.value)
val limitStr = formatDose(a.limit)
Text(
if (a.level == com.hormonetrack.pk.Alerts.Level.HIGH) {
stringResource(R.string.alert_high, a.marker, formatted, a.unit, limitStr)
} else {
stringResource(R.string.alert_low, a.marker, formatted, a.unit, limitStr)
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.alert_disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
NextDoseCard(activeTreatments, doseLogs)
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────
// Même calcul que la page Analyses (pk/LabTiming) — carte COMPACTE
// sur l'accueil : creux daté + créneau associé + disclaimer court.
// Cachée si non calculable (pas d'injectable actif à Posologie) ;
// l'invite « renseigne une Posologie » reste sur la page Analyses
// (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick, effectiveAuto
) {
value = withContext(Dispatchers.Default) {
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
// alimentent la prédiction « ≈ X pg/mL au creux » (null
// si l'option est OFF → carte sans valeur, inchangée)
LabTiming.nextBloodDrawRecommendation(
allTreatments, doseLogs, labResults, System.currentTimeMillis(),
effectiveAuto?.esterScales
)
}
}
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
labRecommendation?.let { rec ->
val fmtDate = remember {
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
.withZone(ZoneId.systemDefault())
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut face à la
// cible de creux personnelle (lecture seule — jamais
// de notification, distinct des seuils d'alerte)
rec.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text(
stringResource(
R.string.labrec_before_injection,
rec.ester,
fmtDate.format(Instant.ofEpochMilli(rec.injectionMs))
),
style = MaterialTheme.typography.bodyMedium
)
if (!rec.wasAlreadyStabilized) {
// Mention SEULEMENT si le régime n'était pas encore
// stabilisé (info utile — sinon le texte est identique
// à chaque ouverture)
Text(
stringResource(
R.string.labrec_not_stabilized,
rec.ester,
fmtDate.format(Instant.ofEpochMilli(rec.stabilizedAtMs))
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -298,9 +457,11 @@ private fun List<LabResult>.filterInRange(curve: List<LevelPoint>, marker: Strin
@Composable @Composable
private fun NowLevelCard(curve: List<LevelPoint>) { private fun NowLevelCard(curve: List<LevelPoint>) {
val now = curve.lastOrNull() val now = curve.lastOrNull()
val sixHAgo = curve.firstOrNull { p -> // v1.4.3 (fix #53) : le point de comparaison « il y a 6 h » est celui le
(curve.last().timestamp - p.timestamp) >= 6 * HOUR_MS // plus PROCHE de −6 h (l'ancien code prenait le PREMIER point de la
} // fenêtre 24 h → le delta affiché était en réalité un delta 24 h,
// trompeur le lendemain d'une injection lente type EEn — cf régression #4).
val sixHAgo = com.hormonetrack.ui.components.pointHoursBefore(curve, hours = 6.0)
Card( Card(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
@ -341,40 +502,78 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
} }
} }
@Composable /**
private fun NextDoseCard(treatments: List<Treatment>) { * Ligne « prochaine prise » d'un traitement (v1.9.6 — PUR, testable JVM) :
val next = PharmacokineticEngine.nextReminderFireMs(treatments) * « Xj Yh · ven. 25 10:00 (nom) » au-delà de 24 h (les heures deviennent peu
if (next == null) return * lisibles, v1.4.1), « XhMM · HH:mm (nom) » en dessous, « N min · HH:mm
val nextTreatment = treatments.filter { it.reminderEnabled } * (nom) » dans l'heure. Utilisée par chaque ligne de [NextDoseCard].
.minByOrNull { tr -> */
val h = tr.reminderHour ?: 23 internal fun nextDoseLine(
val m = tr.reminderMinute ?: 59 nowMs: Long,
val candidate = (h * 60L + m) * 60_000L fireMs: Long,
val nowMs = System.currentTimeMillis() name: String,
val nowOfDay = nowMs % (24 * HOUR_MS) /** Template localisé du cas ≥ 24 h (string `next_dose_days`, 4 args). */
if (candidate >= nowOfDay) candidate else candidate + 24 * HOUR_MS daysTemplate: String,
} zone: ZoneId = ZoneId.systemDefault(),
Card(Modifier.fillMaxWidth()) { locale: Locale = Locale.getDefault()
Column(Modifier.padding(16.dp)) { ): String {
Text( val deltaMs = fireMs - nowMs
stringResource(R.string.next_dose), val h = deltaMs / HOUR_MS
style = MaterialTheme.typography.labelMedium val m = (deltaMs % HOUR_MS) / 60_000L
) return if (h >= 24) {
Spacer(Modifier.height(4.dp)) val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
val deltaMs = next - System.currentTimeMillis() val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
val h = deltaMs / HOUR_MS .withZone(zone)
val m = (deltaMs % HOUR_MS) / 60_000L .withLocale(locale)
val timeStr = DateTimeFormatter.ofPattern("HH:mm") .format(Instant.ofEpochMilli(fireMs))
.withZone(ZoneId.systemDefault()) String.format(locale, daysTemplate, days, remH, dayTimeStr, name)
.format(Instant.ofEpochMilli(next)) } else {
Text( val timeStr = DateTimeFormatter.ofPattern("HH:mm")
if (h > 0) { .withZone(zone)
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, nextTreatment?.name ?: "") .withLocale(locale)
} else { .format(Instant.ofEpochMilli(fireMs))
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, nextTreatment?.name ?: "") if (h > 0) {
}, String.format(locale, "%dh%02d · %s (%s)", h, m, timeStr, name)
style = MaterialTheme.typography.titleMedium } else {
) String.format(locale, "%d min · %s (%s)", m, timeStr, name)
}
}
}
@Composable
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
// ligne par traitement, TRIÉES par prochaine prise — l'ancienne carte ne
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
// totalement le rappel de l'injection hebdomadaire (le rappel EEn
// « n'arrivait qu'après celui de la CPA »). Le titre passe au pluriel
// dès qu'il y a au moins deux lignes.
val nowMs = System.currentTimeMillis()
val fires = treatments
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
.sortedBy { it.second }
if (fires.isEmpty()) return
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
style = MaterialTheme.typography.labelMedium
)
Spacer(Modifier.height(4.dp))
// Première ligne (la plus proche) mise en avant, les suivantes en
// style secondaire — même hiérarchie visuelle que v1.4.x.
fires.forEachIndexed { idx, (treatment, next) ->
Text(
nextDoseLine(
nowMs, next, treatment?.name ?: "",
stringResource(R.string.next_dose_days)
),
style = if (idx == 0) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.bodyMedium
)
}
} }
} }
} }

View File

@ -1,18 +1,20 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -24,9 +26,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -34,12 +39,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue import com.hormonetrack.ui.components.formatLabValue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -62,11 +72,54 @@ fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>
} }
.sortedByDescending { it.first } .sortedByDescending { it.first }
/**
* Notes à AFFICHER pour un groupe de labs (v1.7.1 — fix du bug « une seule
* note sur deux s'affiche »).
*
* HISTORIQUE DU BUG : à l'origine, le dialog de création mettait LA MÊME note
* aux deux entrées d'une prise E2+T — l'affichage ne prenait donc que la
* première non vide (`firstNotNullOfOrNull`), hypothèse documentée « toutes
* identiques en pratique ». Depuis l'édition UNITAIRE (tap → sélecteur E2/T),
* chaque entrée porte SA note (cas réel : « Analyse faite suite à des maux de
* tête. » sur E2 et « DHT : 12ng/dL » sur T) → l'autre note disparaissait de
* l'affichage tout en restant bien sauvegardée. Même bug côté web (mirror
* `.find()`), corrigé en miroir.
*
* RÈGLES :
* - notes vides/blanches ignorées ;
* - ordre du groupe préservé (E2 avant T — ordre de [groupLabsForDisplay]) ;
* - note IDENTIQUE sur plusieurs entrées (création via le dialog commun) →
* affichée UNE seule fois (pas de doublon) ;
* - notes DISTINCTES → une ligne par note, préfixée du marqueur
* (« E2 : … » / « T : … ») pour savoir à quelle entrée elle se rapporte ;
* - une seule note distincte → affichée brute (comportement v1.0 préservé).
*
* PUR (aucune dépendance Android) → testé en JVM (LabsGroupingTest).
*/
fun labNotesForDisplay(group: List<LabResult>): List<String> {
val notes = group.mapNotNull { lab ->
lab.notes?.takeIf { it.isNotBlank() }
?.let { lab.marker.uppercase() to it.trim() }
}
if (notes.isEmpty()) return emptyList()
val distinct = notes.distinctBy { it.second } // même texte = une seule ligne
return if (distinct.size == 1) {
listOf(distinct.first().second)
} else {
distinct.map { (marker, note) -> "$marker : $note" }
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabsScreen() { fun LabsScreen() {
val container = LocalAppContainer.current val container = LocalAppContainer.current
val repo = container.repository val repo = container.repository
// ⚠️ TOUS les traitements + toutes les doses (actifs ET inactifs, §6.bis) :
// la recommandation se base sur le traitement E2 injectable ACTIF, mais le
// moteur reçoit l'historique complet (l'inactif participe à la courbe).
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val labs by repo.allLabResults.collectAsState(initial = emptyList()) val labs by repo.allLabResults.collectAsState(initial = emptyList())
var showLabDialog by remember { mutableStateOf(false) } var showLabDialog by remember { mutableStateOf(false) }
@ -81,6 +134,50 @@ fun LabsScreen() {
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault()) DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
} }
// ── Recommandation de prochaine prise de sang (v1.8.0) ───────────────────
// Tick minute (même pattern que ChartScreen) : « maintenant » avance, la
// fenêtre de recherche de creux se rafraîchit sans interaction.
var tick by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(60_000)
tick++
}
}
val nowMs = remember(tick) { System.currentTimeMillis() }
// Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la
// carte reco (null si l'option est OFF) — même pattern que HomeScreen.
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, treatments, doseLogs, labs, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) null
else withContext(Dispatchers.Default) {
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
}
}
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
// (courbe prévisionnelle), même discipline que Home/Chart.
val recommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
treatments, doseLogs, labs, nowMs, effectiveAuto
) {
value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
)
}
}
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
// un traitement E2 injectable actif n'a PAS de Posologie, aucune
// recommandation n'est calculable — on l'explique pour que le lien
// Posologie → recommandation soit découvrable. Ne s'affiche JAMAIS en
// même temps qu'une vraie recommandation.
val suggestPosology = recommendation == null && LabTiming.shouldSuggestPosology(treatments)
Scaffold( Scaffold(
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut // Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
@ -94,6 +191,104 @@ fun LabsScreen() {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.nav_labs)) } title = { Text(stringResource(R.string.nav_labs)) }
) )
// ── Carte « Prochaine analyse recommandée » (v1.8.0) ─────────────
// Affichée seulement si le calcul est honnête (traitement E2
// injectable actif à Posologie + creux stable trouvé). Sinon, si
// un injectable actif est SANS Posologie : invite à la renseigner
// (les deux cartes sont mutuellement exclusives).
if (recommendation != null) {
val r = recommendation!!
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
// Creux EXACT (choix v1.8.0 : les deux affichés)
Text(
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut cible —
// lecture seule, jamais de notification (cf §9.bis)
r.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text(
stringResource(
R.string.labrec_before_injection,
r.ester,
fmt.format(Instant.ofEpochMilli(r.injectionMs))
),
style = MaterialTheme.typography.bodyMedium
)
Text(
if (r.wasAlreadyStabilized) {
stringResource(
R.string.labrec_stabilized,
r.ester,
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
)
} else {
stringResource(
R.string.labrec_not_stabilized,
r.ester,
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.labrec_disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else if (suggestPosology) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.labrec_needs_posology),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (grouped.isEmpty()) { if (grouped.isEmpty()) {
Text( Text(
stringResource(R.string.no_data), stringResource(R.string.no_data),
@ -114,9 +309,12 @@ fun LabsScreen() {
supportingContent = { supportingContent = {
Column { Column {
Text(fmt.format(Instant.ofEpochMilli(ts))) Text(fmt.format(Instant.ofEpochMilli(ts)))
// Notes du groupe (toutes identiques en pratique) // Notes du groupe (v1.7.1) : TOUTES les notes
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let { // distinctes, préfixées du marqueur quand elles
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) // diffèrent — l'ancien code n'affichait que la
// première non vide (bug « une note sur deux »)
labNotesForDisplay(group).forEach { note ->
Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
}, },

View File

@ -1,16 +1,10 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import android.content.Intent
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
@ -18,22 +12,17 @@ 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
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -45,44 +34,153 @@ 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.pk.PharmacokineticEngine import com.hormonetrack.data.backup.UserSettings
import com.hormonetrack.pk.Alerts
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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.format.DateTimeFormatter
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
val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = Alerts.Thresholds()
)
// Cible de creux (v1.13.0) : référence de lab personnelle — distincte
// des seuils d'alerte (cf TroughTargetCard)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) } // ── 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 importJson by remember { mutableStateOf<String?>(null) }
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
// logExportLauncher l'écrit (un state local doit être déclaré avant le
// lambda qui le capture).
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
/** Écrit le feedback depuis le thread principal (les cartes l'appellent). */
val showMessage: (String) -> Unit = { message = it }
val logExportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
// ── Export des LOGS — même PROCESSUS que l'export JSON (cf §14 #45) ──────
// Historique : v1.3.1 plantait (écriture inline), le « fix » v1.3.2 a
// réécrit inline AUSSI (et laissait des AppLog.log hors try/catch) —
// l'app plantait toujours. Leçon §14 #41 : quand un pattern d'IO marche,
// le réutiliser TEL QUEL. L'export JSON (BackupManager.writeBackup)
// fonctionne sur le téléphone → l'export des logs passe par LA MÊME
// fonction (elle prend un String, peu importe son contenu).
//
// Points de robustesse :
// - le résultat SAF arrive sur le thread UI ; l'IO part dans une
// coroutine à SCOPE RACINE (CoroutineScope standalone, jamais
// rememberCoroutineScope : elle serait annulée si l'écran quittait la
// composition pendant l'écriture) ;
// - TOUT ce qui peut lever est sous try/catch : une exception non
// interceptée dans un scope racine remonte au handler de la thread
// = crash de l'app. (AppLog.log est de toute façon fail-safe depuis
// v1.3.3, double filet.)
uri ?: return@rememberLauncherForActivityResult
CoroutineScope(Dispatchers.IO).launch {
val ok = try {
val logs = AppLog.all()
AppLog.log("logs", "export démarré (${logs.length} caractères)")
// ⚠️ Code path IDENTIQUE à l'export JSON : BackupManager
// .writeBackup (openOutputStream "wt", exceptions capturées).
BackupManager.writeBackup(context, uri, logs)
} catch (e: Exception) {
AppLog.log("logs", "ERREUR export : ${e.message}")
false
}
AppLog.log("logs", "export terminé : ok=$ok")
withContext(Dispatchers.Main) {
// Feedback visible comme l'export JSON + compteur rafraîchi
// (l'export vient d'ajouter des lignes de journal). Écriture
// d'état Compose UNIQUEMENT sur le thread principal.
logLineCount = AppLog.count()
message = context.getString(
if (ok) R.string.logs_export_ok else R.string.logs_export_fail
)
}
}
}
val exportLauncher = rememberLauncherForActivityResult( val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json") ActivityResultContracts.CreateDocument("application/json")
) { uri -> ) { uri ->
uri ?: return@rememberLauncherForActivityResult uri ?: return@rememberLauncherForActivityResult
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val json = BackupManager.exportJson(repo, tConfig) // v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
// (langue, auto-calibration, seuils d'alerte) — collect des
// DataStore Flows (first()) avant sérialisation.
// 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(),
autoCalibrate = container.settings.autoCalibrate.first(),
alertE2High = thresholds.e2High,
alertE2Low = thresholds.e2Low,
alertTHigh = thresholds.tHigh,
alertTLow = thresholds.tLow,
// v1.13.0 : la cible de creux voyage dans le backup (champs
// optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles)
troughTargetLow = container.settings.troughTarget.first()?.first,
troughTargetHigh = container.settings.troughTarget.first()?.second
)
val json = BackupManager.exportJson(repo, tConfig, settings)
val ok = BackupManager.writeBackup(context, uri, json) val ok = BackupManager.writeBackup(context, uri, json)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
message = context.getString( message = context.getString(
@ -109,6 +207,46 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
} }
// ── Sauvegarde automatique (v1.7.0) : dossier SAF persisté ──────────────
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
// la permission persistable survit au reboot, le worker WorkManager écrit
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
// pickFolderToEnable distingue les deux entrées du picker (switch vs
// « Choisir le dossier » — cf AutoBackupCard).
var pickFolderToEnable by remember { mutableStateOf(false) }
val folderPickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri ?: return@rememberLauncherForActivityResult
// Persiste la permission : sans takePersistableUriPermission, le tree
// URI serait mort au prochain démarrage (worker sans droit d'écrire).
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
val toEnable = pickFolderToEnable
pickFolderToEnable = false
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupTreeUri(uri.toString())
if (toEnable) {
container.settings.setAutoBackupEnabled(true)
// Premier backup IMMÉDIAT (feedback instantané + validation
// émulateur directe), puis le périodique 24 h prend le relais
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
}
withContext(Dispatchers.Main) {
message = if (toEnable) {
context.getString(R.string.autobackup_first_run)
} else {
context.getString(R.string.saved)
}
}
}
}
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -130,213 +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)
// --- 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))
TroughTargetCard(troughTarget, showMessage)
// --- 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 = {
val date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
exportLauncher.launch("hormonetrack-backup-$date.json")
}) { 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))
ChartTimezoneCard(chartTimezone, showMessage)
// --- About --- Spacer(Modifier.height(12.dp))
Card(Modifier.fillMaxWidth()) { RemindersCard()
Column(Modifier.padding(12.dp)) { Spacer(Modifier.height(12.dp))
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium) BackupCard(
Text( onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
stringResource(R.string.disclaimer), onImport = { importLauncher.launch(arrayOf("application/json")) }
style = MaterialTheme.typography.labelMedium, )
color = MaterialTheme.colorScheme.onSurfaceVariant Spacer(Modifier.height(12.dp))
) AutoBackupCard(
Text( enabled = autoBackupEnabled,
stringResource(R.string.models_credit), treeUri = autoBackupTreeUri,
style = MaterialTheme.typography.labelMedium, keep = autoBackupKeep,
color = MaterialTheme.colorScheme.onSurfaceVariant lastRun = autoBackupLastRun,
) onPickFolderToEnable = {
pickFolderToEnable = true
folderPickerLauncher.launch(null)
},
onChooseFolder = {
pickFolderToEnable = false
folderPickerLauncher.launch(null)
},
onMessage = showMessage
)
Spacer(Modifier.height(12.dp))
LogsCard(
logLineCount = logLineCount,
onExport = {
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
// et testé — passer un LocalDate au pattern horaire crashait
// l'app au tap (bug v1.3.1→v1.3.3).
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
},
onClear = {
AppLog.clear()
logLineCount = AppLog.count()
} }
} )
Spacer(Modifier.height(12.dp))
AboutCard()
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
} }
@ -350,7 +331,9 @@ fun SettingsScreen(onBack: () -> Unit) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// Import en mode ÉCRASEMENT : les données actuelles sont // Import en mode ÉCRASEMENT : les données actuelles sont
// effacées d'abord (BackupManager), puis le tConfig du // effacées d'abord (BackupManager), puis le tConfig du
// backup restaure DataStore et les rappels sont reprogrammés // backup restaure DataStore, les PARAMÈTRES utilisateur
// (v1.4.2 : langue, auto-calibration, seuils d'alerte)
// et les rappels sont reprogrammés
val result = try { val result = try {
BackupManager.importJson(repo, json) BackupManager.importJson(repo, json)
} catch (e: Exception) { } catch (e: Exception) {
@ -358,7 +341,43 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
result?.let { result?.let {
container.settings.setTConfig(it.tConfig) container.settings.setTConfig(it.tConfig)
AlarmScheduler(context).rescheduleAll(repo.allTreatmentsOnce()) // ⚠️ Paramètres du backup (null = backup v1 → ne rien
// toucher). La LANGUE est appliquée sur le thread
// MAIN : setApplicationLocales recrée l'activité.
it.settings?.let { us ->
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
container.settings.setAlertThresholds(
Alerts.Thresholds(
e2High = us.alertE2High, e2Low = us.alertE2Low,
tHigh = us.alertTHigh, tLow = us.alertTLow
)
)
// v1.13.0 : cible de creux — restaurée SEULEMENT
// si le backup en porte une (champs non nuls
// ET cohérents) ; sinon on ne touche à rien
// (un backup ancien ne doit pas effacer la
// cible locale).
if (us.troughTargetLow != null && us.troughTargetHigh != null &&
us.troughTargetHigh > us.troughTargetLow
) {
container.settings.setTroughTarget(
us.troughTargetLow to us.troughTargetHigh
)
}
withContext(Dispatchers.Main) {
us.language?.let { lang ->
container.settings.setLanguage(lang)
AppCompatDelegate.setApplicationLocales(
if (lang == "system") LocaleListCompat.getEmptyLocaleList()
else LocaleListCompat.forLanguageTags(lang)
)
}
}
}
// Doses + traitements : grille Posologie pour les rappels (#52)
AlarmScheduler(context).rescheduleAll(
repo.allTreatmentsOnce(), repo.allDoseLogsOnce()
)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
message = if (result != null) { message = if (result != null) {
@ -378,6 +397,3 @@ fun SettingsScreen(onBack: () -> Unit) {
) )
} }
} }
private fun formatDoubles(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)

View File

@ -34,6 +34,7 @@ import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -47,11 +48,16 @@ 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 androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.reminder.CalendarEvents
import com.hormonetrack.util.AppLog
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -74,6 +80,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
var route by remember { mutableStateOf(AdministrationRoute.INJECTION_IM) } var route by remember { mutableStateOf(AdministrationRoute.INJECTION_IM) }
var ester by remember { mutableStateOf(Esters.EV) } var ester by remember { mutableStateOf(Esters.EV) }
var model by remember { mutableStateOf(PKModels.ESTRANNAISE) } var model by remember { mutableStateOf(PKModels.ESTRANNAISE) }
// v1.4.9 : état de l'auto-calibration — sert au hint de la carte
// Calibration (l'auto écrase le facteur manuel à l'AFFICHAGE des courbes)
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
var doseText by remember { mutableStateOf("4") } var doseText by remember { mutableStateOf("4") }
var unit by remember { mutableStateOf("mg") } var unit by remember { mutableStateOf("mg") }
var tmaxText by remember { mutableStateOf("46") } var tmaxText by remember { mutableStateOf("46") }
@ -87,11 +96,30 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
// Section « Fréquence » : simulation prévisionnelle des doses à venir // Section « Fréquence » : simulation prévisionnelle des doses à venir
var forecastEnabled by remember { mutableStateOf(false) } var forecastEnabled by remember { mutableStateOf(false) }
var forecastDaysText by remember { mutableStateOf("") } var forecastDaysText by remember { mutableStateOf("") }
// Section « Agenda » : événement récurrent dans l'agenda du téléphone
var calendarEnabled by remember { mutableStateOf(false) }
var calendarPermissionGranted by remember { mutableStateOf(false) }
val calendarPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
// ⚠️ FIX v1.3.1 : le résultat arrive ASYNCHRONE — c'est ICI (et pas
// dans le onCheckedChange synchrone) que le switch doit s'activer.
calendarPermissionGranted = grants.values.all { it }
AppLog.log("agenda", "permission agenda : accordée=$calendarPermissionGranted")
if (calendarPermissionGranted) calendarEnabled = true
}
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showPresetMenu by remember { mutableStateOf(false) } var showPresetMenu by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(treatmentId > 0) } var loading by remember { mutableStateOf(treatmentId > 0) }
var loadedCreatedAt by remember { mutableStateOf(System.currentTimeMillis()) } var loadedCreatedAt by remember { mutableStateOf(System.currentTimeMillis()) }
// ⚠️ BUG #51 (v1.3.5) : buildTreatment() ne transmettait PAS
// calendarEventId → l'id revenait à null à CHAQUE save : l'événement
// d'agenda devenait ORPHELIN (plus jamais supprimable/désactivable par
// l'app, il restait dans le provider). L'id existant est chargé ici,
// reporté par buildTreatment(), puis recalcité par le save (upsert).
var loadedCalendarEventId by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(treatmentId) { LaunchedEffect(treatmentId) {
if (treatmentId > 0) { if (treatmentId > 0) {
@ -111,11 +139,16 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0) reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0)
active = tr.isActive active = tr.isActive
loadedCreatedAt = tr.createdAt loadedCreatedAt = tr.createdAt
loadedCalendarEventId = tr.calendarEventId
// Fréquence de prévision (si configurée) // Fréquence de prévision (si configurée)
tr.forecastIntervalDays?.let { fi -> tr.forecastIntervalDays?.let { fi ->
forecastEnabled = true forecastEnabled = true
forecastDaysText = formatDose(fi) forecastDaysText = formatDose(fi)
} }
if (tr.calendarEventId != null) {
calendarEnabled = true
calendarPermissionGranted = true // déjà accordée pour l'event existant
}
} }
loading = false loading = false
} }
@ -150,6 +183,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
reminderEnabled = reminderEnabled, reminderEnabled = reminderEnabled,
reminderHour = if (reminderEnabled) reminderTime.hour else null, reminderHour = if (reminderEnabled) reminderTime.hour else null,
reminderMinute = if (reminderEnabled) reminderTime.minute else null, reminderMinute = if (reminderEnabled) reminderTime.minute else null,
// ⚠️ #51 : conserver l'id de l'événement d'agenda existant (cf
// déclaration de loadedCalendarEventId) — sinon orphelin au save.
calendarEventId = loadedCalendarEventId,
createdAt = loadedCreatedAt createdAt = loadedCreatedAt
) )
} }
@ -236,23 +272,44 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (PharmacokineticEngine.isInjectionRoute(route)) { if (PharmacokineticEngine.isInjectionRoute(route)) {
// v1.9.4 : la liste des esters suit la couverture RÉELLE du
// modèle choisi (Esters.choicesForModel — miroir du DoseDialog
// et du treatment-editor web) : le modèle ESE ANALYTIQUE offre
// ses 6 esters injectables (EV/EU/EEn/EC/EB/EUCS) directement
// dans l'éditeur, au lieu des seuls EV/EU/EEn de l'époque des
// tables ODS. Chaque ester listé est dispatché par le moteur —
// aucun choix possible ne produit 0 pg/mL.
DropdownField( DropdownField(
label = stringResource(R.string.ester), label = stringResource(R.string.ester),
selectedLabel = ester, selectedLabel = ester,
options = listOf(Esters.EV, Esters.EU, Esters.EEN).map { it to it }, options = Esters.choicesForModel(model).map { it to it },
onSelect = { ester = it } onSelect = { ester = it }
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
DropdownField( DropdownField(
label = stringResource(R.string.pk_model), label = stringResource(R.string.pk_model),
selectedLabel = stringResource( // v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
if (model == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs else R.string.model_ese // cf pk/WhsahModels.kt) superposable dans le graphique.
), // Label par le helper UNIQUE (règle anti-bug #59 :
// jamais de when/ternaire de labels en ligne — l'ancien
// à 2 branches affichait « Estrannaise » pour WHSAH).
selectedLabel = stringResource(modelLabelRes(model)),
options = listOf( options = listOf(
PKModels.ESTRANNAISE to stringResource(R.string.model_ese), PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs) PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
PKModels.WHSAH to stringResource(R.string.model_whs)
), ),
onSelect = { model = it } onSelect = { model = it
// v1.9.4 : l'ester courant peut sortir de la couverture
// du nouveau modèle (ex. EUCS + TFS : le V3C n'a pas de
// paramètres EUCS → le dispatcher donnerait 0) — on
// rebascule sur le premier ester couvert (EV dans les
// trois listes). Le recompose reconstruit ensuite la
// liste des esters (options = choicesForModel(model)).
if (ester !in Esters.choicesForModel(it)) {
ester = Esters.choicesForModel(it).first()
}
}
) )
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -311,6 +368,19 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
if (autoCalibrate) {
// v1.4.9 (demande utilisateur) : quand l'auto-
// calibration est ON, le facteur manuel (champ +
// bouton) est IGNORÉ à l'affichage des courbes —
// l'expliciter pour éviter la confusion « j'ai
// calibré mais rien ne change ».
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.calibration_auto_override),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary
)
}
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
FilledTonalButton(onClick = { FilledTonalButton(onClick = {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@ -380,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),
@ -401,6 +472,69 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// --- Section « Agenda » : événement récurrent (posologie) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.agenda_section), style = MaterialTheme.typography.titleMedium)
Switch(
checked = calendarEnabled,
onCheckedChange = { want ->
if (want) {
// ⚠️ L'agenda exige une Posologie (l'événement
// se répète selon son intervalle) : sans elle,
// on refuse l'activation (feedback rouge)
if (!forecastEnabled) {
AppLog.log("agenda", "switch ON sans Posologie → refusé")
return@Switch
}
if (calendarPermissionGranted) {
calendarEnabled = true
AppLog.log("agenda", "switch ON (permission déjà accordée)")
} else {
// le callback activera le switch si accordée
AppLog.log("agenda", "demande de permission agenda")
calendarPermissionLauncher.launch(
arrayOf(
android.Manifest.permission.WRITE_CALENDAR,
android.Manifest.permission.READ_CALENDAR
)
)
}
} else {
calendarEnabled = false
AppLog.log("agenda", "switch OFF")
}
}
)
}
if (calendarEnabled) {
if (forecastEnabled) {
Text(
stringResource(R.string.agenda_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
stringResource(R.string.agenda_needs_posology),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
}
Text(
stringResource(R.string.agenda_permission),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(Modifier.height(8.dp))
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -422,12 +556,51 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} }
val saved = tr.copy(id = savedId) val saved = tr.copy(id = savedId)
// Rappel actif seulement si le traitement l'est aussi ; // Rappel actif seulement si le traitement l'est aussi ;
// désactiver un traitement annule son alarme // désactiver un traitement annule son alarme. Les doses
// alimentent la grille Posologie (fix #52) : le rappel
// sonne uniquement les jours de créneau.
if (saved.reminderEnabled && saved.isActive) { if (saved.reminderEnabled && saved.isActive) {
scheduler.scheduleDaily(saved) scheduler.scheduleFor(saved, repo.allDoseLogsOnce())
} else { } else {
scheduler.cancel(savedId) scheduler.cancel(savedId)
} }
// Événement d'agenda : upsert si activé + posologie définie,
// suppression sinon (et si un événement existait)
var eventToStore = saved.calendarEventId
try {
// ⚠️ Permission vérifiée RÉELLEMENT via ContextCompat —
// le flag remembered peut être faux après recomposition
val hasCalendarPermission = androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.WRITE_CALENDAR
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null
&& hasCalendarPermission
) {
val calId = CalendarEvents.ensureCalendar(context)
eventToStore = CalendarEvents.upsertEvent(context, calId, saved)
} else if (saved.calendarEventId != null) {
CalendarEvents.deleteEvent(context, saved.calendarEventId!!)
eventToStore = null
}
if (eventToStore != saved.calendarEventId) {
repo.updateTreatment(saved.copy(calendarEventId = eventToStore))
}
AppLog.log("agenda",
// Log VÉRIDIQUE (v1.3.5) : « supprimé » était
// imprimé aussi quand rien n'a été supprimé
// (switch désactivé, posologie absente,
// permission absente) → messages distincts.
"traitement #${saved.id} : " + when {
eventToStore != null ->
"événement agenda créé/mis à jour (id=$eventToStore)"
saved.calendarEventId != null ->
"événement agenda supprimé"
else ->
"agenda non activé (switch/posologie/permission)"
})
} catch (e: Exception) {
AppLog.log("agenda", "ERREUR événement agenda : ${e.message}")
}
withContext(Dispatchers.Main) { onDone() } withContext(Dispatchers.Main) { onDone() }
} }
}, },

View File

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

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

@ -12,7 +12,13 @@ val TransPink = Color(0xFFF5A9B8)
val TealTertiary = Color(0xFF2FA48D) val TealTertiary = Color(0xFF2FA48D)
val TealContainer = Color(0xFFBFF0E4) val TealContainer = Color(0xFFBFF0E4)
val ChartE2 = Color(0xFF4F5BD5) val ChartE2 = Color(0xFF4F5BD5)
// v1.4.6 : modèle WHSAH (3ᵉ PK) — E2 vert profond, T violet (pointillé)
val ChartWhs = Color(0xFF2E7D32)
val ChartTWhs = Color(0xFF9C27B0)
val ChartT = Color(0xFFD6589E) val ChartT = Color(0xFFD6589E)
val LabDot = Color(0xFFE67E22) val LabDot = Color(0xFFE67E22)
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, pk/LabTrajectoryModel)
// — rose foncé distinct de E2/T/TFS/WHS et des dots orange des labs
val ChartLabTrajectory = Color(0xFFC2185B)
val GoodGreen = Color(0xFF2E7D32) val GoodGreen = Color(0xFF2E7D32)
val WarnOrange = Color(0xFFB26A00) val WarnOrange = Color(0xFFB26A00)

View File

@ -0,0 +1,92 @@
package com.hormonetrack.util
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Journal de diagnostic de l'app (v1.3.1) : ligne horodatée par événement
* notable (agenda, rappels, import/export, erreurs), persisté dans
* `filesDir/debug-log.txt`, buffer circulaire de MAX_LINES.
*
* Exportable depuis Paramètres (« Logs de diagnostic ») pour le debug à
* distance — un utilisateur peut joindre le fichier à un rapport de bug.
*
* Les parties PURES (formatLine, trimLines) sont testables en JVM sans
* Android ; l'objet lui-même ne fait que l'IO fichier mince.
*/
object AppLog {
const val MAX_LINES = 500
private val buffer = ArrayDeque<String>()
private var file: File? = null
private val lock = Any()
/**
* À appeler une fois dans Application.onCreate.
*
* ⚠️ La lecture du fichier existant est GARDÉE (try/catch) : un fichier de log
* corrompu ou illisible ne doit jamais empêcher l'app de démarrer — on perd
* l'historique, pas le process (même famille que le fix v1.3.3 de log()).
*/
fun init(context: android.content.Context) {
synchronized(lock) {
val f = File(context.filesDir, "debug-log.txt")
file = f
if (f.exists()) {
buffer.clear()
val loaded = try {
f.readLines().takeLast(MAX_LINES)
} catch (_: Exception) {
emptyList() // fichier illisible → démarrer avec un buffer vide
}
buffer.addAll(loaded)
}
}
log("app", "démarrage (v init) — ${buffer.size} lignes de logs existantes")
}
/**
* Journalise une ligne (buffer mémoire + append fichier).
*
* ⚠️ NE JAMAIS PROPAGER D'EXCEPTION (fix v1.3.3) : log() est appelé depuis
* des dizaines de sites, y compris dans des coroutines à scope racine où une
* exception non interceptée = crash du process (cf export des logs, §14 #45).
* L'IO fichier (appendText) peut lever IOException (disque plein, I/O error) :
* elle est avalée ici — on perd au pire UNE ligne de journal, jamais l'app.
*/
fun log(tag: String, message: String) = synchronized(lock) {
val line = formatLine(System.currentTimeMillis(), tag, message)
buffer.addLast(line)
// Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le
// buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières)
while (buffer.size > MAX_LINES) buffer.removeFirst()
try {
file?.appendText(line + "\n")
} catch (_: Exception) {
// Journalisation best-effort : ne jamais faire planter l'app pour un log
}
}
fun all(): String = synchronized(lock) { buffer.joinToString("\n") }
fun count(): Int = synchronized(lock) { buffer.size }
fun clear() = synchronized(lock) {
buffer.clear()
file?.writeText("")
log("app", "logs effacés")
}
/** Ligne horodatée : « 2026-09-06 12:34:56 [tag] message » (locale). */
fun formatLine(tsMs: Long, tag: String, message: String): String {
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
return "${fmt.format(Date(tsMs))} [$tag] $message"
}
/** Buffer circulaire : garde les MAX dernières lignes. */
fun trimLines(existing: List<String>, max: Int): List<String> =
if (existing.size <= max) existing else existing.takeLast(max)
}

View File

@ -0,0 +1,91 @@
package com.hormonetrack.util
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
/**
* Noms de fichiers d'export (horodatés) — helper PUR, testable en JVM.
*
* Pourquoi cet objet existe (v1.3.4, cf §14 #48) : le nom du fichier des logs
* était généré INLINE dans le handler du bouton via
* `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))`. Un `LocalDate` n'a
* PAS de champ horaire : le pattern `HHmm` levait
* `UnsupportedTemporalTypeException: Unsupported field: HourOfDay` de façon
* SYNCHRONE dans le onClick (thread UI) → crash de l'app au tap, sans même
* ouvrir le sélecteur de fichiers. Crash présent depuis v1.3.1 ; les « fixes »
* v1.3.2 et v1.3.3 réparaient l'IO mais pas cette ligne.
*
* Règle anti-régression : les helpers d'ici centralisent le couplage
* type ↔ pattern, chaque fonction documentant quel type porte le pattern.
* ⚠️ Règle générale : un pattern contenant des champs horaires (HH, mm, ss)
* exige LocalDateTime/LocalTime/Instant.withZone ; yyyyMMdd seul sur LocalDate.
*/
object ExportFileNames {
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd")
/**
* Pattern des AUTO-BACKUPS journaliers (v1.7.0) : `yyyyMMdd-HHmm` — même
* règle que [LOG_FMT] (contient HHmm → type temporel complet requis).
* L'heure permet plusieurs runs le même jour (activation + tick journalier,
* ou plusieurs activations successives) SANS écrasement : chaque fichier
* a un nom unique → la rétention ([com.hormonetrack.data.backup.AutoBackupRetention])
* trie sur l'horodatage du NOM (jamais sur la mtime du document SAF, non
* fiable sur tous les providers).
*/
private val AUTO_BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
/** Préfixe d'un fichier d'AUTO-backup — partagé avec la rétention (PUR). */
const val AUTO_BACKUP_PREFIX = "hormonetrack-auto-"
private const val AUTO_BACKUP_SUFFIX = ".json"
/**
* Nom du fichier d'export des logs de diagnostic :
* `hormonetrack-logs-20260906-1234.txt`.
* Le pattern contient HHmm → il faut un type TEMPOREL COMPLET
* (LocalDateTime). Passer un LocalDate ici = UnsupportedTemporalTypeException
* au tap sur « Exporter » (bug #48, crash réel v1.3.1→v1.3.3).
*/
fun diagnosticLogFileName(now: LocalDateTime = LocalDateTime.now()): String =
LOG_FMT.format(now).let { "hormonetrack-logs-$it.txt" }
/**
* Nom du fichier de sauvegarde JSON :
* `hormonetrack-backup-20260906.json`. Pattern SANS heure →
* `LocalDate` est correct ici (c'est le cas qui marchait, à garder tel quel).
*/
fun backupFileName(today: LocalDate = LocalDate.now()): String =
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json"
/**
* Nom d'un AUTO-backup journalier (v1.7.0) :
* `hormonetrack-auto-20260913-1830.json`. Pattern AVEC heure →
* [LocalDateTime] obligatoire (règle anti-#48 en tête de fichier).
* Pur (déterministe sur [now]) → testé en JVM.
*/
fun autoBackupFileName(now: LocalDateTime = LocalDateTime.now()): String =
"$AUTO_BACKUP_PREFIX${AUTO_BACKUP_FMT.format(now)}$AUTO_BACKUP_SUFFIX"
/**
* Re-trouve l'horodatage dans un NOM de fichier d'auto-backup (round-trip
* de [autoBackupFileName]) — PUR, utilisé par la rétention pour trier par
* l'horodatage du nom. Retourne `null` pour tout ce qui n'est PAS un
* auto-backup (exports manuels `hormonetrack-backup-…`, logs, fichiers
* étrangers du dossier choisi par l'utilisatrice) : la rétention ne
* doit JAMAIS les toucher.
*/
fun parseAutoBackupTimestamp(fileName: String): LocalDateTime? {
if (!fileName.startsWith(AUTO_BACKUP_PREFIX)) return null
if (!fileName.endsWith(AUTO_BACKUP_SUFFIX)) return null
return try {
LocalDateTime.parse(
fileName.removePrefix(AUTO_BACKUP_PREFIX).removeSuffix(AUTO_BACKUP_SUFFIX),
AUTO_BACKUP_FMT
)
} catch (_: Exception) {
null // nom presque-correct mais malformé : JAMAIS supprimé
}
}
}

View File

@ -0,0 +1,23 @@
package com.hormonetrack.util
/** Estimation du temps passé sous THS (v1.3.1). Mois = 30 jours (estimation). */
object HrtDuration {
const val DAY_MS = 86_400_000L
/** Jours entiers écoulés depuis la première prise (0 si futur/invalide). */
fun daysSince(earliestMs: Long, nowMs: Long): Long =
if (nowMs <= earliestMs) 0L else (nowMs - earliestMs) / DAY_MS
/** Décomposition mois(30 j) / jours restants. */
fun monthsAndDays(totalDays: Long): Pair<Long, Long> =
Pair(totalDays / 30, totalDays % 30)
/**
* Conversion d'un delta EN HEURES en (jours, heures restantes) — v1.4.1,
* carte « Prochaine dose » de l'accueil : au-delà de 24 h, les heures
* parlent moins que les jours (ex. 120 h → 5 j 0 h).
*/
fun daysAndHours(totalHours: Long): Pair<Long, Long> =
Pair(totalHours / 24, totalHours % 24)
}

View File

@ -17,6 +17,8 @@
<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="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>
<string name="home_legend_e2">— E2 estimé (pg/mL, gauche)</string> <string name="home_legend_e2">— E2 estimé (pg/mL, gauche)</string>
@ -28,16 +30,81 @@
<string name="chart_24h">24 h</string> <string name="chart_24h">24 h</string>
<string name="chart_7j">7 jours</string> <string name="chart_7j">7 jours</string>
<string name="chart_30j">30 jours</string> <string name="chart_30j">30 jours</string>
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
<string name="labrec_section">Prochaine prise de sang (suggestion)</string>
<string name="labrec_when">Au creux estimé : %1$s</string>
<string name="labrec_before_injection">Juste avant ton injection de %1$s du %2$s — ou simplement la veille de ce créneau.</string>
<string name="labrec_stabilized">Ton %1$s est stabilisé depuis le %2$s (~5 demi-vies terminales).</string>
<string name="labrec_not_stabilized">Ton %1$s n\'était pas stabilisé avant le %2$s (~5 demi-vies terminales) : c\'est le premier creux fiable depuis ton dernier changement.</string>
<string name="labrec_disclaimer">Estimation du modèle pharmacocinétique, pas un avis médical — la prise au creux donne simplement le résultat le plus comparable. Suis toujours ton endocrinologue.</string>
<string name="labrec_needs_posology">Astuce : renseigne une Posologie (intervalle entre prises) sur ton traitement E2 injectable actif (Traitements → éditer) et cette page suggérera quand faire ta prochaine prise de sang (au creux, juste avant l\'injection).</string>
<string name="show_labs">Analyses</string> <string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</string> <string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</string> <string name="show_extrema">Pics / creux</string>
<string name="show_lab_track">Tracé labs</string>
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
actif seulement quand Tracé labs est ON) -->
<string name="show_lab_track_extend">Prolonger</string>
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
estrannaise.js) — exclusif à ESE, off par défaut -->
<string name="show_cloud">Nuage</string>
<string name="legend_cloud">Nuage pointillé = incertitude des paramètres MCMC de l\'Estrannaise (modèle ESE seul)</string>
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
<string name="hrt_since">Sous THS depuis le %1$s</string>
<string name="hrt_duration">%1$d mois et %2$d jours (%3$d jours au total)</string>
<string name="hrt_duration_days_only">%1$d jours</string>
<string name="alert_channel_name">Avertissements de seuil</string>
<string name="alert_channel_desc">Notifications quand un taux estimé franchit une de tes limites (vérification toutes les 15 minutes).</string>
<string name="alert_notif_title">Seuil hormonal franchi</string>
<string name="alert_section">Seuils d\'alerte</string>
<string name="alert_hint">Limites optionnelles pour tes taux estimés, vérifiées contre l\'estimation actuelle de l\'accueil (pas une mesure de labo). E2 en pg/mL, T en ng/mL. Champ vide = alerte désactivée. Le haut doit être au-dessus du bas. Vérification toutes les 15 minutes — tu reçois une notification quand une limite est franchie (sans spam tant que l\'écart ne change pas).</string>
<string name="alert_e2_high">E2 haut (pg/mL)</string>
<string name="alert_e2_low">E2 bas (pg/mL)</string>
<string name="alert_t_high">T haut (ng/mL)</string>
<string name="alert_t_low">T bas (ng/mL)</string>
<string name="alert_invalid">Valeurs invalides : chiffres uniquement, et la limite haute doit être supérieure à la basse.</string>
<string name="alert_card_title">Avertissements de seuil</string>
<string name="alert_high">▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s)</string>
<string name="alert_low">▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s)</string>
<string name="alert_disclaimer">Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue.</string>
<string name="timezone_section">Fuseau horaire du graphique</string>
<string name="timezone_hint">Sert à aligner les jours du graphique (minuit) et à lire les doses. Vide = fuseau du téléphone (automatique). Exemple : Europe/Paris.</string>
<string name="timezone_field">ID de fuseau (ex. Europe/Paris)</string>
<string name="timezone_auto">Fuseau du téléphone</string>
<string name="timezone_invalid">ID de fuseau inconnu (ex. Europe/Paris, America/New_York).</string>
<string name="legend_real_dose">— prises enregistrées (marqueurs bas)</string>
<string name="logs_section">Logs de diagnostic</string>
<string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
<string name="logs_export">Exporter</string>
<string name="logs_clear">Effacer</string>
<string name="logs_export_ok">Logs de diagnostic exportés</string>
<string name="logs_export_fail">Échec de l\'export des logs de diagnostic</string>
<string name="changelog_title">Nouveautés de la version %1$s</string>
<string name="settings_version">Version installée : %1$s</string>
<string name="releases_link">Dernières versions sur Gitea (télécharger les APK)</string>
<string name="agenda_section">Agenda</string>
<string name="agenda_switch">Événement d\'agenda récurrent</string>
<string name="agenda_needs_posology">Renseigne d\'abord une Posologie (intervalle en jours) — l\'événement se répète tous les N jours dans ton agenda.</string>
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string> <string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string>
<string name="legend_lab_track">E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)</string>
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
<string name="legend_lab_track_extend">-- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée)</string>
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
est active — l\'extrapolation n\'a aucune garantie et repose sur des
mesures elles-mêmes faillibles -->
<string name="lab_track_extend_warning">⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.</string>
<string name="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
<string name="back_to_present">Revenir à maintenant</string> <string name="back_to_present">Revenir à maintenant</string>
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string> <string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string> <string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string> <string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
<string name="legend_tfs_e2">— E2 · Transfem Science</string> <string name="legend_tfs_e2">— E2 · Transfem Science</string>
<string name="legend_tfs_t">-- T · Transfem Science</string> <string name="legend_tfs_t">-- T · Transfem Science</string>
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, gauche)</string>
<string name="legend_whs_t">-- T WHSAH (ng/mL, droite)</string>
<string name="legend_labs">● Résultats de prise de sang</string> <string name="legend_labs">● Résultats de prise de sang</string>
<string name="interval_value">Δ %1$s j</string> <string name="interval_value">Δ %1$s j</string>
@ -77,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>
@ -95,6 +182,7 @@
<string name="pk_model">Modèle PK</string> <string name="pk_model">Modèle PK</string>
<string name="model_ese">Estrannaise</string> <string name="model_ese">Estrannaise</string>
<string name="model_tfs">Transfem Science</string> <string name="model_tfs">Transfem Science</string>
<string name="model_whs">WHSAH</string>
<string name="select_preset">Choisir un modèle (optionnel)</string> <string name="select_preset">Choisir un modèle (optionnel)</string>
<string name="pk_absorption">Temps jusqu\'au pic (h)</string> <string name="pk_absorption">Temps jusqu\'au pic (h)</string>
@ -104,14 +192,15 @@
<string name="calibration_title">Calibration</string> <string name="calibration_title">Calibration</string>
<string name="scale_factor">Facteur d\'échelle</string> <string name="scale_factor">Facteur d\'échelle</string>
<string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string> <string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string>
<string name="calibration_auto_override">Auto-calibration active : les courbes utilisent les facteurs automatiques — cette valeur manuelle est ignorée à l\'affichage (désactive-la dans Paramètres pour la prendre en compte).</string>
<string name="calibrate_from_labs">Calibrer avec les analyses</string> <string name="calibrate_from_labs">Calibrer avec les analyses</string>
<string name="auto_calibrate">Calibration automatique (E2 + T)</string> <string name="auto_calibrate">Calibration automatique (E2 + T)</string>
<string name="auto_calibrate_hint">Ajuste automatiquement le facteur d\'échelle de CHAQUE ester à partir des analyses faites pendant sa période (ex. : labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T — affichage uniquement (les valeurs stockées ne changent pas). Les réglages manuels ci-dessous et dans Traitements restent effectifs quand c\'est désactivé.</string> <string name="auto_calibrate_hint">Ajuste automatiquement le facteur d\'échelle de CHAQUE ester à partir des analyses faites pendant sa période (ex. : labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T — affichage uniquement (les valeurs stockées ne changent pas). Les réglages manuels ci-dessous et dans Traitements restent effectifs quand c\'est désactivé.</string>
<string name="freq_section">Fréquence d\'injection</string> <string name="freq_section">Posologie</string>
<string name="freq_enable">Simuler les doses à venir</string> <string name="freq_enable">Simuler les doses à venir</string>
<string name="freq_interval">Intervalle (jours)</string> <string name="freq_interval">Intervalle (jours)</string>
<string name="freq_hint">Les doses à venir sont simulées à cet intervalle après la dernière injection enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string> <string name="freq_hint">Les doses à venir sont simulées à cet intervalle après la dernière prise enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string>
<string name="save">Enregistrer</string> <string name="save">Enregistrer</string>
<string name="cancel">Annuler</string> <string name="cancel">Annuler</string>
@ -148,6 +237,17 @@
<string name="import_json">Importer</string> <string name="import_json">Importer</string>
<string name="export_ok">Sauvegarde exportée.</string> <string name="export_ok">Sauvegarde exportée.</string>
<string name="export_fail">L\'export a échoué.</string> <string name="export_fail">L\'export a échoué.</string>
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
<string name="autobackup_section">Sauvegarde automatique quotidienne</string>
<string name="autobackup_enable">Activer</string>
<string name="autobackup_hint">Chaque jour, l\'app écrit une sauvegarde JSON complète (même format que l\'export manuel) dans le dossier que tu choisis — par exemple un dossier Owncloud synchronisé, pour que le backup atterrisse dans ton cloud. Aucune permission de stockage nécessaire : tu choisis le dossier une fois et tu peux le révoquer à tout moment dans les réglages système. Seules les copies les plus récentes sont conservées (les vieilles copies automatiques sont supprimées ; tes exports manuels ne sont jamais touchés).</string>
<string name="autobackup_folder">Dossier</string>
<string name="autobackup_no_folder">Pas encore de dossier choisi — choisis-en un pour activer.</string>
<string name="autobackup_choose_folder">Choisir le dossier</string>
<string name="autobackup_keep">Copies conservées (1–30)</string>
<string name="autobackup_last_ok">Dernière sauvegarde : OK (%1$s)</string>
<string name="autobackup_last_fail">Dernière sauvegarde : ÉCHEC (%1$s) — vérifie le dossier puis réactive pour réessayer.</string>
<string name="autobackup_first_run">Activée — une première sauvegarde a été écrite dans le dossier.</string>
<string name="import_fail">L\'import a échoué : fichier invalide.</string> <string name="import_fail">L\'import a échoué : fichier invalide.</string>
<string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string> <string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string>
<string name="import_confirm">Effacer &amp; restaurer</string> <string name="import_confirm">Effacer &amp; restaurer</string>
@ -159,11 +259,24 @@
<!-- 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>
<string name="preset_eu_tfs">Injection EU — Transfem Science</string> <string name="preset_eu_tfs">Injection EU — Transfem Science</string>
<string name="preset_een_tfs">Injection EEn — Transfem Science</string> <string name="preset_een_tfs">Injection EEn — Transfem Science</string>
<string name="preset_eb_tfs">Injection EB — Transfem Science</string>
<string name="preset_ec_tfs">Injection EC (huile) — Transfem Science</string>
<string name="preset_ecs_tfs">Injection EC (suspension aqueuse) — Transfem Science</string>
<string name="preset_ev_whs">Injection EV — WHSAH</string>
<string name="preset_eu_whs">Injection EU — WHSAH</string>
<string name="preset_een_whs">Injection EEn — WHSAH</string>
<string name="preset_eb_whs">Injection EB — WHSAH</string>
<string name="preset_ec_whs">Injection EC (huile) — WHSAH</string>
<string name="preset_ecs_whs">Injection EC (suspension aqueuse) — WHSAH</string>
<string name="preset_pep_tfs">Injection PEP — Transfem Science</string>
<string name="preset_e2_gel">E2 gel transdermique</string> <string name="preset_e2_gel">E2 gel transdermique</string>
<string name="preset_e2_patch">E2 patch</string> <string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 orale</string> <string name="preset_e2_oral">E2 orale</string>

View File

@ -17,6 +17,8 @@
<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="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>
<string name="home_legend_e2">— E2 estimate (pg/mL, left)</string> <string name="home_legend_e2">— E2 estimate (pg/mL, left)</string>
@ -28,16 +30,81 @@
<string name="chart_24h">24 h</string> <string name="chart_24h">24 h</string>
<string name="chart_7j">7 days</string> <string name="chart_7j">7 days</string>
<string name="chart_30j">30 days</string> <string name="chart_30j">30 days</string>
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
<string name="labrec_section">Next blood draw (suggested)</string>
<string name="labrec_when">At the estimated trough: %1$s</string>
<string name="labrec_before_injection">Just before your %1$s injection of %2$s — or simply the day before that slot.</string>
<string name="labrec_stabilized">Your %1$s has been stable since %2$s (~5 terminal half-lives).</string>
<string name="labrec_not_stabilized">Your %1$s was not stable until %2$s (~5 terminal half-lives): this is the first reliable trough after your latest change.</string>
<string name="labrec_disclaimer">Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist.</string>
<string name="labrec_needs_posology">Tip: set a Dosage interval on your active injectable E2 treatment (Treatments → edit) and this page will suggest when to do your next blood draw (at the trough, just before the injection).</string>
<string name="show_labs">Labs</string> <string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</string> <string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string> <string name="show_extrema">Peaks / troughs</string>
<string name="show_lab_track">Lab track</string>
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
actif seulement quand Lab track est ON) -->
<string name="show_lab_track_extend">Extend</string>
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
estrannaise.js) — exclusif à ESE, off par défaut -->
<string name="show_cloud">Cloud</string>
<string name="legend_cloud">Dotted cloud = Estrannaise MCMC parameter uncertainty (ESE model only)</string>
<string name="zoom_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string>
<string name="hrt_since">Under HRT since %1$s</string>
<string name="hrt_duration">%1$d months and %2$d days (%3$d days total)</string>
<string name="hrt_duration_days_only">%1$d days</string>
<string name="alert_channel_name">Threshold warnings</string>
<string name="alert_channel_desc">Notifications when an estimated level crosses one of your configured limits (checked every 15 minutes).</string>
<string name="alert_notif_title">Hormone threshold warning</string>
<string name="alert_section">Alert thresholds</string>
<string name="alert_hint">Optional limits for your estimated levels, checked against the current estimate on Home (not a lab value). E2 in pg/mL, T in ng/mL. Leave a field empty to disable that alert. High must be above low. Checked every 15 minutes — you get a notification when a limit is crossed (no repeat spam until the level changes).</string>
<string name="alert_e2_high">E2 high (pg/mL)</string>
<string name="alert_e2_low">E2 low (pg/mL)</string>
<string name="alert_t_high">T high (ng/mL)</string>
<string name="alert_t_low">T low (ng/mL)</string>
<string name="alert_invalid">Invalid values: enter numbers only, and keep the high limit above the low limit.</string>
<string name="alert_card_title">Threshold warnings</string>
<string name="alert_high">▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s)</string>
<string name="alert_low">▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s)</string>
<string name="alert_disclaimer">Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist.</string>
<string name="timezone_section">Chart timezone</string>
<string name="timezone_hint">Used to align the chart days (midnight) and read doses. Empty = the phone\'s timezone (auto). Example: Europe/Paris.</string>
<string name="timezone_field">Timezone ID (e.g. Europe/Paris)</string>
<string name="timezone_auto">Use phone timezone</string>
<string name="timezone_invalid">Unknown timezone ID (e.g. Europe/Paris, America/New_York).</string>
<string name="legend_real_dose">— logged doses (bottom markers)</string>
<string name="logs_section">Diagnostic logs</string>
<string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string>
<string name="logs_export">Export</string>
<string name="logs_clear">Clear</string>
<string name="logs_export_ok">Diagnostic logs exported</string>
<string name="logs_export_fail">Failed to export the diagnostic logs</string>
<string name="changelog_title">What\'s new in %1$s</string>
<string name="settings_version">Installed version: %1$s</string>
<string name="releases_link">Latest releases on Gitea (download APKs)</string>
<string name="agenda_section">Calendar</string>
<string name="agenda_switch">Recurring calendar event</string>
<string name="agenda_needs_posology">Set a Posology interval first — the event repeats every N days in your phone calendar.</string>
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string> <string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string>
<string name="legend_lab_track">Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)</string>
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
<string name="legend_lab_track_extend">-- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored)</string>
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
est active — l\'extrapolation n\'a aucune garantie et repose sur des
mesures elles-mêmes faillibles -->
<string name="lab_track_extend_warning">⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.</string>
<string name="legend_forecast_dose">— next projected dose (vertical marker)</string>
<string name="back_to_present">Back to now</string> <string name="back_to_present">Back to now</string>
<string name="drag_hint">Drag the chart to the right to travel into the past.</string> <string name="drag_hint">Drag the chart to the right to travel into the past.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string> <string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string> <string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
<string name="legend_tfs_e2">— E2 · Transfem Science</string> <string name="legend_tfs_e2">— E2 · Transfem Science</string>
<string name="legend_tfs_t">-- T · Transfem Science</string> <string name="legend_tfs_t">-- T · Transfem Science</string>
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, left)</string>
<string name="legend_whs_t">-- T WHSAH (ng/mL, right)</string>
<string name="legend_labs">● Lab results</string> <string name="legend_labs">● Lab results</string>
<string name="interval_value">Δ %1$s d</string> <string name="interval_value">Δ %1$s d</string>
@ -77,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>
@ -95,6 +182,7 @@
<string name="pk_model">PK model</string> <string name="pk_model">PK model</string>
<string name="model_ese">Estrannaise</string> <string name="model_ese">Estrannaise</string>
<string name="model_tfs">Transfem Science</string> <string name="model_tfs">Transfem Science</string>
<string name="model_whs">WHSAH</string>
<string name="select_preset">Choose a preset (optional)</string> <string name="select_preset">Choose a preset (optional)</string>
<string name="pk_absorption">Time to peak (h)</string> <string name="pk_absorption">Time to peak (h)</string>
@ -104,14 +192,15 @@
<string name="calibration_title">Calibration</string> <string name="calibration_title">Calibration</string>
<string name="scale_factor">Scale factor</string> <string name="scale_factor">Scale factor</string>
<string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string> <string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string>
<string name="calibration_auto_override">Auto-calibration is ON: curves use the automatic factors, this manual value is ignored for display (use Settings to turn it off).</string>
<string name="calibrate_from_labs">Calibrate from labs</string> <string name="calibrate_from_labs">Calibrate from labs</string>
<string name="auto_calibrate">Auto-calibration (E2 + T)</string> <string name="auto_calibrate">Auto-calibration (E2 + T)</string>
<string name="auto_calibrate_hint">Automatically adjusts the scale factor of EACH ester from the labs drawn during that ester\'s period (e.g. valerate labs calibrate the valerate doses, enanthate labs the enanthate doses) and recalibrates the T model — display only (stored values unchanged). Manual values below and in Treatments stay in effect when this is off.</string> <string name="auto_calibrate_hint">Automatically adjusts the scale factor of EACH ester from the labs drawn during that ester\'s period (e.g. valerate labs calibrate the valerate doses, enanthate labs the enanthate doses) and recalibrates the T model — display only (stored values unchanged). Manual values below and in Treatments stay in effect when this is off.</string>
<string name="freq_section">Injection frequency</string> <string name="freq_section">Dosage</string>
<string name="freq_enable">Simulate upcoming doses</string> <string name="freq_enable">Simulate upcoming doses</string>
<string name="freq_interval">Interval (days)</string> <string name="freq_interval">Interval (days)</string>
<string name="freq_hint">Upcoming doses are simulated at this interval after the last logged injection; they appear on the chart (Forecast) but are never saved.</string> <string name="freq_hint">Upcoming doses are simulated at this interval after the last logged dose; they appear on the chart (Forecast) but are never saved.</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
@ -148,6 +237,17 @@
<string name="import_json">Import</string> <string name="import_json">Import</string>
<string name="export_ok">Backup exported.</string> <string name="export_ok">Backup exported.</string>
<string name="export_fail">Export failed.</string> <string name="export_fail">Export failed.</string>
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
<string name="autobackup_section">Automatic daily backup</string>
<string name="autobackup_enable">Enable</string>
<string name="autobackup_hint">Each day, the app writes a full JSON backup (same format as the manual export) into the folder you choose — e.g. a synced Owncloud folder, so it lands in your cloud. No storage permission is needed: you pick the folder once and can revoke it anytime in the system settings. Only the newest copies are kept (older automatic copies are deleted; your manual exports are never touched).</string>
<string name="autobackup_folder">Folder</string>
<string name="autobackup_no_folder">No folder selected yet — pick one to enable.</string>
<string name="autobackup_choose_folder">Choose folder</string>
<string name="autobackup_keep">Copies kept (1–30)</string>
<string name="autobackup_last_ok">Last backup: OK (%1$s)</string>
<string name="autobackup_last_fail">Last backup: FAILED (%1$s) — check the folder, then re-enable to retry.</string>
<string name="autobackup_first_run">Enabled — a first backup was written to the folder.</string>
<string name="import_fail">Import failed: invalid file.</string> <string name="import_fail">Import failed: invalid file.</string>
<string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string> <string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string>
<string name="import_confirm">Erase &amp; restore</string> <string name="import_confirm">Erase &amp; restore</string>
@ -159,11 +259,24 @@
<!-- 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>
<string name="preset_eu_tfs">EU injection — Transfem Science</string> <string name="preset_eu_tfs">EU injection — Transfem Science</string>
<string name="preset_een_tfs">EEn injection — Transfem Science</string> <string name="preset_een_tfs">EEn injection — Transfem Science</string>
<string name="preset_eb_tfs">EB injection — Transfem Science</string>
<string name="preset_ec_tfs">EC injection (oil) — Transfem Science</string>
<string name="preset_ecs_tfs">EC injection (aqueous suspension) — Transfem Science</string>
<string name="preset_ev_whs">EV injection — WHSAH</string>
<string name="preset_eu_whs">EU injection — WHSAH</string>
<string name="preset_een_whs">EEn injection — WHSAH</string>
<string name="preset_eb_whs">EB injection — WHSAH</string>
<string name="preset_ec_whs">EC injection (oil) — WHSAH</string>
<string name="preset_ecs_whs">EC injection (aqueous suspension) — WHSAH</string>
<string name="preset_pep_tfs">PEP injection — Transfem Science</string>
<string name="preset_e2_gel">E2 transdermal gel</string> <string name="preset_e2_gel">E2 transdermal gel</string>
<string name="preset_e2_patch">E2 patch</string> <string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 oral</string> <string name="preset_e2_oral">E2 oral</string>

View File

@ -0,0 +1,88 @@
package com.hormonetrack.data.backup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests de la RÉTENTION des auto-backups journaliers (v1.7.0) — décision
* PURE ([AutoBackupRetention.filesToDelete]) : quels noms supprimer quand
* la rétention dépasse « copies conservées ».
*
* Les deux garanties CRITIques (le dossier cible est CELUI DE L'UTILISATRICE,
* ex. un dossier Owncloud) :
* 1. seuls les auto-backups RECONNAISSABLES (`hormonetrack-auto-<date>-<hhmm>.json`)
* participent — les exports MANUELS (`hormonetrack-backup-…`), les logs
* et les fichiers personnels du dossier ne sont JAMAIS candidats ;
* 2. un nom presque-correct mais MALFORMÉ (horodatage non parsable) n'est
* jamais supprimé non plus.
* + garde anti-idiocratie : `keep` est clampé à ≥ 1 (keep=0 ne doit JAMAIS
* supprimer la dernière copie restante).
*/
class AutoBackupRetentionTest {
private fun auto(date: String) = "hormonetrack-auto-$date.json"
@Test
fun `keeps the newest keepCount copies and returns the older ones`() {
val files = listOf(
auto("20260907-1830"),
auto("20260913-1830"), // plus récent
auto("20260909-1830"),
auto("20260911-1830"),
)
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 2)
// Le tri se fait par horodatage du NOM (pas l'ordre d'entrée)
assertEquals(
listOf(auto("20260907-1830"), auto("20260909-1830")),
toDelete.sorted()
)
}
@Test
fun `never touches manual exports logs or foreign files`() {
val files = listOf(
"hormonetrack-backup-20260101.json", // export MANUEL
"hormonetrack-logs-20260101-1200.txt", // logs
"owncloud-deja-la.txt", // fichier personnel du dossier choisi
auto("20260901-1830"),
auto("20260902-1830"),
)
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
// Seul l'auto-backup le plus ancien part ; les étrangers restent
assertEquals(listOf(auto("20260901-1830")), toDelete)
}
@Test
fun `malformed auto backup names are never deleted`() {
val files = listOf(
"hormonetrack-auto-corrompu.json",
"hormonetrack-auto-20260913-1830", // sans extension
auto("20260910-1830"),
)
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
assertTrue(
"les noms non parsables ne sont jamais candidats à la suppression",
toDelete.isEmpty()
)
}
@Test
fun `keep is clamped to at least 1 (keep 0 never wipes everything)`() {
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 0)
assertTrue(
"keep=0 → clampé à 1 : au moins la copie la plus récente survit",
toDelete == listOf(auto("20260910-1830"))
)
// keep négatif : même garde
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = -5).size == 1)
}
@Test
fun `retention already satisfied returns nothing`() {
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = 5).isEmpty())
assertTrue(AutoBackupRetention.filesToDelete(emptyList(), keepCount = 7).isEmpty())
}
}

View File

@ -92,4 +92,77 @@ class BackupGsonTest {
assertEquals(0.15, parsed.tConfig.floor, 1e-9) assertEquals(0.15, parsed.tConfig.floor, 1e-9)
assertEquals(0.21, parsed.tConfig.k, 1e-9) assertEquals(0.21, parsed.tConfig.k, 1e-9)
} }
@Test
fun `backup v2 round-trip preserves user settings (language, auto-cal, thresholds)`() {
// v1.4.2 : les PARAMÈTRES utilisateur voyagent avec le backup
val data = BackupData(
tConfig = TConfig(),
settings = UserSettings(
language = "fr",
autoCalibrate = true,
alertE2High = 300.0,
alertE2Low = 100.0,
alertTHigh = 1.0,
alertTLow = 0.3
)
)
val json = Gson().toJson(data)
val parsed: BackupData = Gson().fromJson(json, BackupData::class.java)
assertEquals(2, parsed.version)
val s = parsed.settings
// ⚠️ Ne JAMAIS enlever ce check : sous R8 sans les -keep, parsed.settings
// deviendrait non-null avec des champs VIDES (réflexion Gson, cf §16.bis)
org.junit.Assert.assertNotNull(s)
assertEquals("fr", s!!.language)
assertEquals(true, s.autoCalibrate)
assertEquals(300.0, s.alertE2High!!, 1e-9)
assertEquals(100.0, s.alertE2Low!!, 1e-9)
assertEquals(1.0, s.alertTHigh!!, 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
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 :
// ils doivent rester importables tels quels.
val legacyJson = """
{"version":1,"exportedAt":1700000000000,
"treatments":[],"doseLogs":[],"labResults":[],
"tConfig":{"base":6.0,"floor":0.2,"k":0.19}}
""".trimIndent()
val parsed: BackupData = Gson().fromJson(legacyJson, BackupData::class.java)
assertEquals(1, parsed.version)
org.junit.Assert.assertNull(parsed.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

@ -0,0 +1,143 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests d'INTÉGRATION de la chaîne d'alertes (v1.4.2) : moteur PK → niveau
* actuel → évaluation des seuils (`pk/Alerts.kt`).
*
* Ce que couvrent les AUTRES fichiers (pour s'y retrouver) :
* - `AlertsTest` : logique pure des seuils (HIGH/LOW, strict, cohérence,
* codec d'état, décision de notification) ;
* - `BackupGsonTest` : round-trip des seuils dans le backup JSON ;
* - le worker WorkManager (`reminder/AlertNotifier.kt`) n'est PAS testable
* en JVM (NotificationManager/WorkManager) — toute sa logique de DÉCISION
* a été extraite vers `Alerts` et est testée ici et dans `AlertsTest`.
*
* Ce fichier épine le câblage : le niveau que la NOTIFICATION évalue
* (`levelAt` avec `scalePerEster`) est EXACTEMENT celui affiché par la
* carte « niveau actuel » (dernier point de `computeCurve`), et les seuils
* se déclenchent sur ce niveau réel.
*/
class AlertsEngineTest {
private companion object {
const val HOUR_MS = 3_600_000L
val BASE = System.currentTimeMillis()
}
/** Traitement EV — Transfem Science, 5 mg, une seule dose à BASE. */
private fun evTreatment(doseMg: Double = 5.0): Treatment = Treatment(
id = 1L,
name = "EV",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = doseMg,
doseUnit = "mg",
isActive = true,
esterType = Esters.EV,
pkModel = PKModels.TRANSFEM_SCIENCE,
scaleFactor = 1.0
)
@Test
fun `notification level matches the home card level (levelAt vs computeCurve last point)`() {
val tr = evTreatment()
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
val now = BASE + 30 * HOUR_MS
val scales = mapOf("EV" to 0.8)
val tK = mapOf("EV" to 0.25)
val tConfig = TConfig()
// Chemin NOTIFICATION (AlertNotifier → levelAt avec calibration)
val viaLevelAt = PharmacokineticEngine.levelAt(
listOf(tr), doses, now, tConfig, tKPerEster = tK, scalePerEster = scales
)
// Chemin CARTE ACCUEIL (HomeScreen → dernier point de computeCurve)
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), doses, now - HOUR_MS, now, stepMs = HOUR_MS,
tConfig = tConfig, scalePerEster = scales, tKPerEster = tK
)
val last = curve.last()
// ⚠️ Le point de courbe le plus proche de `now` peut s'arrêter à
// now − step/2 selon la grille : on compare les valeurs d'E2 des deux
// chemins ÉVALUÉES AU MÊME INSTANT (le dernier point du curve couvre
// [now-1h, now] par pas de 1 h → dernier point ≤ now ; levelAt évalue
// exactement `now`). Tolérance : dérive PK sur ≤ 1 h (< 10 %).
val gapHours = (now - last.timestamp).toDouble() / HOUR_MS
assertTrue(gapHours in 0.0..1.0)
val ratio = viaLevelAt.e2 / last.e2
assertTrue("levelAt=${viaLevelAt.e2} vs curve=${last.e2}", ratio in 0.9..1.1)
// La T suit la même E2 et le même k → même contrainte
assertEquals(viaLevelAt.t, last.t, viaLevelAt.t * 0.1)
}
@Test
fun `high threshold on the real estimated level triggers HIGH`() {
val tr = evTreatment()
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
val now = BASE + 30 * HOUR_MS
val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig())
// seuil SOUS le niveau estimé → HIGH attendu (limit = seuil)
val half = level.e2 / 2
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = half))
assertEquals(1, alerts.size)
assertEquals("E2", alerts.single().marker)
assertEquals(Alerts.Level.HIGH, alerts.single().level)
assertEquals(half, alerts.single().limit, 1e-9)
// seuil AU-DESSUS du niveau estimé → aucune alerte
val double = level.e2 * 2
assertTrue(Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = double)).isEmpty())
}
@Test
fun `low T threshold triggers LOW (testosterone in ng per mL)`() {
val tr = evTreatment()
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
val now = BASE + 30 * HOUR_MS
val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig())
// T estimée ≈ 0,3 ng/mL : une limite basse 1,0 ng/mL → LOW
assertTrue("T estimée=${level.t} doit être < 1.0", level.t < 1.0)
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(tLow = 1.0))
assertEquals(1, alerts.size)
assertEquals("T", alerts.single().marker)
assertEquals(Alerts.Level.LOW, alerts.single().level)
assertEquals("ng/mL", alerts.single().unit)
// limite basse très basse → pas d'alerte
assertTrue(Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(tLow = 0.001)).isEmpty())
}
@Test
fun `anti-spam state reflects only alerted markers (codec over real alerts)`() {
val tr = evTreatment()
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
val now = BASE + 30 * HOUR_MS
val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig())
// Seuils qui ne déclenchent que l'E2 (pas la T) : l'état persisté ne
// contient QUE l'E2 — c'est ce qui est comparé d'une vérification
// périodique à l'autre (AlertNotifier).
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = level.e2 / 2))
val encoded = Alerts.encodeState(alerts)
assertEquals("E2:HIGH", encoded)
assertEquals(mapOf("E2" to Alerts.Level.HIGH), Alerts.parseState(encoded))
// shouldNotify : même franchissement → pas de re-notif ; changement → notif
val current = Alerts.parseState(encoded)
assertTrue(!Alerts.shouldNotify(current, current))
assertTrue(Alerts.shouldNotify(mapOf("E2" to Alerts.Level.LOW), current))
}
}

View File

@ -0,0 +1,151 @@
package com.hormonetrack.pk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Seuils d'alerte configurables (v1.4.2) — module PUR (`pk/Alerts.kt`).
*
* Règles épinglées :
* - opt-in : seuil null → jamais d'alerte (l'app n'invente pas de normes) ;
* - comparaisons STRICTES (valeur == limite → pas d'alerte, éviter le
* clignotement sur l'arrondi d'affichage) ;
* - HIGH prime LOW en configuration incohérente (défensif : la saisie
* l'interdit) ;
* - évaluation E2 (pg/mL) puis T (ng/mL), ordre stable.
*/
class AlertsTest {
@Test
fun `no thresholds configured - no alerts`() {
val t = Alerts.Thresholds()
assertTrue(Alerts.evaluateAll(420.0, 0.05, t).isEmpty())
assertTrue(Alerts.evaluateAll(0.0, 0.0, t).isEmpty())
}
@Test
fun `high threshold exceeded - HIGH alert`() {
val t = Alerts.Thresholds(e2High = 300.0)
val alerts = Alerts.evaluateAll(currentE2 = 420.0, currentT = 0.3, t = t)
assertEquals(1, alerts.size)
val a = alerts.single()
assertEquals("E2", a.marker)
assertEquals(Alerts.Level.HIGH, a.level)
assertEquals(420.0, a.value, 1e-9)
assertEquals(300.0, a.limit, 1e-9)
assertEquals("pg/mL", a.unit)
}
@Test
fun `low threshold undercut - LOW alert`() {
val t = Alerts.Thresholds(tLow = 0.3)
val alerts = Alerts.evaluateAll(currentE2 = 150.0, currentT = 0.12, t = t)
assertEquals(1, alerts.size)
val a = alerts.single()
assertEquals("T", a.marker)
assertEquals(Alerts.Level.LOW, a.level)
assertEquals("ng/mL", a.unit)
}
@Test
fun `exact value at the limit does not trigger (strict comparison)`() {
val t = Alerts.Thresholds(e2High = 300.0, e2Low = 100.0)
assertTrue(Alerts.evaluateAll(300.0, 0.3, t).isEmpty())
assertTrue(Alerts.evaluateAll(100.0, 0.3, t).isEmpty())
// juste au-dessus / en dessous : alerte
assertEquals(1, Alerts.evaluateAll(300.01, 0.3, t).size)
assertEquals(1, Alerts.evaluateAll(99.99, 0.3, t).size)
}
@Test
fun `both markers can alert at once (E2 first, then T)`() {
val t = Alerts.Thresholds(e2High = 300.0, tLow = 0.3)
val alerts = Alerts.evaluateAll(420.0, 0.12, t)
assertEquals(2, alerts.size)
assertEquals("E2", alerts[0].marker)
assertEquals("T", alerts[1].marker)
}
@Test
fun `coherence check - high must be above low when both set`() {
assertTrue(Alerts.Thresholds(e2High = 300.0, e2Low = 100.0).isCoherent())
assertFalse(Alerts.Thresholds(e2High = 100.0, e2Low = 300.0).isCoherent())
// un seul côté défini → toujours cohérent
assertTrue(Alerts.Thresholds(e2High = 100.0).isCoherent())
assertTrue(Alerts.Thresholds(tHigh = 1.0, tLow = 0.5).isCoherent())
assertFalse(Alerts.Thresholds(tHigh = 0.5, tLow = 1.0).isCoherent())
}
@Test
fun `incoherent config is defused - HIGH wins over LOW`() {
// Cas impossible via la saisie (validée), mais l'évaluation ne doit
// jamais renvoyer deux verdicts contradictoires pour un même marqueur.
val t = Alerts.Thresholds(e2High = 100.0, e2Low = 300.0)
val alerts = Alerts.evaluateAll(420.0, 0.3, t)
assertEquals(1, alerts.size)
assertEquals(Alerts.Level.HIGH, alerts.single().level)
}
@Test
fun `evaluate single value - null limits never trigger`() {
// aucune limite → jamais d'alerte
assertNull(Alerts.evaluate(500.0, low = null, high = null))
// limite basse seule : valeur SOUS la limite → LOW ; au-dessus → rien
assertEquals(Alerts.Level.LOW to 600.0, Alerts.evaluate(500.0, low = 600.0, high = null))
assertNull(Alerts.evaluate(700.0, low = 600.0, high = null))
// limite haute seule : valeur AU-DESSUS → HIGH ; en dessous → rien
assertEquals(Alerts.Level.HIGH to 600.0, Alerts.evaluate(601.0, low = null, high = 600.0))
assertNull(Alerts.evaluate(599.0, low = null, high = 600.0))
// les deux limites : chacune franchit son côté
assertEquals(Alerts.Level.HIGH to 600.0, Alerts.evaluate(601.0, 600.0, 600.0))
assertEquals(Alerts.Level.LOW to 600.0, Alerts.evaluate(599.0, 600.0, 600.0))
}
// ── v1.4.2 : notification d'alerte (état persisté + anti-spam) ──
@Test
fun `state codec round-trips (E2 and T levels)`() {
val alerts = listOf(
Alerts.Alert("E2", Alerts.Level.HIGH, 420.0, 300.0, "pg/mL"),
Alerts.Alert("T", Alerts.Level.LOW, 0.12, 0.3, "ng/mL")
)
val encoded = Alerts.encodeState(alerts)
assertEquals("E2:HIGH;T:LOW", encoded)
val parsed = Alerts.parseState(encoded)
assertEquals(Alerts.Level.HIGH, parsed["E2"])
assertEquals(Alerts.Level.LOW, parsed["T"])
// round-trip complet : encodé puis décodé = mêmes niveaux
assertEquals(
alerts.map { it.marker to it.level }.toMap(),
Alerts.parseState(Alerts.encodeState(alerts))
)
}
@Test
fun `state codec - empty and malformed inputs are safe`() {
assertEquals("", Alerts.encodeState(emptyList()))
assertTrue(Alerts.parseState(null).isEmpty())
assertTrue(Alerts.parseState("").isEmpty())
// niveau inconnu (évolution future) → entrée ignorée, pas de crash
assertTrue(Alerts.parseState("E2:UNKNOWN").isEmpty())
assertTrue(Alerts.parseState("E2").isEmpty()) // sans ":"
}
@Test
fun `shouldNotify - new breach fires, same state does not`() {
val current = mapOf("E2" to Alerts.Level.HIGH)
// jamais notifié → notifie
assertTrue(Alerts.shouldNotify(current, null))
// même état → pas de re-notification (anti-spam 15 min)
assertFalse(Alerts.shouldNotify(current, current))
// changement de niveau (H → L) → re-notify
assertTrue(Alerts.shouldNotify(mapOf("E2" to Alerts.Level.LOW), current))
// écart TERMINE (current vide) → pas de notif « retour à la normale »
assertFalse(Alerts.shouldNotify(emptyMap(), current))
// mais le retour à la normale permet de re-notifier le prochain écart
assertTrue(Alerts.shouldNotify(current, emptyMap()))
}
}

View File

@ -0,0 +1,192 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Process de tests **CALIBRATION** (v1.4.8, fix #60) — sémantique
* « calibration PAR MODÈLE PK » : chaque courbe affichée (ESE/TFS/WHS) est
* calibrée avec la prédiction de SON modèle, donc chaque courbe calibrée
* passe par les labs.
*
* Compléments des tests voisins (pour s'y retrouver) :
* - `WhsahEngineTest.calibration is per model` : TFS vs WHS (le cas de la
* remontée ×2,21) ;
* - `WhsahEngineTest.calibration applies to ALL model curves` : le facteur
* fourni est appliqué à toutes les courbes (application uniforme) ;
* - `PharmacokineticEngineTest` : calibration T manuelle, SF médiane.
*
* Ce fichier couvre ce qui restait sans filet :
* 1. la couverture des **3 modèles** (ESE inclus) — chaque courbe calibrée
* atterrit sur le lab ;
* 2. la **délégation** `autoCalibrated(modelOverride)` →
* `computeEsterScaleFactors(modelOverride)` (le graphique et Home
* utilisent la même fonction, pas deux implémentations) ;
* 3. le **k T par modèle** (calibré contre l'E2 calibrée de SON modèle) ;
* 4. les cas **dégénérés** (labs antérieurs à la 1ʳᵉ dose → échelles vides ;
* prédiction trop faible → lab ignoré).
*/
class CalibrationPerModelTest {
private companion object {
val BASE = System.currentTimeMillis()
const val DAY_MS = 86_400_000L
}
@org.junit.Before
fun loadProfiles() {
// ⚠️ le chemin ESE lit les tables ODS (asset) — sans init,
// PKProfileStore.sample retourne 0 et les labs seraient ignorés
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
// purs (formes closes) et n'en dépendent pas.
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = java.io.File("src/test/assets/pk_profiles.json")
.takeIf { it.exists() }
?: java.io.File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
?: java.io.File("app/src/main/assets/pk_profiles.json")
PKProfileStore.initWithJson(file.readText())
}
}
/** Traitement EEn (modèle TFS stocké), 5 mg à BASE. */
private fun treatment(): Treatment = Treatment(
id = 1L,
name = "EEn",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0,
doseUnit = "mg",
isActive = true,
esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE
)
private fun doses(): List<DoseLog> = listOf(
DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
)
/** Lab E2 de 250 pg/mL 7 jours après l'injection (au creux TFS). */
private fun labE2(): LabResult = LabResult(
marker = "E2", value = 250.0, unit = "pg/mL",
timestamp = BASE + 7 * DAY_MS
)
@Test
fun `all three models calibrated land on the lab (ESE included)`() {
val tr = treatment()
val labs = listOf(labE2())
for (model in listOf("ESE", "TFS", "WHS")) {
val scales = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = model
)
assertTrue("échelle $model absente", scales.containsKey("EEN"))
val calibrated = PharmacokineticEngine.e2At(
listOf(tr), doses(), labE2().timestamp,
modelOverride = model, scalePerEster = scales
)
assertTrue(
"$model : calibrée=$calibrated doit ≈ 250 (échelle ${scales["EEN"]})",
calibrated in 250.0 * 0.98..250.0 * 1.02
)
}
}
@Test
fun `three models give three distinct calibration factors`() {
val tr = treatment()
val labs = listOf(labE2())
val ese = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = "ESE")["EEN"]!!
val tfs = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = "TFS")["EEN"]!!
val whs = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = "WHS")["EEN"]!!
// les prédictions des 3 modèles diffèrent au même instant (7 j) :
// ESE ≈ 156, TFS ≈ 159, WHS ≈ 178 → facteurs distincts. Nuances :
// ESE et TFS sont QUASI IDENTIQUES au creux 7 j (~2 % — la table ODS
// dérive du même article) ; WHSAH s'en écarte (~11 % — décroissance
// lente, t½ 7,3 j) mais sans excès
assertTrue("ESE=$ese vs TFS=$tfs (≥ 1 % : distincts)", kotlin.math.abs(ese - tfs) > 0.01 * ese)
assertTrue("TFS=$tfs vs WHS=$whs (≥ 5 %)", kotlin.math.abs(tfs - whs) > 0.05 * tfs)
assertTrue("WHS=$whs vs ESE=$ese (≥ 5 %)", kotlin.math.abs(whs - ese) > 0.05 * whs)
}
@Test
fun `autoCalibrated delegates to computeEsterScaleFactors per model (single implementation)`() {
val tr = treatment()
val labs = listOf(labE2())
for (model in listOf(null, "ESE", "TFS", "WHS")) {
val viaAuto = PharmacokineticEngine.autoCalibrated(
listOf(tr), doses(), labs, TConfig(), modelOverride = model
).esterScales
val direct = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = model
)
assertEquals("délégation brisée pour modelOverride=$model", direct, viaAuto)
}
// null = modèle stocké du traitement (TFS) → cohérent avec override TFS
assertEquals(
PharmacokineticEngine.autoCalibrated(
listOf(tr), doses(), labs, TConfig(), modelOverride = "TFS"
).esterScales,
PharmacokineticEngine.autoCalibrated(
listOf(tr), doses(), labs, TConfig()
).esterScales
)
}
@Test
fun `testosterone k is calibrated against the E2 of the requested model`() {
val tr = treatment()
// lab T de 0,35 ng/mL 7 jours après l'injection (même instant que le lab E2)
val labs = listOf(
labE2(),
LabResult(marker = "T", value = 0.35, unit = "ng/mL",
timestamp = BASE + 7 * DAY_MS)
)
val tConfig = TConfig() // base 6.0, floor 0.2, k 0.19
// Invariant mathématique : k ∝ 1/E2 calibrée — donner une échelle
// ×2 doit DIVISER le k par 2 exactement (le k est calculé contre
// l'E2 calibrée de la demande modelOverride, pas contre une E2 figée).
val tLabs = labs.filter { it.marker.equals("T", true) }
fun kFor(scale: Double): Double = PharmacokineticEngine.computeTKPerEster(
listOf(tr), doses(), tLabs, tConfig,
scalePerEster = mapOf("EEN" to scale),
modelOverride = "TFS"
)["EEN"]!!
val k1 = kFor(1.0)
val k2 = kFor(2.0)
assertTrue("k(échelle 1)=$k1 doit être dans la garde", k1 > 1e-4 && k1 < 10.0)
assertEquals("k ∝ 1/E2 calibrée : échelle ×2 → k ÷2", k1 / 2.0, k2, k1 * 0.01)
// et le k calibré ≠ k par défaut (0,19) : il a bien été recalculé
// contre l'E2 du modèle (fix #60)
assertTrue(
"k calibré=$k1 doit différer du k stocké (0,19)",
kotlin.math.abs(k1 - 0.19) > 0.01
)
}
@Test
fun `labs before the first dose yield empty scales (per model too)`() {
val tr = treatment()
// lab ANTÉRIEUR à la 1ʳᵉ dose → pas de période d'attribution → vide
val labs = listOf(LabResult(
marker = "E2", value = 250.0, unit = "pg/mL", timestamp = BASE - DAY_MS
))
for (model in listOf(null, "ESE", "TFS", "WHS")) {
val scales = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = model
)
assertTrue("échelles vides attendues pour modelOverride=$model", scales.isEmpty())
}
}
}

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

@ -0,0 +1,259 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, cf doc
* §7.11) — [LabTiming.nextBloodDrawRecommendation] :
* - le creux recommandé tombe JUSTE AVANT le créneau d'injection associé ;
* - régime NON stabilisé → ON SAUTE au premier creux stabilisé
* (5 × t½ terminale — décision v1.8.0 : pas de prise intermédiaire
* trompeuse) ;
* - régime déjà stabilisé → premier creux, `wasAlreadyStabilized = true` ;
* - sans Posologie / sans injectable E2 → `null` (pas de recommandation
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
* creux déjà mesuré) ;
* - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
*/
class LabTimingTest {
private val DAY_MS = 86_400_000L
private val HOUR_MS = 3_600_000L
/** Maintenant FIXE : les tests sont déterministes (aucun System.currentTimeMillis). */
private val NOW = 1_790_000_000_000L
@Before
fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("pk_profiles.json not found for unit tests")
PKProfileStore.initWithJson(file.readText())
}
}
/** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */
private fun eenTfs(posologyDays: Double? = 7.0) = Treatment(
id = 1, name = "EEn", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg",
esterType = "EEN", pkModel = "TFS",
forecastIntervalDays = posologyDays,
createdAt = 0
)
/** Doses hebdomadaires de [weeks]×7 j à [weeks]−1×7 j avant NOW. */
private fun weeklyDoses(weeks: Int): List<DoseLog> =
(weeks downTo 1).map { k ->
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
}
private fun lab(ts: Long) = LabResult(marker = "E2", value = 200.0, unit = "pg/mL", timestamp = ts)
@Test
fun `trough is found just before the associated injection slot`() {
// Régime long (stabilisé) → le 1ᵉʳ creux est recommandé ; il tombe
// juste avant l'injection (le minimum de la fenêtre, à 1 h du créneau)
val doses = weeklyDoses(9)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
val slot = NOW + 7 * DAY_MS
assertEquals(slot, rec.injectionMs)
assertTrue(
"le creux est juste avant l'injection (écart ${(slot - rec.troughMs) / 60_000} min)",
rec.troughMs in (slot - 2 * HOUR_MS) until slot
)
assertTrue(rec.wasAlreadyStabilized)
}
@Test
fun `not stabilized - skips to the first STABILIZED trough`() {
// Une seule dose il y a 7 j → régime commencé hier-semaine : EEn TFS
// t½ = ln2/k3 = 4,53 j → stabilisation ≈ NOW + 15,7 j → les créneaux
// +7 j et +14 j sont TROP TÔT, le +21 j est le premier stable
val doses = weeklyDoses(1)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals("sauter au créneau stabilisé", NOW + 21 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
// stabilisation cohérente : régime (NOW−7 j) + 5 × t½ EEn TFS — le
// t½ est lu du MODÈLE (même expression que le code, zéro arrondi)
val tHalf = com.hormonetrack.pk.TransfemScienceModels.model("EEN")!!.terminalHalfLifeDays
val expectedStable = NOW - 7 * DAY_MS + (5 * tHalf * DAY_MS).toLong()
assertEquals(expectedStable, rec.stabilizedAtMs)
}
@Test
fun `already stabilized - first trough, wasAlreadyStabilized is true`() {
// 9 semaines d'historique : le régime (NOW−63 j) est stabilisé depuis
// bien longtemps (5 × t½ ≈ 22,7 j < 63 j)
val doses = weeklyDoses(9)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(NOW + 7 * DAY_MS, rec.injectionMs)
assertTrue(rec.wasAlreadyStabilized)
assertTrue(rec.stabilizedAtMs < NOW)
}
@Test
fun `a recent blood draw pushes the recommendation to the NEXT trough`() {
// Stabilisé, mais une prise de sang existe juste APRÈS le 1ᵉʳ creux
// → il ne faut PAS recommander deux fois le même creux
val doses = weeklyDoses(9)
val recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000L) // 30 min avant le créneau
val rec = LabTiming.nextBloodDrawRecommendation(
listOf(eenTfs()), doses, listOf(recentLab), NOW
)!!
assertEquals("le creux suivant est recommandé", NOW + 14 * DAY_MS, rec.injectionMs)
}
@Test
fun `no posology returns null (no predictable trough)`() {
val doses = weeklyDoses(9)
assertNull(
LabTiming.nextBloodDrawRecommendation(
listOf(eenTfs(posologyDays = null)), doses, emptyList(), NOW
)
)
}
@Test
fun `oral-only treatment returns null (no injectable E2 carrier)`() {
// CPA oral : usesProfileModel = false → aucun traitement porteur
val oral = Treatment(
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL,
doseAmount = 12.5, doseUnit = "mg", esterType = "NONE",
forecastIntervalDays = 2.0, createdAt = 0
)
assertNull(LabTiming.nextBloodDrawRecommendation(listOf(oral), weeklyDoses(9), emptyList(), NOW))
// et l'invite Posologie ne doit PAS s'afficher pour un non-injectable
assertTrue(!LabTiming.shouldSuggestPosology(listOf(oral)))
}
@Test
fun `posology suggestion only for an injectable E2 without posology`() {
val noPosology = eenTfs(posologyDays = null)
assertTrue(LabTiming.shouldSuggestPosology(listOf(noPosology)))
// avec Posologie : PAS d'invite (la reco s'en charge)
assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs())))
}
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ─────────────
// Critique v1.8.0 : « j'ai changé d'ester, de dosage ET de posologie, et
// l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
// du traitement) ne détectait aucun de ces changements. NOUVELLE règle :
// le régime = séquence terminale à (ester, dose, écart inter-doses)
// constants ; tout changement réinitialise la stabilisation.
@Test
fun `recent dose change resets stabilization`() {
// 9 semaines à 5 mg, puis une dose de 3 mg il y a 2 j : le régime
// courant (3 mg) n'a que 2 j → PAS stabilisé → saut au creux du
// créneau ≥ stabilisation (NOW−2 j + ~22,7 j ≈ NOW+20,7 j → créneau +28 j)
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 2 * DAY_MS, doseAmount = 3.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(NOW + 26 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `recent interval change resets stabilization`() {
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
// de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
// [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
// vrai changement de créneau reste un changement — la tolérance ne
// rattrape pas un écart de 2 j).
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
// injection si l'injection n'est pas faite pile à la même heure » —
// l'ancienne comparaison EXACTE cassait le régime pour un log à
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
val doses = listOf(
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
}
@Test
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
val doses = weeklyDoses(9).mapIndexed { idx, d ->
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
}
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `recent ester override resets stabilization`() {
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
// override d'ESTER (EEn → EV) : switch d'ester = nouveau régime
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW, doseAmount = 5.0, esterType = "EV"
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals("EV", rec.ester)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `Estrannaise carrier uses its ANALYTIC half-life (v1_9_0)`() {
// v1.9.0 : le modèle ESE est devenu analytique (forme close de
// estrannaise.js) — la t½ = ln2/min(k1,k2,k3) des PKParameters, plus
// d'estimation par pente de table. Finie, raisonnable, et la
// recommandation se calcule sans crash.
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
val doses = listOf(
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
)
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
assertTrue("t½ EV analytique plausible ($tHalf j)", tHalf in 0.1..200.0)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
assertNotNull(rec)
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)
}
}

View File

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

View File

@ -0,0 +1,451 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.lang.Math.exp
import java.lang.Math.log
import kotlin.math.ln
/**
* Tests du « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les labs
* (cf doc §7.10), et de sa PROLONGATION au-delà du dernier lab (v1.6.0,
* cf doc §7.10.bis). Chaque test épingle une GARANTIE de la construction :
* - passage EXACT sur chaque lab (comme les sanity checks TFS) ;
* - interp log-linéaire de ρ (math exacte, tolérance FP seule) ;
* - garde de significativité jamais contournée (leçon #61 : une dose de
* test ancienne + labs tardifs ne doivent pas créer de zigzag) ;
* - fenêtre = [1er ; dernier lab] par défaut ; avec l'option v1.6.0,
* prolongation ρ constant jusqu'au cutoff du modèle (JAMAIS au-delà) ;
* - indépendance du scaleFactor stocké (cette courbe est SA PROPRE
* calibration continue — combiner = double correction).
*
* ⚠️ Noms de tests ASCII-sans-ponctuation : les backticks Kotlin avec
* « : . / ; [ ] » sont des caractères illégaux pour le nom de méthode JVM
* (compilation refusée par l'AGP — vérifié v1.5.0).
*/
class LabTrajectoryModelTest {
private val DAY_MS = 86_400_000L
private val HOUR_MS = 3_600_000L
@Before
fun setup() {
// Même pattern que PKProfileStoreTest : l'asset est un FICHIER du
// module (les assets Android ne sont pas sur le classpath des tests JVM)
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("pk_profiles.json not found for unit tests")
PKProfileStore.initWithJson(file.readText())
}
}
// ── Fixtures ─────────────────────────────────────────────────────────────
/** Traitement injectable EEn / TFS 5 mg, une dose à t=0 (id 1). */
private fun treatment(scaleFactor: Double = 1.0): Treatment =
Treatment(
id = 1, name = "EEn TFS", type = TreatmentType.ESTRADIOL,
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_SUBCUT,
doseAmount = 5.0, doseUnit = "mg",
esterType = "EEN", pkModel = "TFS", scaleFactor = scaleFactor,
createdAt = 0
)
private fun doses(t: Treatment): List<com.hormonetrack.data.model.DoseLog> =
listOf(com.hormonetrack.data.model.DoseLog(id = 1, treatmentId = 1, timestamp = 0L, doseAmount = 5.0))
private fun lab(value: Double, tMs: Long): LabResult =
LabResult(marker = "E2", value = value, unit = "pg/mL", timestamp = tMs)
private fun tLab(tMs: Long): LabResult =
LabResult(marker = "T", value = 0.35, unit = "ng/mL", timestamp = tMs)
private fun initProfiles() {
if (!PKProfileStore.hasProfile("EV", "ESE")) setup()
}
/** Point à mi-chemin de l'index (représentatif du milieu de la fenêtre). */
private fun List<LevelPoint>.mid(): LevelPoint = this[size / 2]
// ── Garanties ────────────────────────────────────────────────────────────
@Test
fun `passe exactement sur chaque lab`() {
initProfiles()
val tr = treatment()
val t2 = 2 * DAY_MS
val t7 = 7 * DAY_MS
val labs = listOf(lab(300.0, t2), lab(260.0, t7))
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS
)
assertEquals(2, curve.anchoredLabs)
// Aux timestamps des labs : courbe == lab EXACTEMENT (fp ≈ 1e-6)
assertEquals(labs[0].value, curve.points.first { it.timestamp == t2 }.e2, 1e-6)
assertEquals(labs[1].value, curve.points.first { it.timestamp == t7 }.e2, 1e-6)
}
@Test
fun `rho interpole en log entre deux labs (math exacte)`() {
initProfiles()
val tr = treatment()
val doses = doses(tr)
val t2 = 2 * DAY_MS
val t7 = 7 * DAY_MS
// ⚠️ Labs DATA-DRIVEN : lab = M(t) × facteur voulu — sinon les ratios
// ne sont PAS les valeurs voulues (M(2 j) ≠ le pic de référence 160)
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, t2, null, null)
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, t7, null, null)
val labs = listOf(lab(0.9 * m2, t2), lab(1.2 * m7, t7))
// 1) La fonction PURE ratioAt interpole exactement (ancres artificiels)
val pure = listOf(t2 to 0.9, t7 to 1.2)
val expectedRhoMid = exp((log(0.9) + log(1.2)) / 2.0)
assertEquals(0.9, LabTrajectoryModel.ratioAt(pure, t2), 1e-12)
assertEquals(1.2, LabTrajectoryModel.ratioAt(pure, t7), 1e-12)
assertEquals(expectedRhoMid, LabTrajectoryModel.ratioAt(pure, (t2 + t7) / 2), 1e-9)
// 2) La COURBE restitue le même ρ : recomposer M(t) et vérifier
// courbe/M == ρ attendu (M calculé par le moteur, comme le module)
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses, labs, 0L, 30 * DAY_MS, HOUR_MS
)
val mid = curve.points.mid()
val m = PharmacokineticEngine.e2At(
listOf(tr.copy(scaleFactor = 1.0)), doses, mid.timestamp, null, null)
assertEquals(expectedRhoMid, mid.e2 / m, 1e-9)
}
@Test
fun `un seul lab significatif donne une courbe vide (pas d intervalle)`() {
initProfiles()
val tr = treatment()
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS
)
assertTrue("le lab compte mais ne suffit pas à tracer", curve.points.isEmpty())
assertTrue(curve.anchoredLabs == 1)
}
@Test
fun `garde 61 - un lab hors de la fenetre d action n est pas un ancrage`() {
initProfiles()
val tr = treatment()
// Scénario #61 : labs DANS la fenêtre d'action + un lab TARDIF à
// valeur absurde (prédiction résiduelle ≈ 0 → ratio aberrant)
val t2 = 2 * DAY_MS
val t7 = 7 * DAY_MS
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr),
listOf(lab(300.0, t2), lab(260.0, t7), lab(3000.0, 60 * DAY_MS)),
0L, 90 * DAY_MS, HOUR_MS
)
assertTrue("le lab tardif est rejeté par labIsSignificant", curve.anchoredLabs == 2)
assertTrue("aucun point ne suit la valeur absurde", curve.points.none { it.e2 > 600.0 })
// …et la fenêtre s'arrête au DERNIER lab SIGNIFICATIF (pas au tardif)
assertTrue("modelOverride modifie la forme intermédiaire (ESE ≠ TFS)", curve.points.all { it.timestamp <= t7 })
}
@Test
fun `labs avant la premiere dose ne produisent aucun ancrage (prediction nulle)`() {
initProfiles()
val tr = treatment()
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr),
listOf(lab(200.0, -10 * DAY_MS), lab(210.0, -5 * DAY_MS)),
-15 * DAY_MS, 0L, HOUR_MS
)
assertTrue(curve.points.isEmpty())
assertTrue(curve.anchoredLabs == 0)
}
@Test
fun `la fenetre du trace est bornée par les labs ET par la demande`() {
initProfiles()
val tr = treatment()
val t2 = 2 * DAY_MS
val t7 = 7 * DAY_MS
val labs = listOf(lab(160.0, t2), lab(140.0, t7))
// demande PLUS LARGE que les labs → clippée aux labs
val wide = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS
)
assertTrue(wide.points.isNotEmpty())
assertTrue(wide.points.all { it.timestamp in t2..t7 })
// demande PLUS ÉTROITE que les labs → clippée à la demande
val narrow = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS
)
assertTrue(narrow.points.isNotEmpty())
assertTrue(narrow.points.all { it.timestamp in 3 * DAY_MS..5 * DAY_MS })
}
@Test
fun `indépendant du scaleFactor stocké (sinon double correction)`() {
initProfiles()
val labs = listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS))
val a = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(treatment(1.0)), doses(treatment(1.0)), labs, 0L, 30 * DAY_MS, HOUR_MS
)
val b = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(treatment(0.55)), doses(treatment(0.55)), labs, 0L, 30 * DAY_MS, HOUR_MS
)
// M diffère (0,55 vs 1,0) MAIS ρ compense exactement : courbes identiques
assertEquals(a.points, b.points)
}
@Test
fun `modelOverride - chaque modèle ancre avec SA prédiction (sémantique 60)`() {
initProfiles()
// ⚠️ ESE vs TFS : PROUVENT quasi-identiques par construction (doc §8,
// seuil 2 % — le test WHSAH côté moteur a la même limite). Pour une
// divergence franche : EV avec ESE (pic 61,12 @ 45 h) vs WHS
// (73,5 @ 41 h, biodispo F<1) — ~20 % d'écart de forme.
val tr = treatment().copy(
esterType = "EV", pkModel = "TFS",
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_IM,
)
val labs = listOf(lab(320.0, 2 * DAY_MS), lab(240.0, 7 * DAY_MS))
val ese = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "ESE")
val whs = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "WHS")
// Les DEUX passent par les labs (invariant)…
assertEquals(labs[0].value, ese.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
assertEquals(labs[0].value, whs.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
// …mais les formes intermédiaires diffèrent nettement
val midEse = ese.points.mid().e2
val midWhs = whs.points.mid().e2
val relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs))
// ⚠️ L'ancrage CONVERGE les modèles (mêmes points traversés + ρ interpolé
// dans le même esprit) : l'écart brut ESE/WHS (~20 %) est résorbé à
// ~2 % au mi-chemin — seuil identique au test moteur WHS (doc §8).
assertTrue("le modelOverride garde une empreinte intermédiaire (ESE $midEse vs WHS $midWhs)",
relDiff > 0.02)
}
@Test
fun `les labs T et autres marqueurs sont ignorés (E2 uniquement)`() {
initProfiles()
val tr = treatment()
val labs = listOf(lab(300.0, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240.0, 7 * DAY_MS))
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS)
assertTrue("le lab T n'est pas un ancrage", curve.anchoredLabs == 2)
}
@Test
fun `doublons de timestamp - le deuxième ratio gagne (garde division par zéro)`() {
val t = 5 * DAY_MS
val anchors = listOf(t to 1.0, t to 2.0, 10 * DAY_MS to 3.0)
assertEquals(3.0, LabTrajectoryModel.ratioAt(anchors, 10 * DAY_MS), 1e-12)
// au-delà de t, la section [t ; 10 j] interpole depuis r=2 (le DERNIER
// ratio porté par ce timestamp), jamais division par 0
val expected = exp(ln(2.0) + (7.0 - 5.0) / 5.0 * (ln(3.0) - ln(2.0)))
assertEquals(expected, LabTrajectoryModel.ratioAt(anchors, 7 * DAY_MS), 1e-9)
}
@Test
fun `ratioAt - gardes defensives aux bornes`() {
val anchors = listOf(0L to 1.0, 10L to 2.0)
assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12)
assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 1e-12)
}
// ── Prolongation au-delà du dernier lab (v1.6.0) ─────────────────────────
// Garanties de l'extension (cf doc §7.10.bis) :
// - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT —
// fondement = la garde défensive de ratioAt devenue comportement) ;
// - continuité au point de suture (le dernier lab reste exact) ;
// - horizon = dernière dose E2 + cutoff de son traitement (jamais de
// ligne à zéro inventée au-delà de l'extinction du modèle) ;
// - les doses loguées APRÈS le dernier lab (autre ester/dose compris)
// refont monter la courbe via M(t) — la demande « dosage + type
// d'ester + évolution classique » ;
// - dernier lab non significatif : ni ancre ni point de départ de la
// prolongation (garde #61 inchangée) ;
// - flag OFF (défaut) = comportement v1.5.0 bit-compatible.
/** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */
private fun anchoredLabs(tr: Treatment, doses: List<com.hormonetrack.data.model.DoseLog>):
Pair<List<LabResult>, Double> {
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, 2 * DAY_MS, null, null)
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, 7 * DAY_MS, null, null)
return listOf(lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)) to 1.2
}
@Test
fun `extension - la courbe continue apres le dernier lab avec le rho du dernier lab`() {
initProfiles()
val tr = treatment()
val doses = doses(tr)
val (labs, rhoLast) = anchoredLabs(tr, doses)
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses, labs, 0L, 60 * DAY_MS, HOUR_MS,
extendBeyondLastLab = true
)
assertTrue("la prolongation produit des points après le dernier lab",
curve.points.any { it.timestamp > 7 * DAY_MS })
// Suture : le dernier lab reste EXACT (continuité par construction)
assertEquals(labs[1].value, curve.points.first { it.timestamp == 7 * DAY_MS }.e2, 1e-6)
// Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact
for (day in listOf(10L, 20L, 30L)) {
val t = day * DAY_MS
val p = curve.points.first { it.timestamp == t }
val m = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t, null, null)
assertEquals("courbe($day j) == M × ρ_last", m * rhoLast, p.e2, 1e-9)
}
}
@Test
fun `extension - horizon borne par le cutoff de la derniere dose`() {
initProfiles()
val tr = treatment()
val (labs, _) = anchoredLabs(tr, doses(tr))
// Demande TRÈS large (90 j) : la prolongation s'arrête au modèle
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS,
extendBeyondLastLab = true
)
val horizonEnd = LabTrajectoryModel.extensionHorizonEndMs(
listOf(tr), doses(tr), 7 * DAY_MS)!!
assertTrue("l'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)",
horizonEnd > 7 * DAY_MS)
assertTrue("aucun point au-delà de l'extinction du modèle",
curve.points.all { it.timestamp <= horizonEnd })
assertTrue("le dernier point s'arrête AU cutoff (pas au-delà d'un pas)",
curve.points.last().timestamp <= horizonEnd)
assertTrue(curve.points.last().timestamp + HOUR_MS > horizonEnd)
}
@Test
fun `extension - une dose apres le dernier lab refait monter la courbe (dose et ester)`() {
initProfiles()
val tr = treatment()
val doses = doses(tr) + com.hormonetrack.data.model.DoseLog(
id = 2, treatmentId = 1, timestamp = 10 * DAY_MS,
doseAmount = 4.0, esterType = "EV" // override d'ester : EEn → EV
)
val (labs, rhoLast) = anchoredLabs(tr, doses(tr))
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses, labs, 0L, 20 * DAY_MS, HOUR_MS,
extendBeyondLastLab = true
)
val extension = curve.points.filter { it.timestamp > 7 * DAY_MS }
assertTrue(extension.isNotEmpty())
// L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses
val t12 = 12 * DAY_MS
val m12 = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t12, null, null)
assertEquals(m12 * rhoLast, extension.first { it.timestamp == t12 }.e2, 1e-9)
// La FORME suit l'injection EV : montée nette après le 10 j (Tmax EV
// ~45 h) — la partie prolongée n'est PAS une simple décroissance EEn
val at8 = extension.first { it.timestamp == 8 * DAY_MS }.e2
val peak = extension.filter { it.timestamp in 10 * DAY_MS..14 * DAY_MS }.maxOf { it.e2 }
assertTrue("le pic EV ($peak) doit dépasser le niveau pré-injection ($at8)",
peak > at8 * 1.3)
}
@Test
fun `extension desactivee - rien apres le dernier lab (comportement initial v1-5-0)`() {
initProfiles()
val tr = treatment()
val (labs, _) = anchoredLabs(tr, doses(tr))
// DÉFAUT (flag absent) ET flag explicite à false : identiques
val byDefault = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS)
val explicitOff = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS,
extendBeyondLastLab = false)
assertEquals(byDefault.points, explicitOff.points)
assertTrue(byDefault.points.all { it.timestamp <= 7 * DAY_MS })
assertTrue("lastAnchorMs est positionné même sans prolongation (l'UI peut splitter)",
byDefault.lastAnchorMs == 7 * DAY_MS)
}
@Test
fun `extension - un lab non significatif ne prolonge pas (garde 61)`() {
initProfiles()
val tr = treatment()
// Scénario #61 : lab TARDIF (60 j — prédiction résiduelle 0, ratio aberrant)
val labs = anchoredLabs(tr, doses(tr)).first + lab(3000.0, 60 * DAY_MS)
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS,
extendBeyondLastLab = true
)
assertTrue("le lab tardif reste rejeté", curve.anchoredLabs == 2)
assertEquals("la prolongation part du dernier lab SIGNIFICATIF",
7 * DAY_MS, curve.lastAnchorMs)
assertTrue("des points prolongés existent bien après le dernier ancre",
curve.points.any { it.timestamp > 7 * DAY_MS })
assertTrue("aucun point ne suit la valeur absurde du lab rejeté",
curve.points.none { it.e2 > 600.0 })
}
@Test
fun `extension - demande finissant avant le dernier lab ne prolonge rien`() {
initProfiles()
val tr = treatment()
val (labs, _) = anchoredLabs(tr, doses(tr))
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), labs, 0L, 5 * DAY_MS, HOUR_MS,
extendBeyondLastLab = true
)
assertTrue(curve.points.all { it.timestamp <= 5 * DAY_MS })
// la suture au dernier ancre n'est pas dans la fenêtre → aucun point
// au-delà du dernier lab, comme en v1.5.0
assertTrue(curve.points.none { it.timestamp > 7 * DAY_MS })
}
@Test
fun `extensionHorizonEndMs - gardes aucune dose ou modele deja eteint`() {
initProfiles()
val tr = treatment()
val trs = listOf(tr)
// Aucune dose E2 → rien à prolonger
assertTrue(LabTrajectoryModel.extensionHorizonEndMs(trs, emptyList(), 7 * DAY_MS) == null)
// Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.)
val horizon = LabTrajectoryModel.extensionHorizonEndMs(trs, doses(tr), 7 * DAY_MS)
val expected = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong()
assertEquals(expected, horizon)
// Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre v1.5.0)
assertTrue(LabTrajectoryModel.extensionHorizonEndMs(
trs, doses(tr), expected + HOUR_MS) == null)
}
@Test
fun `lastAnchorMs - null si moins de 2 ancres, positionne sinon`() {
initProfiles()
val tr = treatment()
val one = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS)
assertTrue(one.points.isEmpty())
assertTrue(one.lastAnchorMs == null)
val two = LabTrajectoryModel.computeLabAnchoredCurve(
listOf(tr), doses(tr), listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS)),
0L, 30 * DAY_MS, HOUR_MS)
assertEquals(7 * DAY_MS, two.lastAnchorMs)
}
}

View File

@ -2,18 +2,27 @@ package com.hormonetrack.pk
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.groupLabsForDisplay import com.hormonetrack.ui.screens.groupLabsForDisplay
import com.hormonetrack.ui.screens.labNotesForDisplay
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** /**
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même * Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
* timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant. * timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant.
* v1.7.1 : affichage des NOTES du groupe ([labNotesForDisplay]) — fix du bug
* « une seule note sur deux s'affiche » quand E2 et T portent des notes
* DISTINCTES (chacune conservée en base, une seule affichée avant le fix).
*/ */
class LabsGroupingTest { class LabsGroupingTest {
private fun lab(marker: String, ts: Long, value: Double) = private fun lab(marker: String, ts: Long, value: Double) =
LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts) LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts)
/** Lab avec note (les autres paramètres neutres). */
private fun labWithNotes(marker: String, ts: Long, notes: String?) =
LabResult(marker = marker, value = 1.0, unit = "pg/mL", timestamp = ts, notes = notes)
@Test @Test
fun `E2 and T at the same timestamp are grouped into one blood draw`() { fun `E2 and T at the same timestamp are grouped into one blood draw`() {
val ts = 1_788_621_894_346L val ts = 1_788_621_894_346L
@ -53,4 +62,64 @@ class LabsGroupingTest {
val groups = groupLabsForDisplay(labs) val groups = groupLabsForDisplay(labs)
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() }) assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
} }
// ── Notes d'un groupe (v1.7.1 — fix « une seule note sur deux ») ────────
@Test
fun `distinct notes on E2 and T are BOTH shown, prefixed by marker`() {
// Cas réel remonté (paire v1.7.0 de l'utilisatrice) : note E2 ≠ note T
val ts = 1_788_934_260_000L
val group = listOf(
labWithNotes("E2", ts, "Analyse faite suite à des maux de tête."),
labWithNotes("T", ts, "DHT : 12 ng/dL"),
)
assertEquals(
listOf(
"E2 : Analyse faite suite à des maux de tête.",
"T : DHT : 12 ng/dL",
),
labNotesForDisplay(group)
)
}
@Test
fun `identical notes on both entries are shown once (no duplicate)`() {
// Création via le dialog commun : LA MÊME note part sur E2 et T
val ts = 1_000L
val group = listOf(
labWithNotes("E2", ts, "Prise à jeun"),
labWithNotes("T", ts, "Prise à jeun"),
)
assertEquals(listOf("Prise à jeun"), labNotesForDisplay(group))
}
@Test
fun `single note stays unprefixed (v1_0 behaviour preserved)`() {
val ts = 2_000L
val group = listOf(
labWithNotes("E2", ts, "Seule note"),
labWithNotes("T", ts, null),
)
assertEquals(listOf("Seule note"), labNotesForDisplay(group))
}
@Test
fun `blank and null notes are ignored, order follows the group (E2 before T)`() {
val ts = 3_000L
val group = listOf(
labWithNotes("E2", ts, " "), // blanche → ignorée
labWithNotes("T", ts, "note T"),
labWithNotes("PRL", ts, "note PRL"),
)
// Ordre du groupe (déjà trié E2→T→PRL par le caller) : T avant PRL,
// la note blanche d'E2 n'apparaît pas
assertEquals(listOf("T : note T", "PRL : note PRL"), labNotesForDisplay(group))
}
@Test
fun `no notes at all yields no display lines`() {
val ts = 4_000L
val group = listOf(labWithNotes("E2", ts, null), labWithNotes("T", ts, ""))
assertTrue(labNotesForDisplay(group).isEmpty())
}
} }

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
@ -220,7 +225,9 @@ class PharmacokineticEngineTest {
fun `next reminder fire time is in the future`() { fun `next reminder fire time is in the future`() {
val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30) val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30)
val now = BASE val now = BASE
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), now) // v1.4.0 : signature (treatments, doseLogs, nowMs) — doseLogs vide =
// fallback quotidien (pas de Posologie sur ce traitement)
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), emptyList(), now)
assertTrue(next != null && next > now && next - now <= 24 * HOUR) assertTrue(next != null && next > now && next - now <= 24 * HOUR)
} }

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

@ -2,6 +2,7 @@ package com.hormonetrack.pk
import com.google.gson.Gson import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
@ -16,7 +17,7 @@ import java.io.File
* tous les ~6-7 j), 22 labs couvrant les DEUX périodes. * tous les ~6-7 j), 22 labs couvrant les DEUX périodes.
* *
* ⚠️ Données de santé personnelles : fichier HORS dépôt * ⚠️ Données de santé personnelles : fichier HORS dépôt
* (`local-test-data/backup-v1.2.3.json`, gitignoré) ; tests ignorés si absent. * (`local-test-data/backup-v1.3.1.json`, gitignoré) ; tests ignorés si absent.
* *
* Cas de bug v1.2.4 : passer un traitement à `isActive = false` faisait disparaître * Cas de bug v1.2.4 : passer un traitement à `isActive = false` faisait disparaître
* sa simulation (et faussait la calibration) — « inactif » est un drapeau * sa simulation (et faussait la calibration) — « inactif » est un drapeau
@ -27,20 +28,21 @@ class RegressionUserCase3Test {
companion object { companion object {
private val DATA_FILE: File = listOf( private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.2.3.json"), File("../local-test-data/backup-v1.3.1.json"),
File("local-test-data/backup-v1.2.3.json"), File("local-test-data/backup-v1.3.1.json"),
File("app/local-test-data/backup-v1.2.3.json") File("app/local-test-data/backup-v1.3.1.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.3.json") ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.3.1.json")
} }
@Before @Before
fun setup() { fun setup() {
assumeTrue( assumeTrue(
"local-test-data/backup-v1.2.3.json absent — tests ignorés (données locales)", "local-test-data/backup-v1.3.1.json absent — tests ignorés (données locales)",
DATA_FILE.exists() DATA_FILE.exists()
) )
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() }
@ -54,16 +56,21 @@ class RegressionUserCase3Test {
@Test @Test
fun `third backup parses with an inactive EV treatment and an active EEn one`() { fun `third backup parses with an inactive EV treatment and an active EEn one`() {
val data = importUserBackup() val data = importUserBackup()
assertEquals(2, data.treatments.size) assertEquals(3, data.treatments.size)
assertEquals(38, data.doseLogs.size) assertEquals(51, data.doseLogs.size)
assertEquals(22, data.labResults.size) assertEquals(26, data.labResults.size)
val ev = data.treatments.single { it.esterType == "EV" } val ev = data.treatments.single { it.esterType == "EV" }
val een = data.treatments.single { it.esterType == "EEN" } val een = data.treatments.single { it.esterType == "EEN" }
val cpa = data.treatments.single { it.type == TreatmentType.ANTI_ANDROGEN }
// Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées // Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées
assertTrue(!ev.isActive) assertTrue(!ev.isActive)
assertTrue(een.isActive) assertTrue(een.isActive)
assertTrue(cpa.isActive)
assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20) assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20)
assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5) assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5)
// CPA oral : anti-androgène (0 contribution E2), posologie 1 j
assertTrue(cpa.type == TreatmentType.ANTI_ANDROGEN)
assertTrue(cpa.forecastIntervalDays == 1.0)
} }
@Test @Test

View File

@ -0,0 +1,192 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* **4ᵉ régression épinglée sur données réelles** (export v1.4.2, HORS dépôt
* `local-test-data/backup-v1.4.2.json`, gitignoré — données de santé).
*
* Contexte (remontée 2026-09-07) : l'utilisatrice s'est injectée de l'EEn
* la veille à ~18:54 et trouve « bizarre » que l'app affiche un taux qui
* DESCEND (238 pg/mL, −11 « vs il y a 6 h »). Deux enseignements épinglés :
*
* 1. **Pharmacocinétique normale** : à J+1 d'un EEn (Tmax 6,5 j), la dose
* ne contribue encore qu'à peine (~4 pg/mL à J+0,5, ~18 à J+1) et le
* niveau TOTAL est un plateau (superposition de l'équilibre — PTR bas de
* l'énanthate). La « remontée » individuelle est masquée par la
* décroissance du stock. Ce test épine cette signature.
* 2. **Bug #53 confirmé dans le code** : le delta « vs il y a 6 h » de la
* carte d'accueil comparait en réalité le point d'IL Y A ~24 H
* (`firstOrNull` sur la fenêtre 24 h) — cf fix `pointHoursBefore`.
*
* Le test reproduit le calcul d'app à l'instant d'observation (+15 min
* après exportedAt ≈ 07:25 le lendemain de l'injection) pour épingler le
* niveau exact que le moteur produit.
*/
class RegressionUserCase4Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.4.2.json"),
File("local-test-data/backup-v1.4.2.json"),
File("app/local-test-data/backup-v1.4.2.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.4.2.json")
}
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.4.2.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
if (!PKProfileStore.hasProfile("EV", "TFS")) {
val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
private fun observationMs(): Long = data.exportedAt + 15 * 60_000L
/** Calibration automatique telle que la calcule l'app (option active). */
private fun auto(): PharmacokineticEngine.AutoCalibrated =
PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
@Test
fun `export v2 parses with settings and the real transition scenario`() {
assertEquals(3, data.treatments.size)
assertEquals(52, data.doseLogs.size)
assertEquals(26, data.labResults.size)
assertEquals(2, data.version)
// settings v1.4.2 : opt-in alertes + auto-calibration active
org.junit.Assert.assertNotNull(data.settings)
assertEquals(true, data.settings!!.autoCalibrate)
org.junit.Assert.assertNotNull(data.settings!!.alertE2High)
org.junit.Assert.assertNotNull(data.settings!!.alertE2Low)
// scénario de transition : EV inactif (scaleFactor > 1) + EEn actif (posologie 6 j)
val ev = data.treatments.single { it.esterType == "EV" }
val een = data.treatments.single { it.esterType == "EEN" }
assertTrue(!ev.isActive && ev.scaleFactor != 1.0)
assertTrue(een.isActive && een.forecastIntervalDays != null)
}
@Test
fun `contribution of yesterday injection is tiny at J+1 (EEn Tmax 6-5 days)`() {
val eenDoses = data.doseLogs.filter { it.treatmentId == 1L }
val lastDose = eenDoses.maxByOrNull { it.timestamp }!!
val obs = observationMs()
val dtDays = (obs - lastDose.timestamp) / 86_400_000.0
assertTrue("l'observation est ~J+0,5", dtDays in 0.3..0.7)
// contribution SEULE de la dernière dose, en % du niveau total
val tr = data.treatments.single { it.id == lastDose.treatmentId }
val alone = PharmacokineticEngine.concentrationOfDose(tr, lastDose, obs)
val total = PharmacokineticEngine.e2At(
data.treatments, data.doseLogs, obs, scalePerEster = auto().esterScales
)
assertTrue("contribution seule=${alone} pg/mL doit être faible (< 12 %)", alone < total * 0.12)
// signature EEn : à J+1 la contribution reste marginale devant le plateau
val aloneJ1 = PharmacokineticEngine.concentrationOfDose(
tr, lastDose, lastDose.timestamp + 86_400_000L
)
val totalJ1 = PharmacokineticEngine.e2At(
data.treatments, data.doseLogs, lastDose.timestamp + 86_400_000L,
scalePerEster = auto().esterScales
)
assertTrue("à J+1 : $aloneJ1 vs total ${totalJ1}", aloneJ1 < totalJ1 * 0.12)
}
@Test
fun `level plateau around observation time (EEn equilibrium, no sharp rise)`() {
val obs = observationMs()
val scale = auto().esterScales
// Variation sur 24 h autour de l'observation : le plateau EEn est plat
// (PTR bas) — |Δ| reste une petite fraction du niveau.
val before = PharmacokineticEngine.e2At(
data.treatments, data.doseLogs, obs - 86_400_000L, scalePerEster = scale
)
val at = PharmacokineticEngine.e2At(
data.treatments, data.doseLogs, obs, scalePerEster = scale
)
val after = PharmacokineticEngine.e2At(
data.treatments, data.doseLogs, obs + 86_400_000L, scalePerEster = scale
)
assertTrue("niveau=${at} doit être physiologique (100-500)", at in 100.0..500.0)
val maxSwing = maxOf(before, at, after) - minOf(before, at, after)
assertTrue("swing 48 h = ${maxSwing} doit rester ≤ 25 % du niveau", maxSwing <= at * 0.25)
}
@Test
fun `thresholds evaluate on the real level (no false HIGH at observation)`() {
val obs = observationMs()
val scale = auto().esterScales
val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, obs, scalePerEster = scale)
val t = data.tConfig
val level = PharmacokineticEngine.levelAt(
data.treatments, data.doseLogs, obs, t,
tKPerEster = auto().tKPerEster, scalePerEster = scale
)
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(
e2High = data.settings!!.alertE2High,
e2Low = data.settings!!.alertE2Low,
tHigh = data.settings!!.alertTHigh,
tLow = data.settings!!.alertTLow
))
// À l'observation (J+0,5) : le niveau est dans la plage configurée →
// PAS d'alerte (le franchissement LOW n'arrive qu'au creux, ~J+7)
assertTrue("alertes inattendues à l'observation : ${alerts}", alerts.isEmpty())
assertTrue("niveau=$e2 cohérent avec l'affichage rapporté (~238)", e2 in 200.0..300.0)
}
@Test
fun `forecast slot is exactly last logged dose plus interval (bug 55 report)`() {
// Remontée v1.4.4 : « la prévision simule la prochaine injection le
// 13 au lieu du 12 ». Diagnostic (ce test l'épingle) : la GÉNÉRATION
// est exacte — créneau = dernière dose LOGUÉE + intervalle EXACT en
// ms (aucun arrondi au jour, aucun saut de fuseau).
//
// ⚠️ Subtilité vécue : la dose loguée était à ~19 h un DIMANCHE
// (saisie le lendemain de l'injection réelle du samedi 18 h) → avec
// une posologie de 6 j, le créneau suivant tombe un SAMEDI (6 j plus
// tard) : le jour de semaine DÉCALÉ entre doses loguées et réelles
// dérive d'un jour par cycle. L'app suit les LOGS (comportement
// voulu, v1.2.7) ; replacer la dose à son heure réelle (édition)
// recadre la prévision.
val een = data.treatments.single { it.esterType == "EEN" }
val horizon = System.currentTimeMillis() + 30L * 86_400_000L
val now = data.exportedAt
val slots = PharmacokineticEngine.generateForecastDoses(een, data.doseLogs, horizon, now)
assertTrue("aucune dose prévisionnelle générée", slots.isNotEmpty())
val lastReal = data.doseLogs
.filter { it.treatmentId == een.id && it.timestamp <= now }
.maxByOrNull { it.timestamp }!!
val intervalMs = (een.forecastIntervalDays!! * 24 * 3_600_000L).toLong()
// le 1ᵉʳ créneau = dernière dose + N × intervalle (créneaux passés sautés)
val expected = sequence {
var t = lastReal.timestamp + intervalMs
while (t <= now) t += intervalMs
yield(t)
t += intervalMs
}.first()
assertEquals(expected, slots.first().timestamp)
// et l'écart entre créneaux de la série reste l'intervalle exact
if (slots.size >= 2) {
assertEquals(intervalMs, slots[1].timestamp - slots[0].timestamp)
}
}
}

View File

@ -0,0 +1,186 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.ui.screens.labNotesForDisplay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* **5ᵉ régression épinglée sur données réelles** (export v1.7.0, HORS dépôt
* `local-test-data/backup-v1.7.0.json`, gitigné — données de santé).
*
* Contexte (remontée 2026-09-16) : dans une prise de sang E2+T, quand les
* DEUX entrées portent des notes DISTINCTES (ex. une note clinique sur l'E2
* et une note « DHT » sur la T), une seule s'affichait dans l'écran Analyses
* — l'autre était bien conservée mais perdue à l'affichage. Fix :
* `labNotesForDisplay` (Android + web en miroir), épinglé ici sur la VRAIE
* paire de données. Profil mis à jour au passage : 3 traitements dont un CPA
* oral (anti-androgène, Bateman) + EEn actif + EV historique INACTIF —
* rappelle que l'inactif reste simulé (§6.bis).
*
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
* §8.bis — le scanner refuserait, et ce serait une fuite).
*/
class RegressionUserCase5Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.7.0.json"),
File("local-test-data/backup-v1.7.0.json"),
File("app/local-test-data/backup-v1.7.0.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json")
}
private val DAY_MS = 86_400_000L
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.7.0.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
if (!PKProfileStore.hasProfile("EV", "TFS")) {
val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
@Test
fun `backup parses with the expected shape`() {
assertEquals(3, data.treatments.size)
assertTrue("doses présentes", data.doseLogs.isNotEmpty())
assertTrue("labs présents", data.labResults.isNotEmpty())
// Profil : un anti-androgène ORAL actif, un E2 INACTIF resté dans les
// données (§6.bis : jamais filtré de la simulation), un E2 actif
assertEquals(1, data.treatments.count { it.type == TreatmentType.ANTI_ANDROGEN })
assertEquals(1, data.treatments.count { !it.isActive })
}
/**
* LE fix de cette régression : la prise la plus récente porte DEUX notes
* DISTINCTES (une par entrée) → [labNotesForDisplay] en rend DEUX,
* préfixées du marqueur. Avant le fix, l'affichage n'en montrait qu'une
* (première non vide) bien que les deux fussent conservées.
*/
@Test
fun `the latest blood draw has two distinct notes and BOTH are displayed`() {
val latestTs = data.labResults.maxOf { it.timestamp }
val pair = data.labResults.filter { it.timestamp == latestTs }
assertTrue("la dernière prise est une paire E2+T", pair.size >= 2)
val distinctNotes = pair.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }.distinct()
assertTrue(
"la dernière paire porte au moins deux notes distinctes (données du bug)",
distinctNotes.size >= 2
)
val displayed = labNotesForDisplay(pair)
assertEquals("DEUX lignes affichées", 2, displayed.size)
assertTrue("chaque ligne est préfixée du marqueur", displayed.all { it.contains(" : ") })
// Les DEUX textes d'origine se retrouvent (aucune perdue)
for (note in distinctNotes) {
assertTrue(displayed.any { it.endsWith(note) })
}
}
/** Même prise → note identique dupliquée = une seule ligne (pas de doublon). */
@Test
fun `a blood draw with the SAME note on both entries shows it once`() {
val pair = listOf(
LabResult(marker = "E2", value = 1.0, unit = "pg/mL", timestamp = 0, notes = "Prise à jeun"),
LabResult(marker = "T", value = 1.0, unit = "ng/mL", timestamp = 0, notes = "Prise à jeun"),
)
assertEquals(1, labNotesForDisplay(pair).size)
}
/** Notes de DOSES (côté injection) : parsées et préservées. */
@Test
fun `dose notes are preserved by the round-trip`() {
assertTrue(
"au moins une dose porte une note (données réelles)",
data.doseLogs.any { !it.notes.isNullOrBlank() }
)
}
/**
* Plausibilité du moteur sur CE profil (CPA oral Bateman + EEn actif +
* EV historique inactif) : niveau actuel physiologique, courbe 30 j non
* vide et bornée, auto-calibration (activée dans les settings) couvre
* l'ester actif sans planter.
*/
@Test
fun `engine stays plausible on the new profile`() {
val now = data.exportedAt + 300_000L // +5 min après l'export
val level = PharmacokineticEngine.levelAt(
data.treatments, data.doseLogs, now,
tConfig = data.tConfig, scalePerEster = null, tKPerEster = null
)
assertTrue("E2 estimée physiologique (${level.e2})", level.e2 in 20.0..1000.0)
assertTrue("T estimée physiologique (${level.t})", level.t in 0.01..3.0)
val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs, now - 30 * 3_600_000L * 24, now,
stepMs = 3_600_000L, tConfig = data.tConfig
)
assertTrue("courbe 30 j non vide", curve.isNotEmpty())
// Auto-calibration (option activée dans les settings de cet export)
val auto = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
assertTrue("l'auto-calibration couvre au moins un ester", auto.calibratedEsters >= 1)
}
/** Prévision : le traitement actif a une Posologie → créneaux projetés. */
@Test
fun `forecast slots exist for the active treatment`() {
val active = data.treatments.first { it.isActive && it.type == TreatmentType.ESTRADIOL }
val slots = PharmacokineticEngine.generateForecastDoses(
active, data.doseLogs, data.exportedAt + 10L * 24 * 3_600_000L, data.exportedAt
)
assertTrue("créneaux prévisionnels générés (Posologie 7 j)", slots.isNotEmpty())
}
/**
* v1.8.0 : la RECOMMANDATION de prochaine prise de sang se calcule sur ce
* profil réel (EEn actif, Posologie 7 j) — future, avec le créneau
* associé juste après le creux, et cohérente avec la dernière prise de
* sang existante (jamais un creux déjà mesuré).
*/
@Test
fun `blood draw recommendation exists on the real profile`() {
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt
)
assertNotNull(
"EEn actif + Posologie 7 j → une recommandation est calculable", rec
)
assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt)
assertTrue(
"le creux est DANS la fenêtre inter-créneau associée (trough=${rec.troughMs}, inj=${rec.injectionMs})",
rec.troughMs > rec.injectionMs - 7L * DAY_MS - 2L * 3_600_000L &&
rec.troughMs < rec.injectionMs
)
assertTrue(
"le creux recommandé est postérieur à la dernière prise de sang",
rec.troughMs > data.labResults.maxOf { it.timestamp }
)
assertTrue("ester actif uniquement", rec.ester.equals("EEN", ignoreCase = true))
}
}

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

@ -0,0 +1,158 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
/**
* Épingle la planification des rappels AVEC Posologie (v1.4.0, fix #52) :
* un traitement injecté tous les 7 jours avec un rappel à 18 h doit sonner
* UNIQUEMENT les jours de créneau (le samedi 18 h), pas tous les jours.
*
* Précédemment : `nextReminderFireMs` ignorait les doses et l'intervalle →
* l'alarme se ré-armait chaque jour à HH:mm, même hors jour d'injection.
*/
class ReminderScheduleTest {
private companion object {
const val DAY_MS = 86_400_000L
const val HOUR_MS = 3_600_000L
/** 18 h locales du jour de [ms] (comme le ferait l'alarme). */
fun at(ms: Long, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
timeInMillis = ms
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun injectionTreatment(intervalDays: Double?, hour: Int, minute: Int): Treatment =
Treatment(
id = 1L,
name = "EEn",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_SUBCUT,
doseAmount = 5.0,
doseUnit = "mg",
isActive = true,
esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE,
forecastIntervalDays = intervalDays,
reminderHour = hour,
reminderMinute = minute,
reminderEnabled = true
)
@Test
fun `reminder follows the dosing grid (every 7 days at 18h, not daily)`() {
// now = un instant fixe ; dernière dose il y a 2 jours (le même jour
// de semaine que le créneau suivant, cf grille +7 j) ; rappel 18 h.
val now = System.currentTimeMillis()
val lastDose = now - 2 * DAY_MS
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0))
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
val expected = at(lastDose + 7 * DAY_MS, 18, 0)
assertEquals(expected, fire)
// invariants : dans le futur, à ~5 jours (7 j − 2 j), PAS demain matin
assertTrue("fire doit être futur", fire!! > now)
assertTrue("fire doit être dans ~5 jours, pas demain", fire - now > 3 * DAY_MS)
}
@Test
fun `reminder time already passed on slot day moves to next slot`() {
// Dose loguée à 20 h aujourd'hui, rappel prévu 18 h : l'occurrence
// du jour est déjà passée → le prochain rappel est le créneau suivant.
val now = at(System.currentTimeMillis(), 20, 0)
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now, doseAmount = 5.0))
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
val expected = at(now + 7 * DAY_MS, 18, 0)
assertEquals(expected, fire)
}
@Test
fun `missed slot is skipped without simulating the past`() {
// Dernière dose il y a 10 jours pour un intervalle de 7 j : le créneau
// théorique (il y a 3 jours) est PASSÉ → on avance au suivant (+7 j).
val now = System.currentTimeMillis()
val lastDose = now - 10 * DAY_MS
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0))
val tr = injectionTreatment(intervalDays = 7.0, hour = 9, minute = 30)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
val expected = at(lastDose + 14 * DAY_MS, 9, 30)
assertEquals(expected, fire)
assertTrue(fire!! > now)
}
@Test
fun `no posology keeps the legacy daily behaviour`() {
val now = at(System.currentTimeMillis(), 10, 0) // 10 h < 18 h
val tr = injectionTreatment(intervalDays = null, hour = 18, minute = 0)
// Sans historique de doses non plus (gel/oral ou app neuve)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now)
assertEquals(at(now, 18, 0), fire)
}
@Test
fun `posology without any logged dose falls back to daily`() {
// Aucune dose pour cadrer la grille : on retombe sur le quotidien
// (l'app neuve ne peut pas deviner le jour d'injection).
val now = at(System.currentTimeMillis(), 10, 0)
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now)
assertEquals(at(now, 18, 0), fire)
}
@Test
fun `aggregate helper picks the earliest fire across treatments`() {
val now = System.currentTimeMillis()
val trWeekly = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val trDaily = injectionTreatment(intervalDays = null, hour = 8, minute = 0)
.copy(id = 2L)
val doses = listOf(
DoseLog(treatmentId = 1L, timestamp = now - 2 * DAY_MS, doseAmount = 5.0)
)
val next = PharmacokineticEngine.nextReminderFireMs(listOf(trWeekly, trDaily), doses, now)
assertEquals(PharmacokineticEngine.nextReminderFireFor(trDaily, doses, now), next)
}
@Test
fun `inactive or disabled treatments never schedule`() {
val now = System.currentTimeMillis()
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now - DAY_MS, doseAmount = 5.0))
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
injectionTreatment(7.0, 18, 0).copy(isActive = false), doses, now))
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
injectionTreatment(7.0, 18, 0).copy(reminderEnabled = false), doses, now))
}
@Test
fun `sub-daily grid fires at the reminder hour on slot days (known limitation)`() {
// Deux injections/jour (intervalle 0,5 j) avec UNE heure de rappel :
// l'app ne peut pas exprimer deux rappels/jour à des heures différentes
// — elle sonne à l'heure de rappel les JOURS de créneau (limitation
// documentée §19 de la doc de dev). Ici : dose à 08 h, maintenant 10 h,
// créneau suivant 20 h → l'alarme du jour sonne à 18 h (aujourd'hui).
val now = at(System.currentTimeMillis(), 10, 0)
val doses = listOf(
DoseLog(treatmentId = 1L, timestamp = now - 2 * HOUR_MS, doseAmount = 2.0)
)
val tr = injectionTreatment(intervalDays = 0.5, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
assertEquals(at(now, 18, 0), fire)
}
}

View File

@ -0,0 +1,129 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Épinglé sur le **bug ×2,21 de la calibration manuelle** (#61, v1.4.9,
* remontée : « le bouton Calibrer avec les analyses du traitement EEn WHSAH
* me met toujours à ×2,21 »). Données : 4ᵉ export réel (HORS dépôt,
* `local-test-data/backup-v1.4.2.json`, gitignoré).
*
* DIAGNOSTIC (reproduit par ce test à l'origine) : l'export contient une
* dose EEn de test logguée en **janvier** (10 mg, bien avant la transition
* de juillet) ; les labs de janvier-mars tombaient 15–76 jours après cette
* dose, quand la prédiction WHSAH est RÉSIDUELLE (184 → 1 pg/mL) → ratios
* aberrants (3,3 / 15,9 / 89,6 / 383 / 391) → la MÉDIANE des ratios tombait
* à ×2,21, et ce DANS LES DEUX pipelines (manuel ET auto — même défaut de
* frontière).
*
* FIX #61 : **garde de fenêtre d'attribution** (`withinAttributionWindow`) —
* un lab ne calibre une dose que s'il est ≤ 3 demi-vies d'élimination du
* traitement après elle (au-delà, la contribution est < 12,5 % : le lab
* reflète autre chose). Appliqué aux TROIS pipelines de calibration
* (manuel, échelles par ester, k T).
*
* Résultat post-fix (réel, épinglé) : le facteur manuel WHSAH = **0,55**
* (médiane des labs dans la fenêtre) au lieu de 2,21.
*/
class ScaleFactorWhsahReproTest {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.4.2.json"),
File("local-test-data/backup-v1.4.2.json"),
File("app/local-test-data/backup-v1.4.2.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.4.2.json")
}
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = File("src/test/assets/pk_profiles.json")
.takeIf { it.exists() }
?: File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
?: File("app/src/main/assets/pk_profiles.json")
PKProfileStore.initWithJson(file.readText())
}
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
/** Le traitement EEn, basculé WHSAH (ce que fait l'utilisatrice). */
private fun eenWhs(): com.hormonetrack.data.model.Treatment =
data.treatments.single { it.esterType == "EEN" }.copy(pkModel = "WHS")
@Test
fun `manual calibration for EEn WHSAH lands in the physiological range (not 2-21)`() {
val sf = PharmacokineticEngine.computeScaleFactor(
eenWhs(), data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) }
)
assertNotNull(sf)
// AVANT le fix : 2,21 (médiane polluée par les labs hors fenêtre).
// APRÈS : ~0,55 (les labs dans la fenêtre de 3 × t½).
assertTrue("facteur manuel = $sf doit être physiologique (0,3–1,2)", sf!! in 0.3..1.2)
// épinglage explicite du bug : plus JAMAIS 2,21
assertTrue("régression #61 : facteur=$sf", kotlin.math.abs(sf - 2.21) > 0.5)
}
@Test
fun `auto calibration WHSAH uses the same attribution window (coherent with manual)`() {
// Auto (par modèle, fix #60) et manuel (computeScaleFactor) partagent
// la même garde → sur les mêmes données, les facteurs convergent
// (même set de labs attribués, même prédiction superposée).
val auto = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig,
modelOverride = "WHS"
).esterScales["EEN"]
val manual = PharmacokineticEngine.computeScaleFactor(
eenWhs(), data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) }
)
assertNotNull(auto)
assertTrue(
"auto=$auto vs manuel=$manual doivent converger (±15 %)",
kotlin.math.abs(auto!! - manual!!) <= 0.15 * maxOf(auto, manual)
)
// et la valeur est physiologique
assertTrue("auto WHSAH=$auto doit être < 1,2", auto in 0.3..1.2)
}
@Test
fun `significant-lab guard excludes residual predictions (labs at 37-76 days)`() {
// La garde v1.4.9 est RELATIVE : un lab ne calibre que si la prédiction
// reste ≥ 15 % du maximum déjà observé (l'injection gouverne encore).
// Réproduit le scénario réel : prédiction WHSAH montante 184 → 155 →
// puis résiduelle 23 → 3,5 → 1,5 → 1,0 (labs de janvier-mars).
val seq = listOf(184.0, 155.0, 23.4, 3.5, 1.5, 1.0)
var maxSoFar = 0.0
val included = mutableListOf<Double>()
for (p in seq) {
if (PharmacokineticEngine.labIsSignificant(p, maxSoFar)) included.add(p)
if (p > maxSoFar) maxSoFar = p
}
// inclus : 184 (1ᵉʳ), 155 (85 % du max) ; exclus : 23,4 (12,7 % < 15 %),
// 3,5, 1,5, 1,0 (résiduels — les ratios 15,9 / 89,6 / 383 / 391 du bug)
assertEquals(listOf(184.0, 155.0), included)
}
@Test
fun `significant-lab guard - first lab and boundary cases`() {
// premier lab (max 0) → seule la garde > 0,5 s'applique
assertTrue(PharmacokineticEngine.labIsSignificant(0.6, 0.0))
assertTrue(!PharmacokineticEngine.labIsSignificant(0.4, 0.0))
// exactement à la frontière 15 % → inclus (≥)
assertTrue(PharmacokineticEngine.labIsSignificant(15.0, 100.0))
// juste en dessous → exclu
assertTrue(!PharmacokineticEngine.labIsSignificant(14.9, 100.0))
}
}

View File

@ -0,0 +1,138 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Test d'INTÉGRATION du câblage moteur ↔ modèles V3C (v1.4.0).
*
* Différence avec `TransfemScienceModelsTest` (qui vérifie la fidélité du
* modèle lui-même) : ici on vérifie que le MOTEUR route bien vers V3C —
* `concentrationOfDose`/`e2At`/`computeCurve` avec `pkModel = TFS` ou
* `modelOverride = TFS` — et que la coupure moteur (`cutoffHours`, privée)
* tient compte des demi-vies terminales V3C (10 × t½).
*
* Scénarios épinglés :
* 1. EB (benzoate, SANS table ODS) en modèle TFS → pic article 971 pg/mL
* @ 0,65 j pour 5 mg — prouve l'ajout des nouveaux esters end-to-end ;
* 2. le même traitement forcé en ESE (`modelOverride`) → 0 partout : les
* tables Estrannaise ne couvrent pas EB (comportement documenté, les
* presets EB/EC/ECS/PEP sont créés en TFS uniquement) ;
* 3. PEP 32,5 mg : contribution visible à 10 j ET à 100 j — avec l'ANCIENNE
* coupure (longueur de table ODS, et pas de table pour PEP → 24 h), le
* niveau à 100 j aurait été 0. C'est le fix de `cutoffHours` (10 × t½).
*/
class TransfemScienceEngineTest {
private companion object {
val BASE = System.currentTimeMillis()
const val DAY_MS = 86_400_000L
const val HOUR_MS = 3_600_000L
}
@Before
fun loadProfiles() {
// Les tables ODS ne sont PAS requises pour le chemin V3C, mais la
// route ESE (test 2) doit renvoyer 0 SANS crash même non initialisée
// (PKProfileStore.sample gère !initialized → 0). On charge quand même
// l'asset pour rester au plus près du comportement réel de l'app.
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf(
File("src/test/assets/pk_profiles.json"),
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
}
private fun treatment(ester: String, doseMg: Double): Treatment = Treatment(
id = 1L,
name = "Injection $ester",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = doseMg,
doseUnit = "mg",
isActive = true,
esterType = ester,
pkModel = PKModels.TRANSFEM_SCIENCE
)
@Test
fun `EB treatment routed through V3C reaches the article peak`() {
val tr = treatment(Esters.EB, doseMg = 5.0)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
// au pic de l'article : 971 pg/mL @ 0,65 j
val atPeak = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + (0.65 * DAY_MS).toLong())
assertTrue("EB@0,65j = $atPeak (attendu ~971)", atPeak in 950.0..990.0)
// 5 j après : 91,5 pg/mL (queue V3C exacte)
val at5d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 5 * DAY_MS)
assertTrue("EB@5j = $atPeak (attendu ~91)", at5d in 85.0..98.0)
}
@Test
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 dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
)
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
fun `PEP at 32_5 mg contributes far beyond 24h (V3C cutoff at 10 half-lives)`() {
val tr = treatment(Esters.PEP, doseMg = 32.5)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 32.5)
// à 10 j : proche du pic de l'article (34 pg/mL @ 18 j)
val at10d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 10 * DAY_MS)
assertTrue("PEP@10j = $at10d (attendu ~29,5)", at10d in 27.0..32.0)
// à 100 j : l'ANCIENNE coupure (pas de table ODS pour PEP → 24 h)
// aurait renvoyé 0 ; la nouvelle (10 × t½ ≈ 284 j) donne ~5,8 pg/mL
val at100d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 100 * DAY_MS)
assertTrue("PEP@100j = $at100d (attendu ~5,8)", at100d > 4.0)
// au-delà de la coupure (300 j > 10 × t½ = 284 j) : 0 par design
val at300d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 300 * DAY_MS)
assertEquals(0.0, at300d, 1e-9)
}
@Test
fun `computeCurve with modelOverride TFS matches V3C on EV`() {
val tr = Treatment(
id = 1L, name = "EV", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM, doseAmount = 5.0, doseUnit = "mg",
isActive = true, esterType = Esters.EV, pkModel = PKModels.ESTRANNAISE
)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
// Override TFS → courbe V3C (pic article 295 pg/mL @ 2,1 j), même si
// le traitement est STOCKÉ en Estrannaise (le graphique superpose
// les deux modèles depuis le même traitement).
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), listOf(dose), BASE, BASE + 3 * DAY_MS,
stepMs = HOUR_MS, tConfig = TConfig(),
modelOverride = PKModels.TRANSFEM_SCIENCE
)
val peak = curve.maxOf { it.e2 }
assertTrue("pic TFS via moteur = $peak (attendu ~295)", peak in 285.0..305.0)
}
}

View File

@ -0,0 +1,160 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.Esters
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Épingle le modèle **Transfem Science V3C** (v1.4.0) sur les valeurs de la
* méta-analyse https://transfemscience.org/articles/injectable-e2-meta-analysis/
* (Tableaux 9–10 : dose unique de 5 mg IM ; Figure 11 : état d'équilibre EV).
*
* Les paramètres (D/k1/k2/k3) viennent du simulateur officiel TFS
* (cf pk/TransfemScienceModels.kt pour la provenance exacte) : ce test
* garantit que l'app reproduit le simulateur à ~1 % près.
*/
class TransfemScienceModelsTest {
companion object {
private const val DAY_MS = 86_400_000L
/** Pas d'intégration numérique pour l'AUC (jours). */
private const val DT_DAYS = 0.01
}
@Before
fun warmup() {
// V3C est purement analytique : rien à charger (les tables ODS ne sont
// pas impliquées). Ce hook garde la même structure que les autres tests.
}
/** Courbe pour une dose de 5 mg = sample × 5 (pg/mL). */
private fun c5mg(ester: String, days: Double): Double =
TransfemScienceModels.sample(ester, days * 24.0) * 5.0
/** Max/t de la courbe 5 mg sur 0..60 j (pas 0,01 j). */
private fun peak(ester: String): Pair<Double, Double> {
var best = 0.0 to 0.0
var t = 0.0
while (t <= 60.0) {
val v = c5mg(ester, t)
if (v > best.first) best = v to t
t += DT_DAYS
}
return best
}
@Test
fun `single-dose peaks match the article tables (5 mg IM)`() {
// (Cmax pg/mL, Tmax jours) — Tableaux 9/10 de la méta-analyse
val expected = mapOf(
"EV" to (295.0 to 2.1),
"EEN" to (160.0 to 6.5),
"EB" to (971.0 to 0.65),
"EC" to (155.0 to 4.3),
"ECS" to (241.0 to 1.2),
"EU" to null, // pas de valeurs tabulées fiables pour l'undécylate
// PEP : l'article donne Cmax=34 pg/mL à la dose ÉQUIVALENTE 32,5 mg
// (biodispo réduite) → à la dose de fit 5 mg : 34/6,5 ≈ 5,23
"PEP" to (34.0 / 6.5 to 18.0)
)
for ((ester, exp) in expected) {
val (cmax, tmax) = peak(ester)
if (exp == null) continue
val (cExp, tExp) = exp
assertTrue("$ester Cmax=$cmax attendu $cExp", cmax in cExp * 0.98..cExp * 1.02)
assertTrue("$ester Tmax=$tmax attendu $tExp", tmax in tExp * 0.9..tExp * 1.1)
}
}
@Test
fun `terminal half-lives match the article tables`() {
// t½ terminale (la phase la plus lente) — Tableaux 9/10
val expected = mapOf(
"EV" to 3.0, "EEN" to 4.6, "EB" to 1.2,
"EC" to 6.7, "ECS" to 5.1, "PEP" to 28.4
)
for ((ester, tHalfDays) in expected) {
val m = TransfemScienceModels.model(ester)!!
assertTrue(
"$ester t½=${m.terminalHalfLifeDays} attendu $tHalfDays",
m.terminalHalfLifeDays in tHalfDays * 0.95..tHalfDays * 1.05
)
}
}
/** AUC numérique (pg·jour/mL par mg) = exposition totale normalisée. */
private fun aucPerMg(ester: String, cutDays: Double = 400.0): Double {
var auc = 0.0
var t = 0.0
while (t < cutDays) {
auc += TransfemScienceModels.sample(ester, t * 24.0) * DT_DAYS
t += DT_DAYS
}
// Queue exponentielle au-delà de cutDays : valeur / λ terminale
val tail = TransfemScienceModels.sample(ester, cutDays * 24.0) *
TransfemScienceModels.model(ester)!!.terminalHalfLifeDays / kotlin.math.ln(2.0)
return auc + tail
}
@Test
fun `AUC matches the article (bioavailability ~100 percent, PEP reduced)`() {
// Article (5 mg) : EV 1886, EEn 2183, EB 2410, EC 2150, ECS 2096, PEP 2117 @32,5 mg
val expectedPerMg = mapOf(
"EV" to 1886.0 / 5.0,
"EEN" to 2183.0 / 5.0,
"EC" to 2150.0 / 5.0,
"PEP" to 2117.0 / 32.5 // l'article donne l'AUC à la dose équivalente 32,5 mg
)
for ((ester, exp) in expectedPerMg) {
val auc = aucPerMg(ester)
assertTrue("$ester AUC/mg=$auc attendu $exp", auc in exp * 0.97..exp * 1.03)
}
}
@Test
fun `EV steady state at 5 mg per week matches figure 11 (Cmax 384, Cmin 142, Cavg 269)`() {
// Superposition de 40 doses hebdomadaires de 5 mg ; on analyse les
// 2 derniers cycles (équilibre atteint, t½ EV = 3 j).
val interval = 7.0
val doses = 40
fun steady(days: Double): Double {
var total = 0.0
for (k in 0 until doses) {
val dt = days - k * interval
if (dt > 0) total += c5mg("EV", dt)
}
return total
}
// Fenêtre = dernier intervalle complet [ (doses-1)·7 , doses·7 )
val start = (doses - 1) * interval
var cmax = 0.0
var cmin = Double.MAX_VALUE
var sum = 0.0
var n = 0
var t = start
while (t < start + interval) {
val v = steady(t)
cmax = maxOf(cmax, v)
cmin = minOf(cmin, v)
sum += v
n++
t += DT_DAYS
}
val cavg = sum / n
assertTrue("Cmax=$cmax", cmax in 384.0 * 0.95..384.0 * 1.05)
assertTrue("Cmin=$cmin", cmin in 142.0 * 0.9..142.0 * 1.1)
assertTrue("Cavg=$cavg", cavg in 269.0 * 0.95..269.0 * 1.05)
}
@Test
fun `unknown esters have no V3C model and sample to zero`() {
assertTrue(!TransfemScienceModels.hasModel("NONE"))
assertEquals(0.0, TransfemScienceModels.sample("NONE", 10.0), 1e-12)
// avant la dose : 0
assertEquals(0.0, TransfemScienceModels.sample(Esters.EV, -1.0), 1e-12)
assertEquals(0.0, TransfemScienceModels.sample(Esters.EV, 0.0), 1e-12)
}
}

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() }
@ -50,6 +51,46 @@ class V120FeaturesTest {
forecastIntervalDays = intervalDays forecastIntervalDays = intervalDays
) )
@Test
fun `forecast skips past slots after a missed dose (oubli)`() {
// Rythme 6 j, dernier rappel... dernière PRISE il y a 12 j (une injection
// a été manquée il y a 6 j) : le créneau théorique BASE-6j est passé.
val tr = eenTreatment(intervalDays = 6.0)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 18 * DAY, doseAmount = 5.0),
DoseLog(treatmentId = 1, timestamp = BASE - 12 * DAY, doseAmount = 5.0)
)
val forecast = PharmacokineticEngine.generateForecastDoses(
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
)
// Aucun créneau simulé dans le passé : premier = BASE+6 j (dernière+12 j
// est le premier créneau strictement futur au rythme de 6 j)
assertTrue(forecast.isNotEmpty())
forecast.forEach { f ->
assertTrue("créneau passé simulé : ${f.timestamp}", f.timestamp > BASE)
}
assertEquals(BASE + 6 * DAY, forecast.first().timestamp)
forecast.zipWithNext { prev, cur ->
assertEquals(6 * DAY, cur.timestamp - prev.timestamp)
}
}
@Test
fun `forecast follows a delayed last dose (retard d un jour)`() {
// Rythme 6 j, dernière prise faite avec 1 jour de retard (à J-5 au lieu
// de J-6) : TOUTE la prévision se décale d'un jour, en partant de la
// dernière prise réelle.
val tr = eenTreatment(intervalDays = 6.0)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 5 * DAY, doseAmount = 5.0)
)
val forecast = PharmacokineticEngine.generateForecastDoses(
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
)
assertEquals(BASE + 1 * DAY, forecast.first().timestamp)
assertTrue(forecast.all { it.timestamp > BASE })
}
@Test @Test
fun `forecast doses follow the configured interval from the last real dose`() { fun `forecast doses follow the configured interval from the last real dose`() {
val tr = eenTreatment(intervalDays = 7.0) val tr = eenTreatment(intervalDays = 7.0)
@ -223,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

Some files were not shown because too many files have changed in this diff Show More