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:
parent
41b9c82a52
commit
a622650c94
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
164
app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt
Normal file
164
app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt
Normal 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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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() }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 & creux estimés (par courbe)</string>
|
<string name="legend_extrema">▲▼ pics & 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>
|
||||||
|
|||||||
@ -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 & troughs (per curve)</string>
|
<string name="legend_extrema">▲▼ estimated peaks & 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>
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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.*
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user