HormoneTrack/docs/DEVELOPPEMENT.md
Siphonight e8f87c096d Documentation v1.2.0 : changelog, guide (fonctions du graphique), doc de dev (AGP 9, migrations, panoramique, prévision, auto-cal), README (git, features)
- docs/CHANGELOG.md : entrée v1.2.0 complète
- docs/DEVELOPPEMENT.md : versions épinglées à jour, note Material Expressive
  (API encore internal), migration Room v1→v2, §7.3b (override modèle/prévision/
  auto-cal), §8 (36 tests), §10/§11 (panoramique, multi-séries), §16 (workflow git),
  §18 (disque), historique des sessions
- README : statut v1.2.0, features à jour, section Git (tags de release)
- GUIDE : nouvelle section « Fonctions du graphique » (panoramique, toggles
  modèles, prévision, calibration auto)
2026-09-05 16:58:32 +02:00

601 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Documentation de développement — HormoneTrack
> Doc de référence pour toute future session (humaine ou IA) : contexte, décisions,
> architecture, maths, build, tests, bugs corrigés, montre, évolutions.
> Projet : `~/projects/HormoneTrack` — voir aussi [README.md](../README.md),
> [GUIDE_INSTALLATION.md](GUIDE_INSTALLATION.md), [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md).
---
## Table des matières
1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées)](#3-stack--versions-épinglées)
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique)
8. [Tests unitaires](#8-tests-unitaires)
9. [Système de rappels](#9-système-de-rappels)
10. [UI & navigation](#10-ui--navigation)
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
12. [i18n FR/EN](#12-i18n-fren)
13. [Sauvegarde JSON](#13-sauvegarde-json)
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
16. [Workflow build / test / install](#16-workflow-build--test--install)
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
18. [Espace disque & coûts](#18-espace-disque--coûts)
19. [Limites connues & choix volontaires](#19-limites-connues)
20. [Idées d'évolution (Phase 2+)](#20-idées-dévolution)
21. [Checklist de test manuel](#21-checklist-de-test-manuel)
---
## 1. Contexte & objectifs
Utilisatrice : femme trans, THS (thérapie hormonale), injections d'estradiol (esters
EV/EU/EEn, switchables) ± anti-androgènes. Elle tient déjà un suivi rigoureux dans
**LibreOffice Calc** (`Estrogen.ods`, cf §7.1) avec deux modèles PK : **Estrannaise
(EstraNase)** et **Transfem Science**. L'app doit reproduire fidèlement ces modèles.
Montre : **Huawei Watch GT 3 (HarmonyOS 4.0.0.120)** = *Lite Wearable*, pas d'apps
Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équipe de
**Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir
sur la montre** via GB (ou Huawei Health).
Fonctionnalités v1 :
- Courbes estimées heure par heure : E2 (pg/mL) + T (ng/mL) — 24 h / 7 j / 30 j
- Deux modèles PK du `.ods` (Estrannaise / TFS) pour injections EV/EU/EEn ;
Bateman paramétrable pour gel/patch/oral
- Log des doses (date/heure exacte, mg, **ester par injection**)
- Labs (E2/T/PRL) + **calibration** (facteur d'échelle + calibration k du modèle T)
- Rappels quotidiens, actions « Pris » / « Reporter 1 h » dans la notification
- Export/Import JSON, FR/EN, 100 % local
## 2. Historique du projet
| Date | Événement |
|---|---|
| 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du `Estrogen.ods` → `/tmp/pk_models.json` (6 profils × 8001 h + params D/k1–k3). |
| 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « **Mimo V2.5** » : **aucune trace trouvée** (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. |
| 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. **Installation SDK Android (brew) + premier build.** |
| 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. |
| 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. |
| 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). |
Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
et en testant** — aucun build n'avait été lancé avant la session 3.
## 3. Stack & versions (épinglées — à jour v1.2.0)
| Composant | Version | Où |
|---|---|---|
| Gradle | **9.7.1** (wrapper) | `gradle/wrapper/gradle-wrapper.properties` |
| AGP | **9.4.0** — **Kotlin intégré** : ne PAS appliquer `org.jetbrains.kotlin.android` ; `kotlinOptions` supprimé (cible JVM via `compileOptions`, 17) | `build.gradle.kts` racine |
| Kotlin | 2.3.21 (plugin compose 2.3.21) | idem |
| KSP | 2.3.11 (versionnage indépendant depuis KSP2) | idem |
| Compose BOM | **2026.08.00** (Compose 1.12 ; material3 pinné par le BOM) | `app/build.gradle.kts` |
| Room | 2.8.4 (KSP) — **DB v2 + MIGRATION_1_2**, `fallbackToDestructiveMigration` retiré | idem + `AppDatabase.kt` |
| Navigation Compose | 2.10.0 | idem |
| AppCompat | 1.8.0 (langue par app) | idem |
| DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation) | idem |
| WorkManager | 2.11.2 (déclaré, non utilisé — supprimable) | idem |
| compileSdk / targetSdk | **37** / 37 ; minSdk 26 ; Java target 17 | app |
Notes importantes (v1.2.0) :
- **AGP 9** : Kotlin est intégré à AGP — appliquer `org.jetbrains.kotlin.android` est une
erreur ; le plugin `org.jetbrains.kotlin.plugin.compose` reste appliqué normalement.
- **compileSdk 37** : la plateforme `platforms;android-37` n'était pas dans sdkmanager
(API 37 en preview à la date du build) mais **AGP l'a auto-téléchargée** (licences
signées) — le build passe.
- **Compose 1.12 (BOM 2026.08.00) exige compileSdk ≥ 37 et AGP ≥ 9.1** ; le BOM
2026.06.01 est le dernier compatible compileSdk 36.
- **Material Expressive** : `MaterialExpressiveTheme` / `ExperimentalMaterial3ExpressiveApi`
sont encore **internal** dans la ligne material3 pinnée par ce BOM (erreur de
compilation vérifiée — javap montre `public` JVM mais la visibilité **Kotlin** est
internal). `MaterialTheme` standard conservé ; basculer dès que l'API devient
publique (NOTE dans `ui/theme/Theme.kt`).
- Kotlin 2.0 → compose compiler via `org.jetbrains.kotlin.plugin.compose`. Room convertit
les enums ↔ String automatiquement. **Ne pas monter Kotlin/AGP/Gradle sans vérifier la
matrice de compatibilité** (les versions sont récupérées via maven-metadata.xml de
dl.google.com / repo1.maven.org, pas devinées).
## 4. Environnement de build (cette machine)
- **macOS (Apple Silicon), brew présent, Java 21 (Microsoft OpenJDK) sur `/usr/bin/java`** ✓
- **SDK Android** : installé via `brew install --cask android-commandlinetools`
→ `/opt/homebrew/share/android-commandlinetools` (524 MB)
- licences acceptées : `yes | sdkmanager --licenses`
- paquets : `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`
- **`local.properties`** à la racine (non commité) : `sdk.dir=/opt/homebrew/share/android-commandlinetools`
- Gradle 8.9 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB
- Build validé : `./gradlew assembleDebug testDebugUnitTest` → **BUILD SUCCESSFUL**,
APK debug 18 MB (`app/build/outputs/apk/debug/app-debug.apk`)
## 5. Architecture générale
Pas de ViewModel ni de DI externe — volontairement simple pour une v1 :
```
HormoneTrackApp (Application)
└─ AppContainer
├─ AppDatabase (Room singleton)
├─ HormoneRepository (DAOs : Flow réactifs + one-shots suspend)
└─ AppSettings (DataStore : TConfig, langue)
MainActivity (AppCompatActivity)
└─ setContent { HormoneTrackTheme { HormoneTrackRoot } }
├─ CompositionLocal LocalAppContainer
└─ NavHost + NavigationBar (5 tabs + settings + treatment_edit/{id})
Écrans = collectAsState sur les Flows + calcul PK dans produceState(Dispatchers.Default)
```
Points clés :
- `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` +
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
## 6. Modèle de données (Room)
DB `hormonetrack.db`, **version 2**, migrations explicites (⚠️ plus de
`fallbackToDestructiveMigration` — retiré en v1.2.0 car l'utilisatrice a des données
réelles ; toute évolution de schéma = `Migration(x, y)` + ALTER TABLE).
### `Treatment` (treatments)
- base : `id`, `name`, `type` (ESTRADIOL/ANTI_ANDROGEN/PROGESTOGEN/OTHER), `route`
(ORAL/TRANSDERMAL_GEL/TRANSDERMAL_PATCH/INJECTION_IM/INJECTION_SUBCUT/OTHER),
`doseAmount`, `doseUnit`, `isActive`, `notes`, `createdAt`
- PK par table : `esterType` ("NONE"/"EV"/"EU"/"EEN" — objets `Esters`), `pkModel`
("ESE"/"TFS" — objets `PKModels`)
- PK Bateman : `absorptionHours` (Tmax), `eliminationHalfLifeHours`, `bioavailabilityFraction`
- Calibration : `scaleFactor` (défaut 1.0)
- **Prévision (v1.2)** : `forecastIntervalDays: Double?` (jours ; null = pas de
simulation à venir) — colonne ajoutée par la **migration Room v1→v2**
- Rappel : `reminderHour/Minute/Enabled`
- Helpers : `isInjection` (IM/SC), `usesProfileModel` (injection **et** ester ≠ NONE)
### `DoseLog` (dose_logs)
FK → treatments (CASCADE), index `treatmentId` + `timestamp`. `esterType: String?` =
**override par injection** (l'ODS permet de switcher d'ester d'une injection à l'autre) ;
null = ester du traitement.
### `LabResult` (lab_results)
`marker` libre ("E2", "T", "PRL"…), `value`, `unit` libre. La calibration et les charts
comparent `marker.equals("E2", true)` / `"T"` — **les dropdown suggèrent E2/T** ; si
l'utilisatrice tape autre chose, la calibration ignorera ces labs.
### DAOs
`Flow` pour l'UI + one-shots `suspend *Once()` pour backup/boot/calibration :
`TreatmentDao.getActiveOnce/getAllOnce`, `DoseLogDao.getAllOnce`, `LabResultDao.getAllOnce`.
## 7. Moteur pharmacocinétique
`pk/PharmacokineticEngine.kt` + `pk/PKProfileStore.kt`.
### 7.1 Source : `Estrogen.ods`
- Fichier : `Estrogen.ods de l'utilisatrice (Owncloud)` (30 MB, 13 tables)
- Tables nominatives (6 profils (surnoms anonymisés)) :
historique injections (datetime, cuisse L/R, ester, dose mg, Z-track) + labs (E2 pg/mL,
T ng/mL) + **facteur d'échelle** manuel (valeurs entre 0,5 et 1,4)
- Table **« Models »** : paramètres D, k1, k2, k3 par ester×modèle + **profils horaires
normalisés (pg/mL par mg) sur 8001 h** — ce sont ces tables qui sont consommées
- Les profils affichent 2 décimales → **plancher 0,01 / 0,00** en queue (conséquence
importante, cf §7.2)
Pics de référence (pg/mL par mg) :
| Clé | Modèle | Ester | Pic | Tmax |
|---|---|---|---|---|
| `EV_ese` | Estrannaise | valerate | 61,12 | ~45 h |
| `EU_ese` | Estrannaise | undecylate | 3,44 | ~55 h (plateau très long) |
| `EEn_ese` | Estrannaise | enanthate | 31,35 | ~152 h |
| `EV_tfs` | Transfem Science | valerate | 58,96 | ~51 h |
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
### 7.2 `PKProfileStore` (asset loader + échantillonnage)
- Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset
- `sample(ester, model, dtHours)` :
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
cf §14)
- interpolation **linéaire** entre heures entières
- **extrapolation terminale** : dernier point **≥ 1 % du pic** (pour éviter le plancher
d'affichage 0,01/0,00 de l'ODS), pente = décroissance moyenne sur les 48 h précédentes
(jamais avant le pic)
- ⚠️ tous les calculs en **Double** (Float×Double n'existe pas en Kotlin — source d'erreurs
de compilation, cf §14)
### 7.3 Superposition
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
`30 × t½` pour Bateman.
### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
- **`modelOverride`** : paramètre optionnel de `concentrationOfDose` / `e2At` /
`computeCurve` qui force ESE ou TFS pour les traitements par profil — le graphique
dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné :
les deux séries y sont identiques).
- **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à
venir = dernière dose réelle + k × `forecastIntervalDays` jusqu'à `toMs`, strictement
après `nowMs` ; dose = standard du traitement, ester = override de la dernière
injection. Jamais persistées : uniquement passées à `computeCurve` par le ChartScreen
quand le chip « Prévision » est actif.
- **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration
automatique » — renvoie des **copies** de traitements avec les scale factors
recalculés (médiane lab ÷ prédiction) + TConfig recalibré. Les valeurs stockées ne
bougent jamais ; HomeScreen et ChartScreen branchent dessus quand l'option est active.
### 7.4 Bateman (gel/patch/oral)
`C(dt) = (F·D·ka/(ka−ke))·(e^(−ke·dt) − e^(−ka·dt))` ; cas dégénéré ka≈ke :
`F·D·ke·dt·e^(−ke·dt)`. **`computeKa`** résout `ln(ka/ke) = (ka−ke)·Tmax` par bisection
(50 itérations, bornes `ke×1.001 … ke×1000`) — **direction corrigée** (cf §14 : `eq > 0`
⇒ la racine est **au-dessus** de mid ⇒ `lo = mid`).
### 7.5 Courbe T (empirique)
`T(t) = floor + (base − floor) / (1 + k·E2(t))` [ng/mL]. Défauts `TConfig` :
base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise
pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout.
Calibration : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde
k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé).
**Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L —
`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884) ;
appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100,
bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
### 7.6 Calibration E2 (par traitement)
`computeScaleFactor(treatment, allDoseLogs, e2Labs)` :
`ratio_i = lab.value / Σ contributions du traitement seul (scale=1) à t_lab`
→ **médiane** des ratios (garde : prédiction > 0.5 pg/mL), arrondi 2 décimales.
C'est l'automatisation de la colonne « Scale factor » du `.ods`. Déclenchable depuis
l'éditeur de traitement ; valeur éditable manuellement.
### 7.7 API du moteur
`levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At /
testosteroneAt / computeScaleFactor / computeTConfigCalibration / nextReminderFireMs /
batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`.
Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires
**36 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
- **`PKProfileStoreTest`** (8) : les 6 profils présents (8001 pts) ; pic EV_ese = 61,12
@45 h ; zéro avant injection ; interpolation stricte entre points (heures 100/101 —
le plateau 45/46 est plat, piège de test) ; modèle inconnu → 0 ; extrapolation
terminale décroissante ; esters longs mesurables à 8000 h ; **les 6 pics == valeurs ODS**
- **`PharmacokineticEngineTest`** (15) : ke = ln2/t½ ; **pic Bateman ≈ Tmax** (attrape la
bisection inversée) ; EV 4 mg → pic ≈ 4×61 pg/mL ; superposition ; linéarité du
scaleFactor ; anti-androgène → 0 en E2 ; modèle T monotone/borné ; calibration SF =
médiane (0,5/0,9/1,4 → 0,9) ; calibration nulle sans labs ; **calibration T récupère un
k planté (0,25)** ; grille horaire clampée à la 1ʳᵉ dose ; vide sans doses ; override
d'ester par dose (EV≫EU à 45 h) ; prochain rappel dans le futur ; levelAt combiné
- **`BackupGsonTest`** (1) : round-trip JSON complet (enums, IDs, notes, TConfig)
- **`RegressionUserCaseTest`** (6) : **régression épinglée sur les données réelles
exportées** par l'utilisatrice (backup JSON v1.0.0 : 1 traitement EEn/ESE 5 mg, 1 dose,
4 labs dont T en ng/dL). Vérifie : parsing du JSON réel, courbes non vides et
physiologiquement plausibles pour 24 h/7 j/30 j (attrape le bug #22 de casse EEn),
labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL,
calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice :
exporter le JSON, l'épingler ici, reproduire, corriger.**
- **`V120FeaturesTest`** (6) : v1.2.0 — doses prévisionnelles (rythme 7 j depuis la
dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans
doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ;
sans override = modèle du traitement), auto-calibration (SF 0,72 recalculé depuis un
lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab utilisable).
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
(UI/logic Android), tests Compose, lint.
## 9. Système de rappels
`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`).
- `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule
ET cancel (même action = même PendingIntent — cf bug §14.3)
- `AlarmScheduler` :
- quotidien : `setExactAndAllowWhileIdle` si `canScheduleExact()` (API≥31 :
`alarmManager.canScheduleExactAlarms()`), sinon `setWindow` ±10 min
- permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur
(`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`)
- `scheduleDaily` (prochaine occurrence HH:mm), `scheduleSnooze` (+1 h), `rescheduleAll`
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()`
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
(jamais un Flow en runBlocking !) → reschedule
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`.
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
## 10. UI & navigation
- `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`), disclaimer ; données
auto-calibrées si l'option est active ; rafraîchissement `tick` 60 s
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j ; **panoramique**
(`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé,
`panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ;
**toggles indépendants Estrannaise/TFS** → deux `computeCurve` avec `modelOverride`
superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (E2/T/PRL, unité suggérée)
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
(scaleFactor + « Calibrer avec les analyses ») ; **section « Fréquence » (v1.2 :
switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch +
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; réglages
T + calibration manuelle ; **option Calibration automatique (switch, désactivée par
défaut)** ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
- Thème M3 custom (`ui/theme/Color.kt` : bleu #4F5BD5, rose #D6589E, labs orange, bandeau
TransSky/TransPink), dynamic color désactivé ; `MaterialExpressiveTheme` encore
**internal** dans la ligne material3 pinnée (cf §3) → `MaterialTheme` standard
- ⚠️ `Card(onClick=…)` et `ExposedDropdownMenuBox` = **API expérimentales M3** → `@OptIn`
requis sur chaque composable qui les utilise
## 11. Graphiques (CurveChart)
Canvas pur (aucune lib), **multi-séries** (v1.2) : `ChartSeries(points, e2Style, tStyle?)`
— le ChartScreen superpose les courbes Estrannaise **et** Transfem Science depuis le
même traitement (`modelOverride`), styles plein/pointillé par série. Dual axe : E2
gauche (pg/mL), T droite (ng/mL). Échelle « nice » (`niceCeil` : 1/2/2.5/5/10 × 10ⁿ)
partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas
6 h/24 h/5 j selon plage (`SimpleDateFormat` HH'h' / dd/MM). Labs : cercles (E2) et
carrés (T) orange + valeur, **T convertie en ng/mL** (`convertTToNgMl`) au rendu.
Ligne verticale « maintenant ».
Pièges :
- `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom
- Tout label passe par `drawContext.canvas.nativeCanvas` + `android.graphics.Paint`
- Mélange Double/Float interdit (`1 - i / 4f` et pas `/4.0`)
- Le panoramique est géré **par le parent** (ChartScreen change `startMs/endMs`), pas par
le Canvas — le chart reste un composant purement déclaratif
## 12. i18n FR/EN
- Standard Android : `values/strings.xml` (EN défaut) + `values-fr/strings.xml` (FR).
L'objet `Strings.kt` custom de la session 1 a été **supprimé**.
- **Langue par app** : AppCompat 1.7 + `AppCompatDelegate.setApplicationLocales`
(fonctionne < API 33) ; choix persisté DataStore (`system`/`fr`/`en`), appliqué au
démarrage. Thème app = `Theme.AppCompat.DayNight.NoActionBar` (requis par AppCompat).
- Notifs localisées via `context.getString(R.string.*)`
- ⚠️ **Toute nouvelle string = les DEUX fichiers** (une référence manquante = erreur de
compilation `Unresolved reference 'active'` — déjà arrivé)
## 13. Sauvegarde JSON
`data/backup/BackupManager.kt` :
- `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson
- **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK
dose→traitement restent valides
- Import = **ajout** (traitements → doses → labs) ; ré-import du même fichier → conflit
d'ID unique → exception catchée → `import_fail` (voulu ; un mode « replace » est en §20)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
## 14. Bugs corrigés
Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
**Session 1 → 2 (avant tout build) :**
1. `settings.gradle.kts` : `dependencyResolution` (inexistant) → `dependencyResolutionManagement`
2. `BootReceiver` : `runBlocking { flow.collect {…} }` → blocage infini → one-shot + goAsync
3. `AlarmScheduler.cancel` : Intent sans l'action → annulation inopérante → fabrique unique
4. `PKProfileStore` : parsait la racine JSON → crash → lecture de `profiles`
**Session build (détectés à la compilation) :**
5. `kotlin.math.ln2` **n'existe pas** (hallucination) → `ln(2.0)` ; cascade d'erreurs sur
les lignes suivantes du même fichier (opérateurs sur types error)
6. Mélange **Double/Float** interdit en Kotlin : `mg * bioavailabilityFraction` (Float),
`30.0 * t½` (Float), `Float×exp()`… → `.toDouble()` partout
7. `BackupManager.writeBackup` : `return` dans expression body `= try{}` → block body
8. `DateTimeField` : `spacedBy(8f/2f*8)` (Float sans unité) → `8.dp` + import `dp` manquant
9. `DateTimeField` : extension `fun LocalDate.Companion.ofEpochMs` (java.time n'a pas de
Companion) → supprimée ; imports nettoyés
10. `CurveChart` : helper `dpToPx()` custom cassé → `dp.toPx()` de `DrawScope`
11. `CurveChart` : labels Y en Double (`i / 4.0`) → `i / 4f`
12. `LabsScreen` / `TreatmentsScreen` : imports `dp` / `fillMaxWidth` manquants
13. `TreatmentEditorScreen` : `R.string.active` inexistante → string ajoutée EN+FR
14. `TreatmentCard` : `Card(onClick=…)` sans `@OptIn(ExperimentalMaterial3Api::class)`
15. Typo `Locale.getDefault` sans parenthèses (SimpleDateFormat)
16. `SettingsScreen` : chips de langue codées en dur → état depuis DataStore
17. `ReminderManager` : constantes d'action mortes → implémentées (`DoseActionReceiver`)
18. `TreatmentEditorScreen` : `createdAt` écrasé à l'édition → préservé
**Session tests (bugs SÉMANTIQUES trouvés par les tests unitaires) :**
19. **`computeKa` : bisection inversée** — `if (eq > 0) hi = mid else lo = mid` convergeait
vers ka énorme (pic à ~0 h au lieu de Tmax) ; bug présent depuis la session 1, jamais
testé. → `if (eq > 0) lo = mid else hi = mid` (eq décroît en mid ; eq>0 ⇒ racine au-dessus)
20. **Plancher d'affichage des profils** : l'ODS arrondit à 2 décimales → queues à 0,01/0,00
; extrapoler depuis la fin de table donnait 0 à vie (ou une constante plate). →
extrapolation depuis le dernier point ≥ 1 % du pic avec pente sur 48 h
21. **Mapping silencieux du modèle** : `profileKey` mappe tout modèle ≠ "TFS" sur "ese"
→ `sample("EV","XXX")` renvoyait EV_ese. → validation stricte dans `sample`
**Session v1.1.0 (remontées par l'utilisatrice, reproduites en test) :**
22. **Casse des clés de profils** — LE bug « les graphiques ne se génèrent pas » :
l'asset contient `"EEn_ese"`/`"EEn_tfs"` (casing biologique du `.ods`) mais
`Esters.EEN = "EEN"` → lookup exact null → `sample()=0` pour tout traitement EEn
(courbe E2 plate à 0, T plate à la base). EV/EU marchaient (casse identique) et les
tests profils utilisaient la casse "EEn" — le trou passait entre les deux.
→ **lookup insensible à la casse** (`PKProfileStore.lookup()`), régression épinglée
sur les données réelles (`RegressionUserCaseTest`).
23. **Unités T non converties** : labs saisis en ng/dL (32/45) → axe T du chart à
×100 (courbe T invisible) et calibration T fausse. →
`PharmacokineticEngine.convertTToNgMl()` (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884),
appliqué à la calibration ; **à utiliser aussi au rendu du chart** pour les dots T
(cf §11 — patch UI restant : convertir les valeurs T des labs avant yT()).
24. **Pas d'édition des doses** : suppression+recréation obligatoire. → `DoseDialog`
create/edit (préfill, changement de traitement, date/heure, notes, **override
d'ester par injection**), appelé depuis DosesScreen (tap sur la ligne) ;
`LogDoseDialog` supprimé (attention : `formatDose` vivait dedans → déplacée
top-level dans `DoseDialog.kt`).
**Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ;
(b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des
constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3
expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données
utilisateur** (`RegressionUserCaseTest` = export JSON réel) attrape les bugs de
convention (casse, unités) que les tests synthétiques ratent ; (e) attention aux
identifiants « presque pareils » entre sources (constantes app vs clés d'asset).
## 15. Comment régénérer l'asset pk_profiles.json
Si le `.ods` change (re-fits, nouveaux esters) :
```python
# python3 stdlib only :
# 1. zipfile.ZipFile(ods).read("content.xml")
# 2. ElementTree (ns table/office/text) → table "Models"
# 3. lignes 1-4 = D, k1, k2, k3 (colonnes EV/EU/EEn ese + tfs) — informatif, non utilisé
# 4. lignes 5+ = profils horaires (00:00 … 8000:00), décimaux FR "61,12" → float
# 5. json.dump({"params": …, "profiles": {"EV_ese": [8001], "EU_ese": …, "EEn_ese": …,
# "EV_tfs": …, "EU_tfs": …, "EEn_tfs": …}})
# 6. cp vers app/src/main/assets/pk_profiles.json
# 7. vérifier : 6 clés × 8001 valeurs, pics == référence (§7.1) ; les tests le vérifient
```
Le script de la session 1 a été exécuté inline (non archivé) — le refaire depuis la
structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
`PKProfileStore.initWithJson`.**
## 16. Workflow build / test / git
```bash
cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 36 tests
./gradlew lint # linters Android (à configurer)
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
**Git (initialisé le 2026-09-05, branche `main`)** :
- Historique = commits logiques par couche (toolchain / moteur / UI / docs) ;
- **Chaque release = tag annoté** (`v1.1.0`, `v1.2.0`, …) :
`git tag -a vX.Y.Z -m "…" && git tag` pour lister ;
- `local.properties`, `build/`, `.gradle/`, `.idea/` sont ignorés (`.gitignore`) ;
- Avant chaque commit de release : `./gradlew testDebugUnitTest` doit être vert ;
- Prochaine étape repo : ajouter un remote et `git push -u origin main --tags`.
- Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md)
- À ma charge (assistant) : build + tests JVM ✓ ; émulateur possible sur demande ;
**les tests humains sur vrai téléphone restent la référence**
(notifs → montre, UX de saisie, pickers, panoramique du chart)
## 17. Montre : Gadgetbridge & options
Doc dédiée : [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md). Synthèse :
- GT 3 = Lite Wearable ; GB supporte la GT 3 (« mostly supported ») : **notifications ✓,
watchfaces `.hwt` ✓, apps `.hap` ✗**
- Health et GB ne peuvent pas être appairés simultanément
- Watchface via GB : **aucune signature requise** ; app `.hap` : certificat debug AGC +
UDID (chaîne DevEco Studio → DevEco Assistant)
- Régression connue : HarmonyOS 6.1+ casse l'install `.hwt` via GB (issues #5968/#6005/#6199) ;
GT 3 en HarmonyOS 4.0.0.120 probablement OK, à valider
- Choix v1 : notifications via GB/Health ; Phase 2 : watchface custom (statique) ou
mini-app Lite Wearable autonome (Wear Engine = accès partenaire)
## 18. Espace disque & coûts
Mesuré le 5 sept. 2026 (Mac, 228 Go, **33 Go libres** au départ) :
| Élément | Taille |
|---|---|
| SDK Android (cmdline-tools + platforms 34/36/**37** + build-tools 34/36/37 + platform-tools) | ≈ 700 MB |
| Cache Gradle (~/.gradle, plusieurs distributions 8.9→9.7.1 + deps AGP 9/Compose 1.12) | ≈ 3–4 GB |
| Projet (sources + build outputs) | ≈ 100 MB |
| **Total outillage actuel** | **≈ 4–5 GB** |
Marges : émulateur + image système ≈ +2–3 GB ; DevEco Studio (Phase 2) ≈ +10 GB →
tout rentre très largement. Note : **AGP télécharge automatiquement les plateformes
manquantes** (licences signées) — c'est comme ça que android-37 est arrivé.
## 19. Limites connues
Volontaires (v1) :
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
- Modèle T empirique (non publié) — étiqueté estimation partout
- Import JSON = ajout seulement (pas de mode replace/dédup)
- `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma
- WorkManager déclaré non utilisé
- Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés —
rétro-ingénierie des fits non tentée ; les tables sont exactes
- DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au
prochain reschedule (boot/save) — mineur
- Labs : marqueur libre — E2/T exacts requis pour calibration/charts
- `allowBackup=false` → seul backup = export JSON manuel
## 20. Idées d'évolution
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
4. Import JSON : mode **replace** (wipe + insert) + détection de doublons
5. Migration Room v2 (retirer fallbackToDestructiveMigration)
6. Verrou biométrique (BiometricPrompt), widget, export CSV
7. Charts : zoom/pan + tooltip au toucher
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien)
## 21. Checklist de test manuel
Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] App se lance sans crash (asset chargé — sinon cf §14.4)
- [ ] Créer traitement « EV — Estrannaise » 4 mg + rappel 2 min à l'avance
- [ ] Notif arrive sur le téléphone **et** la GT 3 (via GB ou Health)
- [ ] « Pris » → dose loguée dans Doses ; « Reporter 1 h » → nouvelle notif 1 h après
- [ ] Logger 2–3 injections passées → Home affiche E2/T + delta 6 h cohérents
(4 mg EV → pic ≈ 4×61×scale ≈ 244 pg/mL à scale=1)
- [ ] Ajouter un lab E2 → « Calibrer avec les analyses » → scaleFactor plausible (0,5–1,2)
- [ ] Labs T + « Calibrer k » → k mis à jour, courbe T proche des points
- [ ] Charts 24 h/7 j/30 j, toggles T/labs, axes lisibles
- [ ] Export JSON → fichier inspectable ; ré-import → compteur correct
- [ ] Langue FR↔EN↔Système : UI + notifs basculent
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
---
*Doc mise à jour le 5 sept. 2026 (v1.2.0) — build OK, 36/36 tests verts, APK debug 23 MB, repo git avec tags.*