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
This commit is contained in:
Siphonight 2026-09-05 22:51:27 +02:00
parent 139f818172
commit ab84f5e0ce
9 changed files with 151 additions and 16 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}
}
}
}

View File

@ -31,6 +31,8 @@
<string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</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="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</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>

View File

@ -31,6 +31,8 @@
<string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string>
<string name="zoom_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</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>

View File

@ -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))
}
}

View File

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

View File

@ -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.*