diff --git a/README.md b/README.md
index 2948f59..85be40f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,12 @@
# HormoneTrack
+> **🤖 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**
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
@@ -9,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
-- **Statut** : v1.2.8 — build Android ✅, **64 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.2.9 — build Android ✅, **67 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** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@@ -18,8 +25,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
## Fonctionnalités
- **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
- (triangles ▲▼ aux extrema locaux, toggle)
+ **panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
+ − / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
+ leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
affichés côte à côte avec toggles indépendants
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bb20ea3..702bb58 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack"
minSdk = 26
targetSdk = 36
- versionCode = 11
- versionName = "1.2.8"
+ versionCode = 12
+ versionName = "1.2.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt
index 615788b..9c546a9 100644
--- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt
+++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt
@@ -66,6 +66,17 @@ fun niceCeil(value: Double): Double {
return nice * exp
}
+/**
+ * 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
+}
+
/**
* Graphique Canvas pur, multi-séries :
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;
@@ -309,7 +320,10 @@ private fun DrawScope.drawXLabels(
) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
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 {
+ spanH <= 8 -> 1
+ spanH <= 18 -> 3
spanH <= 25 -> 6
spanH <= 25 * 7 -> 24
else -> 24 * 5
diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt
index 7bce7f8..d9acbd3 100644
--- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt
+++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt
@@ -1,6 +1,6 @@
package com.hormonetrack.ui.screens
-import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,12 +9,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.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.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.material3.Icon
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,6 +57,9 @@ import kotlinx.coroutines.withContext
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.
@@ -79,6 +88,7 @@ fun ChartScreen() {
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
+ // Zoom temporel (v1.2.9) : plage bornée 6 h → 300 j, pinch + boutons
var rangeHours by remember { mutableLongStateOf(24L) }
var panHours by remember { mutableLongStateOf(0L) }
var showT by remember { mutableStateOf(true) }
@@ -144,10 +154,13 @@ fun ChartScreen() {
}
} else emptyList()
val allDoses = doseLogs + forecastDoses
+ // Pas d'échantillonnage adaptatif au zoom (v1.2.9)
+ val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
if (showEse) {
list.add(
"ESE" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
+ stepMs = step,
tConfig = effTConfig, modelOverride = "ESE",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
@@ -158,6 +171,7 @@ fun ChartScreen() {
list.add(
"TFS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
+ stepMs = step,
tConfig = effTConfig, modelOverride = "TFS",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
@@ -217,6 +231,20 @@ fun ChartScreen() {
onClick = { rangeHours = 24L * 30 },
label = { Text(stringResource(R.string.chart_30j)) }
)
+ Spacer(Modifier.width(4.dp))
+ // Zoom manuel (accessibilité — le pinch fonctionne aussi)
+ FilledTonalIconButton(
+ onClick = { rangeHours = (rangeHours * 2).coerceAtMost(MAX_RANGE_H) },
+ enabled = rangeHours < MAX_RANGE_H
+ ) {
+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.zoom_in))
+ }
+ FilledTonalIconButton(
+ onClick = { rangeHours = (rangeHours / 2).coerceIn(MIN_RANGE_H, MAX_RANGE_H) },
+ enabled = rangeHours > MIN_RANGE_H
+ ) {
+ Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.zoom_out))
+ }
}
Spacer(Modifier.height(8.dp))
@@ -268,15 +296,33 @@ fun ChartScreen() {
Modifier
.fillMaxWidth()
.onSizeChanged { chartWidthPx = it.width }
- .pointerInput(rangeHours, maxPanHours) {
- // Glisser vers la droite = remonter dans le passé
- detectHorizontalDragGestures { change, dragAmount ->
- change.consume()
- if (chartWidthPx > 0 && maxPanHours > 0) {
- val hoursDragged = (dragAmount / chartWidthPx * rangeHours).toLong()
+ .pointerInput(Unit) {
+ // Pan (1 doigt) + pinch-zoom (2 doigts, v1.2.9) — un seul
+ // handler pour éviter les conflits de consommation
+ detectTransformGestures { centroid, pan, zoom, _ ->
+ val forecastMs =
+ (if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L)
+ if (chartWidthPx > 0) {
+ // Pan horizontal : glisser à droite = remonter le temps
+ val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
if (hoursDragged != 0L) {
panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours)
}
+ // 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) {
+ val endAnchor = nowMs + forecastMs - panHours * 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 + forecastMs - newEnd) / HOUR_MS).toLong()
+ panHours = newPan.coerceIn(0L, maxPanHours)
+ rangeHours = newRange
+ }
+ }
}
}
}
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index de1de6e..77974d8 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -31,6 +31,8 @@
Analyses
Prévision
Pics / creux
+ Zoom avant (fenêtre plus courte)
+ Zoom arrière (fenêtre plus longue)
▲▼ pics & creux estimés (par courbe)
Revenir à maintenant
Fais glisser le graphique vers la droite pour remonter dans le passé.
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8535738..44aaff1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -31,6 +31,8 @@
Labs
Forecast
Peaks / troughs
+ Zoom in (shorter window)
+ Zoom out (longer window)
▲▼ estimated peaks & troughs (per curve)
Back to now
Drag the chart to the right to travel into the past.
diff --git a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt
new file mode 100644
index 0000000..baedb79
--- /dev/null
+++ b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt
@@ -0,0 +1,31 @@
+package com.hormonetrack.ui
+
+import com.hormonetrack.ui.components.stepForRange
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Pas d'échantillonnage adaptatif au zoom (v1.2.9) : plus la fenêtre est
+ * courte, plus le pas est fin — sinon les courbes paraissent anguleuses.
+ */
+class ChartZoomTest {
+
+ @Test
+ fun `deep zoom uses 15 minute steps`() {
+ assertEquals(900_000L, stepForRange(6L))
+ assertEquals(900_000L, stepForRange(12L))
+ }
+
+ @Test
+ fun `mid zoom uses 30 minute steps`() {
+ assertEquals(1_800_000L, stepForRange(13L))
+ assertEquals(1_800_000L, stepForRange(24L))
+ }
+
+ @Test
+ fun `wide ranges use hourly steps`() {
+ assertEquals(3_600_000L, stepForRange(25L))
+ assertEquals(3_600_000L, stepForRange(24L * 30))
+ assertEquals(3_600_000L, stepForRange(24L * 300))
+ }
+}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 9e5e189..79d0e87 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,7 +1,21 @@
# Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
-Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.8`, …).
+Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.9`, …).
+
+## [1.2.9] — 2026-09-05 (versionCode 12)
+
+### Ajouté
+- **Zoom du graphique de simulation** : pinch à 2 doigts **et** boutons − / +
+ (accessibilité), plage bornée **6 h → 300 j** ; le point focal du pinch reste
+ fixe dans le temps ; échantillonnage adaptatif (15 min ≤ 12 h, 30 min ≤
+ 24 h, sinon 1 h — courbes lisses à fort zoom) ; labels X adaptatifs
+ (1 h / 3 h). Un seul gestionnaire de gestes gère pan + zoom (pas de
+ conflit de consommation).
+- **README : disclaimer IA** en en-tête — projet développé avec l'IA,
+ contribution humaine essentielle (feedback, retours utilisateur, suggestions) ;
+ mention factuelle reprise en §1/§2 de la doc de développement.
+- 3 tests `stepForRange` (67 au total).
## [1.2.8] — 2026-09-05 (versionCode 11)
diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md
index c632c75..4d9223a 100644
--- a/docs/DEVELOPPEMENT.md
+++ b/docs/DEVELOPPEMENT.md
@@ -45,6 +45,11 @@ Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équi
**Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir
sur la montre** via GB (ou Huawei Health).
+> **Développement IA-assisté** : le code a été produit avec un assistant IA ;
+> la contribution humaine = feedback continu, retours utilisateur (tests réels
+> sur téléphone, bugs avec exports), suggestions et validation des releases
+> (détail §2 — factuel, session par session).
+
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 ;
@@ -71,6 +76,7 @@ Fonctionnalités v1 :
| 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. |
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. |
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
+| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
| 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → **« Posologie »** (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. |
| 5 sept. 2026 (session v1.2.7) | Prévision : les créneaux passés ne sont plus simulés (bug oubli → faux pic historique) ; retard = décalage voulu ; mismatch doc-code §7.3b corrigé ; 2 tests ; **64 tests verts**, APK v1.2.7 + releases. |
@@ -396,7 +402,7 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
- `.gitignore` contient `local-test-data/` → jamais commités ;
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
- exécute 44 tests au lieu de 56 ;
+ exécute 44 tests au lieu de 67 ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
@@ -455,7 +461,8 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **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îchissement `tick` 60 s
-- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j ; **panoramique**
+- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j + **boutons zoom − / +**
+ (v1.2.9) ; **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`
@@ -518,6 +525,15 @@ triangles ▲▼ via `PharmacokineticEngine.detectExtrema` **accompagnés de la
valeur estimée** du pic (au-dessus) ou du creux (en dessous), dans la couleur
de la série (`drawExtremum`).
+**Zoom (v1.2.9)** : pinch **et** boutons − / + — plage temporelle bornée
+**6 h → 300 j** (`MIN_RANGE_H`/`MAX_RANGE_H`), point focal du pinch maintenu
+fixe dans le temps (formule centroid), échantillonnage **adaptatif**
+(`stepForRange` : 15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h — courbes lisses Ã
+fort zoom) et labels X adaptatifs (1 h / 3 h ajoutés). Le pan ET le zoom
+partagent un seul `detectTransformGestures` (un handler = pas de conflit de
+consommation) ; le zoom/pan restent gérés **par le parent**, le Canvas demeure
+purement déclaratif.
+
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`
@@ -918,6 +934,8 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne
Analyses → sélecteur E2/T si paire) ; suppression par prise entière
- [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels)
+- [ ] **v1.2.9** : zoom du graphique (pinch 2 doigts + boutons − / + ; labels
+ X adaptatifs ; courbes lisses à fort zoom)
- [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé)
et après un RETARD (prévision décalée, suivant la dernière prise)
- [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER &
@@ -927,4 +945,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
sur le graphique** et la calibration couvre toujours ses périodes
---
-*Doc mise à jour le 5 sept. 2026 (v1.2.8) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.*
+*Doc mise à jour le 5 sept. 2026 (v1.2.9) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.*