- computeEsterScaleFactors : chaque lab est attribué à la période d'injection dans laquelle il tombe (dernière dose E2 <= lab → son ester) ; facteur final par ester = médiane des ratios de cette période. Corrige le mélange des périodes valerate/enanthate qui gonflait les courbes (250-375 pg/mL) - e2At/computeCurve : paramètre scalePerEster (chaque dose scalée par le facteur de SON ester, fallback = scaleFactor stocké du traitement) - autoCalibrated : renvoie esterScales + tConfig recalibré, traitements inchangés - convertTToNgMl : conversion défensive pg/mL et µg/L (un lab T « 38 pg/mL » écrasait l'axe T et rendait la courbe T invisible — remonté utilisateur) - LabDialog create/edit : tap sur une ligne de l'écran Analyses → édition pré-remplie (formatLabValue préserve les décimales) - Home : tap sur le mini-graphique → écran Graphiques + mini-légende E2/T - Settings : UNE seule option « Calibration automatique (E2 + T) » (E2 par période d'ester + modèle T), placée au-dessus des réglages T manuels - RegressionUserCase2Test : 2e export réel épinglé (9 doses EEn/TFS, 8 labs, fréquence 6 j) — vérifie l'état d'équilibre EEn (~270 pg/mL calibré, cohérent labs 306/248 ; non calibré ≈ 367 = les « 375 » rapportés) - V120FeaturesTest : attribution des labs par période d'ester + scalePerEster - 44 tests verts ; versionCode 4, versionName 1.2.1 - Docs : CHANGELOG, DEVELOPPEMENT (§7.6 réécrit), GUIDE, README
39 KiB
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, GUIDE_INSTALLATION.md, MONTRE-GADGETBRIDGE.md.
Table des matières
- Contexte & objectifs
- Historique du projet
- Stack & versions (épinglées)
- Environnement de build (cette machine)
- Architecture générale
- Modèle de données (Room)
- Moteur pharmacocinétique
- Tests unitaires
- Système de rappels
- UI & navigation
- Graphiques (CurveChart)
- i18n FR/EN
- Sauvegarde JSON
- Bugs corrigés (historique complet — à ne pas réintroduire)
- Comment régénérer l'asset pk_profiles.json
- Workflow build / test / install
- Montre : Gadgetbridge & options
- Espace disque & coûts
- Limites connues & choix volontaires
- Idées d'évolution (Phase 2+)
- 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). |
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration par période d'ester (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, édition des labs, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; 44 tests verts, APK v1.2.1. |
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.androidest une erreur ; le pluginorg.jetbrains.kotlin.plugin.composereste appliqué normalement. - compileSdk 37 : la plateforme
platforms;android-37n'é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/ExperimentalMaterial3ExpressiveApisont encore internal dans la ligne material3 pinnée par ce BOM (erreur de compilation vérifiée — javap montrepublicJVM mais la visibilité Kotlin est internal).MaterialThemestandard conservé ; basculer dès que l'API devient publique (NOTE dansui/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
- licences acceptées :
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(): initPKProfileStore(asset), canal de notificationMainActivity: applique la langue sauvegardée (AppCompatDelegate.setApplicationLocales), demande POST_NOTIFICATIONS (API 33+), lit les extras d'intentopen_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" — objetsEsters),pkModel("ESE"/"TFS" — objetsPKModels) - 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 viaJsonParser) initWithJson(json)= point d'entrée testable (JVM) ;init(context)lit l'assetsample(ester, model, dtHours):- modèle strict : seul "TFS"→
tfset "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)
- modèle strict : seul "TFS"→
- ⚠️ 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 deconcentrationOfDose/e2At/computeCurvequi 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 ×forecastIntervalDaysjusqu'àtoMs, strictement aprèsnowMs; dose = standard du traitement, ester = override de la dernière injection. Jamais persistées : uniquement passées àcomputeCurvepar 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 (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
Automatique (computeEsterScaleFactors + scalePerEster, option « Auto-calibration ») :
- chaque lab est attribué à la période d'injection dans laquelle il tombe = dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
- ratio = lab ÷ prédiction non calibrée (toutes doses superposées, scaleFactor forcé 1) ;
- facteur final par ester = médiane des ratios de sa période (EV/EU/EEN) ;
- application :
e2At/computeCurveacceptentscalePerEster: Map<String, Double>?— chaque dose est scalée par le facteur de son ester (doseEster, override compris), fallback =scaleFactorstocké du traitement pour les esters sans lab. - Pourquoi : un facteur unique par traitement mélangeait les périodes (labs valerate mesurés contre une prédiction enanthate → ratio aberrant → courbes gonflées à 250–375 pg/mL, remontée v1.2.0). Cas vérifié sur données réelles : EEn 5 mg tous les 6–7 j (t½ ≈ 6,7 j) → accumulation ×2 → ~270 pg/mL calibré, cohérent labs 300/250 ; non calibré ≈ 367.
autoCalibrated()renvoieAutoCalibrated(treatments **inchangés**, tConfig recalibré, esterScales, calibratedEsters, tRecalibrated)— écrans :scalePerEster = effectiveAuto?.esterScales.
Manuelle (computeScaleFactor, bouton « Calibrer avec les analyses » dans
l'éditeur de traitement) : facteur unique par traitement (médiane lab ÷ prédiction,
garde prédiction > 0,5), écrit le scaleFactor stocké. ⚠️ Limite documentée : en cas
de changement d'ester dans un même traitement, la manuelle mélange les périodes —
préférer l'auto-calibration dans ce cas.
T (computeTConfigCalibration) : k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est,
garde k ∈ (1e-4, 10), médiane ; labs normalisés via convertTToNgMl.
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
44 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 ODSPharmacokineticEngineTest(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(8) : v1.2.0/v1.2.1 — 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 v1.2.1 (facteur par ester depuis un lab planté, T recalibré, originaux non modifiés ; inchangée sans lab utilisable), attribution des labs par période d'ester (EV calibré par les labs EV, EEn par les labs EEn — le scénario valerate→enanthate de l'utilisatrice) et application descalePerEsterpar dose.RegressionUserCase2Test(6) : 2ᵉ régression épinglée sur données réelles (export v1.2.0 : 9 doses EEn/TFS 5 mg ~6-7 j, 8 labs dont un T "pg/mL" par erreur, SF stocké 0,72, fréquence 6 j). Vérifie : parsing, état d'équilibre EEn (t½ ≈ 6,7 j + doses ~6-7 j → accumulation ×2 → e2 ≈ 270 calibré, cohérent labs 300/250 ; non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente. Tout nouvel export utilisateur = un nouveau test de régression.
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 uniquereminderIntent()pour schedule ET cancel (même action = même PendingIntent — cf bug §14.3)AlarmScheduler:- quotidien :
setExactAndAllowWhileIdlesicanScheduleExact()(API≥31 :alarmManager.canScheduleExactAlarms()), sinonsetWindow±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
- quotidien :
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 notifBootReceiver:goAsync()+ thread +runBlocking+ one-shotgetActiveOnce()(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) + routessettings,treatment_edit/{id}(-1 = nouveau) ; barre masquée sur ces 2 routesHomeScreen: 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 viaChartSeries) cliquable → écran Graphiques (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est active (scalePerEster = effectiveAuto?.esterScales) ; rafraîchissementtick60 sChartScreen(v1.2, le plus riche) : plages 24 h/7 j/30 j ; panoramique (detectHorizontalDragGestures— tirer vers la droite remonte dans le passé,panHoursborné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ; toggles indépendants Estrannaise/TFS → deuxcomputeCurveavecmodelOverridesuperposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose pointillé) ; chip Prévision (doses projetées viagenerateForecastDoses, 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/mLDosesScreen: 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 → éditionLabsScreen: groupée par marqueur, FAB →LabDialog(création), tap sur la ligne → édition (v1.2.1, même mécanique que les doses), suppression avec confirmation ; suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/LTreatmentsScreen: cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰, badge inactif), FAB → éditeurTreatmentEditorScreen: 12 presets (PKPresets, cfnameRes) 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 ;createdAtpréservéSettingsScreen: langue (Système/Français/English, chips reflétant l'état) ; « Calibration automatique (E2 + T) » — UNE option (par période d'ester + modèle T, v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos- Composants :
CurveChart(§11),DateTimeField(DatePicker+TimePicker Material3, LocalDateTime, horloge centrée),DoseDialog(create/edit + override d'ester),LabDialog,formatDose()(top-level, dansDoseDialog.kt) - Thème M3 custom (
ui/theme/Color.kt: bleu #4F5BD5, rose #D6589E, labs orange, bandeau TransSky/TransPink), dynamic color désactivé ;MaterialExpressiveThemeencore internal dans la ligne material3 pinnée (cf §3) →MaterialThemestandard - ⚠️
Card(onClick=…)etExposedDropdownMenuBox= API expérimentales M3 →@OptInrequis 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 :
DrawScopeimplémenteDensity→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 / 4fet 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'objetStrings.ktcustom 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), écritureopenOutputStream(uri, "wt"); ⚠️ pas dereturndans un expression body= try{}
14. Bugs corrigés
Historique complet — à ne pas réintroduire (utile pour diff/revert) :
Session 1 → 2 (avant tout build) :
settings.gradle.kts:dependencyResolution(inexistant) →dependencyResolutionManagementBootReceiver:runBlocking { flow.collect {…} }→ blocage infini → one-shot + goAsyncAlarmScheduler.cancel: Intent sans l'action → annulation inopérante → fabrique uniquePKProfileStore: parsait la racine JSON → crash → lecture deprofiles
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) :
# 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
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 tagpour lister ; -
local.properties,build/,.gradle/,.idea/sont ignorés (.gitignore) ; -
Avant chaque commit de release :
./gradlew testDebugUnitTestdoit ê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. 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
.hwtvia 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
- Robolectric + tests Compose (VM Android en JVM — pas besoin d'appareil)
- Émulateur local pour smoke-tests UI (sur demande, ~2–3 Go)
- Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
- Import JSON : mode replace (wipe + insert) + détection de doublons
- Migration Room v2 (retirer fallbackToDestructiveMigration)
- Verrou biométrique (BiometricPrompt), widget, export CSV
- Charts : zoom/pan + tooltip au toucher
- Phase 2 montre : watchface
.hwtcustom, puis mini-app Lite Wearable (cf §17) - 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
.hwtvia Gadgetbridge (pour la Phase 2)
Doc mise à jour le 5 sept. 2026 (v1.2.1) — build OK, 44/44 tests verts, repo git avec tags v1.1.0/v1.2.0/v1.2.1.