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:
Siphonight 2026-09-07 00:02:22 +02:00
parent 922d49841f
commit 633c6debf4
19 changed files with 1143 additions and 37 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.4.1 — build Android ✅, **lint vert** ✅, **112 tests unitaires** ✅ (94 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Statut** : v1.4.2 — build Android ✅, **lint vert** ✅, **129 tests unitaires** ✅ (111 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -62,6 +62,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
7 jours ne sonne que le jour d'injection, pas tous les jours
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
canal dédié) — évaluées sur le taux **estimé**, opt-in
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2)
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
@ -69,7 +75,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Logs de diagnostic** exportables (Paramètres) — utile pour le support
- **Événements d'agenda** : les rappels de prises peuvent créer un événement
récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T + paramètres, v1.4.2)
- **FR + EN** (langue par app, indépendante du système)
- UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte
@ -82,7 +88,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 112 tests (94 sans les données locales)
./gradlew testDebugUnitTest # 129 tests (111 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```
@ -94,7 +100,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.4.1`) et **deux APK par release** (depuis v1.2.5)
**releases taguées** (`v1.1.0` → `v1.4.2`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
@ -175,7 +181,7 @@ HormoneTrack/
│ ├── java/com/hormonetrack/
│ │ ├── data/ (Room : models, DAOs, repository, backup)
│ │ ├── pk/ (moteur pharmacocinétique + profils)
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot)
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
│ │ ├── settings/ (DataStore : TConfig, langue)
│ │ ├── ui/ (Compose : screens, components, theme)
│ │ ├── HormoneTrackApp.kt

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 21
versionName = "1.4.1"
versionCode = 22
versionName = "1.4.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -114,7 +114,7 @@ dependencies {
// DataStore (préférences)
implementation("androidx.datastore:datastore-preferences:1.2.1")
// WorkManager (déclaré, non utilisé — cf docs §19)
// WorkManager (UTILISÉ v1.4.2 : worker périodique des seuils d'alerte, cf §9.bis)
implementation("androidx.work:work-runtime-ktx:2.11.2")
// Coroutines

View File

@ -10,6 +10,7 @@
-keep class com.hormonetrack.data.model.** { <fields>; }
-keep class com.hormonetrack.data.backup.BackupData { <fields>; }
-keep class com.hormonetrack.data.backup.UserSettings { <fields>; }
-keep class com.hormonetrack.pk.TConfig { <fields>; }
# Énumérations : converties en String par Room et Gson (par nom)

View File

@ -7,6 +7,7 @@ import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.PKProfileStore
import com.hormonetrack.reminder.createNotificationChannel
import com.hormonetrack.util.AppLog
import com.hormonetrack.reminder.AlertNotifier
import com.hormonetrack.settings.AppSettings
class HormoneTrackApp : Application() {
@ -20,6 +21,12 @@ class HormoneTrackApp : Application() {
PKProfileStore.init(this)
createNotificationChannel(this)
AppLog.init(this)
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
// évalue le niveau estimé toutes les 15 min, notifie au franchissement
// (anti-spam par état persisté). KEEP : replanifier à chaque
// démarrage ne change rien ; WorkManager survit aux reboots.
AlertNotifier.createChannel(this)
AlertNotifier.schedulePeriodic(this)
}
}

View File

@ -10,23 +10,59 @@ import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.TConfig
/**
* Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
* transporte désormais les RÉGLAGES en plus des données de santé :
* - `language` : "system" / "fr" / "en" (null = ne pas changer à l'import) ;
* - `autoCalibrate` : option de calibration automatique (null = défaut) ;
* - `alertE2High/Low` (pg/mL) et `alertTHigh/Low` (ng/mL) : seuils d'alerte
* configurés (null = pas de seuil).
*
* ⚠️ Champs PLATS volontairement (pas de nested data class) : Gson lit ces
* champs par RÉFLEXION → chaque classe embarquée doit être couverte par un
* `-keep` explicite dans proguard-rules.pro (sinon l'export/import produit
* des objets vides EN RELEASE SEULE — leçon §16.bis smoke-test R8).
* `changelog_seen_version` est volontairement EXCLU : pas une donnée de
* configuration utile à restaurer (le dialog « Nouveautés » se montre une
* fois de plus après une restauration, ce n'est pas gênant).
*/
data class UserSettings(
val language: String? = null,
val autoCalibrate: Boolean? = null,
val alertE2High: Double? = null,
val alertE2Low: Double? = null,
val alertTHigh: Double? = null,
val alertTLow: Double? = null
)
data class BackupData(
val version: Int = 1,
// v2 (v1.4.2) : champ optionnel `settings` ajouté. RÉTROCOMPATIBILITÉ :
// les backups v1 (sans settings) restent parsables (Gson → null) et
// importables — l'import ne vérifie pas strictement la version.
val version: Int = 2,
val exportedAt: Long = System.currentTimeMillis(),
val treatments: List<Treatment> = emptyList(),
val doseLogs: List<DoseLog> = 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 {
suspend fun exportJson(repo: HormoneRepository, tConfig: TConfig): String {
suspend fun exportJson(
repo: HormoneRepository,
tConfig: TConfig,
/** Réglages utilisateur à embarquer (v1.4.2) ; null = backup minimal. */
settings: UserSettings? = null
): String {
val data = BackupData(
treatments = repo.allTreatmentsOnce(),
doseLogs = repo.allDoseLogsOnce(),
labResults = repo.allLabResultsOnce(),
tConfig = tConfig
tConfig = tConfig,
settings = settings
)
return Gson().toJson(data)
}
@ -36,7 +72,9 @@ object BackupManager {
val doseLogs: Int,
val labResults: Int,
/** Réglages T du backup, à restaurer dans DataStore par l'appelant. */
val tConfig: TConfig
val tConfig: TConfig,
/** Paramètres utilisateur (v1.4.2) ; null = backup v1 sans réglages. */
val settings: UserSettings?
)
/**
@ -48,8 +86,10 @@ object BackupManager {
* Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite
* (FK CASCADE des doses vers les traitements).
*
* @return le nombre d'entrées restaurées par table + le tConfig du backup
* (à restaurer dans DataStore par l'appelant).
* @return le nombre d'entrées restaurées par table + le tConfig et les
* paramètres utilisateur du backup (à restaurer dans DataStore par
* l'appelant — cf SettingsScreen : langue appliquée sur le thread Main
* car elle recrée l'activité).
*/
suspend fun importJson(
repo: HormoneRepository,
@ -71,7 +111,8 @@ object BackupManager {
data.treatments.size,
data.doseLogs.size,
data.labResults.size,
data.tConfig
data.tConfig,
data.settings
)
}

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

View File

@ -192,14 +192,22 @@ object PharmacokineticEngine {
}
}
/**
* Niveau combiné à un instant donné. [scalePerEster] (v1.4.2, optionnel)
* applique la calibration par période d'ester EXACTEMENT comme
* [computeCurve] — la notification d'alerte (AlertNotifier) et la carte
* « niveau actuel » de l'accueil partagent ainsi le même calcul.
* null = scaleFactor stocké des traitements (comportement historique).
*/
fun levelAt(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
tMs: Long,
tConfig: TConfig,
tKPerEster: Map<String, Double>? = null
tKPerEster: Map<String, Double>? = null,
scalePerEster: Map<String, Double>? = null
): LevelPoint {
val e2 = e2At(treatments, doseLogs, tMs)
val e2 = e2At(treatments, doseLogs, tMs, scalePerEster = scalePerEster)
val k = activeEsterAt(treatments, doseLogs, tMs)
?.let { tKPerEster?.get(it) } ?: tConfig.k
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
@ -230,8 +238,9 @@ object PharmacokineticEngine {
doseLogs: List<DoseLog>,
tConfig: TConfig,
tKPerEster: Map<String, Double>? = null,
nowMs: Long = System.currentTimeMillis()
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
nowMs: Long = System.currentTimeMillis(),
scalePerEster: Map<String, Double>? = null
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster)
fun computeCurve(
treatments: List<Treatment>,

View 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()
}
}

View File

@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.TConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -15,7 +16,8 @@ import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<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) {
@ -30,6 +32,20 @@ class AppSettings(private val context: Context) {
/** Dernière version pour laquelle le dialog « Nouveautés » a été montré. */
val CHANGELOG_SEEN = stringPreferencesKey("changelog_seen_version")
// ── Seuils d'alerte (v1.4.2) : limites E2 (pg/mL) et T (ng/mL). ──
// Clé absente = pas de seuil → pas d'alerte sur ce côté (opt-in pur).
val ALERT_E2_HIGH = doublePreferencesKey("alert_e2_high")
val ALERT_E2_LOW = doublePreferencesKey("alert_e2_low")
val ALERT_T_HIGH = doublePreferencesKey("alert_t_high")
val ALERT_T_LOW = doublePreferencesKey("alert_t_low")
/**
* État des alertes DÉJÀ NOTIFIÉES (encodage [Alerts.encodeState],
* ex. "E2:HIGH;T:LOW") — anti-spam du worker périodique : on ne
* re-notify pas le même franchissement toutes les 15 min.
*/
val ALERT_NOTIFIED_STATE = stringPreferencesKey("alert_notified_state")
}
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -54,6 +70,20 @@ class AppSettings(private val context: Context) {
prefs[Keys.CHANGELOG_SEEN]
}
/**
* Seuils d'alerte (v1.4.2) : null = pas de limite pour la valeur
* (les champs laissés vides dans Paramètres). Évalués contre le taux
* ESTIMÉ actuel sur l'accueil (cf pk/Alerts.kt).
*/
val alertThresholds: Flow<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) {
context.dataStore.edit { prefs ->
prefs[Keys.T_BASE] = config.base
@ -79,4 +109,29 @@ class AppSettings(private val context: Context) {
prefs[Keys.CHANGELOG_SEEN] = version
}
}
/** État des alertes déjà notifiées (cf [Keys.ALERT_NOTIFIED_STATE]). */
val alertNotifiedState: Flow<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)
}
}
}

View File

@ -173,6 +173,53 @@ fun HomeScreen(
NowLevelCard(curve)
// Seuils d'alerte (v1.4.2) : évaluation du TAUX ESTIMÉ ACTUEL
// (dernier point de la courbe = même valeur que la carte
// « niveau actuel ») contre les limites configurées dans
// Paramètres (pk/Alerts.kt — opt-in, champ vide = pas d'alerte).
val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = com.hormonetrack.pk.Alerts.Thresholds()
)
curve.lastOrNull()?.let { now ->
val alerts = com.hormonetrack.pk.Alerts.evaluateAll(now.e2, now.t, alertThresholds)
if (alerts.isNotEmpty()) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.alert_card_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(Modifier.height(4.dp))
alerts.forEach { a ->
val formatted = formatDose(a.value)
val limitStr = formatDose(a.limit)
Text(
if (a.level == com.hormonetrack.pk.Alerts.Level.HIGH) {
stringResource(R.string.alert_high, a.marker, formatted, a.unit, limitStr)
} else {
stringResource(R.string.alert_low, a.marker, formatted, a.unit, limitStr)
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.alert_disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
NextDoseCard(activeTreatments, doseLogs)
Card(

View File

@ -48,6 +48,7 @@ import androidx.core.os.LocaleListCompat
import com.hormonetrack.R
import com.hormonetrack.data.backup.BackupManager
import com.hormonetrack.util.AppLog
import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.reminder.AlarmScheduler
@ -56,6 +57,7 @@ import com.hormonetrack.util.ExportFileNames
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.util.Locale
@ -70,6 +72,10 @@ fun SettingsScreen(onBack: () -> Unit) {
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val currentLanguage by container.settings.language.collectAsState(initial = "system")
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = com.hormonetrack.pk.Alerts.Thresholds()
)
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
@ -133,7 +139,18 @@ fun SettingsScreen(onBack: () -> Unit) {
) { uri ->
uri ?: return@rememberLauncherForActivityResult
CoroutineScope(Dispatchers.IO).launch {
val json = BackupManager.exportJson(repo, tConfig)
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
// (langue, auto-calibration, seuils d'alerte) — collect des
// DataStore Flows (first()) avant sérialisation.
val settings = com.hormonetrack.data.backup.UserSettings(
language = container.settings.language.first(),
autoCalibrate = container.settings.autoCalibrate.first(),
alertE2High = container.settings.alertThresholds.first().e2High,
alertE2Low = container.settings.alertThresholds.first().e2Low,
alertTHigh = container.settings.alertThresholds.first().tHigh,
alertTLow = container.settings.alertThresholds.first().tLow
)
val json = BackupManager.exportJson(repo, tConfig, settings)
val ok = BackupManager.writeBackup(context, uri, json)
withContext(Dispatchers.Main) {
message = context.getString(
@ -325,6 +342,102 @@ fun SettingsScreen(onBack: () -> Unit) {
}
Spacer(Modifier.height(12.dp))
// --- Seuils d'alerte (v1.4.2) : limites E2/T évaluées contre le taux
// estimé actuel de l'accueil (pk/Alerts.kt). Opt-in : champ vide =
// pas d'alerte. Haut > bas requis si les deux sont définis.
var e2HighText by remember(alertThresholds.e2High) {
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
}
var e2LowText by remember(alertThresholds.e2Low) {
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
}
var tHighText by remember(alertThresholds.tHigh) {
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
}
var tLowText by remember(alertThresholds.tLow) {
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
}
var alertError by remember { mutableStateOf<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 ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
@ -435,7 +548,9 @@ fun SettingsScreen(onBack: () -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
// Import en mode ÉCRASEMENT : les données actuelles sont
// effacées d'abord (BackupManager), puis le tConfig du
// backup restaure DataStore et les rappels sont reprogrammés
// backup restaure DataStore, les PARAMÈTRES utilisateur
// (v1.4.2 : langue, auto-calibration, seuils d'alerte)
// et les rappels sont reprogrammés
val result = try {
BackupManager.importJson(repo, json)
} catch (e: Exception) {
@ -443,6 +558,27 @@ fun SettingsScreen(onBack: () -> Unit) {
}
result?.let {
container.settings.setTConfig(it.tConfig)
// ⚠️ Paramètres du backup (null = backup v1 → ne rien
// toucher). La LANGUE est appliquée sur le thread
// MAIN : setApplicationLocales recrée l'activité.
it.settings?.let { us ->
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
container.settings.setAlertThresholds(
com.hormonetrack.pk.Alerts.Thresholds(
e2High = us.alertE2High, e2Low = us.alertE2Low,
tHigh = us.alertTHigh, tLow = us.alertTLow
)
)
withContext(Dispatchers.Main) {
us.language?.let { lang ->
container.settings.setLanguage(lang)
AppCompatDelegate.setApplicationLocales(
if (lang == "system") LocaleListCompat.getEmptyLocaleList()
else LocaleListCompat.forLanguageTags(lang)
)
}
}
}
// Doses + traitements : grille Posologie pour les rappels (#52)
AlarmScheduler(context).rescheduleAll(
repo.allTreatmentsOnce(), repo.allDoseLogsOnce()

View File

@ -37,6 +37,20 @@
<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_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_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
<string name="logs_export">Exporter</string>

View File

@ -37,6 +37,20 @@
<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_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_count">%1$d lines (reminders, calendar, import/export, errors)</string>
<string name="logs_export">Export</string>

View File

@ -92,4 +92,49 @@ class BackupGsonTest {
assertEquals(0.15, parsed.tConfig.floor, 1e-9)
assertEquals(0.21, parsed.tConfig.k, 1e-9)
}
@Test
fun `backup v2 round-trip preserves user settings (language, auto-cal, thresholds)`() {
// v1.4.2 : les PARAMÈTRES utilisateur voyagent avec le backup
val data = BackupData(
tConfig = TConfig(),
settings = UserSettings(
language = "fr",
autoCalibrate = true,
alertE2High = 300.0,
alertE2Low = 100.0,
alertTHigh = 1.0,
alertTLow = 0.3
)
)
val json = Gson().toJson(data)
val parsed: BackupData = Gson().fromJson(json, BackupData::class.java)
assertEquals(2, parsed.version)
val s = parsed.settings
// ⚠️ Ne JAMAIS enlever ce check : sous R8 sans les -keep, parsed.settings
// deviendrait non-null avec des champs VIDES (réflexion Gson, cf §16.bis)
org.junit.Assert.assertNotNull(s)
assertEquals("fr", s!!.language)
assertEquals(true, s.autoCalibrate)
assertEquals(300.0, s.alertE2High!!, 1e-9)
assertEquals(100.0, s.alertE2Low!!, 1e-9)
assertEquals(1.0, s.alertTHigh!!, 1e-9)
assertEquals(0.3, s.alertTLow!!, 1e-9)
}
@Test
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :
// ils doivent rester importables tels quels.
val legacyJson = """
{"version":1,"exportedAt":1700000000000,
"treatments":[],"doseLogs":[],"labResults":[],
"tConfig":{"base":6.0,"floor":0.2,"k":0.19}}
""".trimIndent()
val parsed: BackupData = Gson().fromJson(legacyJson, BackupData::class.java)
assertEquals(1, parsed.version)
org.junit.Assert.assertNull(parsed.settings)
}
}

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

View 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()))
}
}

View File

@ -1,7 +1,36 @@
# Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1`, …).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.2`, …).
## [1.4.2] — 2026-09-07 (versionCode 22)
### Ajouté
- **Seuils d'alerte configurables** (E2 et T) : dans Paramètres, une limite
HAUTE et une limite BASSE par marqueur (E2 en pg/mL, T en ng/mL), chacune
optionnelle (champ vide = pas d'alerte), validées (haut > bas requis) et
**évaluées contre le taux ESTIMÉ actuel** (le même que la carte « niveau
actuel », calibration incluse — pas une mesure de labo). En cas de
dépassement : carte d'avertissement rouge sur l'accueil (« ▲ E2 estimé
≈ 259 pg/mL — au-dessus de ta limite haute (200) ») + disclaimer «
estimation, pas une mesure ». Comparaison STRICTE (valeur == limite → rien).
- **Notification d'alerte** : la vérification tourne **toutes les 15 min
même app fermée** (worker WorkManager — la dépendance déclarée depuis v1.0
est enfin utilisée) + **check immédiat au save des seuils**. Anti-spam :
l'état des alertes déjà notifiées est mémorisé — pas de re-notification
du même franchissement ; le retour à la normale ré-arme la prochaine
alerte ; pas de notification de « retour à la normale ». Canal de
notification dédié (réglable séparément des rappels, remonte sur la
Watch GT 3). Journalisation AppLog complète (debug à distance).
- **9 nouveaux tests** (129 au total / 111 sans les données locales) :
`AlertsTest` (12 : logique pure des seuils, codec d'état anti-spam) et
`AlertsEngineTest` (4 : la notification évalue EXACTEMENT le niveau de la
carte accueil ; déclenchement sur niveau réel).
- **Backup JSON avec les paramètres utilisateur** : l'export embarque
désormais langue, auto-calibration et seuils d'alerte (`"settings"`,
format v2) ; l'import les restaure (la langue est appliquée au retour).
**Rétrocompatible** : les anciens exports (sans settings) restent
importables ; 2 tests round-trip + compat v1.
## [1.4.1] — 2026-09-06 (versionCode 21)

View File

@ -84,6 +84,7 @@ Fonctionnalités v1 :
| 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. |
| 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. |
| 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. |
| 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. |
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
@ -109,7 +110,7 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
| DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation) | idem |
| WorkManager | 2.11.2 (déclaré, non utilisé — supprimable) | idem |
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
@ -408,7 +409,7 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k
## 8. Tests unitaires
**112 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **94 sans
**129 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **111 sans
les données de test locales** (cf §8.bis : les 3 classes de régression,
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
@ -519,6 +520,19 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters
sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt
passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
- **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) :
HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le
clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas,
HIGH prime LOW en config incohérente (défensif), les deux marqueurs à la
fois (E2 puis T), **codec d'état** (`encodeState`/`parseState` — rétro-résistant
aux entrées malformées) et **décision de notification** (`shouldNotify` :
nouveau franchissement → notif, même état → pas de spam, retour à la
normale → pas de notif mais ré-arme).
- **`AlertsEngineTest`** (4) : v1.4.2 — câblage moteur → seuils : le niveau
évalué par la NOTIFICATION (`levelAt(scalePerEster)`) est celui de la
CARTE accueil (`computeCurve` dernier point, ±10 % de dérive horaire) ;
seuil à la moitié du niveau réel → HIGH, seuil au double → rien ; T bas
→ LOW en ng/mL ; l'état persisté ne contient que les marqueurs en alerte.
- **`TransfemScienceEngineTest`** (4) : v1.4.0 — câblage MOTEUR ↔ V3C :
EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ;
EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté :
@ -587,12 +601,36 @@ depuis v1.3.0 mais **jamais déclarées dans le XML** (bug #44, §14) : tout
jeton « le manifest contient X » doit être VÉRIFIÉ dans le fichier réel.
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2)
- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
- **Worker WorkManager** : `AlertWorker` (CoroutineWorker) périodique **15 min**
(minimum WorkManager) + **one-time au save des seuils** (feedback immédiat) ;
planifié dans `HormoneTrackApp.onCreate` (`ExistingPeriodicWorkPolicy.KEEP`,
survit aux reboots — BootReceiver n'a RIEN à faire).
- **Calcul du niveau** : `levelAt(..., scalePerEster, tKPerEster)` — MÊME
calibration que la carte accueil (auto-cal incluse si l'option est active).
- **Anti-spam** : l'état des alertes déjà notifiées est persisté en DataStore
(`alert_notified_state` = `Alerts.encodeState`, ex. « E2:HIGH;T:LOW ») ;
`shouldNotify` : nouveau franchissement ou changement H↔L → notif, même
état → rien, retour à la normale → état effacé (ré-arme), pas de notif de retour.
- **Canal dédié** `hormonetrack_alerts` (IMPORTANCE_HIGH) : réglable
indépendamment des rappels ; notification avec texte multi-lignes (BigText),
tap → MainActivity. Garde défensive POST_NOTIFICATIONS (API 33+) journalisée.
- Journalisation AppLog complète (« notification : E2:HIGH... », « franchissement
inchangé », « retour à la normale », erreurs worker) — diagnostic à distance.
## 10. UI & navigation
- `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose (**v1.4.1 :
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), **cartes d'alerte v1.4.2** (si un
seuil est franchi : fond `errorContainer`, « ▲ E2 estimé ≈ X pg/mL — au-dessus
de ta limite (Y) », basées sur le DERNIER point de courbe = niveau affiché,
disclaimer « estimation, pas une mesure »), carte prochaine dose (**v1.4.1 :
delta en JOURS au-delà de 24 h — « 5 j 2 h · sam. 6 18:00 » via
`HrtDuration.daysAndHours` + `next_dose_days` ; en dessous, h/min**), chips de log rapide
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
@ -649,6 +687,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
fermable, ne réapparaît pas avant la prochaine mise à jour.
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
`BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ;
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
@ -716,7 +757,14 @@ Pièges :
## 13. Sauvegarde JSON
`data/backup/BackupManager.kt` :
- `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson
- `BackupData{version=2, exportedAt, treatments[], doseLogs[], labResults[], tConfig,
settings?}` → Gson — **v2 (v1.4.2)** : champ optionnel `settings: UserSettings`
(champs PLATS : language, autoCalibrate, alertE2High/Low pg/mL, alertTHigh/Low
ng/mL). **Rétrocompat** : les backups v1 (sans settings) restent parsables
(null) et importables ; l'import ne vérifie pas strictement la version.
`changelog_seen_version` volontairement EXCLU (pas une donnée utile à
restaurer). ⚠️ R8 : UserSettings est lue par réflexion Gson → `-keep`
explicite dans proguard-rules.pro (sinon objets vides en release seule).
- **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK
dose→traitement restent valides
- Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées
@ -725,8 +773,10 @@ Pièges :
restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) :
insertion en « ajout » → conflit d'ID dès que l'app contenait des données →
l'import échouait (bug remonté). Après un import : le `tConfig` du backup est
restauré dans DataStore et les rappels sont reprogrammés
(`rescheduleAll(allTreatmentsOnce())`). Le dialog prévient que TOUT sera
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
appliquée sur le thread MAIN car `setApplicationLocales` recrée
l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera
remplacé (bouton « Effacer & restaurer »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
@ -1158,8 +1208,8 @@ vérifie pas en tests JVM).
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 112 au
total, 94 si `local-test-data/` est absent (les 3 classes de régression
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 129 au
total, 111 si `local-test-data/` est absent (les 3 classes de régression
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
@ -1307,7 +1357,6 @@ Volontaires (v1) :
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
- Modèle T empirique (non publié) — étiqueté estimation partout
- Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté)
- WorkManager déclaré non utilisé
- Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés —
rétro-ingénierie des fits non tentée ; les tables sont exactes
- DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au
@ -1347,7 +1396,6 @@ E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
(push session).
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien)
## 21. Checklist de test manuel
@ -1437,14 +1485,25 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] **v1.4.1** : **carte « Prochaine dose » en jours** : avec un créneau
à > 24 h, l'accueil affiche « 5 j 2 h · sam. 6 18:00 (traitement) »
(et non « 122h22 ») ; à < 24 h, format heures/minutes inchangé
- [ ] **v1.4.2** : **seuils d'alerte** : Paramètres → « Seuils d'alerte » →
E2 haut 200 (avec un niveau estimé > 200) → Save → message « Saved » +
**notification immédiate** (canal dédié) + carte rouge sur l'accueil
(« ▲ E2 estimé ≈ X — au-dessus de ta limite (200) ») ; re-save sans
changer → PAS de nouvelle notif (anti-spam) ; seuil haute au-dessus du
niveau → tout disparaît ; validation : haut ≤ bas → message rouge
- [ ] **v1.4.2** : **backup avec paramètres** : Export JSON → le fichier
contient `"settings"` (langue, auto_calibrate, seuils) ; réinstaller +
Import → langue/seuils restaurés ; un VIEUX backup (v1.4.1) s'importe
sans les réglages (rétrocompat) ; smoke-test R8 : export/import
(réflexion Gson sur UserSettings, `-keep` ajouté)
---
*Doc mise à jour le 6 sept. 2026 (v1.4.1) — build OK, lint vert, 112/112 tests verts (94 sans
*Doc mise à jour le 6 sept. 2026 (v1.4.2) — build OK, lint vert, 129/129 tests verts (111 sans
les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée
de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de
maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil. Dette connue : fragments de
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres. Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*

View File

@ -127,9 +127,16 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
5. **Paramètres** :
- **Langue** : Système / Français / English
- **Seuils d'alerte** (v1.4.2) : limites hautes/basses optionnelles E2
(pg/mL) et T (ng/mL) — l'accueil affiche un avertissement (et une
notification toutes les 15 min, canal dédié réglable) quand ton taux
ESTIMÉ franchit une limite. Champ vide = alerte désactivée ; haut >
bas requis. C'est une estimation du modèle, pas une mesure
- **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL),
calibrable avec tes résultats T
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses + réglages T)
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
v1.4.2)
## 7. Les modèles mathématiques