diff --git a/.gitignore b/.gitignore index 44835e3..d771ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ Thumbs.db # Logs *.log local-test-data/ +app/build/generated/changelog/ +app/src/main/assets/changelog.md diff --git a/README.md b/README.md index 605e7bf..c30f4ff 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.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) - **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) @@ -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 - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; 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) - **FR + EN** (langue par app, indépendante du système) - UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4bd2cb..bd52e87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.hormonetrack" minSdk = 26 targetSdk = 36 - versionCode = 13 - versionName = "1.2.10" + versionCode = 14 + versionName = "1.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -47,6 +47,8 @@ android { buildFeatures { compose = true + // BuildConfig.VERSION_NAME : écran Paramètres + dialog de changelog + buildConfig = true } 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("copyChangelog") { + from(rootProject.file("docs/CHANGELOG.md")) + into(file("src/main/assets")) + rename { "changelog.md" } +} +tasks.named("preBuild") { dependsOn("copyChangelog") } + dependencies { // BOM Compose : épingle toutes les versions des artefacts androidx.compose.* // (material3 >= 1.4 = Material 3 Expressive) diff --git a/app/src/main/java/com/hormonetrack/data/AppDatabase.kt b/app/src/main/java/com/hormonetrack/data/AppDatabase.kt index f60944c..acd8510 100644 --- a/app/src/main/java/com/hormonetrack/data/AppDatabase.kt +++ b/app/src/main/java/com/hormonetrack/data/AppDatabase.kt @@ -23,7 +23,7 @@ import com.hormonetrack.data.model.Treatment */ @Database( entities = [Treatment::class, DoseLog::class, LabResult::class], - version = 2, + version = 3, exportSchema = false ) 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 private var INSTANCE: AppDatabase? = null @@ -53,7 +63,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "hormonetrack.db" ) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() INSTANCE = instance instance diff --git a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt index 7bfd8e7..5b4129a 100644 --- a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt +++ b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt @@ -64,6 +64,11 @@ data class Treatment( val reminderMinute: Int? = null, 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 isInjection: Boolean diff --git a/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt b/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt new file mode 100644 index 0000000..175beb7 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt @@ -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) +} diff --git a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt index 43aa770..0c991be 100644 --- a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt +++ b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt @@ -27,6 +27,9 @@ class AppSettings(private val context: Context) { /** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */ 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 = context.dataStore.data.map { prefs -> @@ -46,6 +49,11 @@ class AppSettings(private val context: Context) { prefs[Keys.AUTO_CALIBRATE] ?: false } + /** Version dont les nouveautés ont déjà été montrées (null = jamais). */ + val changelogSeenVersion: Flow = context.dataStore.data.map { prefs -> + prefs[Keys.CHANGELOG_SEEN] + } + suspend fun setTConfig(config: TConfig) { context.dataStore.edit { prefs -> prefs[Keys.T_BASE] = config.base @@ -65,4 +73,10 @@ class AppSettings(private val context: Context) { prefs[Keys.AUTO_CALIBRATE] = enabled } } + + suspend fun setChangelogSeenVersion(version: String) { + context.dataStore.edit { prefs -> + prefs[Keys.CHANGELOG_SEEN] = version + } + } } diff --git a/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt b/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt new file mode 100644 index 0000000..d457de8 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt @@ -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? { + 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() + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt b/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt index aeedd79..aa4be7a 100644 --- a/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt +++ b/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt @@ -15,8 +15,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf 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.res.stringResource import androidx.navigation.NavGraph.Companion.findStartDestination @@ -43,6 +48,34 @@ private data class TabItem(val route: String, val labelRes: Int, val icon: andro @Composable fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { 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(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) { val navController = rememberNavController() val tabs = listOf( diff --git a/app/src/main/java/com/hormonetrack/ui/components/ChangelogDialog.kt b/app/src/main/java/com/hormonetrack/ui/components/ChangelogDialog.kt new file mode 100644 index 0000000..e4a5967 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/components/ChangelogDialog.kt @@ -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 + ) + } + } + } + } + ) +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index 1594431..5243587 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -1,6 +1,7 @@ package com.hormonetrack.ui.screens import android.content.Intent +import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult @@ -321,10 +322,19 @@ fun SettingsScreen(onBack: () -> Unit) { } Spacer(Modifier.height(12.dp)) - // --- About --- + // --- About : version installée + lien releases Gitea --- Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { 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( stringResource(R.string.disclaimer), style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt index cc677d9..4f57148 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt @@ -51,7 +51,11 @@ import com.hormonetrack.data.model.PKPresets import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.TreatmentType 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.CalendarEvents import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.components.formatDose import kotlinx.coroutines.CoroutineScope @@ -87,6 +91,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { // Section « Fréquence » : simulation prévisionnelle des doses à venir var forecastEnabled by remember { mutableStateOf(false) } 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 showPresetMenu by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) } @@ -116,6 +127,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { forecastEnabled = true forecastDaysText = formatDose(fi) } + if (tr.calendarEventId != null) { + calendarEnabled = true + calendarPermissionGranted = true // déjà accordée pour l'event existant + } } loading = false } @@ -401,6 +416,58 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { } 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( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() @@ -428,6 +495,25 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { } else { 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() } } }, diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 77974d8..a29a261 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -33,6 +33,14 @@ Pics / creux Zoom avant (fenêtre plus courte) Zoom arrière (fenêtre plus longue) + Nouveautés de la version %1$s + Version installée : %1$s + Dernières versions sur Gitea (télécharger les APK) + Agenda + Événement d\'agenda récurrent + Renseigne d\'abord une Posologie (intervalle en jours) — l\'événement se répète tous les N jours dans ton agenda. + 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. + L\'autorisation agenda est demandée à l\'activation. ▲▼ pics & creux estimés (par courbe) Revenir à maintenant Fais glisser le graphique vers la droite pour remonter dans le passé. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44aaff1..5b012bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,14 @@ Peaks / troughs Zoom in (shorter window) Zoom out (longer window) + What\'s new in %1$s + Installed version: %1$s + Latest releases on Gitea (download APKs) + Calendar + Recurring calendar event + Set a Posology interval first — the event repeats every N days in your phone calendar. + 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. + Calendar permission is requested when you enable this. ▲▼ estimated peaks & troughs (per curve) Back to now Drag the chart to the right to travel into the past. diff --git a/app/src/test/java/com/hormonetrack/reminder/CalendarRruleTest.kt b/app/src/test/java/com/hormonetrack/reminder/CalendarRruleTest.kt new file mode 100644 index 0000000..8dca440 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/reminder/CalendarRruleTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/hormonetrack/settings/ChangelogHelperTest.kt b/app/src/test/java/com/hormonetrack/settings/ChangelogHelperTest.kt new file mode 100644 index 0000000..25a126b --- /dev/null +++ b/app/src/test/java/com/hormonetrack/settings/ChangelogHelperTest.kt @@ -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")) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a463e3e..9752c1b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,29 @@ # 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.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) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index ad1fa8b..491b9c9 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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.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. | +| 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. | | 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 -**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. 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/…`). @@ -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 même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ; 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`) : 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 → @@ -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()` (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). ## 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 + TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save → 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) ; + **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, 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 ; @@ -765,6 +789,10 @@ Le tout est automatisé par `scripts/gitea-release.py` — **multi-instances** (`cloudyfy` par défaut, `farewell`) : ```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) git checkout vX.Y.Z ./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 --- -*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.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index b8e5c15..0f596a9 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -87,7 +87,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei parfait pour une transition valerate → enanthate - 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) -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) - **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