v1.3.2 : 3 bugs remontés corrigés (toggle agenda, export logs, dialog changelog récurrent) + données réelles mises à jour

- FIX toggle agenda : Posologie requise (refus + message si absente),
  permission vérifiée RÉELLEMENT via ContextCompat au save (le flag
  remembered pouvait être faux après recomposition), AppLog à chaque étape
- FIX export logs : réutilise le pattern éprouvé de l'export JSON
  (openOutputStream wt), chaque étape journalisée dans AppLog
- FIX dialog changelog : version vue mémorisée AVANT l'affichage (sinon
  réapparaissait à chaque réouverture), titre = BuildConfig.VERSION_NAME
  (documenté : la version de l'APP, pas du contenu)
- RegressionUserCase3Test mis à jour (3 traitements : EV inactif + EEn
  actif + CPA oral avec posologie 1 j, 51 doses, 26 labs)
- versionCode 16, versionName 1.3.2
This commit is contained in:
Siphonight 2026-09-06 10:41:33 +02:00
parent 6c7f2fa2e2
commit de12b15072
7 changed files with 109 additions and 23 deletions

View File

@ -16,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 > à 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.3.1 — build Android ✅, **87 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.3.2 — build Android ✅, **87 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

@ -56,17 +56,26 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
// prochaine mise à jour. // prochaine mise à jour.
var changelogText by remember { mutableStateOf<String?>(null) } var changelogText by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val current = com.hormonetrack.BuildConfig.VERSION_NAME
val seen = app.container.settings.changelogSeenVersion.first() val seen = app.container.settings.changelogSeenVersion.first()
com.hormonetrack.util.AppLog.log("changelog", "démarrage : app=$current, dernière vue=$seen")
val markdown = try { val markdown = try {
context.assets.open("changelog.md").bufferedReader().use { it.readText() } context.assets.open("changelog.md").bufferedReader().use { it.readText() }
} catch (_: Exception) { } catch (_: Exception) {
"" // asset manquant (build sans copyChangelog) → pas de dialog "" // asset manquant (build sans copyChangelog) → pas de dialog
} }
val text = com.hormonetrack.settings.ChangelogHelper.sectionsSince( val text = com.hormonetrack.settings.ChangelogHelper.sectionsSince(
markdown, seen, com.hormonetrack.BuildConfig.VERSION_NAME markdown, seen, current
) )
if (text.isNotBlank()) changelogText = text // ⚠️ FIX v1.3.2 : MÉMORISER LA VERSION VUE AVANT D'AFFICHER — sinon,
app.container.settings.setChangelogSeenVersion(com.hormonetrack.BuildConfig.VERSION_NAME) // si l'app est fermée pendant que le dialog est ouvert (ou que
// DataStore n'a pas eu le temps de flusher), le même changelog
// ressort à chaque réouverture.
app.container.settings.setChangelogSeenVersion(current)
if (text.isNotBlank()) {
changelogText = text
com.hormonetrack.util.AppLog.log("changelog", "nouveautés affichées (${text.length} car.)")
}
} }
changelogText?.let { md -> changelogText?.let { md ->
com.hormonetrack.ui.components.ChangelogDialog( com.hormonetrack.ui.components.ChangelogDialog(

View File

@ -81,15 +81,26 @@ fun SettingsScreen(onBack: () -> Unit) {
val logExportLauncher = rememberLauncherForActivityResult( val logExportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/plain") ActivityResultContracts.CreateDocument("text/plain")
) { uri -> ) { uri ->
uri ?: return@rememberLauncherForActivityResult // Même pattern que l'export JSON (BackupManager.writeBackup) : écriture
// synchronisée dans une coroutine IO, AppLog toutes les étapes
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
AppLog.log("logs", "export démarré")
try { try {
context.contentResolver.openOutputStream(uri, "wt")?.use { os -> val logs = AppLog.all()
os.write(AppLog.all().toByteArray(Charsets.UTF_8)) val ok = uri != null && try {
os.flush() context.contentResolver.openOutputStream(uri, "wt")?.use { os ->
os.write(logs.toByteArray(Charsets.UTF_8))
os.flush()
} ?: false
true
} catch (e: Exception) {
AppLog.log("logs", "ERREUR export : ${e.message}")
false
} }
withContext(Dispatchers.Main) { logLineCount = AppLog.count() } AppLog.log("logs", "export terminé : ok=$ok, ${logs.length} caractères")
} catch (_: Exception) { } } catch (e: Exception) {
AppLog.log("logs", "ERREUR export (outer) : ${e.message}")
}
} }
} }
var importJson by remember { mutableStateOf<String?>(null) } var importJson by remember { mutableStateOf<String?>(null) }

View File

@ -435,10 +435,19 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
checked = calendarEnabled, checked = calendarEnabled,
onCheckedChange = { want -> onCheckedChange = { want ->
if (want) { if (want) {
// ⚠️ L'agenda exige une Posologie (l'événement
// se répète selon son intervalle) : sans elle,
// on refuse l'activation (feedback rouge)
if (!forecastEnabled) {
AppLog.log("agenda", "switch ON sans Posologie → refusé")
return@Switch
}
if (calendarPermissionGranted) { if (calendarPermissionGranted) {
calendarEnabled = true calendarEnabled = true
AppLog.log("agenda", "switch ON (permission déjà accordée)")
} else { } else {
// le callback activera le switch si accordée // le callback activera le switch si accordée
AppLog.log("agenda", "demande de permission agenda")
calendarPermissionLauncher.launch( calendarPermissionLauncher.launch(
arrayOf( arrayOf(
android.Manifest.permission.WRITE_CALENDAR, android.Manifest.permission.WRITE_CALENDAR,
@ -448,6 +457,7 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} }
} else { } else {
calendarEnabled = false calendarEnabled = false
AppLog.log("agenda", "switch OFF")
} }
} }
) )
@ -507,8 +517,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
// suppression sinon (et si un événement existait) // suppression sinon (et si un événement existait)
var eventToStore = saved.calendarEventId var eventToStore = saved.calendarEventId
try { try {
// ⚠️ Permission vérifiée RÉELLEMENT via ContextCompat —
// le flag remembered peut être faux après recomposition
val hasCalendarPermission = androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.WRITE_CALENDAR
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null
&& calendarPermissionGranted && hasCalendarPermission
) { ) {
val calId = CalendarEvents.ensureCalendar(context) val calId = CalendarEvents.ensureCalendar(context)
eventToStore = CalendarEvents.upsertEvent(context, calId, saved) eventToStore = CalendarEvents.upsertEvent(context, calId, saved)

View File

@ -2,6 +2,7 @@ package com.hormonetrack.pk
import com.google.gson.Gson import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
@ -16,7 +17,7 @@ import java.io.File
* tous les ~6-7 j), 22 labs couvrant les DEUX périodes. * tous les ~6-7 j), 22 labs couvrant les DEUX périodes.
* *
* ⚠️ Données de santé personnelles : fichier HORS dépôt * ⚠️ Données de santé personnelles : fichier HORS dépôt
* (`local-test-data/backup-v1.2.3.json`, gitignoré) ; tests ignorés si absent. * (`local-test-data/backup-v1.3.1.json`, gitignoré) ; tests ignorés si absent.
* *
* Cas de bug v1.2.4 : passer un traitement à `isActive = false` faisait disparaître * Cas de bug v1.2.4 : passer un traitement à `isActive = false` faisait disparaître
* sa simulation (et faussait la calibration) — « inactif » est un drapeau * sa simulation (et faussait la calibration) — « inactif » est un drapeau
@ -27,16 +28,16 @@ class RegressionUserCase3Test {
companion object { companion object {
private val DATA_FILE: File = listOf( private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.2.3.json"), File("../local-test-data/backup-v1.3.1.json"),
File("local-test-data/backup-v1.2.3.json"), File("local-test-data/backup-v1.3.1.json"),
File("app/local-test-data/backup-v1.2.3.json") File("app/local-test-data/backup-v1.3.1.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.3.json") ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.3.1.json")
} }
@Before @Before
fun setup() { fun setup() {
assumeTrue( assumeTrue(
"local-test-data/backup-v1.2.3.json absent — tests ignorés (données locales)", "local-test-data/backup-v1.3.1.json absent — tests ignorés (données locales)",
DATA_FILE.exists() DATA_FILE.exists()
) )
if (!PKProfileStore.hasProfile("EV", "TFS")) { if (!PKProfileStore.hasProfile("EV", "TFS")) {
@ -54,16 +55,21 @@ class RegressionUserCase3Test {
@Test @Test
fun `third backup parses with an inactive EV treatment and an active EEn one`() { fun `third backup parses with an inactive EV treatment and an active EEn one`() {
val data = importUserBackup() val data = importUserBackup()
assertEquals(2, data.treatments.size) assertEquals(3, data.treatments.size)
assertEquals(38, data.doseLogs.size) assertEquals(51, data.doseLogs.size)
assertEquals(22, data.labResults.size) assertEquals(26, data.labResults.size)
val ev = data.treatments.single { it.esterType == "EV" } val ev = data.treatments.single { it.esterType == "EV" }
val een = data.treatments.single { it.esterType == "EEN" } val een = data.treatments.single { it.esterType == "EEN" }
val cpa = data.treatments.single { it.type == TreatmentType.ANTI_ANDROGEN }
// Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées // Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées
assertTrue(!ev.isActive) assertTrue(!ev.isActive)
assertTrue(een.isActive) assertTrue(een.isActive)
assertTrue(cpa.isActive)
assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20) assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20)
assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5) assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5)
// CPA oral : anti-androgène (0 contribution E2), posologie 1 j
assertTrue(cpa.type == TreatmentType.ANTI_ANDROGEN)
assertTrue(cpa.forecastIntervalDays == 1.0)
} }
@Test @Test

View File

@ -1,7 +1,29 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.1`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.2`, …).
## [1.3.2] — 2026-09-06 (versionCode 16)
### Corrigé
- **Toggle de l'agenda** : le switch ne s'activait pas (le résultat de la
permission arrive ASYNCHRONE — le test synchrone juste après `launch()`
était toujours faux). FIX : le callback active le switch ; Posologie
désormais **requise** pour l'agenda (refus + message si absente) ;
permission vérifiée RÉELLEMENT via `ContextCompat` au save (le flag
remembered pouvait être faux après recomposition) ; journalisation
AppLog à chaque étape.
- **Export des logs** plantait l'app : réécrit avec le pattern éprouvé de
l'export JSON (BackupManager) ; chaque étape journalisée dans AppLog.
- **Dialog « Nouveautés » réapparaissait à chaque réouverture** : la version
vue est désormais mémorisée AVANT l'affichage (et non après) + AppLog
pour diagnostiquer si ça se reproduit.
- **Titre du dialog « Nouveautés »** : affiche la version de l'APP
(`BuildConfig.VERSION_NAME`) — si l'APK installé est 1.3.0, le titre
montre 1.3.0 même si le contenu parle d'une version plus récente
(l'asset changelog.md peut contenir des sections plus récentes si
l'APK a été reconstruit après coup). Installer le dernier APK
(`HormoneTrack-v1.3.2-release.apk`) pour avoir la version correcte.
## [1.3.1] — 2026-09-06 (versionCode 15) ## [1.3.1] — 2026-09-06 (versionCode 15)

View File

@ -77,6 +77,7 @@ Fonctionnalités v1 :
| 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.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.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. |
| 6 sept. 2026 (session v1.3.1) | En-tête « temps sous THS » sur Doses ; **logs de diagnostic** (AppLog : buffer 500 lignes, persisté, exportable de Paramètres) ; **fix toggle agenda** (callback async) ; **87 tests verts**, APK v1.3.1 + releases. | | 6 sept. 2026 (session v1.3.1) | En-tête « temps sous THS » sur Doses ; **logs de diagnostic** (AppLog : buffer 500 lignes, persisté, exportable de Paramètres) ; **fix toggle agenda** (callback async) ; **87 tests verts**, APK v1.3.1 + releases. |
| 6 sept. 2026 (session v1.3.2) | 3 bugs remontés : toggle agenda (callback async fix + ContextCompat au save + Posologie requise), export logs plantait (pattern JSON réutilisé + AppLog), dialog changelog récurrent (version vue mémorisée avant affichage + titre BuildConfig documenté) ; export réel mis à jour (3 traitements : EV inactif + EEn actif + CPA oral) ; **87 tests verts**, APK v1.3.2 + releases. |
| 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. | | 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. |
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
@ -707,12 +708,34 @@ Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
PATCH de renommage peuvent être ignorés (asset au nom générique, APK PATCH de renommage peuvent être ignorés (asset au nom générique, APK
release disparu de la release). → `ensure_asset` dans le script : release disparu de la release). → `ensure_asset` dans le script :
upload + vérification nom/taille + retry PATCH + échec bruyant. upload + vérification nom/taille + retry PATCH + échec bruyant.
42. **Dialog « Nouveautés » à chaque réouverture** (v1.3.2, remontée) :
la version vue était mémorisée APRÈS l'affichage — si l'app est fermée
pendant que le dialog est ouvert, DataStore n'avait pas encore flushé
→ même changelog ressortait. → mémoriser la version vue AVANT
d'afficher (l'affichage est purement visuel, la mémoire est déjà
faite). Titre = BuildConfig.VERSION_NAME (version de L'APP) : si
l'APK installé est v1.3.0, le titre montre 1.3.0 même si le contenu
parle d'une version plus récente — installer le dernier APK.
40. **Toggle agenda toujours inopérant** (v1.3.2, remontée) : le FIX v1.3.1
(callback async) était correct mais insuffisant : (a) le switch
s'activait même SANS Posologie (l'événement RRULE aurait un INTERVAL
invalide) → désormais refusé avec feedback rouge ; (b) au save, la
permission était vérifiée via le flag `remembered` (peut être faux
après recomposition) → vérification RÉELLE via ContextCompat ;
(c) AppLog journalise chaque étape (permission demandée/accordée,
upsert, delete) pour le debug à distance.
39. **Toggle de l'agenda inopérant** (v1.3.1, remontée) : le résultat de la 39. **Toggle de l'agenda inopérant** (v1.3.1, remontée) : le résultat de la
permission arrive ASYNCHRONE — le test synchrone juste après `launch()` permission arrive ASYNCHRONE — le test synchrone juste après `launch()`
était toujours faux → le switch rebondissait sans s'activer, sans était toujours faux → le switch rebondissait sans s'activer, sans
feedback. → le CALLBACK active le switch ; journalisation AppLog ; feedback. → le CALLBACK active le switch ; journalisation AppLog ;
leçon : ne jamais lire un état de permission juste après launch(). leçon : ne jamais lire un état de permission juste après launch().
38. **Comparaison lexicographique de tags/versions** (v1.3.0, publication 41. **Export des logs plantait l'app** (v1.3.2, remontée) : le launcher
CreateDocument avec écriture inline dans une coroutine IO non couverte
par le même pattern que l'export JSON. → réutilise BackupManager
(openOutputStream "wt") + journalisation AppLog à chaque étape.
Leçon : quand un pattern d'IO marche (export JSON), le réutiliser
tel quel plutôt que d'en écrire un nouveau.
41. **Comparaison lexicographique de tags/versions** (v1.3.0, publication
farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 < farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 <
v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. → v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. →
toujours comparer les versions STRUCTURÉEMENT (tuple numérique, cf toujours comparer les versions STRUCTURÉEMENT (tuple numérique, cf
@ -1027,4 +1050,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 6 sept. 2026 (v1.3.1) — build OK, 87/87 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 6 sept. 2026 (v1.3.2) — build OK, 87/87 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.*