From 633c6debf4807937264872bbd6efaa7458ff3a90 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Mon, 7 Sep 2026 00:02:22 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.2=20:=20seuils=20d'alerte=20configurables?= =?UTF-8?q?=20E2/T=20(Param=C3=A8tres,=20opt-in,=20valid=C3=A9s=20haut>bas?= =?UTF-8?q?)=20=E2=80=94=20cartes=20d'avertissement=20sur=20l'accueil=20(n?= =?UTF-8?q?iveau=20ESTIM=C3=89,=20calibration=20incluse)=20;=20notificatio?= =?UTF-8?q?n=20via=20worker=20WorkManager=20p=C3=A9riodique=2015=20min=20+?= =?UTF-8?q?=20check=20one-time=20au=20save,=20anti-spam=20par=20=C3=A9tat?= =?UTF-8?q?=20persist=C3=A9=20(Alerts.encodeState/shouldNotify),=20canal?= =?UTF-8?q?=20d=C3=A9di=C3=A9=20;=20backup=20JSON=20v2=20avec=20les=20para?= =?UTF-8?q?m=C3=A8tres=20utilisateur=20(langue/auto-cal/seuils,=20r=C3=A9t?= =?UTF-8?q?rocompat=20v1,=20-keep=20R8)=20;=2013=20nouveaux=20tests=20(129?= =?UTF-8?q?=20verts)=20+=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +- app/build.gradle.kts | 6 +- app/proguard-rules.pro | 1 + .../java/com/hormonetrack/HormoneTrackApp.kt | 7 + .../hormonetrack/data/backup/BackupManager.kt | 57 ++++- .../main/java/com/hormonetrack/pk/Alerts.kt | 127 +++++++++++ .../hormonetrack/pk/PharmacokineticEngine.kt | 17 +- .../hormonetrack/reminder/AlertNotifier.kt | 215 ++++++++++++++++++ .../com/hormonetrack/settings/AppSettings.kt | 57 ++++- .../com/hormonetrack/ui/screens/HomeScreen.kt | 47 ++++ .../hormonetrack/ui/screens/SettingsScreen.kt | 140 +++++++++++- app/src/main/res/values-fr/strings.xml | 14 ++ app/src/main/res/values/strings.xml | 14 ++ .../data/backup/BackupGsonTest.kt | 45 ++++ .../com/hormonetrack/pk/AlertsEngineTest.kt | 143 ++++++++++++ .../java/com/hormonetrack/pk/AlertsTest.kt | 151 ++++++++++++ docs/CHANGELOG.md | 31 ++- docs/DEVELOPPEMENT.md | 83 ++++++- docs/GUIDE_INSTALLATION.md | 9 +- 19 files changed, 1143 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/hormonetrack/pk/Alerts.kt create mode 100644 app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt create mode 100644 app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt create mode 100644 app/src/test/java/com/hormonetrack/pk/AlertsTest.kt diff --git a/README.md b/README.md index e629c85..0ce660c 100644 --- a/README.md +++ b/README.md @@ -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 > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.4.1 — build Android ✅, **lint vert** ✅, **112 tests unitaires** ✅ (94 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.4.2 — build Android ✅, **lint vert** ✅, **129 tests unitaires** ✅ (111 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) @@ -62,6 +62,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ; **les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les 7 jours ne sonne que le jour d'injection, pas tous les jours +- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL) + et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil + + **notification toutes les 15 min même app fermée** (WorkManager, anti-spam, + canal dédié) — évaluées sur le taux **estimé**, opt-in +- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T + **+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2) - **Nouveautés à chaque mise à jour** : dialog de changelog automatique (fermé = ne réapparaît pas avant la prochaine version) - **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ; @@ -69,7 +75,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** - **Logs de diagnostic** exportables (Paramètres) — utile pour le support - **Événements d'agenda** : les rappels de prises peuvent créer un événement récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone -- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T) +- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T + paramètres, v1.4.2) - **FR + EN** (langue par app, indépendante du système) - UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte @@ -82,7 +88,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk -./gradlew testDebugUnitTest # 112 tests (94 sans les données locales) +./gradlew testDebugUnitTest # 129 tests (111 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` @@ -94,7 +100,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — `Siphonight/HormoneTrack` sur les deux (privé), avec -**releases taguées** (`v1.1.0` → `v1.4.1`) et **deux APK par release** (depuis v1.2.5) +**releases taguées** (`v1.1.0` → `v1.4.2`) et **deux APK par release** (depuis v1.2.5) (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : @@ -175,7 +181,7 @@ HormoneTrack/ │ ├── java/com/hormonetrack/ │ │ ├── data/ (Room : models, DAOs, repository, backup) │ │ ├── pk/ (moteur pharmacocinétique + profils) - │ │ ├── reminder/ (alarmes exactes, notifs + actions, boot) + │ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes) │ │ ├── settings/ (DataStore : TConfig, langue) │ │ ├── ui/ (Compose : screens, components, theme) │ │ ├── HormoneTrackApp.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b15a93c..bcef3cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 21 - versionName = "1.4.1" + versionCode = 22 + versionName = "1.4.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -114,7 +114,7 @@ dependencies { // DataStore (préférences) implementation("androidx.datastore:datastore-preferences:1.2.1") - // WorkManager (déclaré, non utilisé — cf docs §19) + // WorkManager (UTILISÉ v1.4.2 : worker périodique des seuils d'alerte, cf §9.bis) implementation("androidx.work:work-runtime-ktx:2.11.2") // Coroutines diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 09cfc1a..e73382a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -10,6 +10,7 @@ -keep class com.hormonetrack.data.model.** { ; } -keep class com.hormonetrack.data.backup.BackupData { ; } +-keep class com.hormonetrack.data.backup.UserSettings { ; } -keep class com.hormonetrack.pk.TConfig { ; } # Énumérations : converties en String par Room et Gson (par nom) diff --git a/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt b/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt index 1f03ea5..1067c92 100644 --- a/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt +++ b/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt @@ -7,6 +7,7 @@ import com.hormonetrack.data.repository.HormoneRepository import com.hormonetrack.pk.PKProfileStore import com.hormonetrack.reminder.createNotificationChannel import com.hormonetrack.util.AppLog +import com.hormonetrack.reminder.AlertNotifier import com.hormonetrack.settings.AppSettings class HormoneTrackApp : Application() { @@ -20,6 +21,12 @@ class HormoneTrackApp : Application() { PKProfileStore.init(this) createNotificationChannel(this) AppLog.init(this) + // v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte — + // évalue le niveau estimé toutes les 15 min, notifie au franchissement + // (anti-spam par état persisté). KEEP : replanifier à chaque + // démarrage ne change rien ; WorkManager survit aux reboots. + AlertNotifier.createChannel(this) + AlertNotifier.schedulePeriodic(this) } } diff --git a/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt b/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt index 1443459..2815ee2 100644 --- a/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt +++ b/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt @@ -10,23 +10,59 @@ import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.repository.HormoneRepository import com.hormonetrack.pk.TConfig +/** + * Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde + * transporte désormais les RÉGLAGES en plus des données de santé : + * - `language` : "system" / "fr" / "en" (null = ne pas changer à l'import) ; + * - `autoCalibrate` : option de calibration automatique (null = défaut) ; + * - `alertE2High/Low` (pg/mL) et `alertTHigh/Low` (ng/mL) : seuils d'alerte + * configurés (null = pas de seuil). + * + * ⚠️ Champs PLATS volontairement (pas de nested data class) : Gson lit ces + * champs par RÉFLEXION → chaque classe embarquée doit être couverte par un + * `-keep` explicite dans proguard-rules.pro (sinon l'export/import produit + * des objets vides EN RELEASE SEULE — leçon §16.bis smoke-test R8). + * `changelog_seen_version` est volontairement EXCLU : pas une donnée de + * configuration utile à restaurer (le dialog « Nouveautés » se montre une + * fois de plus après une restauration, ce n'est pas gênant). + */ +data class UserSettings( + val language: String? = null, + val autoCalibrate: Boolean? = null, + val alertE2High: Double? = null, + val alertE2Low: Double? = null, + val alertTHigh: Double? = null, + val alertTLow: Double? = null +) + data class BackupData( - val version: Int = 1, + // v2 (v1.4.2) : champ optionnel `settings` ajouté. RÉTROCOMPATIBILITÉ : + // les backups v1 (sans settings) restent parsables (Gson → null) et + // importables — l'import ne vérifie pas strictement la version. + val version: Int = 2, val exportedAt: Long = System.currentTimeMillis(), val treatments: List = emptyList(), val doseLogs: List = emptyList(), val labResults: List = emptyList(), - val tConfig: TConfig = TConfig() + val tConfig: TConfig = TConfig(), + /** Paramètres utilisateur (null = backup v1 sans réglages). */ + val settings: UserSettings? = null ) object BackupManager { - suspend fun exportJson(repo: HormoneRepository, tConfig: TConfig): String { + suspend fun exportJson( + repo: HormoneRepository, + tConfig: TConfig, + /** Réglages utilisateur à embarquer (v1.4.2) ; null = backup minimal. */ + settings: UserSettings? = null + ): String { val data = BackupData( treatments = repo.allTreatmentsOnce(), doseLogs = repo.allDoseLogsOnce(), labResults = repo.allLabResultsOnce(), - tConfig = tConfig + tConfig = tConfig, + settings = settings ) return Gson().toJson(data) } @@ -36,7 +72,9 @@ object BackupManager { val doseLogs: Int, val labResults: Int, /** Réglages T du backup, à restaurer dans DataStore par l'appelant. */ - val tConfig: TConfig + val tConfig: TConfig, + /** Paramètres utilisateur (v1.4.2) ; null = backup v1 sans réglages. */ + val settings: UserSettings? ) /** @@ -48,8 +86,10 @@ object BackupManager { * 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). + * @return le nombre d'entrées restaurées par table + le tConfig et les + * paramètres utilisateur du backup (à restaurer dans DataStore par + * l'appelant — cf SettingsScreen : langue appliquée sur le thread Main + * car elle recrée l'activité). */ suspend fun importJson( repo: HormoneRepository, @@ -71,7 +111,8 @@ object BackupManager { data.treatments.size, data.doseLogs.size, data.labResults.size, - data.tConfig + data.tConfig, + data.settings ) } diff --git a/app/src/main/java/com/hormonetrack/pk/Alerts.kt b/app/src/main/java/com/hormonetrack/pk/Alerts.kt new file mode 100644 index 0000000..cdc905d --- /dev/null +++ b/app/src/main/java/com/hormonetrack/pk/Alerts.kt @@ -0,0 +1,127 @@ +package com.hormonetrack.pk + +/** + * Seuils d'alerte configurables (v1.4.2) : limites HAUTE et BASSE définies + * par l'utilisatrice pour l'estradiol (pg/mL) et la testostérone (ng/mL), + * évaluées contre le TAUX ESTIMÉ ACTUEL (dernier point de courbe, i.e. le + * niveau « ≈ X pg/mL » affiché sur l'accueil — pas une mesure de labo). + * + * Philosophie : + * - **opt-in** : aucun seuil par défaut (l'app n'invente pas de normes + * médicales — tout champ laissé vide désactive l'alerte correspondante) ; + * - l'objet est PUR (aucune dépendance Android) → testable en JVM + * (`AlertsTest`) et réutilisable plus loin (widget, notification…). + * - Les courbes restent des ESTIMATIONS pharmacocinétiques : l'avertissement + * est indicatif, libellé en ce sens dans l'UI (cf strings `alert_*`). + * + * Validation de cohérence : si haut ET bas sont définis, il faut haut > bas + * (sinon l'évaluation serait ambiguë) — vérifié à la saisie dans + * SettingsScreen avec feedback, et défensivement ici (`isCoherent`). + */ +object Alerts { + + /** + * Seuils de l'utilisatrice (DataStore). null = pas de limite pour cette + * valeur → jamais d'alerte dessus. + */ + data class Thresholds( + /** Limite HAUTE E2 en pg/mL (null = pas d'alerte haute). */ + val e2High: Double? = null, + /** Limite BASSE E2 en pg/mL (null = pas d'alerte basse). */ + val e2Low: Double? = null, + /** Limite HAUTE T en ng/mL (null = pas d'alerte haute). */ + val tHigh: Double? = null, + /** Limite BASSE T en ng/mL (null = pas d'alerte basse). */ + val tLow: Double? = null + ) { + /** Cohérence : une limite haute doit être strictement au-dessus de la basse. */ + fun isCoherent(): Boolean = + (e2High == null || e2Low == null || e2High > e2Low) && + (tHigh == null || tLow == null || tHigh > tLow) + } + + /** Type de dépassement. */ + enum class Level { HIGH, LOW } + + /** Une alerte déclenchée : valeur estimée vs la limite franchie. */ + data class Alert( + val marker: String, // "E2" ou "T" + val level: Level, + val value: Double, // taux estimé actuel (même unité que la limite) + val limit: Double, // limite franchie + val unit: String // "pg/mL" (E2) ou "ng/mL" (T) + ) + + /** + * Évalue UNE valeur contre ses limites. + * + * - `value > high` → HIGH ; `value < low` → LOW (strict : la valeur + * exactement à la limite ne déclenche rien — éviter les alertes + * « clignotantes » sur la précision d'affichage) ; + * - limite null → jamais d'alerte sur ce côté ; + * - un seul verdict par appel : HIGH prime LOW si la configuration était + * incohérente (haut < bas) — défensif, la saisie interdit ce cas. + */ + fun evaluate(value: Double, low: Double?, high: Double?): Pair? = when { + high != null && value > high -> Level.HIGH to high + low != null && value < low -> Level.LOW to low + else -> null + } + + /** + * Évalue le niveau actuel (E2 pg/mL + T ng/mL) contre tous les seuils. + * @return la liste des alertes déclenchées (vide = tout va bien / rien + * de configuré). Ordre stable : E2 d'abord, puis T. + */ + fun evaluateAll(currentE2: Double, currentT: Double, t: Thresholds): List { + val out = mutableListOf() + evaluate(currentE2, t.e2Low, t.e2High)?.let { (level, limit) -> + out += Alert("E2", level, currentE2, limit, "pg/mL") + } + evaluate(currentT, t.tLow, t.tHigh)?.let { (level, limit) -> + out += Alert("T", level, currentT, limit, "ng/mL") + } + return out + } + + // ── État de notification (v1.4.2) — anti-spam des vérifications périodiques ── + + /** + * Sérialise l'état des alertes déjà NOTIFIÉES pour la persistance + * (DataStore) : `"E2:HIGH;T:LOW"`. Chaîne vide = rien de notifié. + * Pur → testable en JVM (l'encodage est stocké par l'OS entre les + * vérifications du worker, cf reminder/AlertNotifier.kt). + */ + fun encodeState(alerts: List): String = + alerts.joinToString(";") { "${it.marker}:${it.level}" } + + /** Décodage de l'état persisté (format [encodeState]) → map marqueur → niveau. */ + fun parseState(encoded: String?): Map = + encoded.orEmpty().split(";") + .filter { it.contains(":") } + .mapNotNull { part -> + val parts = part.split(":", limit = 2) + val level = Level.entries.firstOrNull { it.name == parts[1] } + if (parts.size == 2 && level != null) parts[0] to level else null + } + .toMap() + + /** + * Décide si une NOTIFICATION doit être envoyée pour cette vérification + * (anti-spam du worker périodique, appelé toutes les 15 min) : + * + * - current vide → **false** (pas de notif de « retour à la normale » ; + * l'appelant efface l'état persisté pour permettre la re-notification + * au PROCHAIN franchissement) ; + * - current identique au dernier état notifié → **false** (l'écart + * continue, pas de re-notif toutes les 15 min) ; + * - nouveau franchissement OU changement de niveau (H↔L) → **true**. + * + * @param current état évalué à CETTE vérification (map marqueur → niveau). + * @param lastNotified état persisté de la dernière notification (null = jamais). + */ + fun shouldNotify(current: Map, lastNotified: Map?): Boolean { + if (current.isEmpty()) return false + return current != lastNotified + } +} diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 512a6b1..04e195b 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -192,14 +192,22 @@ object PharmacokineticEngine { } } + /** + * Niveau combiné à un instant donné. [scalePerEster] (v1.4.2, optionnel) + * applique la calibration par période d'ester EXACTEMENT comme + * [computeCurve] — la notification d'alerte (AlertNotifier) et la carte + * « niveau actuel » de l'accueil partagent ainsi le même calcul. + * null = scaleFactor stocké des traitements (comportement historique). + */ fun levelAt( treatments: List, doseLogs: List, tMs: Long, tConfig: TConfig, - tKPerEster: Map? = null + tKPerEster: Map? = null, + scalePerEster: Map? = null ): LevelPoint { - val e2 = e2At(treatments, doseLogs, tMs) + val e2 = e2At(treatments, doseLogs, tMs, scalePerEster = scalePerEster) val k = activeEsterAt(treatments, doseLogs, tMs) ?.let { tKPerEster?.get(it) } ?: tConfig.k return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k))) @@ -230,8 +238,9 @@ object PharmacokineticEngine { doseLogs: List, tConfig: TConfig, tKPerEster: Map? = null, - nowMs: Long = System.currentTimeMillis() - ): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster) + nowMs: Long = System.currentTimeMillis(), + scalePerEster: Map? = null + ): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster) fun computeCurve( treatments: List, diff --git a/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt b/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt new file mode 100644 index 0000000..e67e3fd --- /dev/null +++ b/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt @@ -0,0 +1,215 @@ +package com.hormonetrack.reminder + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.hormonetrack.MainActivity +import com.hormonetrack.R +import com.hormonetrack.data.AppDatabase +import com.hormonetrack.pk.Alerts +import com.hormonetrack.pk.PharmacokineticEngine +import com.hormonetrack.pk.TConfig +import com.hormonetrack.util.AppLog +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit + +/** + * Notification d'alerte de seuil (v1.4.2) — complète la carte d'avertissement + * de l'accueil : l'évaluation tourne MÊME app fermée via **WorkManager** + * (dépendance déclarée depuis v1.0, enfin utilisée). + * + * Fonctionnement : + * - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le + * niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil : + * calibration par période d'ester si l'option est active) contre les + * seuils configurés ; + * - **anti-spam** : l'état des alertes déjà notifiées est persisté en + * DataStore (`Alerts.encodeState`) → pas de re-notification du même + * franchissement ; le retour à la normale ré-arme la prochaine notif + * (cf `Alerts.shouldNotify`) ; + * - au SAVE des seuils dans Paramètres, un check **one-time immédiat** est + * déclenché (feedback quasi instantané sans attendre les 15 min) ; + * - canal de notification DÉDIÉ (`hormonetrack_alerts`) : l'utilisatrice + * règle les avertissements indépendamment des rappels (montre GT 3). + * + * ⚠️ WorkManager persiste les travaux entre reboots — BootReceiver n'a + * rien à faire pour ce worker. + */ +object AlertNotifier { + + const val CHANNEL_ID = "hormonetrack_alerts" + /** Id du travail périodique (unique) et nom du travail one-time. */ + private const val PERIODIC_WORK = "alert_threshold_periodic" + private const val ONESHOT_WORK = "alert_threshold_oneshot" + /** Période de vérification : minimum autorisé par WorkManager. */ + private const val PERIOD_MINUTES = 15L + + /** Notification id — une seule notification d'alerte à la fois (update). */ + private const val NOTIFICATION_ID = 4_002 + + fun createChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.alert_channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = context.getString(R.string.alert_channel_desc) + } + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + + /** + * Planifie le worker PÉRIODIQUE (à appeler une fois dans Application + *.onCreate). KEEP : si le travail existe déjà, on ne le replanifie pas. + */ + fun schedulePeriodic(context: Context) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC_WORK, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(PERIOD_MINUTES, TimeUnit.MINUTES).build() + ) + } + + /** Check IMMÉDIAT (au save des seuils) — remplace le travail one-time en attente. */ + fun checkNow(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + ONESHOT_WORK, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build() + ) + } + + /** + * Évalue le niveau actuel et notifie si nécessaire. Retourne l'état à + * persister (encodé, cf [Alerts.encodeState]) — l'appelant le stocke. + * Séparé du worker pour rester lisible ; le worker ne fait que l'IO. + */ + suspend fun evaluateAndNotify(context: Context): String { + val db = AppDatabase.getInstance(context) + val container = (context.applicationContext as com.hormonetrack.HormoneTrackApp).container + val settings = container.settings + + val thresholds = settings.alertThresholds.first() + if (thresholds == Alerts.Thresholds()) { + // Aucun seuil configuré → nettoyer l'état et sortir (pas de notif) + settings.setAlertNotifiedState(null) + return "" + } + + // Même calcul que la carte accueil : calibration auto si activée + val treatments = db.treatmentDao().getAllOnce() + val doseLogs = db.doseLogDao().getAllOnce() + val labs = db.labResultDao().getAllOnce() + val tConfig = settings.tConfig.first() + val autoCalibrate = settings.autoCalibrate.first() + val auto = if (autoCalibrate && treatments.isNotEmpty()) { + PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig) + } else null + + val now = System.currentTimeMillis() + val level = PharmacokineticEngine.levelAt( + treatments, doseLogs, now, + tConfig = auto?.tConfig ?: tConfig, + tKPerEster = auto?.tKPerEster, + scalePerEster = auto?.esterScales + ) + + val alerts = Alerts.evaluateAll(level.e2, level.t, thresholds) + val lastNotified = Alerts.parseState(settings.alertNotifiedState.first()) + val current = alerts.associate { it.marker to it.level } + + when { + alerts.isEmpty() -> { + // Retour à la normale : on efface l'état (prochain écart re-notifiera) + if (lastNotified.isNotEmpty()) { + settings.setAlertNotifiedState(null) + AppLog.log("alerte", "seuils : retour à la normale (E2≈${"%.0f".format(level.e2)}, T≈${"%.2f".format(level.t)})") + } + } + Alerts.shouldNotify(current, lastNotified) -> { + notify(context, alerts) + settings.setAlertNotifiedState(Alerts.encodeState(alerts)) + AppLog.log("alerte", "notification : ${alerts.joinToString { "${it.marker}:${it.level}" }} (E2≈${"%.0f".format(level.e2)}, T≈${"%.2f".format(level.t)})") + } + else -> { + AppLog.log("alerte", "seuils : franchissement inchangé (${current}) — pas de re-notification") + } + } + return Alerts.encodeState(alerts) + } + + /** Construit et poste la notification d'alerte (canal dédié). */ + private fun notify(context: Context, alerts: List) { + // POST_NOTIFICATIONS runtime (API 33+) : sans permission, ne pas + // poster (l'app ne crashe pas — NotificationManager ignore silencieusement + // sauf SecurityException selon les builds ; garde défensive). + if (android.os.Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + AppLog.log("alerte", "notification impossible : POST_NOTIFICATIONS refusée") + return + } + val title = context.getString(R.string.alert_notif_title) + val text = alerts.joinToString("\n") { a -> + val valueStr = if (a.value >= 10) "%.0f".format(a.value) else "%.2f".format(a.value) + val limitStr = if (a.limit >= 10) "%.0f".format(a.limit) else "%.2f".format(a.limit) + if (a.level == Alerts.Level.HIGH) { + context.getString(R.string.alert_high, a.marker, valueStr, a.unit, limitStr) + } else { + context.getString(R.string.alert_low, a.marker, valueStr, a.unit, limitStr) + } + } + val openIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val openPi = PendingIntent.getActivity( + context, NOTIFICATION_ID, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setContentIntent(openPi) + .setAutoCancel(true) + .build() + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID, notification) + } +} + +/** + * Worker WorkManager : appelle [AlertNotifier.evaluateAndNotify] en coroutine + * (CoroutineWorker → dispatchers WorkManager, IO-friendly). Toute exception + * est journalisée puis renvoyée en échec RETRYABLE (WorkManager re-tentera). + */ +class AlertWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = try { + AlertNotifier.evaluateAndNotify(applicationContext) + Result.success() + } catch (e: Exception) { + AppLog.log("alerte", "ERREUR worker : ${e.message}") + Result.retry() + } +} diff --git a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt index 0c991be..caeeaf6 100644 --- a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt +++ b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.hormonetrack.pk.Alerts import com.hormonetrack.pk.TConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -15,7 +16,8 @@ import kotlinx.coroutines.flow.map val Context.dataStore: DataStore by preferencesDataStore(name = "settings") /** - * Préférences persistées (DataStore) : modèle T, langue, options d'affichage. + * Préférences persistées (DataStore) : modèle T, langue, options d'affichage, + * seuils d'alerte (v1.4.2). */ class AppSettings(private val context: Context) { @@ -30,6 +32,20 @@ class AppSettings(private val context: Context) { /** Dernière version pour laquelle le dialog « Nouveautés » a été montré. */ val CHANGELOG_SEEN = stringPreferencesKey("changelog_seen_version") + + // ── Seuils d'alerte (v1.4.2) : limites E2 (pg/mL) et T (ng/mL). ── + // Clé absente = pas de seuil → pas d'alerte sur ce côté (opt-in pur). + val ALERT_E2_HIGH = doublePreferencesKey("alert_e2_high") + val ALERT_E2_LOW = doublePreferencesKey("alert_e2_low") + val ALERT_T_HIGH = doublePreferencesKey("alert_t_high") + val ALERT_T_LOW = doublePreferencesKey("alert_t_low") + + /** + * État des alertes DÉJÀ NOTIFIÉES (encodage [Alerts.encodeState], + * ex. "E2:HIGH;T:LOW") — anti-spam du worker périodique : on ne + * re-notify pas le même franchissement toutes les 15 min. + */ + val ALERT_NOTIFIED_STATE = stringPreferencesKey("alert_notified_state") } val tConfig: Flow = context.dataStore.data.map { prefs -> @@ -54,6 +70,20 @@ class AppSettings(private val context: Context) { prefs[Keys.CHANGELOG_SEEN] } + /** + * Seuils d'alerte (v1.4.2) : null = pas de limite pour la valeur + * (les champs laissés vides dans Paramètres). Évalués contre le taux + * ESTIMÉ actuel sur l'accueil (cf pk/Alerts.kt). + */ + val alertThresholds: Flow = context.dataStore.data.map { prefs -> + Alerts.Thresholds( + e2High = prefs[Keys.ALERT_E2_HIGH], + e2Low = prefs[Keys.ALERT_E2_LOW], + tHigh = prefs[Keys.ALERT_T_HIGH], + tLow = prefs[Keys.ALERT_T_LOW] + ) + } + suspend fun setTConfig(config: TConfig) { context.dataStore.edit { prefs -> prefs[Keys.T_BASE] = config.base @@ -79,4 +109,29 @@ class AppSettings(private val context: Context) { prefs[Keys.CHANGELOG_SEEN] = version } } + + /** État des alertes déjà notifiées (cf [Keys.ALERT_NOTIFIED_STATE]). */ + val alertNotifiedState: Flow = context.dataStore.data.map { prefs -> + prefs[Keys.ALERT_NOTIFIED_STATE] + } + + suspend fun setAlertNotifiedState(encoded: String?) { + context.dataStore.edit { prefs -> + if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE) + else prefs[Keys.ALERT_NOTIFIED_STATE] = encoded + } + } + + /** + * Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé + * (DataStore : remove) — « champ vide = pas d'alerte ». + */ + suspend fun setAlertThresholds(t: Alerts.Thresholds) { + context.dataStore.edit { prefs -> + if (t.e2High != null) prefs[Keys.ALERT_E2_HIGH] = t.e2High else prefs.remove(Keys.ALERT_E2_HIGH) + if (t.e2Low != null) prefs[Keys.ALERT_E2_LOW] = t.e2Low else prefs.remove(Keys.ALERT_E2_LOW) + if (t.tHigh != null) prefs[Keys.ALERT_T_HIGH] = t.tHigh else prefs.remove(Keys.ALERT_T_HIGH) + if (t.tLow != null) prefs[Keys.ALERT_T_LOW] = t.tLow else prefs.remove(Keys.ALERT_T_LOW) + } + } } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index 9ac4b0c..be8e734 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -173,6 +173,53 @@ fun HomeScreen( NowLevelCard(curve) + // Seuils d'alerte (v1.4.2) : évaluation du TAUX ESTIMÉ ACTUEL + // (dernier point de la courbe = même valeur que la carte + // « niveau actuel ») contre les limites configurées dans + // Paramètres (pk/Alerts.kt — opt-in, champ vide = pas d'alerte). + val alertThresholds by container.settings.alertThresholds.collectAsState( + initial = com.hormonetrack.pk.Alerts.Thresholds() + ) + curve.lastOrNull()?.let { now -> + val alerts = com.hormonetrack.pk.Alerts.evaluateAll(now.e2, now.t, alertThresholds) + if (alerts.isNotEmpty()) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column(Modifier.padding(16.dp)) { + Text( + stringResource(R.string.alert_card_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(Modifier.height(4.dp)) + alerts.forEach { a -> + val formatted = formatDose(a.value) + val limitStr = formatDose(a.limit) + Text( + if (a.level == com.hormonetrack.pk.Alerts.Level.HIGH) { + stringResource(R.string.alert_high, a.marker, formatted, a.unit, limitStr) + } else { + stringResource(R.string.alert_low, a.marker, formatted, a.unit, limitStr) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.alert_disclaimer), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + NextDoseCard(activeTreatments, doseLogs) Card( diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index ac5abb8..abd5924 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -48,6 +48,7 @@ import androidx.core.os.LocaleListCompat import com.hormonetrack.R import com.hormonetrack.data.backup.BackupManager import com.hormonetrack.util.AppLog +import com.hormonetrack.pk.Alerts import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.TConfig import com.hormonetrack.reminder.AlarmScheduler @@ -56,6 +57,7 @@ import com.hormonetrack.util.ExportFileNames import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import java.util.Locale @@ -70,6 +72,10 @@ fun SettingsScreen(onBack: () -> Unit) { val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val currentLanguage by container.settings.language.collectAsState(initial = "system") val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) + // Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil + val alertThresholds by container.settings.alertThresholds.collectAsState( + initial = com.hormonetrack.pk.Alerts.Thresholds() + ) var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) } var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) } @@ -133,7 +139,18 @@ fun SettingsScreen(onBack: () -> Unit) { ) { uri -> uri ?: return@rememberLauncherForActivityResult CoroutineScope(Dispatchers.IO).launch { - val json = BackupManager.exportJson(repo, tConfig) + // v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur + // (langue, auto-calibration, seuils d'alerte) — collect des + // DataStore Flows (first()) avant sérialisation. + val settings = com.hormonetrack.data.backup.UserSettings( + language = container.settings.language.first(), + autoCalibrate = container.settings.autoCalibrate.first(), + alertE2High = container.settings.alertThresholds.first().e2High, + alertE2Low = container.settings.alertThresholds.first().e2Low, + alertTHigh = container.settings.alertThresholds.first().tHigh, + alertTLow = container.settings.alertThresholds.first().tLow + ) + val json = BackupManager.exportJson(repo, tConfig, settings) val ok = BackupManager.writeBackup(context, uri, json) withContext(Dispatchers.Main) { message = context.getString( @@ -325,6 +342,102 @@ fun SettingsScreen(onBack: () -> Unit) { } Spacer(Modifier.height(12.dp)) + // --- Seuils d'alerte (v1.4.2) : limites E2/T évaluées contre le taux + // estimé actuel de l'accueil (pk/Alerts.kt). Opt-in : champ vide = + // pas d'alerte. Haut > bas requis si les deux sont définis. + var e2HighText by remember(alertThresholds.e2High) { + mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "") + } + var e2LowText by remember(alertThresholds.e2Low) { + mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "") + } + var tHighText by remember(alertThresholds.tHigh) { + mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "") + } + var tLowText by remember(alertThresholds.tLow) { + mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "") + } + var alertError by remember { mutableStateOf(null) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.alert_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = e2HighText, + onValueChange = { e2HighText = it }, + label = { Text(stringResource(R.string.alert_e2_high)) }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = e2LowText, + onValueChange = { e2LowText = it }, + label = { Text(stringResource(R.string.alert_e2_low)) }, + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = tHighText, + onValueChange = { tHighText = it }, + label = { Text(stringResource(R.string.alert_t_high)) }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = tLowText, + onValueChange = { tLowText = it }, + label = { Text(stringResource(R.string.alert_t_low)) }, + modifier = Modifier.weight(1f) + ) + } + alertError?.let { + Spacer(Modifier.height(4.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { + // Parsing tolérant (virgule décimale FR), champ vide = null + fun parse(s: String): Double? = + s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull() + val e2High = parse(e2HighText) + val e2Low = parse(e2LowText) + val tHigh = parse(tHighText) + val tLow = parse(tLowText) + // Saisie invalide (texte non numérique) ou incohérence haut ≤ bas + val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText) + .any { it.trim().isNotEmpty() && parse(it) == null } + val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow) + when { + invalidSyntax || !thresholds.isCoherent() -> { + alertError = context.getString(R.string.alert_invalid) + } + else -> { + alertError = null + CoroutineScope(Dispatchers.IO).launch { + container.settings.setAlertThresholds(thresholds) + // v1.4.2 : check IMMÉDIAT (worker one-time) — + // si le seuil est déjà franchi, la notification + // part sans attendre le cycle périodique de + // 15 min (état anti-spam réinitialisé au besoin). + com.hormonetrack.reminder.AlertNotifier.checkNow(context) + withContext(Dispatchers.Main) { + message = context.getString(R.string.saved) + } + } + } + } + }) { Text(stringResource(R.string.save)) } + } + } + Spacer(Modifier.height(12.dp)) + // --- Reminders / exact alarms --- Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { @@ -435,7 +548,9 @@ fun SettingsScreen(onBack: () -> Unit) { 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 + // backup restaure DataStore, les PARAMÈTRES utilisateur + // (v1.4.2 : langue, auto-calibration, seuils d'alerte) + // et les rappels sont reprogrammés val result = try { BackupManager.importJson(repo, json) } catch (e: Exception) { @@ -443,6 +558,27 @@ fun SettingsScreen(onBack: () -> Unit) { } result?.let { container.settings.setTConfig(it.tConfig) + // ⚠️ Paramètres du backup (null = backup v1 → ne rien + // toucher). La LANGUE est appliquée sur le thread + // MAIN : setApplicationLocales recrée l'activité. + it.settings?.let { us -> + container.settings.setAutoCalibrate(us.autoCalibrate ?: false) + container.settings.setAlertThresholds( + com.hormonetrack.pk.Alerts.Thresholds( + e2High = us.alertE2High, e2Low = us.alertE2Low, + tHigh = us.alertTHigh, tLow = us.alertTLow + ) + ) + withContext(Dispatchers.Main) { + us.language?.let { lang -> + container.settings.setLanguage(lang) + AppCompatDelegate.setApplicationLocales( + if (lang == "system") LocaleListCompat.getEmptyLocaleList() + else LocaleListCompat.forLanguageTags(lang) + ) + } + } + } // Doses + traitements : grille Posologie pour les rappels (#52) AlarmScheduler(context).rescheduleAll( repo.allTreatmentsOnce(), repo.allDoseLogsOnce() diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c67aba1..852ceb5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -37,6 +37,20 @@ Sous THS depuis le %1$s %1$d mois et %2$d jours (%3$d jours au total) %1$d jours + Avertissements de seuil + Notifications quand un taux estimé franchit une de tes limites (vérification toutes les 15 minutes). + Seuil hormonal franchi + Seuils d\'alerte + Limites optionnelles pour tes taux estimés, vérifiées contre l\'estimation actuelle de l\'accueil (pas une mesure de labo). E2 en pg/mL, T en ng/mL. Champ vide = alerte désactivée. Le haut doit être au-dessus du bas. Vérification toutes les 15 minutes — tu reçois une notification quand une limite est franchie (sans spam tant que l\'écart ne change pas). + E2 haut (pg/mL) + E2 bas (pg/mL) + T haut (ng/mL) + T bas (ng/mL) + Valeurs invalides : chiffres uniquement, et la limite haute doit être supérieure à la basse. + Avertissements de seuil + ▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s) + ▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s) + Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue. Logs de diagnostic %1$d lignes (rappels, agenda, import/export, erreurs) Exporter diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d97f25..c408cb1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,20 @@ Under HRT since %1$s %1$d months and %2$d days (%3$d days total) %1$d days + Threshold warnings + Notifications when an estimated level crosses one of your configured limits (checked every 15 minutes). + Hormone threshold warning + Alert thresholds + Optional limits for your estimated levels, checked against the current estimate on Home (not a lab value). E2 in pg/mL, T in ng/mL. Leave a field empty to disable that alert. High must be above low. Checked every 15 minutes — you get a notification when a limit is crossed (no repeat spam until the level changes). + E2 high (pg/mL) + E2 low (pg/mL) + T high (ng/mL) + T low (ng/mL) + Invalid values: enter numbers only, and keep the high limit above the low limit. + Threshold warnings + ▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s) + ▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s) + Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist. Diagnostic logs %1$d lines (reminders, calendar, import/export, errors) Export diff --git a/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt b/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt index aeb9260..6594cee 100644 --- a/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt +++ b/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt @@ -92,4 +92,49 @@ class BackupGsonTest { assertEquals(0.15, parsed.tConfig.floor, 1e-9) assertEquals(0.21, parsed.tConfig.k, 1e-9) } + + @Test + fun `backup v2 round-trip preserves user settings (language, auto-cal, thresholds)`() { + // v1.4.2 : les PARAMÈTRES utilisateur voyagent avec le backup + val data = BackupData( + tConfig = TConfig(), + settings = UserSettings( + language = "fr", + autoCalibrate = true, + alertE2High = 300.0, + alertE2Low = 100.0, + alertTHigh = 1.0, + alertTLow = 0.3 + ) + ) + + val json = Gson().toJson(data) + val parsed: BackupData = Gson().fromJson(json, BackupData::class.java) + + assertEquals(2, parsed.version) + val s = parsed.settings + // ⚠️ Ne JAMAIS enlever ce check : sous R8 sans les -keep, parsed.settings + // deviendrait non-null avec des champs VIDES (réflexion Gson, cf §16.bis) + org.junit.Assert.assertNotNull(s) + assertEquals("fr", s!!.language) + assertEquals(true, s.autoCalibrate) + assertEquals(300.0, s.alertE2High!!, 1e-9) + assertEquals(100.0, s.alertE2Low!!, 1e-9) + assertEquals(1.0, s.alertTHigh!!, 1e-9) + assertEquals(0.3, s.alertTLow!!, 1e-9) + } + + @Test + fun `legacy v1 backup without settings parses with null settings (backward compat)`() { + // Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings : + // ils doivent rester importables tels quels. + val legacyJson = """ + {"version":1,"exportedAt":1700000000000, + "treatments":[],"doseLogs":[],"labResults":[], + "tConfig":{"base":6.0,"floor":0.2,"k":0.19}} + """.trimIndent() + val parsed: BackupData = Gson().fromJson(legacyJson, BackupData::class.java) + assertEquals(1, parsed.version) + org.junit.Assert.assertNull(parsed.settings) + } } diff --git a/app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt b/app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt new file mode 100644 index 0000000..9fda564 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt @@ -0,0 +1,143 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.AdministrationRoute +import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.Esters +import com.hormonetrack.data.model.PKModels +import com.hormonetrack.data.model.Treatment +import com.hormonetrack.data.model.TreatmentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests d'INTÉGRATION de la chaîne d'alertes (v1.4.2) : moteur PK → niveau + * actuel → évaluation des seuils (`pk/Alerts.kt`). + * + * Ce que couvrent les AUTRES fichiers (pour s'y retrouver) : + * - `AlertsTest` : logique pure des seuils (HIGH/LOW, strict, cohérence, + * codec d'état, décision de notification) ; + * - `BackupGsonTest` : round-trip des seuils dans le backup JSON ; + * - le worker WorkManager (`reminder/AlertNotifier.kt`) n'est PAS testable + * en JVM (NotificationManager/WorkManager) — toute sa logique de DÉCISION + * a été extraite vers `Alerts` et est testée ici et dans `AlertsTest`. + * + * Ce fichier épine le câblage : le niveau que la NOTIFICATION évalue + * (`levelAt` avec `scalePerEster`) est EXACTEMENT celui affiché par la + * carte « niveau actuel » (dernier point de `computeCurve`), et les seuils + * se déclenchent sur ce niveau réel. + */ +class AlertsEngineTest { + + private companion object { + const val HOUR_MS = 3_600_000L + val BASE = System.currentTimeMillis() + } + + /** Traitement EV — Transfem Science, 5 mg, une seule dose à BASE. */ + private fun evTreatment(doseMg: Double = 5.0): Treatment = Treatment( + id = 1L, + name = "EV", + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + doseAmount = doseMg, + doseUnit = "mg", + isActive = true, + esterType = Esters.EV, + pkModel = PKModels.TRANSFEM_SCIENCE, + scaleFactor = 1.0 + ) + + @Test + fun `notification level matches the home card level (levelAt vs computeCurve last point)`() { + val tr = evTreatment() + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)) + val now = BASE + 30 * HOUR_MS + val scales = mapOf("EV" to 0.8) + val tK = mapOf("EV" to 0.25) + val tConfig = TConfig() + + // Chemin NOTIFICATION (AlertNotifier → levelAt avec calibration) + val viaLevelAt = PharmacokineticEngine.levelAt( + listOf(tr), doses, now, tConfig, tKPerEster = tK, scalePerEster = scales + ) + // Chemin CARTE ACCUEIL (HomeScreen → dernier point de computeCurve) + val curve = PharmacokineticEngine.computeCurve( + listOf(tr), doses, now - HOUR_MS, now, stepMs = HOUR_MS, + tConfig = tConfig, scalePerEster = scales, tKPerEster = tK + ) + val last = curve.last() + + // ⚠️ Le point de courbe le plus proche de `now` peut s'arrêter à + // now − step/2 selon la grille : on compare les valeurs d'E2 des deux + // chemins ÉVALUÉES AU MÊME INSTANT (le dernier point du curve couvre + // [now-1h, now] par pas de 1 h → dernier point ≤ now ; levelAt évalue + // exactement `now`). Tolérance : dérive PK sur ≤ 1 h (< 10 %). + val gapHours = (now - last.timestamp).toDouble() / HOUR_MS + assertTrue(gapHours in 0.0..1.0) + val ratio = viaLevelAt.e2 / last.e2 + assertTrue("levelAt=${viaLevelAt.e2} vs curve=${last.e2}", ratio in 0.9..1.1) + // La T suit la même E2 et le même k → même contrainte + assertEquals(viaLevelAt.t, last.t, viaLevelAt.t * 0.1) + } + + @Test + fun `high threshold on the real estimated level triggers HIGH`() { + val tr = evTreatment() + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)) + val now = BASE + 30 * HOUR_MS + val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig()) + + // seuil SOUS le niveau estimé → HIGH attendu (limit = seuil) + val half = level.e2 / 2 + val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = half)) + assertEquals(1, alerts.size) + assertEquals("E2", alerts.single().marker) + assertEquals(Alerts.Level.HIGH, alerts.single().level) + assertEquals(half, alerts.single().limit, 1e-9) + + // seuil AU-DESSUS du niveau estimé → aucune alerte + val double = level.e2 * 2 + assertTrue(Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = double)).isEmpty()) + } + + @Test + fun `low T threshold triggers LOW (testosterone in ng per mL)`() { + val tr = evTreatment() + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)) + val now = BASE + 30 * HOUR_MS + val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig()) + + // T estimée ≈ 0,3 ng/mL : une limite basse 1,0 ng/mL → LOW + assertTrue("T estimée=${level.t} doit être < 1.0", level.t < 1.0) + val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(tLow = 1.0)) + assertEquals(1, alerts.size) + assertEquals("T", alerts.single().marker) + assertEquals(Alerts.Level.LOW, alerts.single().level) + assertEquals("ng/mL", alerts.single().unit) + + // limite basse très basse → pas d'alerte + assertTrue(Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(tLow = 0.001)).isEmpty()) + } + + @Test + fun `anti-spam state reflects only alerted markers (codec over real alerts)`() { + val tr = evTreatment() + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)) + val now = BASE + 30 * HOUR_MS + val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig()) + + // Seuils qui ne déclenchent que l'E2 (pas la T) : l'état persisté ne + // contient QUE l'E2 — c'est ce qui est comparé d'une vérification + // périodique à l'autre (AlertNotifier). + val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = level.e2 / 2)) + val encoded = Alerts.encodeState(alerts) + assertEquals("E2:HIGH", encoded) + assertEquals(mapOf("E2" to Alerts.Level.HIGH), Alerts.parseState(encoded)) + + // shouldNotify : même franchissement → pas de re-notif ; changement → notif + val current = Alerts.parseState(encoded) + assertTrue(!Alerts.shouldNotify(current, current)) + assertTrue(Alerts.shouldNotify(mapOf("E2" to Alerts.Level.LOW), current)) + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/AlertsTest.kt b/app/src/test/java/com/hormonetrack/pk/AlertsTest.kt new file mode 100644 index 0000000..cba61cb --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/AlertsTest.kt @@ -0,0 +1,151 @@ +package com.hormonetrack.pk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Seuils d'alerte configurables (v1.4.2) — module PUR (`pk/Alerts.kt`). + * + * Règles épinglées : + * - opt-in : seuil null → jamais d'alerte (l'app n'invente pas de normes) ; + * - comparaisons STRICTES (valeur == limite → pas d'alerte, éviter le + * clignotement sur l'arrondi d'affichage) ; + * - HIGH prime LOW en configuration incohérente (défensif : la saisie + * l'interdit) ; + * - évaluation E2 (pg/mL) puis T (ng/mL), ordre stable. + */ +class AlertsTest { + + @Test + fun `no thresholds configured - no alerts`() { + val t = Alerts.Thresholds() + assertTrue(Alerts.evaluateAll(420.0, 0.05, t).isEmpty()) + assertTrue(Alerts.evaluateAll(0.0, 0.0, t).isEmpty()) + } + + @Test + fun `high threshold exceeded - HIGH alert`() { + val t = Alerts.Thresholds(e2High = 300.0) + val alerts = Alerts.evaluateAll(currentE2 = 420.0, currentT = 0.3, t = t) + assertEquals(1, alerts.size) + val a = alerts.single() + assertEquals("E2", a.marker) + assertEquals(Alerts.Level.HIGH, a.level) + assertEquals(420.0, a.value, 1e-9) + assertEquals(300.0, a.limit, 1e-9) + assertEquals("pg/mL", a.unit) + } + + @Test + fun `low threshold undercut - LOW alert`() { + val t = Alerts.Thresholds(tLow = 0.3) + val alerts = Alerts.evaluateAll(currentE2 = 150.0, currentT = 0.12, t = t) + assertEquals(1, alerts.size) + val a = alerts.single() + assertEquals("T", a.marker) + assertEquals(Alerts.Level.LOW, a.level) + assertEquals("ng/mL", a.unit) + } + + @Test + fun `exact value at the limit does not trigger (strict comparison)`() { + val t = Alerts.Thresholds(e2High = 300.0, e2Low = 100.0) + assertTrue(Alerts.evaluateAll(300.0, 0.3, t).isEmpty()) + assertTrue(Alerts.evaluateAll(100.0, 0.3, t).isEmpty()) + // juste au-dessus / en dessous : alerte + assertEquals(1, Alerts.evaluateAll(300.01, 0.3, t).size) + assertEquals(1, Alerts.evaluateAll(99.99, 0.3, t).size) + } + + @Test + fun `both markers can alert at once (E2 first, then T)`() { + val t = Alerts.Thresholds(e2High = 300.0, tLow = 0.3) + val alerts = Alerts.evaluateAll(420.0, 0.12, t) + assertEquals(2, alerts.size) + assertEquals("E2", alerts[0].marker) + assertEquals("T", alerts[1].marker) + } + + @Test + fun `coherence check - high must be above low when both set`() { + assertTrue(Alerts.Thresholds(e2High = 300.0, e2Low = 100.0).isCoherent()) + assertFalse(Alerts.Thresholds(e2High = 100.0, e2Low = 300.0).isCoherent()) + // un seul côté défini → toujours cohérent + assertTrue(Alerts.Thresholds(e2High = 100.0).isCoherent()) + assertTrue(Alerts.Thresholds(tHigh = 1.0, tLow = 0.5).isCoherent()) + assertFalse(Alerts.Thresholds(tHigh = 0.5, tLow = 1.0).isCoherent()) + } + + @Test + fun `incoherent config is defused - HIGH wins over LOW`() { + // Cas impossible via la saisie (validée), mais l'évaluation ne doit + // jamais renvoyer deux verdicts contradictoires pour un même marqueur. + val t = Alerts.Thresholds(e2High = 100.0, e2Low = 300.0) + val alerts = Alerts.evaluateAll(420.0, 0.3, t) + assertEquals(1, alerts.size) + assertEquals(Alerts.Level.HIGH, alerts.single().level) + } + + @Test + fun `evaluate single value - null limits never trigger`() { + // aucune limite → jamais d'alerte + assertNull(Alerts.evaluate(500.0, low = null, high = null)) + // limite basse seule : valeur SOUS la limite → LOW ; au-dessus → rien + assertEquals(Alerts.Level.LOW to 600.0, Alerts.evaluate(500.0, low = 600.0, high = null)) + assertNull(Alerts.evaluate(700.0, low = 600.0, high = null)) + // limite haute seule : valeur AU-DESSUS → HIGH ; en dessous → rien + assertEquals(Alerts.Level.HIGH to 600.0, Alerts.evaluate(601.0, low = null, high = 600.0)) + assertNull(Alerts.evaluate(599.0, low = null, high = 600.0)) + // les deux limites : chacune franchit son côté + assertEquals(Alerts.Level.HIGH to 600.0, Alerts.evaluate(601.0, 600.0, 600.0)) + assertEquals(Alerts.Level.LOW to 600.0, Alerts.evaluate(599.0, 600.0, 600.0)) + } + + // ── v1.4.2 : notification d'alerte (état persisté + anti-spam) ── + + @Test + fun `state codec round-trips (E2 and T levels)`() { + val alerts = listOf( + Alerts.Alert("E2", Alerts.Level.HIGH, 420.0, 300.0, "pg/mL"), + Alerts.Alert("T", Alerts.Level.LOW, 0.12, 0.3, "ng/mL") + ) + val encoded = Alerts.encodeState(alerts) + assertEquals("E2:HIGH;T:LOW", encoded) + val parsed = Alerts.parseState(encoded) + assertEquals(Alerts.Level.HIGH, parsed["E2"]) + assertEquals(Alerts.Level.LOW, parsed["T"]) + // round-trip complet : encodé puis décodé = mêmes niveaux + assertEquals( + alerts.map { it.marker to it.level }.toMap(), + Alerts.parseState(Alerts.encodeState(alerts)) + ) + } + + @Test + fun `state codec - empty and malformed inputs are safe`() { + assertEquals("", Alerts.encodeState(emptyList())) + assertTrue(Alerts.parseState(null).isEmpty()) + assertTrue(Alerts.parseState("").isEmpty()) + // niveau inconnu (évolution future) → entrée ignorée, pas de crash + assertTrue(Alerts.parseState("E2:UNKNOWN").isEmpty()) + assertTrue(Alerts.parseState("E2").isEmpty()) // sans ":" + } + + @Test + fun `shouldNotify - new breach fires, same state does not`() { + val current = mapOf("E2" to Alerts.Level.HIGH) + // jamais notifié → notifie + assertTrue(Alerts.shouldNotify(current, null)) + // même état → pas de re-notification (anti-spam 15 min) + assertFalse(Alerts.shouldNotify(current, current)) + // changement de niveau (H → L) → re-notify + assertTrue(Alerts.shouldNotify(mapOf("E2" to Alerts.Level.LOW), current)) + // écart TERMINE (current vide) → pas de notif « retour à la normale » + assertFalse(Alerts.shouldNotify(emptyMap(), current)) + // mais le retour à la normale permet de re-notifier le prochain écart + assertTrue(Alerts.shouldNotify(current, emptyMap())) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 64fdf63..14f6f80 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,36 @@ # 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.4.1`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.2`, …). + +## [1.4.2] — 2026-09-07 (versionCode 22) + +### Ajouté +- **Seuils d'alerte configurables** (E2 et T) : dans Paramètres, une limite + HAUTE et une limite BASSE par marqueur (E2 en pg/mL, T en ng/mL), chacune + optionnelle (champ vide = pas d'alerte), validées (haut > bas requis) et + **évaluées contre le taux ESTIMÉ actuel** (le même que la carte « niveau + actuel », calibration incluse — pas une mesure de labo). En cas de + dépassement : carte d'avertissement rouge sur l'accueil (« ▲ E2 estimé + ≈ 259 pg/mL — au-dessus de ta limite haute (200) ») + disclaimer « + estimation, pas une mesure ». Comparaison STRICTE (valeur == limite → rien). +- **Notification d'alerte** : la vérification tourne **toutes les 15 min + même app fermée** (worker WorkManager — la dépendance déclarée depuis v1.0 + est enfin utilisée) + **check immédiat au save des seuils**. Anti-spam : + l'état des alertes déjà notifiées est mémorisé — pas de re-notification + du même franchissement ; le retour à la normale ré-arme la prochaine + alerte ; pas de notification de « retour à la normale ». Canal de + notification dédié (réglable séparément des rappels, remonte sur la + Watch GT 3). Journalisation AppLog complète (debug à distance). +- **9 nouveaux tests** (129 au total / 111 sans les données locales) : + `AlertsTest` (12 : logique pure des seuils, codec d'état anti-spam) et + `AlertsEngineTest` (4 : la notification évalue EXACTEMENT le niveau de la + carte accueil ; déclenchement sur niveau réel). +- **Backup JSON avec les paramètres utilisateur** : l'export embarque + désormais langue, auto-calibration et seuils d'alerte (`"settings"`, + format v2) ; l'import les restaure (la langue est appliquée au retour). + **Rétrocompatible** : les anciens exports (sans settings) restent + importables ; 2 tests round-trip + compat v1. ## [1.4.1] — 2026-09-06 (versionCode 21) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 574b703..b8cb015 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -84,6 +84,7 @@ Fonctionnalités v1 : | 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. | | 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. | | 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. | +| 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. | | 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.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. | @@ -109,7 +110,7 @@ et en testant** — aucun build n'avait été lancé avant la session 3. | 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 | +| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem | | compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app | | buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app | @@ -408,7 +409,7 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k ## 8. Tests unitaires -**112 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **94 sans +**129 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **111 sans les données de test locales** (cf §8.bis : les 3 classes de régression, 6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = @@ -519,6 +520,19 @@ dépôt (ni maintenant, ni si le repo devient public un jour). d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée). +- **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) : + HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le + clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas, + HIGH prime LOW en config incohérente (défensif), les deux marqueurs à la + fois (E2 puis T), **codec d'état** (`encodeState`/`parseState` — rétro-résistant + aux entrées malformées) et **décision de notification** (`shouldNotify` : + nouveau franchissement → notif, même état → pas de spam, retour à la + normale → pas de notif mais ré-arme). +- **`AlertsEngineTest`** (4) : v1.4.2 — câblage moteur → seuils : le niveau + évalué par la NOTIFICATION (`levelAt(scalePerEster)`) est celui de la + CARTE accueil (`computeCurve` dernier point, ±10 % de dérive horaire) ; + seuil à la moitié du niveau réel → HIGH, seuil au double → rien ; T bas + → LOW en ng/mL ; l'état persisté ne contient que les marqueurs en alerte. - **`TransfemScienceEngineTest`** (4) : v1.4.0 — câblage MOTEUR ↔ V3C : EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ; EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté : @@ -587,12 +601,36 @@ depuis v1.3.0 mais **jamais déclarées dans le XML** (bug #44, §14) : tout jeton « le manifest contient X » doit être VÉRIFIÉ dans le fichier réel. Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). +### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2) + +- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas + ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien), + `evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`). +- **Worker WorkManager** : `AlertWorker` (CoroutineWorker) périodique **15 min** + (minimum WorkManager) + **one-time au save des seuils** (feedback immédiat) ; + planifié dans `HormoneTrackApp.onCreate` (`ExistingPeriodicWorkPolicy.KEEP`, + survit aux reboots — BootReceiver n'a RIEN à faire). +- **Calcul du niveau** : `levelAt(..., scalePerEster, tKPerEster)` — MÊME + calibration que la carte accueil (auto-cal incluse si l'option est active). +- **Anti-spam** : l'état des alertes déjà notifiées est persisté en DataStore + (`alert_notified_state` = `Alerts.encodeState`, ex. « E2:HIGH;T:LOW ») ; + `shouldNotify` : nouveau franchissement ou changement H↔L → notif, même + état → rien, retour à la normale → état effacé (ré-arme), pas de notif de retour. +- **Canal dédié** `hormonetrack_alerts` (IMPORTANCE_HIGH) : réglable + indépendamment des rappels ; notification avec texte multi-lignes (BigText), + tap → MainActivity. Garde défensive POST_NOTIFICATIONS (API 33+) journalisée. +- Journalisation AppLog complète (« notification : E2:HIGH... », « franchissement + inchangé », « retour à la normale », erreurs worker) — diagnostic à distance. + ## 10. UI & navigation - `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes `settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes - `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel** - (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose (**v1.4.1 : + (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), **cartes d'alerte v1.4.2** (si un + seuil est franchi : fond `errorContainer`, « ▲ E2 estimé ≈ X pg/mL — au-dessus + de ta limite (Y) », basées sur le DERNIER point de courbe = niveau affiché, + disclaimer « estimation, pas une mesure »), carte prochaine dose (**v1.4.1 : delta en JOURS au-delà de 24 h — « 5 j 2 h · sam. 6 18:00 » via `HrtDuration.daysAndHours` + `next_dose_days` ; en dessous, h/min**), chips de log rapide (+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran @@ -649,6 +687,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ; fermable, ne réapparaît pas avant la prochaine mise à jour. - `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; + **carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL, + T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message + rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ; **version installée + lien cliquable vers les releases Gitea** (v1.3.0, `BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ; **« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T, @@ -716,7 +757,14 @@ Pièges : ## 13. Sauvegarde JSON `data/backup/BackupManager.kt` : -- `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson +- `BackupData{version=2, exportedAt, treatments[], doseLogs[], labResults[], tConfig, + settings?}` → Gson — **v2 (v1.4.2)** : champ optionnel `settings: UserSettings` + (champs PLATS : language, autoCalibrate, alertE2High/Low pg/mL, alertTHigh/Low + ng/mL). **Rétrocompat** : les backups v1 (sans settings) restent parsables + (null) et importables ; l'import ne vérifie pas strictement la version. + `changelog_seen_version` volontairement EXCLU (pas une donnée utile à + restaurer). ⚠️ R8 : UserSettings est lue par réflexion Gson → `-keep` + explicite dans proguard-rules.pro (sinon objets vides en release seule). - **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK dose→traitement restent valides - Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées @@ -725,8 +773,10 @@ Pièges : restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) : insertion en « ajout » → conflit d'ID dès que l'app contenait des données → l'import échouait (bug remonté). Après un import : le `tConfig` du backup est - restauré dans DataStore et les rappels sont reprogrammés - (`rescheduleAll(allTreatmentsOnce())`). Le dialog prévient que TOUT sera + restauré dans DataStore, **les paramètres utilisateur sont restaurés** + (v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est + appliquée sur le thread MAIN car `setApplicationLocales` recrée + l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera remplacé (bouton « Effacer & restaurer ») - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture `openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` @@ -1158,8 +1208,8 @@ vérifie pas en tests JVM). 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` — 112 au - total, 94 si `local-test-data/` est absent (les 3 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 129 au + total, 111 si `local-test-data/` est absent (les 3 classes de régression réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : @@ -1307,7 +1357,6 @@ Volontaires (v1) : - Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal) - Modèle T empirique (non publié) — étiqueté estimation partout - Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté) -- 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 @@ -1347,7 +1396,6 @@ E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif (v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK (push session). 8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17) -9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien) ## 21. Checklist de test manuel @@ -1437,14 +1485,25 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : - [ ] **v1.4.1** : **carte « Prochaine dose » en jours** : avec un créneau à > 24 h, l'accueil affiche « 5 j 2 h · sam. 6 18:00 (traitement) » (et non « 122h22 ») ; à < 24 h, format heures/minutes inchangé +- [ ] **v1.4.2** : **seuils d'alerte** : Paramètres → « Seuils d'alerte » → + E2 haut 200 (avec un niveau estimé > 200) → Save → message « Saved » + + **notification immédiate** (canal dédié) + carte rouge sur l'accueil + (« ▲ E2 estimé ≈ X — au-dessus de ta limite (200) ») ; re-save sans + changer → PAS de nouvelle notif (anti-spam) ; seuil haute au-dessus du + niveau → tout disparaît ; validation : haut ≤ bas → message rouge +- [ ] **v1.4.2** : **backup avec paramètres** : Export JSON → le fichier + contient `"settings"` (langue, auto_calibrate, seuils) ; réinstaller + + Import → langue/seuils restaurés ; un VIEUX backup (v1.4.1) s'importe + sans les réglages (rétrocompat) ; smoke-test R8 : export/import + (réflexion Gson sur UserSettings, `-keep` ajouté) --- -*Doc mise à jour le 6 sept. 2026 (v1.4.1) — build OK, lint vert, 112/112 tests verts (94 sans +*Doc mise à jour le 6 sept. 2026 (v1.4.2) — build OK, lint vert, 129/129 tests verts (111 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. Fil des corrections : v1.3.3 = reprise de maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité -~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil. Dette connue : fragments de +~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres. Dette connue : fragments de labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index 899a626..d6d5240 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -127,9 +127,16 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei → calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods` 5. **Paramètres** : - **Langue** : Système / Français / English + - **Seuils d'alerte** (v1.4.2) : limites hautes/basses optionnelles E2 + (pg/mL) et T (ng/mL) — l'accueil affiche un avertissement (et une + notification toutes les 15 min, canal dédié réglable) quand ton taux + ESTIMÉ franchit une limite. Champ vide = alerte désactivée ; haut > + bas requis. C'est une estimation du modèle, pas une mesure - **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL), calibrable avec tes résultats T - - **Sauvegarde JSON** : Export / Import (traitements + doses + analyses + réglages T) + - **Sauvegarde JSON** : Export / Import (traitements + doses + analyses + + réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** — + v1.4.2) ## 7. Les modèles mathématiques