v1.2.6 : valeurs des pics/creux sur le graphique + import JSON en mode écrasement

- CurveChart : drawExtremum = triangle ▲▼ + VALEUR estimée de l'extremum
  (au-dessus d'un pic, en dessous d'un creux), couleur de la série
- Import JSON : mode ÉCRASEMENT (bug remonté : échec dès que l'app contenait
  des données — conflit d'IDs backup vs existants)
  · DAOs : deleteAll() sur les 3 tables (ordre enfants → parents, FK CASCADE)
  · BackupManager.importJson : wipe puis restauration (IDs conservés → FK
    valides), retourne aussi le tConfig du backup
  · SettingsScreen : après import → tConfig restauré dans DataStore + rappels
    reprogrammés (rescheduleAll) ; dialog renommé « Effacer & restaurer »
    avec avertissement explicite
- docs : §13 (écrasement), §11 (valeurs extrema), §14 #34, §16.bis CHECKLIST
  DE DÉPLOIEMENT complète numérotée (version → tests → docs → commit/tag →
  push 2 remotes → build 2 APK → releases 2 instances → smoke-test R8),
  §20/§21, CHANGELOG, README
- versionCode 9, versionName 1.2.6
This commit is contained in:
Siphonight 2026-09-05 21:41:58 +02:00
parent fb9806afd3
commit e16d370b2f
12 changed files with 121 additions and 19 deletions

View File

@ -9,7 +9,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.2.5 — build Android ✅, **62 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.2.6 — build Android ✅, **62 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 8 versionCode = 9
versionName = "1.2.5" versionName = "1.2.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -31,8 +31,26 @@ object BackupManager {
return Gson().toJson(data) return Gson().toJson(data)
} }
data class ImportResult(val treatments: Int, val doseLogs: Int, val labResults: Int) data class ImportResult(
val treatments: Int,
val doseLogs: Int,
val labResults: Int,
/** Réglages T du backup, à restaurer dans DataStore par l'appelant. */
val tConfig: TConfig
)
/**
* Restaure un backup en mode **ÉCRASEMENT** (v1.2.6) : les données actuelles
* sont effacées AVANT l'insertion — sinon les IDs du backup (conservés pour
* les FK dose→traitement) entrent en conflit avec les IDs existants et
* l'import échouait dès que l'app contenait déjà des données.
*
* Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite
* (FK CASCADE des doses vers les traitements).
*
* @return le nombre d'entrées restaurées par table + le tConfig du backup
* (à restaurer dans DataStore par l'appelant).
*/
suspend fun importJson( suspend fun importJson(
repo: HormoneRepository, repo: HormoneRepository,
json: String json: String
@ -40,6 +58,11 @@ object BackupManager {
val type = object : TypeToken<BackupData>() {}.type val type = object : TypeToken<BackupData>() {}.type
val data: BackupData = Gson().fromJson(json, type) val data: BackupData = Gson().fromJson(json, type)
// Écrasement : on part d'une base vide (ordre : enfants → parents)
repo.deleteAllDoseLogs()
repo.deleteAllLabResults()
repo.deleteAllTreatments()
data.treatments.forEach { repo.insertTreatment(it) } data.treatments.forEach { repo.insertTreatment(it) }
data.doseLogs.forEach { repo.insertDoseLog(it) } data.doseLogs.forEach { repo.insertDoseLog(it) }
data.labResults.forEach { repo.insertLabResult(it) } data.labResults.forEach { repo.insertLabResult(it) }
@ -47,7 +70,8 @@ object BackupManager {
return ImportResult( return ImportResult(
data.treatments.size, data.treatments.size,
data.doseLogs.size, data.doseLogs.size,
data.labResults.size data.labResults.size,
data.tConfig
) )
} }

View File

@ -24,6 +24,10 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC") @Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<DoseLog> suspend fun getAllOnce(): List<DoseLog>
/** Efface TOUT l'historique (import en mode écrasement). */
@Query("DELETE FROM dose_logs")
suspend fun deleteAll()
@Insert @Insert
suspend fun insert(log: DoseLog): Long suspend fun insert(log: DoseLog): Long

View File

@ -18,6 +18,10 @@ interface LabResultDao {
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC") @Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<LabResult> suspend fun getAllOnce(): List<LabResult>
/** Efface toutes les analyses (import en mode écrasement). */
@Query("DELETE FROM lab_results")
suspend fun deleteAll()
@Insert @Insert
suspend fun insert(result: LabResult): Long suspend fun insert(result: LabResult): Long

View File

@ -21,6 +21,11 @@ interface TreatmentDao {
@Query("SELECT * FROM treatments ORDER BY createdAt DESC") @Query("SELECT * FROM treatments ORDER BY createdAt DESC")
suspend fun getAllOnce(): List<Treatment> suspend fun getAllOnce(): List<Treatment>
/** Efface tous les traitements (import en mode écrasement).
* ⚠️ À appeler APRÈS deleteAll() des dose_logs (FK CASCADE sinon). */
@Query("DELETE FROM treatments")
suspend fun deleteAll()
@Insert @Insert
suspend fun insert(treatment: Treatment): Long suspend fun insert(treatment: Treatment): Long

View File

@ -44,4 +44,9 @@ class HormoneRepository(
suspend fun insertLabResult(result: LabResult): Long = labResultDao.insert(result) suspend fun insertLabResult(result: LabResult): Long = labResultDao.insert(result)
suspend fun updateLabResult(result: LabResult) = labResultDao.update(result) suspend fun updateLabResult(result: LabResult) = labResultDao.update(result)
suspend fun deleteLabResult(result: LabResult) = labResultDao.delete(result) suspend fun deleteLabResult(result: LabResult) = labResultDao.delete(result)
// --- Effacement complet (import en mode écrasement, cf BackupManager) ---
suspend fun deleteAllDoseLogs() = doseLogDao.deleteAll()
suspend fun deleteAllLabResults() = labResultDao.deleteAll()
suspend fun deleteAllTreatments() = treatmentDao.deleteAll()
} }

View File

@ -168,17 +168,18 @@ fun CurveChart(
} }
} }
// Pics et creux (v1.2.3) : triangles aux extrema locaux, couleur de la série // Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
// (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux)
if (options.showExtrema) { if (options.showExtrema) {
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0) PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
.forEach { ex -> .forEach { ex ->
drawTriangle(xOf(ex.timestamp), yE2(ex.value), ex.isPeak, s.e2Style.color) drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
} }
if (options.showT) { if (options.showT) {
s.tStyle?.let { tStyle -> s.tStyle?.let { tStyle ->
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
.forEach { ex -> .forEach { ex ->
drawTriangle(xOf(ex.timestamp), yT(ex.value), ex.isPeak, tStyle.color) drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
} }
} }
} }
@ -219,24 +220,47 @@ fun CurveChart(
} }
} }
/** Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous), couleur de la série. */ /**
private fun DrawScope.drawTriangle(cx: Float, cy: Float, isPeak: Boolean, color: Color) { * Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous) + la VALEUR
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
* creux — dans la couleur de la série, unité implicite (celle de l'axe).
*/
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetrack.pk.PharmacokineticEngine.Extremum, color: Color) {
val r = 5.dp.toPx() val r = 5.dp.toPx()
val offset = 6.dp.toPx() val offset = 6.dp.toPx()
val path = Path() val path = Path()
if (isPeak) { val valueY: Float
if (extremum.isPeak) {
val y = cy - offset val y = cy - offset
path.moveTo(cx, y - r) path.moveTo(cx, y - r)
path.lineTo(cx - r, y + r * 0.5f) path.lineTo(cx - r, y + r * 0.5f)
path.lineTo(cx + r, y + r * 0.5f) path.lineTo(cx + r, y + r * 0.5f)
valueY = y - r - 3.dp.toPx()
} else { } else {
val y = cy + offset val y = cy + offset
path.moveTo(cx, y + r) path.moveTo(cx, y + r)
path.lineTo(cx - r, y - r * 0.5f) path.lineTo(cx - r, y - r * 0.5f)
path.lineTo(cx + r, y - r * 0.5f) path.lineTo(cx + r, y - r * 0.5f)
valueY = y + r + 11.dp.toPx()
} }
path.close() path.close()
drawPath(path, color) drawPath(path, color)
// Valeur estimée du pic/creux, dans la couleur de la courbe
val paint = android.graphics.Paint().apply {
this.color = android.graphics.Color.argb(
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
)
textSize = 9.dp.toPx()
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText(
formatValue(extremum.value), cx, valueY, paint
)
} }
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) { private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) {

View File

@ -348,11 +348,18 @@ fun SettingsScreen(onBack: () -> Unit) {
showImportConfirm = false showImportConfirm = false
val json = importJson ?: return@TextButton val json = importJson ?: return@TextButton
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// Import en mode ÉCRASEMENT : les données actuelles sont
// effacées d'abord (BackupManager), puis le tConfig du
// backup restaure DataStore et les rappels sont reprogrammés
val result = try { val result = try {
BackupManager.importJson(repo, json) BackupManager.importJson(repo, json)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
result?.let {
container.settings.setTConfig(it.tConfig)
AlarmScheduler(context).rescheduleAll(repo.allTreatmentsOnce())
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
message = if (result != null) { message = if (result != null) {
context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults) context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults)

View File

@ -149,10 +149,10 @@
<string name="export_ok">Sauvegarde exportée.</string> <string name="export_ok">Sauvegarde exportée.</string>
<string name="export_fail">L\'export a échoué.</string> <string name="export_fail">L\'export a échoué.</string>
<string name="import_fail">L\'import a échoué : fichier invalide.</string> <string name="import_fail">L\'import a échoué : fichier invalide.</string>
<string name="import_ok">Importé : %1$d traitements, %2$d doses, %3$d analyses.</string> <string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string>
<string name="import_confirm">Restaurer</string> <string name="import_confirm">Effacer &amp; restaurer</string>
<string name="import_title">Importer une sauvegarde</string> <string name="import_title">Importer une sauvegarde</string>
<string name="import_warning">Le contenu de la sauvegarde sera AJOUTÉ aux données actuelles (aucune suppression).</string> <string name="import_warning">Le backup REMPLACERA toutes les données actuelles : les traitements, doses et analyses existants sont effacés d\'abord. Exporte une sauvegarde avant si tu veux les garder.</string>
<string name="about_title">À propos</string> <string name="about_title">À propos</string>
<string name="models_credit">Profils PK issus des modèles d\'injection Estrannaise (EstraNase) et Transfem Science, extraits de Estrogen.ods (tables horaires D, k1–k3).</string> <string name="models_credit">Profils PK issus des modèles d\'injection Estrannaise (EstraNase) et Transfem Science, extraits de Estrogen.ods (tables horaires D, k1–k3).</string>

View File

@ -149,10 +149,10 @@
<string name="export_ok">Backup exported.</string> <string name="export_ok">Backup exported.</string>
<string name="export_fail">Export failed.</string> <string name="export_fail">Export failed.</string>
<string name="import_fail">Import failed: invalid file.</string> <string name="import_fail">Import failed: invalid file.</string>
<string name="import_ok">Imported: %1$d treatments, %2$d doses, %3$d labs.</string> <string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string>
<string name="import_confirm">Restore</string> <string name="import_confirm">Erase &amp; restore</string>
<string name="import_title">Import backup</string> <string name="import_title">Import backup</string>
<string name="import_warning">The backup content will be ADDED to the current data (no deletion).</string> <string name="import_warning">The backup will REPLACE all current data: existing treatments, doses and labs are erased first. Export a backup now if you want to keep them.</string>
<string name="about_title">About</string> <string name="about_title">About</string>
<string name="models_credit">PK profiles from Estrannaise (EstraNase) and Transfem Science injection models, extracted from Estrogen.ods (D, k1–k3 hourly tables).</string> <string name="models_credit">PK profiles from Estrannaise (EstraNase) and Transfem Science injection models, extracted from Estrogen.ods (D, k1–k3 hourly tables).</string>

View File

@ -506,7 +506,10 @@ gauche (pg/mL), T droite (ng/mL). Échelle « nice » (`niceCeil` : 1/2/2.5/5/10
partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas 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 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. carrés (T) orange + valeur, **T convertie en ng/mL** (`convertTToNgMl`) au rendu.
Ligne verticale « maintenant ». Ligne verticale « maintenant ». **Pics/creux** (v1.2.3, valeurs v1.2.6) :
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`).
Pièges : Pièges :
- `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom - `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom
@ -751,6 +754,32 @@ JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
vérifie pas en tests JVM). vérifie pas en tests JVM).
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 62 au total,
44 si `local-test-data/` est absent (les régressions réelles sont skippées).
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
corrigé, + §2 (historique) si notable.
4. **Commit** (message descriptif par couche) + **tag annoté** :
`git tag -a vX.Y.Z -m "résumé"`.
5. **Push** : `git push origin main --tags` + `git push farewell main --tags`
(farewell : le repo doit exister sur l'instance ; push-to-create désactivé).
6. **Construire les 2 APK au niveau du tag** :
`git checkout vX.Y.Z` → `./gradlew assembleRelease assembleDebug` → copier
`app-release.apk` → `/tmp/apks/HormoneTrack-vX.Y.Z-release.apk` et
`app-debug.apk` → `HormoneTrack-vX.Y.Z-debug.apk` → `git checkout main`.
7. **Publier les releases** (corps = section CHANGELOG + les 2 APK attachés) :
`python3 scripts/gitea-release.py cloudyfy vX.Y.Z <release.apk>` puis
idem avec `<debug.apk>` ; répéter avec l'instance `farewell` (token
`gitea.farewell.dev` requis dans le trousseau, absent à ce jour).
8. **Smoke-test R8 sur téléphone** (le release APK n'est pas vérifiable en
tests JVM) : installation par-dessus l'existant, graphiques, export ET
import d'un backup JSON, rappel. Le debug APK est le repli (même signature).
9. **Mettre à jour §2/§8/§19/§21 si besoin** puis pusher la doc.
Notes : Notes :
- L'APK est une **build debug** signée avec la clé debug locale — installable en - L'APK est une **build debug** signée avec la clé debug locale — installable en
sideload, mises à jour entre versions OK (même signature) ; sideload, mises à jour entre versions OK (même signature) ;
@ -864,4 +893,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
sur le graphique** et la calibration couvre toujours ses périodes sur le graphique** et la calibration couvre toujours ses périodes
--- ---
*Doc mise à jour le 5 sept. 2026 (v1.2.5) — build OK, 62/62 tests verts (44 sans les données locales), dépôt Gitea privé + releases APK, aucune donnée de santé dans le dépôt ni l'historique.* *Doc mise à jour le 5 sept. 2026 (v1.2.6) — 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.*