v1.3.0 : dialog « Nouveautés » post-update, événements d'agenda récurrents, version + lien releases dans Paramètres

- Dialog « Nouveautés » (v1.3.0) : au démarrage, si la version installée est
  plus récente que la dernière vue (DataStore changelog_seen_version), un
  AlertDialog affiche les sections CHANGELOG non vues (ChangelogHelper
  sectionsSince + comparaison SemVer NUMÉRIQUE — 1.2.9 < 1.2.10, lexicographique
  aurait tort) ; asset changelog.md synchronisé à chaque build par la tâche
  Gradle copyChangelog (gitignoré) ; fermable, ne réapparaît pas avant la
  prochaine mise à jour
- Événements d'agenda récurrents (CalendarEvents.kt) : calendrier LOCAL dédié
  « HormoneTrack » (CalendarContract, ACCOUNT_TYPE_LOCAL), événement avec
  RRULE FREQ=DAILY;INTERVAL=N dérivé de la Posologie (arrondi demi-supérieur
  EXPLICITE floor(x+0.5) — kotlin.math.round arrondit les ties vers l'entier
  PAIR : 6,5 → 6, piège épinglé), début = prochaine occurrence à l'heure de
  rappel (ou 12:00) ; switch dans l'éditeur sous « Rappels », permissions
  WRITE_CALENDAR + READ_CALENDAR demandées à l'activation ; suppression/
  recréation au save ; id stocké sur Treatment (Room v3, MIGRATION_2_3)
- Paramètres : version installée (BuildConfig.VERSION_NAME, buildConfig=true)
  + lien cliquable vers les releases Gitea
- Tests : ChangelogHelperTest (8) + CalendarRruleTest (3) → 78 tests verts
- versionCode 14, versionName 1.3.0
This commit is contained in:
Siphonight 2026-09-06 08:30:31 +02:00
parent 274f82b6c1
commit 435997717e
19 changed files with 658 additions and 11 deletions

2
.gitignore vendored
View File

@ -29,3 +29,5 @@ Thumbs.db
# Logs # Logs
*.log *.log
local-test-data/ local-test-data/
app/build/generated/changelog/
app/src/main/assets/changelog.md

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.2.10 — build Android ✅, **67 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.3.0 — build Android ✅, **78 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -54,6 +54,10 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **É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)
- **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

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 13 versionCode = 14
versionName = "1.2.10" versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -47,6 +47,8 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// BuildConfig.VERSION_NAME : écran Paramètres + dialog de changelog
buildConfig = true
} }
packaging { packaging {
@ -56,6 +58,16 @@ android {
} }
} }
// Synchronise docs/CHANGELOG.md → src/main/assets/changelog.md (dialog
// « Nouveautés » affiché après chaque mise à jour). Automatique à chaque
// build ; le fichier généré est gitignoré (source de vérité = docs/).
tasks.register<Copy>("copyChangelog") {
from(rootProject.file("docs/CHANGELOG.md"))
into(file("src/main/assets"))
rename { "changelog.md" }
}
tasks.named("preBuild") { dependsOn("copyChangelog") }
dependencies { dependencies {
// BOM Compose : épingle toutes les versions des artefacts androidx.compose.* // BOM Compose : épingle toutes les versions des artefacts androidx.compose.*
// (material3 >= 1.4 = Material 3 Expressive) // (material3 >= 1.4 = Material 3 Expressive)

View File

@ -23,7 +23,7 @@ import com.hormonetrack.data.model.Treatment
*/ */
@Database( @Database(
entities = [Treatment::class, DoseLog::class, LabResult::class], entities = [Treatment::class, DoseLog::class, LabResult::class],
version = 2, version = 3,
exportSchema = false exportSchema = false
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -43,6 +43,16 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
/**
* v2 → v3 (2026-09-05) : ajout de calendarEventId (nullable, INTEGER) —
* id de l'événement d'agenda récurrent (CalendarContract) du traitement.
*/
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE treatments ADD COLUMN calendarEventId INTEGER")
}
}
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
@ -53,7 +63,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"hormonetrack.db" "hormonetrack.db"
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

View File

@ -64,6 +64,11 @@ data class Treatment(
val reminderMinute: Int? = null, val reminderMinute: Int? = null,
val reminderEnabled: Boolean = false, val reminderEnabled: Boolean = false,
// Événement d'agenda récurrent (CalendarContract) : id de l'event inséré
// dans le calendrier local « HormoneTrack » ; null = pas d'événement.
// Répétition dérivée de la Posologie (forecastIntervalDays).
val calendarEventId: Long? = null,
val createdAt: Long = System.currentTimeMillis() val createdAt: Long = System.currentTimeMillis()
) { ) {
val isInjection: Boolean val isInjection: Boolean

View File

@ -0,0 +1,164 @@
package com.hormonetrack.reminder
import android.content.ContentUris
import android.content.Context
import android.content.ContentValues
import android.provider.CalendarContract
import com.hormonetrack.data.model.Treatment
import java.util.Calendar
import java.util.TimeZone
/**
* Gestion des événements d'agenda récurrents pour les prises hormonales
* (v1.3.0) : un événement est inséré dans un calendrier LOCAL « HormoneTrack »
* (visible dans l'agenda du téléphone) avec une répétition RRULE dérivée de la
* **Posologie** du traitement (forecastIntervalDays).
*
* Cycle de vie :
* - activation (switch « Événement d'agenda » dans l'éditeur de traitement,
* sous « Rappels ») → [upsertEvent] crée/met à jour l'événement et l'id est
* stocké sur le traitement ;
* - désactivation / suppression / changement de posologie → l'ancien
* événement est supprimé puis recréé au save.
*
* Permission : WRITE_CALENDAR (+ READ_CALENDAR pour les requêtes), demandée à
* l'exécution par l'éditeur au moment de l'activation du switch.
*/
object CalendarEvents {
const val CALENDAR_NAME = "HormoneTrack"
/**
* RRULE de répétition dérivé de la posologie (jours) : répétition DAILY
* tous les N jours (INTERVAL = N). L'intervalle est arrondi à l'entier
* (RRULE n'accepte pas de décimales) — documenté dans l'UI.
* ⚠️ Arrondi demi-supérieur EXPLICITE via floor(x + 0.5) : kotlin.math.round
* arrondit les ties vers l'entier PAIR (6,5 → 6 !) — piégé en v1.3.0.
*/
fun rruleForIntervalDays(intervalDays: Double): String =
"FREQ=DAILY;INTERVAL=${maxOf(1, kotlin.math.floor(intervalDays + 0.5).toInt())}"
/**
* Prochaine occurrence (ms) d'une prise à hh:mm : aujourd'hui si pas encore
* passé, sinon demain. Heure par défaut 12:00 si aucun rappel configuré.
*/
fun nextOccurrenceMs(nowMs: Long, hour: Int, minute: Int): Long {
val cal = Calendar.getInstance(TimeZone.getDefault()).apply {
timeInMillis = nowMs
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (cal.timeInMillis <= nowMs) cal.add(Calendar.DAY_OF_MONTH, 1)
return cal.timeInMillis
}
fun defaultStartMs(treatment: Treatment, nowMs: Long): Long {
val hour = treatment.reminderHour ?: 12
val minute = treatment.reminderMinute ?: 0
return nextOccurrenceMs(nowMs, hour, minute)
}
/**
* Retourne l'id du calendrier local « HormoneTrack » (le crée s'il manque).
* Nécessite WRITE_CALENDAR (l'insertion d'un calendrier local se fait avec
* CALLER_IS_SYNCADAPTER=true).
*/
fun ensureCalendar(context: Context): Long {
val calUri = CalendarContract.Calendars.CONTENT_URI
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build()
context.contentResolver.query(
calUri,
arrayOf(CalendarContract.Calendars._ID, CalendarContract.Calendars.NAME),
"${CalendarContract.Calendars.NAME} = ?",
arrayOf(CALENDAR_NAME),
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getLong(0)
}
}
val values = ContentValues().apply {
put(CalendarContract.Calendars.NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, CALENDAR_NAME)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF4F5BD5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val newUri = context.contentResolver.insert(calUri, values)!!
return ContentUris.parseId(newUri)
}
/**
* Crée OU met à jour l'événement récurrent du traitement :
* - début = prochaine occurrence à l'heure de rappel (ou 12:00) ;
* - répétition = posologie (RRULE FREQ=DAILY;INTERVAL=N) ;
* - si l'événement précédent existe toujours, il est supprimé puis recréé
* (la modification d'un RRULE via l'API étant capricieuse selon les
* fournisseurs d'agenda — supprimer/recréer est fiable).
*
* @return le nouvel id d'événement (à stocker sur le traitement).
*/
fun upsertEvent(context: Context, calendarId: Long, treatment: Treatment): Long {
treatment.calendarEventId?.let { deleteEvent(context, it) }
val start = defaultStartMs(treatment, System.currentTimeMillis())
val end = start + 30 * 60_000L // 30 min de bloc
val tz = TimeZone.getDefault().id
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "${treatment.name} · ${formatDoseLine(treatment)}")
put(
CalendarContract.Events.DESCRIPTION,
"Prise hormonale récurrente (Posologie : tous les " +
"${formatInterval(treatment)} j). Généré par HormoneTrack."
)
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, end)
put(CalendarContract.Events.EVENT_TIMEZONE, tz)
put(
CalendarContract.Events.RRULE,
rruleForIntervalDays(treatment.forecastIntervalDays ?: 1.0)
)
}
val eventUri = CalendarContract.Events.CONTENT_URI
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val eventUriInserted = context.contentResolver.insert(eventUri, values)!!
return ContentUris.parseId(eventUriInserted)
}
/** Supprime l'événement d'agenda du traitement (si présent). */
fun deleteEvent(context: Context, eventId: Long) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build()
try {
context.contentResolver.delete(uri, null, null)
} catch (_: Exception) {
// L'événement peut déjà avoir été supprimé côté agenda — ignorer.
}
}
private fun formatDoseLine(treatment: Treatment): String =
"${formatDose(treatment.doseAmount)} ${treatment.doseUnit}"
private fun formatInterval(treatment: Treatment): String =
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
private fun formatDose(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d)
}

View File

@ -27,6 +27,9 @@ class AppSettings(private val context: Context) {
/** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */ /** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */
val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate") val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate")
/** Dernière version pour laquelle le dialog « Nouveautés » a été montré. */
val CHANGELOG_SEEN = stringPreferencesKey("changelog_seen_version")
} }
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs -> val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -46,6 +49,11 @@ class AppSettings(private val context: Context) {
prefs[Keys.AUTO_CALIBRATE] ?: false prefs[Keys.AUTO_CALIBRATE] ?: false
} }
/** Version dont les nouveautés ont déjà été montrées (null = jamais). */
val changelogSeenVersion: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.CHANGELOG_SEEN]
}
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
@ -65,4 +73,10 @@ class AppSettings(private val context: Context) {
prefs[Keys.AUTO_CALIBRATE] = enabled prefs[Keys.AUTO_CALIBRATE] = enabled
} }
} }
suspend fun setChangelogSeenVersion(version: String) {
context.dataStore.edit { prefs ->
prefs[Keys.CHANGELOG_SEEN] = version
}
}
} }

View File

@ -0,0 +1,62 @@
package com.hormonetrack.settings
/**
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
*
* Le contenu vient de `docs/CHANGELOG.md` embarqué comme asset (synchronisé
* automatiquement à chaque build par la tâche Gradle `copyChangelog`).
*/
object ChangelogHelper {
/**
* Compare deux versions SemVer « X.Y.Z » NUMÉRIQUEMENT (composant par
* composant) — une comparaison lexicographique considérerait à tort
* « 1.2.9 > 1.2.10 ».
*/
fun isVersionNewer(current: String, reference: String?): Boolean {
if (reference.isNullOrBlank()) return true // première installation
val c = parse(current) ?: return true
val r = parse(reference) ?: return true
for (i in 0 until maxOf(c.size, r.size)) {
val a = c.getOrElse(i) { 0 }
val b = r.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false // identiques
}
private fun parse(version: String): List<Int>? {
val cleaned = version.trim().removePrefix("v")
if (!Regex("^\\d+(\\.\\d+)*$").matches(cleaned)) return null
return cleaned.split(".").map { it.toInt() }
}
/**
* Extrait de la CHANGELOG (markdown) les sections des versions STRICTEMENT
* plus récentes que `lastSeenVersion` (null = première installation → la
* version courante seule), jusqu'à `currentVersion` incluse.
*
* Format attendu : des sections « ## [X.Y.Z] — … » avec leur corps, la plus
* récente EN PREMIER (convention Keep a Changelog du dépôt).
*
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
*/
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
val pattern = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
val out = StringBuilder()
for (match in pattern.findAll(markdown)) {
val version = match.groupValues[1]
// Sections triées de la plus récente à la plus ancienne :
// - inclure ce qui est STRICTEMENT plus récent que la dernière version
// vue (lastSeen null = première installation → section courante seule) ;
// - s'arrêter dès qu'on descend sous lastSeen (déjà vu).
if (lastSeenVersion != null && !isVersionNewer(version, lastSeenVersion)) break
if (lastSeenVersion == null && version != currentVersion) continue
if (out.isNotEmpty()) out.append("\n\n---\n\n")
out.append("## Version ").append(version).append("\n\n")
out.append(match.groupValues[2].trim())
}
return out.toString().trim()
}
}

View File

@ -15,8 +15,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.first
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
@ -43,6 +48,34 @@ private data class TabItem(val route: String, val labelRes: Int, val icon: andro
@Composable @Composable
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
val context = androidx.compose.ui.platform.LocalContext.current
// Dialog « Nouveautés » (v1.3.0) : affiché après une mise à jour (ou à la
// première installation) avec le changelog des versions non vues ; la
// version vue est mémorisée dans DataStore → ne réapparaît pas avant la
// prochaine mise à jour.
var changelogText by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
val seen = app.container.settings.changelogSeenVersion.first()
val markdown = try {
context.assets.open("changelog.md").bufferedReader().use { it.readText() }
} catch (_: Exception) {
"" // asset manquant (build sans copyChangelog) → pas de dialog
}
val text = com.hormonetrack.settings.ChangelogHelper.sectionsSince(
markdown, seen, com.hormonetrack.BuildConfig.VERSION_NAME
)
if (text.isNotBlank()) changelogText = text
app.container.settings.setChangelogSeenVersion(com.hormonetrack.BuildConfig.VERSION_NAME)
}
changelogText?.let { md ->
com.hormonetrack.ui.components.ChangelogDialog(
changelogMarkdown = md,
version = com.hormonetrack.BuildConfig.VERSION_NAME,
onDismiss = { changelogText = null }
)
}
CompositionLocalProvider(LocalAppContainer provides app.container) { CompositionLocalProvider(LocalAppContainer provides app.container) {
val navController = rememberNavController() val navController = rememberNavController()
val tabs = listOf( val tabs = listOf(

View File

@ -0,0 +1,70 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
/**
* Dialog « Nouveautés » (v1.3.0) : affiché au démarrage après une mise à jour
* (ou à la première installation) avec le changelog des versions depuis la
* dernière vue ; fermable, et ne réapparaît pas jusqu'à la prochaine mise à
* jour (la version vue est mémorisée dans DataStore par l'appelant).
*/
@Composable
fun ChangelogDialog(changelogMarkdown: String, version: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) }
},
title = { Text(stringResource(R.string.changelog_title, version)) },
text = {
Column(
Modifier
.fillMaxWidth()
.height(360.dp)
.verticalScroll(rememberScrollState())
) {
// Rendu simple du markdown : les lignes de titre ressortent en gras,
// le reste en texte courant (pas de lib de rendu markdown)
changelogMarkdown.lines().forEach { line ->
val trimmed = line.trim()
when {
trimmed.startsWith("###") -> Text(
trimmed.removePrefix("###").trim(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
trimmed.startsWith("##") -> Text(
trimmed.removePrefix("##").trim(),
style = MaterialTheme.typography.titleLarge
)
trimmed.startsWith("-") -> Text(
"• " + trimmed.removePrefix("-").trim(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
)
trimmed.isEmpty() -> Text(" ")
else -> Text(
trimmed,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
)
}

View File

@ -1,6 +1,7 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -321,10 +322,19 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- About --- // --- About : version installée + lien releases Gitea ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodyMedium
)
TextButton(onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
)
}) { Text(stringResource(R.string.releases_link)) }
Text( Text(
stringResource(R.string.disclaimer), stringResource(R.string.disclaimer),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,

View File

@ -51,7 +51,11 @@ import com.hormonetrack.data.model.PKPresets
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.reminder.CalendarEvents
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -87,6 +91,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
// Section « Fréquence » : simulation prévisionnelle des doses à venir // Section « Fréquence » : simulation prévisionnelle des doses à venir
var forecastEnabled by remember { mutableStateOf(false) } var forecastEnabled by remember { mutableStateOf(false) }
var forecastDaysText by remember { mutableStateOf("") } var forecastDaysText by remember { mutableStateOf("") }
// Section « Agenda » : événement récurrent dans l'agenda du téléphone
var calendarEnabled by remember { mutableStateOf(false) }
var calendarPermissionGranted by remember { mutableStateOf(false) }
val calendarPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants -> calendarPermissionGranted = grants.values.all { it } }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showPresetMenu by remember { mutableStateOf(false) } var showPresetMenu by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) }
@ -116,6 +127,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
forecastEnabled = true forecastEnabled = true
forecastDaysText = formatDose(fi) forecastDaysText = formatDose(fi)
} }
if (tr.calendarEventId != null) {
calendarEnabled = true
calendarPermissionGranted = true // déjà accordée pour l'event existant
}
} }
loading = false loading = false
} }
@ -401,6 +416,58 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// --- Section « Agenda » : événement récurrent (posologie) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.agenda_section), style = MaterialTheme.typography.titleMedium)
Switch(
checked = calendarEnabled,
onCheckedChange = { want ->
if (want && !calendarPermissionGranted) {
calendarPermissionLauncher.launch(
arrayOf(
android.Manifest.permission.WRITE_CALENDAR,
android.Manifest.permission.READ_CALENDAR
)
)
// activé seulement si la permission est accordée (callback)
if (calendarPermissionGranted) calendarEnabled = true
} else if (want) {
calendarEnabled = true
} else {
calendarEnabled = false
}
}
)
}
if (calendarEnabled) {
if (forecastEnabled) {
Text(
stringResource(R.string.agenda_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
stringResource(R.string.agenda_needs_posology),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
}
Text(
stringResource(R.string.agenda_permission),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(Modifier.height(8.dp))
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -428,6 +495,25 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} else { } else {
scheduler.cancel(savedId) scheduler.cancel(savedId)
} }
// Événement d'agenda : upsert si activé + posologie définie,
// suppression sinon (et si un événement existait)
var eventToStore = saved.calendarEventId
try {
if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null
&& calendarPermissionGranted
) {
val calId = CalendarEvents.ensureCalendar(context)
eventToStore = CalendarEvents.upsertEvent(context, calId, saved)
} else if (saved.calendarEventId != null) {
CalendarEvents.deleteEvent(context, saved.calendarEventId!!)
eventToStore = null
}
if (eventToStore != saved.calendarEventId) {
repo.updateTreatment(saved.copy(calendarEventId = eventToStore))
}
} catch (_: SecurityException) {
// permission agenda révoquée entre-temps → ignorer
}
withContext(Dispatchers.Main) { onDone() } withContext(Dispatchers.Main) { onDone() }
} }
}, },

View File

@ -33,6 +33,14 @@
<string name="show_extrema">Pics / creux</string> <string name="show_extrema">Pics / creux</string>
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string> <string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string> <string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
<string name="changelog_title">Nouveautés de la version %1$s</string>
<string name="settings_version">Version installée : %1$s</string>
<string name="releases_link">Dernières versions sur Gitea (télécharger les APK)</string>
<string name="agenda_section">Agenda</string>
<string name="agenda_switch">Événement d\'agenda récurrent</string>
<string name="agenda_needs_posology">Renseigne d\'abord une Posologie (intervalle en jours) — l\'événement se répète tous les N jours dans ton agenda.</string>
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string> <string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string>
<string name="back_to_present">Revenir à maintenant</string> <string name="back_to_present">Revenir à maintenant</string>
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string> <string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>

View File

@ -33,6 +33,14 @@
<string name="show_extrema">Peaks / troughs</string> <string name="show_extrema">Peaks / troughs</string>
<string name="zoom_in">Zoom in (shorter window)</string> <string name="zoom_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string> <string name="zoom_out">Zoom out (longer window)</string>
<string name="changelog_title">What\'s new in %1$s</string>
<string name="settings_version">Installed version: %1$s</string>
<string name="releases_link">Latest releases on Gitea (download APKs)</string>
<string name="agenda_section">Calendar</string>
<string name="agenda_switch">Recurring calendar event</string>
<string name="agenda_needs_posology">Set a Posology interval first — the event repeats every N days in your phone calendar.</string>
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string> <string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string>
<string name="back_to_present">Back to now</string> <string name="back_to_present">Back to now</string>
<string name="drag_hint">Drag the chart to the right to travel into the past.</string> <string name="drag_hint">Drag the chart to the right to travel into the past.</string>

View File

@ -0,0 +1,26 @@
package com.hormonetrack.reminder
import org.junit.Assert.assertEquals
import org.junit.Test
/** RRULE de l'événement d'agenda : répétition DAILY tous les N jours. */
class CalendarRruleTest {
@Test
fun `intervalle entier donne un RRULE exact`() {
assertEquals("FREQ=DAILY;INTERVAL=6", CalendarEvents.rruleForIntervalDays(6.0))
assertEquals("FREQ=DAILY;INTERVAL=14", CalendarEvents.rruleForIntervalDays(14.0))
}
@Test
fun `intervalle decimal arrondi a l entier le plus proche`() {
assertEquals("FREQ=DAILY;INTERVAL=7", CalendarEvents.rruleForIntervalDays(6.5))
assertEquals("FREQ=DAILY;INTERVAL=6", CalendarEvents.rruleForIntervalDays(6.4))
}
@Test
fun `intervalle nul ou negatif est clampe a 1 jour`() {
assertEquals("FREQ=DAILY;INTERVAL=1", CalendarEvents.rruleForIntervalDays(0.0))
assertEquals("FREQ=DAILY;INTERVAL=1", CalendarEvents.rruleForIntervalDays(-2.0))
}
}

View File

@ -0,0 +1,80 @@
package com.hormonetrack.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** Helper « Nouveautés » : comparaison SemVer + extraction de sections. */
class ChangelogHelperTest {
private val markdown = """
## [1.3.0] — changelog 1.3.0
### Ajouté
- truc 1.3.0
## [1.2.10] — changelog 1.2.10
- truc 1.2.10
## [1.2.9] — changelog 1.2.9
- truc 1.2.9
""".trim()
@Test
fun `comparaison semver numerique - 1_2_9 plus vieux que 1_2_10`() {
// une comparaison lexicographique donnerait 1.2.9 > 1.2.10 — piège épinglé
assertTrue(ChangelogHelper.isVersionNewer("1.2.10", "1.2.9"))
assertFalse(ChangelogHelper.isVersionNewer("1.2.9", "1.2.10"))
}
@Test
fun `versions identiques ne sont pas plus recentes`() {
assertFalse(ChangelogHelper.isVersionNewer("1.2.10", "1.2.10"))
}
@Test
fun `premiere installation - reference nulle est toujours plus ancienne`() {
assertTrue(ChangelogHelper.isVersionNewer("1.3.0", null))
}
@Test
fun `sections extraites depuis la derniere version vue`() {
val out = ChangelogHelper.sectionsSince(markdown, "1.2.10", "1.3.0")
assertTrue(out.contains("1.3.0"))
assertFalse(out.contains("1.2.10"))
assertFalse(out.contains("1.2.9"))
}
@Test
fun `mise a jour de plusieurs versions - toutes les sections intermediaires`() {
// dernière vue 1.2.8 (absente de la markdown) → tout ce qui est plus
// récent jusqu'à la version courante est inclus
val out = ChangelogHelper.sectionsSince(markdown, "1.2.8", "1.3.0")
assertTrue(out.contains("1.3.0"))
assertTrue(out.contains("1.2.10"))
assertTrue(out.contains("1.2.9"))
}
@Test
fun `premiere installation - section courante seule`() {
val out = ChangelogHelper.sectionsSince(markdown, null, "1.3.0")
assertTrue(out.contains("1.3.0"))
assertFalse(out.contains("1.2.10"))
}
@Test
fun `aucune nouveauté - version vue égale à la courante`() {
assertEquals("", ChangelogHelper.sectionsSince(markdown, "1.3.0", "1.3.0"))
}
@Test
fun `versions non numeriques ignorees proprement`() {
// markdown avec une section exotique entre les deux
val md = markdown.replace("## [1.2.10]", "## [1.2.10-beta]")
val out = ChangelogHelper.sectionsSince(md, "1.2.9", "1.3.0")
assertTrue(out.contains("1.3.0"))
}
}

View File

@ -1,7 +1,29 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.10`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.0`, …).
## [1.3.0] — 2026-09-06 (versionCode 14)
### Ajouté
- **Dialog « Nouveautés »** : après chaque mise à jour (et à la première
installation), l'app affiche le changelog des versions non vues dans une
fenêtre fermable — ne réapparaît pas avant la prochaine mise à jour
(version vue mémorisée). Contenu = `docs/CHANGELOG.md` embarqué en asset,
synchronisé automatiquement à chaque build (tâche Gradle `copyChangelog`).
- **Événements d'agenda récurrents** : dans l'éditeur de traitement, sous
« Rappels », un switch « Événement d'agenda récurrent » crée un événement
dans un calendrier local dédié « HormoneTrack » (visible dans l'agenda du
téléphone) avec répétition RRULE dérivée de la **Posologie** (tous les N
jours, arrondi demi-supérieur). Permission agenda demandée à l'activation ;
suppression/recréation automatique au save.
- **Paramètres : version installée + lien cliquable** vers les releases Gitea
(télécharger les APK sans attendre le rappel de mise à jour).
- 11 nouveaux tests (78 au total) : ChangelogHelper (8 : comparaison SemVer
numérique 1.2.9 < 1.2.10 — lexicographique aurait tort, extraction de
sections, première installation), CalendarRruleTest (3 : RRULE, arrondi
demi-supérieur — **kotlin.math.round arrondit les ties vers l'entier PAIR
(6,5 → 6)**, piège documenté).
## [1.2.10] — 2026-09-05 (versionCode 13) ## [1.2.10] — 2026-09-05 (versionCode 13)

View File

@ -76,6 +76,7 @@ Fonctionnalités v1 :
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. | | 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. |
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. | | 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. |
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. | | 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
| 6 sept. 2026 (session v1.3.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. |
| 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → **« Posologie »** (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. | | 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → **« Posologie »** (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. |
@ -346,7 +347,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires ## 8. Tests unitaires
**64 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les **78 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les
données de test locales (cf §8.bis). Dépendance : JUnit 4.13.2. données de test locales (cf §8.bis). 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 =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -418,6 +419,12 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T - **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ; même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant). ordre chronologique décroissant).
- **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique**
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
de sections depuis la dernière vue, première installation (section courante
seule), plusieurs versions intermédiaires, versions non numériques.
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) : - **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema, alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema,
pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate → pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate →
@ -450,7 +457,16 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()` - `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
(jamais un Flow en runBlocking !) → reschedule (jamais un Flow en runBlocking !) → reschedule
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`. **Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans
l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié
« HormoneTrack »** (CalendarContract, ACCOUNT_TYPE_LOCAL) d'un événement
récurrent RRULE `FREQ=DAILY;INTERVAL=N` (N = Posologie, arrondi demi-supérieur
**explicite** : `floor(x + 0.5)` — kotlin.math.round arrondit les ties vers
l'entier PAIR !), début = prochaine occurrence à l'heure de rappel (ou 12:00).
L'id est stocké sur le traitement (`calendarEventId`, Room v3) ; au save :
supprimer + recréer (fiable) si changé, supprimer si désactivé. Permissions
WRITE_CALENDAR + READ_CALENDAR demandées à l'activation du switch.
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`, `WRITE_CALENDAR`, `READ_CALENDAR`.
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
## 10. UI & navigation ## 10. UI & navigation
@ -492,7 +508,15 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch + switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch +
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save → TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
- **Dialog « Nouveautés » (v1.3.0)** : au démarrage, si `BuildConfig.VERSION_NAME`
est plus récente que la dernière vue (DataStore `changelog_seen_version`),
un AlertDialog affiche les sections CHANGELOG concernées (extraites par
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
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) ; - `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**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, **« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ; « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
@ -765,6 +789,10 @@ Le tout est automatisé par `scripts/gitea-release.py` — **multi-instances**
(`cloudyfy` par défaut, `farewell`) : (`cloudyfy` par défaut, `farewell`) :
```bash ```bash
# 0. Le CHANGELOG est embarqué dans l'app (dialog « Nouveautés ») :
# la tâche Gradle copyChangelog copie docs/CHANGELOG.md vers
# src/main/assets/changelog.md à chaque build (gitignoré, auto)
# 1. Construire les DEUX APK au niveau du tag (debug + release R8) # 1. Construire les DEUX APK au niveau du tag (debug + release R8)
git checkout vX.Y.Z git checkout vX.Y.Z
./gradlew assembleRelease assembleDebug # release = recommandé (R8, cf plus bas) ./gradlew assembleRelease assembleDebug # release = recommandé (R8, cf plus bas)
@ -962,4 +990,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
sur le graphique** et la calibration couvre toujours ses périodes sur le graphique** et la calibration couvre toujours ses périodes
--- ---
*Doc mise à jour le 5 sept. 2026 (v1.2.10) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.* *Doc mise à jour le 6 sept. 2026 (v1.3.0) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.*

View File

@ -87,7 +87,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
parfait pour une transition valerate → enanthate parfait pour une transition valerate → enanthate
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods` - Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo) - Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées (date/heure exactes, dose en mg) 2. **Doses → +** → logue tes injections passées
- Astuce mise à jour : l'app affiche les **nouveautés** de chaque version au
démarrage (fermable) ; la **version installée** et le **lien des releases**
sont dans Paramètres (date/heure exactes, dose en mg)
- Astuce : tu peux changer l'ester par injection (comme dans ton tableur) - Astuce : tu peux changer l'ester par injection (comme dans ton tableur)
- **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses - **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de (traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de