v1.4.2 : seuils d'alerte configurables E2/T (Paramètres, opt-in, validés haut>bas) — cartes d'avertissement sur l'accueil (niveau ESTIMÉ, calibration incluse) ; notification via worker WorkManager périodique 15 min + check one-time au save, anti-spam par état persisté (Alerts.encodeState/shouldNotify), canal dédié ; backup JSON v2 avec les paramètres utilisateur (langue/auto-cal/seuils, rétrocompat v1, -keep R8) ; 13 nouveaux tests (129 verts) + lint
This commit is contained in:
parent
922d49841f
commit
633c6debf4
16
README.md
16
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
|
> à 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.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)
|
- **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)
|
||||||
@ -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 notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
||||||
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
|
**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
|
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
|
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
|
||||||
(fermé = ne réapparaît pas avant la prochaine version)
|
(fermé = ne réapparaît pas avant la prochaine version)
|
||||||
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
|
- **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
|
- **Logs de diagnostic** exportables (Paramètres) — utile pour le support
|
||||||
- **Événements d'agenda** : les rappels de prises peuvent créer un événement
|
- **É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
|
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)
|
- **FR + EN** (langue par app, indépendante du système)
|
||||||
- UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte
|
- 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 <repo> && cd HormoneTrack
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./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
|
./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) —
|
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
`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)) :
|
(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) :
|
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
||||||
|
|
||||||
@ -175,7 +181,7 @@ HormoneTrack/
|
|||||||
│ ├── java/com/hormonetrack/
|
│ ├── java/com/hormonetrack/
|
||||||
│ │ ├── data/ (Room : models, DAOs, repository, backup)
|
│ │ ├── data/ (Room : models, DAOs, repository, backup)
|
||||||
│ │ ├── pk/ (moteur pharmacocinétique + profils)
|
│ │ ├── pk/ (moteur pharmacocinétique + profils)
|
||||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot)
|
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||||
│ │ ├── HormoneTrackApp.kt
|
│ │ ├── HormoneTrackApp.kt
|
||||||
|
|||||||
@ -17,8 +17,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 21
|
versionCode = 22
|
||||||
versionName = "1.4.1"
|
versionName = "1.4.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -114,7 +114,7 @@ dependencies {
|
|||||||
// DataStore (préférences)
|
// DataStore (préférences)
|
||||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
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")
|
implementation("androidx.work:work-runtime-ktx:2.11.2")
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
|
|||||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
-keep class com.hormonetrack.data.model.** { <fields>; }
|
-keep class com.hormonetrack.data.model.** { <fields>; }
|
||||||
-keep class com.hormonetrack.data.backup.BackupData { <fields>; }
|
-keep class com.hormonetrack.data.backup.BackupData { <fields>; }
|
||||||
|
-keep class com.hormonetrack.data.backup.UserSettings { <fields>; }
|
||||||
-keep class com.hormonetrack.pk.TConfig { <fields>; }
|
-keep class com.hormonetrack.pk.TConfig { <fields>; }
|
||||||
|
|
||||||
# Énumérations : converties en String par Room et Gson (par nom)
|
# Énumérations : converties en String par Room et Gson (par nom)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.hormonetrack.data.repository.HormoneRepository
|
|||||||
import com.hormonetrack.pk.PKProfileStore
|
import com.hormonetrack.pk.PKProfileStore
|
||||||
import com.hormonetrack.reminder.createNotificationChannel
|
import com.hormonetrack.reminder.createNotificationChannel
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.util.AppLog
|
||||||
|
import com.hormonetrack.reminder.AlertNotifier
|
||||||
import com.hormonetrack.settings.AppSettings
|
import com.hormonetrack.settings.AppSettings
|
||||||
|
|
||||||
class HormoneTrackApp : Application() {
|
class HormoneTrackApp : Application() {
|
||||||
@ -20,6 +21,12 @@ class HormoneTrackApp : Application() {
|
|||||||
PKProfileStore.init(this)
|
PKProfileStore.init(this)
|
||||||
createNotificationChannel(this)
|
createNotificationChannel(this)
|
||||||
AppLog.init(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,23 +10,59 @@ import com.hormonetrack.data.model.Treatment
|
|||||||
import com.hormonetrack.data.repository.HormoneRepository
|
import com.hormonetrack.data.repository.HormoneRepository
|
||||||
import com.hormonetrack.pk.TConfig
|
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(
|
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 exportedAt: Long = System.currentTimeMillis(),
|
||||||
val treatments: List<Treatment> = emptyList(),
|
val treatments: List<Treatment> = emptyList(),
|
||||||
val doseLogs: List<DoseLog> = emptyList(),
|
val doseLogs: List<DoseLog> = emptyList(),
|
||||||
val labResults: List<LabResult> = emptyList(),
|
val labResults: List<LabResult> = emptyList(),
|
||||||
val tConfig: TConfig = TConfig()
|
val tConfig: TConfig = TConfig(),
|
||||||
|
/** Paramètres utilisateur (null = backup v1 sans réglages). */
|
||||||
|
val settings: UserSettings? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
object BackupManager {
|
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(
|
val data = BackupData(
|
||||||
treatments = repo.allTreatmentsOnce(),
|
treatments = repo.allTreatmentsOnce(),
|
||||||
doseLogs = repo.allDoseLogsOnce(),
|
doseLogs = repo.allDoseLogsOnce(),
|
||||||
labResults = repo.allLabResultsOnce(),
|
labResults = repo.allLabResultsOnce(),
|
||||||
tConfig = tConfig
|
tConfig = tConfig,
|
||||||
|
settings = settings
|
||||||
)
|
)
|
||||||
return Gson().toJson(data)
|
return Gson().toJson(data)
|
||||||
}
|
}
|
||||||
@ -36,7 +72,9 @@ object BackupManager {
|
|||||||
val doseLogs: Int,
|
val doseLogs: Int,
|
||||||
val labResults: Int,
|
val labResults: Int,
|
||||||
/** Réglages T du backup, à restaurer dans DataStore par l'appelant. */
|
/** 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
|
* Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite
|
||||||
* (FK CASCADE des doses vers les traitements).
|
* (FK CASCADE des doses vers les traitements).
|
||||||
*
|
*
|
||||||
* @return le nombre d'entrées restaurées par table + le tConfig du backup
|
* @return le nombre d'entrées restaurées par table + le tConfig et les
|
||||||
* (à restaurer dans DataStore par l'appelant).
|
* 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(
|
suspend fun importJson(
|
||||||
repo: HormoneRepository,
|
repo: HormoneRepository,
|
||||||
@ -71,7 +111,8 @@ object BackupManager {
|
|||||||
data.treatments.size,
|
data.treatments.size,
|
||||||
data.doseLogs.size,
|
data.doseLogs.size,
|
||||||
data.labResults.size,
|
data.labResults.size,
|
||||||
data.tConfig
|
data.tConfig,
|
||||||
|
data.settings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
app/src/main/java/com/hormonetrack/pk/Alerts.kt
Normal file
127
app/src/main/java/com/hormonetrack/pk/Alerts.kt
Normal file
@ -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<Level, Double>? = 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<Alert> {
|
||||||
|
val out = mutableListOf<Alert>()
|
||||||
|
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<Alert>): String =
|
||||||
|
alerts.joinToString(";") { "${it.marker}:${it.level}" }
|
||||||
|
|
||||||
|
/** Décodage de l'état persisté (format [encodeState]) → map marqueur → niveau. */
|
||||||
|
fun parseState(encoded: String?): Map<String, Level> =
|
||||||
|
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<String, Level>, lastNotified: Map<String, Level>?): Boolean {
|
||||||
|
if (current.isEmpty()) return false
|
||||||
|
return current != lastNotified
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
fun levelAt(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
tMs: Long,
|
tMs: Long,
|
||||||
tConfig: TConfig,
|
tConfig: TConfig,
|
||||||
tKPerEster: Map<String, Double>? = null
|
tKPerEster: Map<String, Double>? = null,
|
||||||
|
scalePerEster: Map<String, Double>? = null
|
||||||
): LevelPoint {
|
): LevelPoint {
|
||||||
val e2 = e2At(treatments, doseLogs, tMs)
|
val e2 = e2At(treatments, doseLogs, tMs, scalePerEster = scalePerEster)
|
||||||
val k = activeEsterAt(treatments, doseLogs, tMs)
|
val k = activeEsterAt(treatments, doseLogs, tMs)
|
||||||
?.let { tKPerEster?.get(it) } ?: tConfig.k
|
?.let { tKPerEster?.get(it) } ?: tConfig.k
|
||||||
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
|
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
|
||||||
@ -230,8 +238,9 @@ object PharmacokineticEngine {
|
|||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
tConfig: TConfig,
|
tConfig: TConfig,
|
||||||
tKPerEster: Map<String, Double>? = null,
|
tKPerEster: Map<String, Double>? = null,
|
||||||
nowMs: Long = System.currentTimeMillis()
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
|
scalePerEster: Map<String, Double>? = null
|
||||||
|
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster)
|
||||||
|
|
||||||
fun computeCurve(
|
fun computeCurve(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
|
|||||||
215
app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt
Normal file
215
app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt
Normal file
@ -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<AlertWorker>(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<AlertWorker>().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<Alerts.Alert>) {
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.doublePreferencesKey
|
|||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.hormonetrack.pk.Alerts
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@ -15,7 +16,8 @@ import kotlinx.coroutines.flow.map
|
|||||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
val Context.dataStore: DataStore<Preferences> 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) {
|
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é. */
|
/** Dernière version pour laquelle le dialog « Nouveautés » a été montré. */
|
||||||
val CHANGELOG_SEEN = stringPreferencesKey("changelog_seen_version")
|
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<TConfig> = context.dataStore.data.map { prefs ->
|
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||||
@ -54,6 +70,20 @@ class AppSettings(private val context: Context) {
|
|||||||
prefs[Keys.CHANGELOG_SEEN]
|
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<Alerts.Thresholds> = 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) {
|
suspend fun setTConfig(config: TConfig) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[Keys.T_BASE] = config.base
|
prefs[Keys.T_BASE] = config.base
|
||||||
@ -79,4 +109,29 @@ class AppSettings(private val context: Context) {
|
|||||||
prefs[Keys.CHANGELOG_SEEN] = version
|
prefs[Keys.CHANGELOG_SEEN] = version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** État des alertes déjà notifiées (cf [Keys.ALERT_NOTIFIED_STATE]). */
|
||||||
|
val alertNotifiedState: Flow<String?> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,6 +173,53 @@ fun HomeScreen(
|
|||||||
|
|
||||||
NowLevelCard(curve)
|
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)
|
NextDoseCard(activeTreatments, doseLogs)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import androidx.core.os.LocaleListCompat
|
|||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.backup.BackupManager
|
import com.hormonetrack.data.backup.BackupManager
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.util.AppLog
|
||||||
|
import com.hormonetrack.pk.Alerts
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.reminder.AlarmScheduler
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
@ -56,6 +57,7 @@ import com.hormonetrack.util.ExportFileNames
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@ -70,6 +72,10 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
||||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
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 tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||||
@ -133,7 +139,18 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
) { uri ->
|
) { uri ->
|
||||||
uri ?: return@rememberLauncherForActivityResult
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
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)
|
val ok = BackupManager.writeBackup(context, uri, json)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
message = context.getString(
|
message = context.getString(
|
||||||
@ -325,6 +342,102 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
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<String?>(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 ---
|
// --- Reminders / exact alarms ---
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
@ -435,7 +548,9 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
// Import en mode ÉCRASEMENT : les données actuelles sont
|
// Import en mode ÉCRASEMENT : les données actuelles sont
|
||||||
// effacées d'abord (BackupManager), puis le tConfig du
|
// 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 {
|
val result = try {
|
||||||
BackupManager.importJson(repo, json)
|
BackupManager.importJson(repo, json)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -443,6 +558,27 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
result?.let {
|
result?.let {
|
||||||
container.settings.setTConfig(it.tConfig)
|
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)
|
// Doses + traitements : grille Posologie pour les rappels (#52)
|
||||||
AlarmScheduler(context).rescheduleAll(
|
AlarmScheduler(context).rescheduleAll(
|
||||||
repo.allTreatmentsOnce(), repo.allDoseLogsOnce()
|
repo.allTreatmentsOnce(), repo.allDoseLogsOnce()
|
||||||
|
|||||||
@ -37,6 +37,20 @@
|
|||||||
<string name="hrt_since">Sous THS depuis le %1$s</string>
|
<string name="hrt_since">Sous THS depuis le %1$s</string>
|
||||||
<string name="hrt_duration">%1$d mois et %2$d jours (%3$d jours au total)</string>
|
<string name="hrt_duration">%1$d mois et %2$d jours (%3$d jours au total)</string>
|
||||||
<string name="hrt_duration_days_only">%1$d jours</string>
|
<string name="hrt_duration_days_only">%1$d jours</string>
|
||||||
|
<string name="alert_channel_name">Avertissements de seuil</string>
|
||||||
|
<string name="alert_channel_desc">Notifications quand un taux estimé franchit une de tes limites (vérification toutes les 15 minutes).</string>
|
||||||
|
<string name="alert_notif_title">Seuil hormonal franchi</string>
|
||||||
|
<string name="alert_section">Seuils d\'alerte</string>
|
||||||
|
<string name="alert_hint">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).</string>
|
||||||
|
<string name="alert_e2_high">E2 haut (pg/mL)</string>
|
||||||
|
<string name="alert_e2_low">E2 bas (pg/mL)</string>
|
||||||
|
<string name="alert_t_high">T haut (ng/mL)</string>
|
||||||
|
<string name="alert_t_low">T bas (ng/mL)</string>
|
||||||
|
<string name="alert_invalid">Valeurs invalides : chiffres uniquement, et la limite haute doit être supérieure à la basse.</string>
|
||||||
|
<string name="alert_card_title">Avertissements de seuil</string>
|
||||||
|
<string name="alert_high">▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s)</string>
|
||||||
|
<string name="alert_low">▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s)</string>
|
||||||
|
<string name="alert_disclaimer">Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue.</string>
|
||||||
<string name="logs_section">Logs de diagnostic</string>
|
<string name="logs_section">Logs de diagnostic</string>
|
||||||
<string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
|
<string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
|
||||||
<string name="logs_export">Exporter</string>
|
<string name="logs_export">Exporter</string>
|
||||||
|
|||||||
@ -37,6 +37,20 @@
|
|||||||
<string name="hrt_since">Under HRT since %1$s</string>
|
<string name="hrt_since">Under HRT since %1$s</string>
|
||||||
<string name="hrt_duration">%1$d months and %2$d days (%3$d days total)</string>
|
<string name="hrt_duration">%1$d months and %2$d days (%3$d days total)</string>
|
||||||
<string name="hrt_duration_days_only">%1$d days</string>
|
<string name="hrt_duration_days_only">%1$d days</string>
|
||||||
|
<string name="alert_channel_name">Threshold warnings</string>
|
||||||
|
<string name="alert_channel_desc">Notifications when an estimated level crosses one of your configured limits (checked every 15 minutes).</string>
|
||||||
|
<string name="alert_notif_title">Hormone threshold warning</string>
|
||||||
|
<string name="alert_section">Alert thresholds</string>
|
||||||
|
<string name="alert_hint">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).</string>
|
||||||
|
<string name="alert_e2_high">E2 high (pg/mL)</string>
|
||||||
|
<string name="alert_e2_low">E2 low (pg/mL)</string>
|
||||||
|
<string name="alert_t_high">T high (ng/mL)</string>
|
||||||
|
<string name="alert_t_low">T low (ng/mL)</string>
|
||||||
|
<string name="alert_invalid">Invalid values: enter numbers only, and keep the high limit above the low limit.</string>
|
||||||
|
<string name="alert_card_title">Threshold warnings</string>
|
||||||
|
<string name="alert_high">▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s)</string>
|
||||||
|
<string name="alert_low">▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s)</string>
|
||||||
|
<string name="alert_disclaimer">Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist.</string>
|
||||||
<string name="logs_section">Diagnostic logs</string>
|
<string name="logs_section">Diagnostic logs</string>
|
||||||
<string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string>
|
<string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string>
|
||||||
<string name="logs_export">Export</string>
|
<string name="logs_export">Export</string>
|
||||||
|
|||||||
@ -92,4 +92,49 @@ class BackupGsonTest {
|
|||||||
assertEquals(0.15, parsed.tConfig.floor, 1e-9)
|
assertEquals(0.15, parsed.tConfig.floor, 1e-9)
|
||||||
assertEquals(0.21, parsed.tConfig.k, 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt
Normal file
143
app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/src/test/java/com/hormonetrack/pk/AlertsTest.kt
Normal file
151
app/src/test/java/com/hormonetrack/pk/AlertsTest.kt
Normal file
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,36 @@
|
|||||||
# 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.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)
|
## [1.4.1] — 2026-09-06 (versionCode 21)
|
||||||
|
|
||||||
|
|||||||
@ -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.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.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.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. |
|
| 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. |
|
||||||
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
|
| 5 sept. 2026 (session v1.2.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 |
|
| DataStore Preferences | 1.2.1 | idem |
|
||||||
| Gson | 2.14.0 | idem |
|
| Gson | 2.14.0 | idem |
|
||||||
| JUnit | 4.13.2 (testImplementation) | 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 |
|
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
|
||||||
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | 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
|
## 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,
|
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.
|
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 =
|
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
|
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
|
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).
|
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 :
|
- **`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 (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ;
|
||||||
EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté :
|
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.
|
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).
|
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
|
## 10. UI & navigation
|
||||||
|
|
||||||
- `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes
|
- `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes
|
||||||
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
|
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
|
||||||
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
|
- `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
|
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
|
`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
|
(+ 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) ;
|
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
|
||||||
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
||||||
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
|
- `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,
|
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
|
||||||
`BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ;
|
`BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ;
|
||||||
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
||||||
@ -716,7 +757,14 @@ Pièges :
|
|||||||
## 13. Sauvegarde JSON
|
## 13. Sauvegarde JSON
|
||||||
|
|
||||||
`data/backup/BackupManager.kt` :
|
`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
|
- **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK
|
||||||
dose→traitement restent valides
|
dose→traitement restent valides
|
||||||
- Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées
|
- 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) :
|
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 →
|
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
|
l'import échouait (bug remonté). Après un import : le `tConfig` du backup est
|
||||||
restauré dans DataStore et les rappels sont reprogrammés
|
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
|
||||||
(`rescheduleAll(allTreatmentsOnce())`). Le dialog prévient que TOUT sera
|
(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 »)
|
remplacé (bouton « Effacer & restaurer »)
|
||||||
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
||||||
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
|
`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`,
|
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 112 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 129 au
|
||||||
total, 94 si `local-test-data/` est absent (les 3 classes de régression
|
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
|
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
|
||||||
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
||||||
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
|
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)
|
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
|
||||||
- Modèle T empirique (non publié) — étiqueté estimation partout
|
- Modèle T empirique (non publié) — étiqueté estimation partout
|
||||||
- Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté)
|
- 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 —
|
- 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
|
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
|
- 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
|
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
|
||||||
(push session).
|
(push session).
|
||||||
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
|
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
|
## 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
|
- [ ] **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) »
|
à > 24 h, l'accueil affiche « 5 j 2 h · sam. 6 18:00 (traitement) »
|
||||||
(et non « 122h22 ») ; à < 24 h, format heures/minutes inchangé
|
(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
|
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
|
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
|
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
|
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
|
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é
|
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.*
|
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*
|
||||||
|
|||||||
@ -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`
|
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
|
||||||
5. **Paramètres** :
|
5. **Paramètres** :
|
||||||
- **Langue** : Système / Français / English
|
- **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),
|
- **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL),
|
||||||
calibrable avec tes résultats T
|
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
|
## 7. Les modèles mathématiques
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user