From 8ab97d4ed464431309313673c9a5eb5102a0653c Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sat, 5 Sep 2026 16:53:53 +0200 Subject: [PATCH] =?UTF-8?q?UI=20v1.2.0=20:=20graphique=20multi-s=C3=A9ries?= =?UTF-8?q?=20panoramique,=20toggles=20mod=C3=A8les/pr=C3=A9vision,=20inte?= =?UTF-8?q?rvalles=20de=20dosage,=20TimePicker=20centr=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CurveChart : API ChartSeries/CurveStyle (superposition de plusieurs modèles, styles plein/pointillé), labs T normalisés en ng/mL au rendu - ChartScreen : panoramique horizontal (remonter dans le passé, bouton « Revenir à maintenant »), toggles Estrannaise/TFS indépendants, chip Prévision (horizon = 2× l'intervalle configuré, borné 7-30 j), auto-calibration intégrée, légende dynamique - HomeScreen : données auto-calibrées pour la courbe 24 h - DosesScreen : intervalle en jours entre chaque dose et la précédente du même traitement (colonne « Interval (d) » du .ods) - TreatmentEditorScreen : section « Fréquence » (switch + intervalle en jours) - SettingsScreen : option Calibration automatique - TimePicker : centré dans ses dialogs (bug d'alignement à gauche signalé) - strings FR/EN complètes pour toutes les nouveautés --- app/build.gradle.kts | 2 +- .../hormonetrack/ui/components/CurveChart.kt | 114 ++++++--- .../ui/components/DateTimeField.kt | 9 +- .../hormonetrack/ui/screens/ChartScreen.kt | 235 ++++++++++++++++-- .../hormonetrack/ui/screens/DosesScreen.kt | 24 ++ .../com/hormonetrack/ui/screens/HomeScreen.kt | 33 ++- .../hormonetrack/ui/screens/SettingsScreen.kt | 28 +++ .../ui/screens/TreatmentEditorScreen.kt | 55 +++- .../java/com/hormonetrack/ui/theme/Theme.kt | 12 + app/src/main/res/values-fr/strings.xml | 17 +- app/src/main/res/values/strings.xml | 17 +- 11 files changed, 477 insertions(+), 69 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 763ddfe..425919d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,7 +51,7 @@ android { dependencies { // BOM Compose : épingle toutes les versions des artefacts androidx.compose.* // (material3 >= 1.4 = Material 3 Expressive) - val composeBom = platform("androidx.compose:compose-bom:2026.06.01") + val composeBom = platform("androidx.compose:compose-bom:2026.08.00") implementation(composeBom) // Core diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index fa27cb6..5b7bca6 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.unit.dp import com.hormonetrack.data.model.LabResult import com.hormonetrack.pk.LevelPoint +import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.ui.theme.ChartE2 import com.hormonetrack.ui.theme.ChartT import com.hormonetrack.ui.theme.LabDot @@ -23,12 +24,32 @@ import java.util.Date import java.util.Locale import kotlin.math.ceil +/** + * Style d'une courbe dessinée sur le graphique. + * @param color couleur du tracé + * @param dashed true = trait pointillé (utilisé pour distinguer TFS d'Estrannaise, ou T) + */ +data class CurveStyle(val color: Color, val dashed: Boolean = false) + +/** + * Une série = un jeu de points (LevelPoint avec e2 ET t) + les styles de tracé + * associés. Le graphique superpose autant de séries qu'on veut (ex. : les deux + * modèles Estrannaise et Transfem Science côte à côte). + */ +data class ChartSeries( + val points: List, + val e2Style: CurveStyle, + /** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */ + val tStyle: CurveStyle? = null +) + data class ChartOptions( val showT: Boolean = true, val showLabs: Boolean = true, val nowMs: Long = System.currentTimeMillis() ) +/** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */ fun niceCeil(value: Double): Double { if (value <= 0) return 100.0 val exp = Math.pow(10.0, Math.floor(Math.log10(value))) @@ -43,9 +64,18 @@ fun niceCeil(value: Double): Double { return nice * exp } +/** + * Graphique Canvas pur, multi-séries : + * - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ; + * - toutes les séries partagent les mêmes échelles (comparaison directe des modèles) ; + * - les labs E2 sont des points orange ronds, les labs T des carrés orange + * (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs + * peuvent être saisis en ng/dL ou nmol/L) ; + * - ligne verticale « maintenant ». + */ @Composable fun CurveChart( - points: List, + series: List, e2Labs: List, tLabs: List, options: ChartOptions, @@ -56,7 +86,8 @@ fun CurveChart( val nowLineColor = MaterialTheme.colorScheme.tertiary Canvas(modifier = modifier) { - if (points.size < 2) return@Canvas + val allPoints = series.flatMap { it.points } + if (allPoints.size < 2) return@Canvas val padLeft = 42.dp.toPx() val padRight = 42.dp.toPx() val padTop = 12.dp.toPx() @@ -66,20 +97,23 @@ fun CurveChart( val h = size.height - padTop - padBottom if (w <= 0 || h <= 0) return@Canvas - val t0 = points.first().timestamp - val t1 = points.last().timestamp + // Fenêtre temporelle commune à toutes les séries (min/max) + val t0 = allPoints.minOf { it.timestamp } + val t1 = allPoints.maxOf { it.timestamp } if (t1 <= t0) return@Canvas + val convertedTLabs = tLabs.map { lab -> + lab.copy(value = PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit)) + } + val e2DataMax = maxOf( - points.maxOf { it.e2 }, + allPoints.maxOf { it.e2 }, if (options.showLabs) e2Labs.maxOfOrNull { it.value } ?: 0.0 else 0.0 ) val e2Max = niceCeil(maxOf(e2DataMax, 50.0)) val tDataMax = maxOf( - points.maxOf { it.t }, - if (options.showLabs) { - tLabs.maxOfOrNull { com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(it.value, it.unit) } ?: 0.0 - } else 0.0 + allPoints.maxOf { it.t }, + if (options.showLabs) convertedTLabs.maxOfOrNull { it.value } ?: 0.0 else 0.0 ) val tMax = niceCeil(tDataMax * 1.1) @@ -89,33 +123,51 @@ fun CurveChart( drawGrid(gridColor, padLeft, padTop, w, h, rows = 4) drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h) - if (options.showT) drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h) + if (options.showT && series.any { it.tStyle != null }) { + drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h) + } drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w) - val path = Path() - points.forEachIndexed { i, p -> - val x = xOf(p.timestamp) - val y = yE2(p.e2) - if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) - } - drawPath(path, ChartE2, style = Stroke(width = 2.5.dp.toPx())) - - if (options.showT) { - val tPath = Path() - points.forEachIndexed { i, p -> + // Une courbe E2 + une courbe T par série + for (s in series) { + if (s.points.size < 2) continue + val e2Path = Path() + s.points.forEachIndexed { i, p -> val x = xOf(p.timestamp) - val y = yT(p.t) - if (i == 0) tPath.moveTo(x, y) else tPath.lineTo(x, y) + val y = yE2(p.e2) + if (i == 0) e2Path.moveTo(x, y) else e2Path.lineTo(x, y) } drawPath( - tPath, ChartT, + e2Path, s.e2Style.color, style = Stroke( - width = 2.dp.toPx(), - pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f)) + width = 2.5.dp.toPx(), + pathEffect = if (s.e2Style.dashed) { + PathEffect.dashPathEffect(floatArrayOf(12f, 10f)) + } else null ) ) + if (options.showT) { + s.tStyle?.let { tStyle -> + val tPath = Path() + s.points.forEachIndexed { i, p -> + val x = xOf(p.timestamp) + val y = yT(p.t) + if (i == 0) tPath.moveTo(x, y) else tPath.lineTo(x, y) + } + drawPath( + tPath, tStyle.color, + style = Stroke( + width = 2.dp.toPx(), + pathEffect = if (tStyle.dashed) { + PathEffect.dashPathEffect(floatArrayOf(12f, 10f)) + } else null + ) + ) + } + } } + // Labs (points/carrés oranges) if (options.showLabs) { e2Labs.forEach { lab -> if (lab.timestamp in t0..t1) { @@ -124,21 +176,20 @@ fun CurveChart( drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx())) } } - tLabs.forEach { lab -> + convertedTLabs.forEach { lab -> if (lab.timestamp in t0..t1) { - // labs may be in ng/dL or nmol/L: normalize to the model's ng/mL - val tVal = com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit) - val c = Offset(xOf(lab.timestamp), yT(tVal)) + val c = Offset(xOf(lab.timestamp), yT(lab.value)) drawRect( LabDot, topLeft = Offset(c.x - 4.dp.toPx(), c.y - 4.dp.toPx()), size = Size(8.dp.toPx(), 8.dp.toPx()) ) - drawDotLabel(tVal, Offset(c.x, c.y - 10.dp.toPx())) + drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx())) } } } + // Repère « maintenant » if (options.nowMs in t0..t1) { drawLine( nowLineColor, @@ -157,6 +208,7 @@ private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: F } } +/** Paint partagé pour les labels texte (couleur gris neutre fixe). */ private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint { val c = Color(0xFF6B7280) return android.graphics.Paint().apply { diff --git a/app/src/main/java/com/hormonetrack/ui/components/DateTimeField.kt b/app/src/main/java/com/hormonetrack/ui/components/DateTimeField.kt index 5dd4984..eb63b75 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/DateTimeField.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/DateTimeField.kt @@ -91,7 +91,14 @@ fun DateTimeField( TextButton(onClick = { showTime = false }) { Text(stringResource(R.string.cancel)) } } ) { - TimePicker(state = state) + // Le contenu d'AlertDialog est aligné à gauche : on centre l'horloge + // explicitement (bug d'affichage signalé — horloge partant vers la gauche). + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + TimePicker(state = state) + } } } } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index 48bc776..95494cf 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -1,5 +1,6 @@ package com.hormonetrack.ui.screens +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,28 +14,57 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.hormonetrack.R +import com.hormonetrack.data.model.Treatment import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.TConfig import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.components.ChartOptions +import com.hormonetrack.ui.components.ChartSeries import com.hormonetrack.ui.components.CurveChart +import com.hormonetrack.ui.components.CurveStyle +import com.hormonetrack.ui.theme.ChartE2 +import com.hormonetrack.ui.theme.ChartT +import com.hormonetrack.ui.theme.LabDot +import com.hormonetrack.ui.theme.TealTertiary import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +private const val HOUR_MS = 3_600_000L +private const val DAY_MS = 24 * HOUR_MS + +/** + * Écran Graphiques. + * + * Fonctions : + * - plages 24 h / 7 j / 30 j ; + * - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé ; + * - **les deux modèles PK** (Estrannaise / Transfem Science) peuvent être affichés + * simultanément (override de modèle dans le moteur), avec toggle indépendant ; + * - **prévision** : prolonge la courbe avec des doses projetées à partir de la + * « fréquence » configurée sur chaque traitement ; + * - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T + * recalculés à la volée depuis les labs avant tracé. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChartScreen() { @@ -44,29 +74,109 @@ fun ChartScreen() { val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList()) val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) + val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) var rangeHours by remember { mutableLongStateOf(24L) } + var panHours by remember { mutableLongStateOf(0L) } var showT by remember { mutableStateOf(true) } var showLabs by remember { mutableStateOf(true) } + var showEse by remember { mutableStateOf(true) } + var showTfs by remember { mutableStateOf(true) } + var showForecast by remember { mutableStateOf(false) } + var chartWidthPx by remember { mutableIntStateOf(0) } - val curve by produceState>(emptyList(), treatments, doseLogs, tConfig, rangeHours) { - withContext(Dispatchers.Default) { - val end = System.currentTimeMillis() - value = PharmacokineticEngine.computeCurve( - treatments, doseLogs, - startMs = end - rangeHours * 3_600_000L, - endMs = end, - tConfig = tConfig - ) + // « Maintenant » rafraîchi chaque minute pour le repère vertical + var tick by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + while (true) { + delay(60_000) + tick++ } } - val (a, b) = if (curve.size >= 2) { - curve.first().timestamp to curve.last().timestamp + // Traitement + TConfig éventuellement recalibrés à la volée (option auto-calibration) ; + // les valeurs stockées ne sont jamais modifiées, on ne travaille que sur des copies. + val effective by produceState( + treatments to tConfig, + treatments, doseLogs, labResults, tConfig, autoCalibrate + ) { + value = if (!autoCalibrate) { + treatments to tConfig + } else { + withContext(Dispatchers.Default) { + val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) + r.treatments to r.tConfig + } + } + } + + // Horizon de prévision : 2 × le plus grand intervalle configuré (borné 7–30 jours) + val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() + val forecastHorizonHours = maxIntervalDays?.let { (it * 24 * 2).toLong().coerceIn(DAY_MS / HOUR_MS * 7, DAY_MS / HOUR_MS * 30) } + + // Fenêtre temporelle courante : endMs recule quand on tire vers la droite + val nowMs = System.currentTimeMillis() + val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs + val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L) + val endMs = nowMs - panHours * HOUR_MS + + (if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L) + val startMs = endMs - rangeHours * HOUR_MS + + // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") + val curves by produceState( + emptyList>>(), + effective.first, effective.second, doseLogs, startMs, endMs, showEse, showTfs, showForecast + ) { + withContext(Dispatchers.Default) { + val list = mutableListOf>>() + if (showEse || showTfs) { + // Valeurs actuelles (traitement + TConfig), auto-calibrées si l'option est active + val (effTreatments, effTConfig) = effective + // Doses réelles + doses prévisionnelles éventuelles + val forecastDoses = if (showForecast) { + effTreatments.flatMap { + PharmacokineticEngine.generateForecastDoses(it, doseLogs, endMs, nowMs) + } + } else emptyList() + val allDoses = doseLogs + forecastDoses + if (showEse) { + list.add( + "ESE" to PharmacokineticEngine.computeCurve( + effTreatments, allDoses, startMs, endMs, + tConfig = effTConfig, modelOverride = "ESE" + ) + ) + } + if (showTfs) { + list.add( + "TFS" to PharmacokineticEngine.computeCurve( + effTreatments, allDoses, startMs, endMs, + tConfig = effTConfig, modelOverride = "TFS" + ) + ) + } + } + value = list + } + } + + val allPoints = curves.flatMap { it.second } + val (a, b) = if (allPoints.size >= 2) { + allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp } } else 0L to 0L val e2Labs = labResults.filter { it.marker.equals("E2", true) && it.timestamp in a..b } val tLabs = labResults.filter { it.marker.equals("T", true) && it.timestamp in a..b } + // Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ; + // T Estrannaise = rose plein, T TFS = rose pointillé. + val series = curves.map { (model, points) -> + if (model == "ESE") { + ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT)) + } else { + ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true)) + } + } + Column( Modifier .fillMaxSize() @@ -78,6 +188,7 @@ fun ChartScreen() { Spacer(Modifier.height(8.dp)) + // Plage temporelle Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = rangeHours == 24L, @@ -98,6 +209,7 @@ fun ChartScreen() { Spacer(Modifier.height(8.dp)) + // Affichage : T, labs, prévision Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = showT, @@ -109,13 +221,51 @@ fun ChartScreen() { onClick = { showLabs = !showLabs }, label = { Text(stringResource(R.string.show_labs)) } ) + FilterChip( + selected = showForecast, + onClick = { showForecast = !showForecast }, + enabled = forecastHorizonHours != null, + label = { Text(stringResource(R.string.show_forecast)) } + ) + } + + Spacer(Modifier.height(8.dp)) + + // Modèles PK superposables + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = showEse, + onClick = { showEse = !showEse }, + label = { Text(stringResource(R.string.model_ese)) } + ) + FilterChip( + selected = showTfs, + onClick = { showTfs = !showTfs }, + label = { Text(stringResource(R.string.model_tfs)) } + ) } Spacer(Modifier.height(12.dp)) - Card(Modifier.fillMaxWidth()) { + Card( + Modifier + .fillMaxWidth() + .onSizeChanged { chartWidthPx = it.width } + .pointerInput(rangeHours, maxPanHours) { + // Glisser vers la droite = remonter dans le passé + detectHorizontalDragGestures { change, dragAmount -> + change.consume() + if (chartWidthPx > 0 && maxPanHours > 0) { + val hoursDragged = (dragAmount / chartWidthPx * rangeHours).toLong() + if (hoursDragged != 0L) { + panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours) + } + } + } + } + ) { Column(Modifier.padding(12.dp)) { - if (curve.isEmpty()) { + if (series.isEmpty() || allPoints.size < 2) { Text( stringResource(R.string.no_data), style = MaterialTheme.typography.bodyLarge, @@ -123,34 +273,65 @@ fun ChartScreen() { ) } else { CurveChart( - points = curve, + series = series, e2Labs = e2Labs, tLabs = tLabs, - options = ChartOptions(showT = showT, showLabs = showLabs), + options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs), modifier = Modifier .fillMaxWidth() .height(320.dp) ) Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.legend_e2), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary - ) - if (showT) { - Text( - stringResource(R.string.legend_t), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.secondary - ) + + // Retour au présent dès qu'on a voyagé dans le passé + if (panHours > 0L) { + TextButton(onClick = { panHours = 0L }) { + Text(stringResource(R.string.back_to_present)) + } + } + + // Légende dynamique + curves.forEach { (model, _) -> + if (model == "ESE") { + Text( + stringResource(R.string.legend_ese_e2), + style = MaterialTheme.typography.labelMedium, + color = ChartE2 + ) + if (showT) { + Text( + stringResource(R.string.legend_ese_t), + style = MaterialTheme.typography.labelMedium, + color = ChartT + ) + } + } else { + Text( + stringResource(R.string.legend_tfs_e2), + style = MaterialTheme.typography.labelMedium, + color = TealTertiary + ) + if (showT) { + Text( + stringResource(R.string.legend_tfs_t), + style = MaterialTheme.typography.labelMedium, + color = ChartT + ) + } + } } if (showLabs) { Text( stringResource(R.string.legend_labs), style = MaterialTheme.typography.labelMedium, - color = com.hormonetrack.ui.theme.LabDot + color = LabDot ) } + Text( + stringResource(R.string.drag_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt index 43d6776..ecbeec2 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt @@ -69,6 +69,21 @@ fun DosesScreen() { }.toSortedMap(compareByDescending { it }) } + /** + * Intervalle (en jours) entre chaque dose et la dose précédente du MÊME traitement + * (colonne « Interval (d) » du .ods). Clé = id de la dose. + */ + val intervalsByDoseId = remember(doseLogs) { + val byTreatment = doseLogs.sortedBy { it.timestamp }.groupBy { it.treatmentId } + buildMap { + byTreatment.values.forEach { logs -> + logs.zipWithNext { prev, cur -> + put(cur.id, (cur.timestamp - prev.timestamp) / 86_400_000.0) + } + } + } + } + val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") } Scaffold( @@ -109,6 +124,15 @@ fun DosesScreen() { supportingContent = { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Text(time) + // Intervalle depuis la dose précédente du même traitement + intervalsByDoseId[log.id]?.let { + Text( + stringResource( + R.string.interval_value, + String.format(java.util.Locale.getDefault(), "%.1f", it) + ) + ) + } log.esterType?.takeIf { it.isNotEmpty() && it != "NONE" }?.let { AssistChip(onClick = {}, label = { Text(it) }) } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index 23068ce..926df92 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -50,11 +50,15 @@ import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.TConfig import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.components.ChartOptions +import com.hormonetrack.ui.components.ChartSeries import com.hormonetrack.ui.components.CurveChart +import com.hormonetrack.ui.components.CurveStyle import com.hormonetrack.ui.components.DoseDialog import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.theme.TransPink import com.hormonetrack.ui.theme.TransSky +import com.hormonetrack.ui.theme.ChartE2 +import com.hormonetrack.ui.theme.ChartT import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -81,6 +85,20 @@ fun HomeScreen( val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList()) val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) + val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) + + // Données « effectives » : si l'option auto-calibration est active, on recalcule + // à la volée les facteurs d'échelle et le modèle T depuis les labs (affichage seul) + val effective by produceState(treatments to tConfig, treatments, doseLogs, labResults, tConfig, autoCalibrate) { + value = if (!autoCalibrate) { + treatments to tConfig + } else { + withContext(Dispatchers.Default) { + val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) + r.treatments to r.tConfig + } + } + } var showLogDialog by remember { mutableStateOf(false) } var preselectId by remember { mutableStateOf(null) } @@ -100,14 +118,14 @@ fun HomeScreen( } } - val curve by produceState>(emptyList(), treatments, doseLogs, tConfig, tick) { + val curve by produceState>(emptyList(), effective, doseLogs, tick) { withContext(Dispatchers.Default) { val end = System.currentTimeMillis() value = PharmacokineticEngine.computeCurve( - treatments, doseLogs, + effective.first, doseLogs, startMs = end - 24 * HOUR_MS, endMs = end, - tConfig = tConfig + tConfig = effective.second ) } } @@ -185,7 +203,14 @@ fun HomeScreen( ) Spacer(Modifier.height(8.dp)) CurveChart( - points = curve, + series = listOf( + // Home affiche le modèle propre à chaque traitement (pas d'override) + ChartSeries( + curve, + CurveStyle(ChartE2), + CurveStyle(ChartT) + ) + ), e2Labs = labResults.filterInRange(curve, "E2"), tLabs = labResults.filterInRange(curve, "T"), options = ChartOptions(showT = true, showLabs = true), 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 22661e0..193bb48 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -66,6 +67,7 @@ fun SettingsScreen(onBack: () -> Unit) { val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val currentLanguage by container.settings.language.collectAsState(initial = "system") + val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) } var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) } @@ -245,6 +247,32 @@ fun SettingsScreen(onBack: () -> Unit) { } Spacer(Modifier.height(12.dp)) + // --- Auto-calibration (option) --- + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium) + Switch( + checked = autoCalibrate, + onCheckedChange = { enabled -> + CoroutineScope(Dispatchers.IO).launch { + container.settings.setAutoCalibrate(enabled) + } + } + ) + } + Text( + stringResource(R.string.auto_calibrate_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(Modifier.height(12.dp)) + // --- Reminders / exact alarms --- Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { 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 ccddd8f..e9ea5e1 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt @@ -83,6 +83,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { var reminderEnabled by remember { mutableStateOf(false) } var reminderTime by remember { mutableStateOf(LocalTime.of(12, 0)) } var active by remember { mutableStateOf(true) } + + // Section « Fréquence » : simulation prévisionnelle des doses à venir + var forecastEnabled by remember { mutableStateOf(false) } + var forecastDaysText by remember { mutableStateOf("") } var showDeleteConfirm by remember { mutableStateOf(false) } var showPresetMenu by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) } @@ -107,6 +111,11 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0) active = tr.isActive loadedCreatedAt = tr.createdAt + // Fréquence de prévision (si configurée) + tr.forecastIntervalDays?.let { fi -> + forecastEnabled = true + forecastDaysText = formatDose(fi) + } } loading = false } @@ -119,6 +128,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { val bio = bioText.replace(',', '.').toFloatOrNull() ?: 1f val scale = scaleText.replace(',', '.').toDoubleOrNull() ?: 1.0 if (name.isBlank() || dose <= 0.0) return null + // Prévision : uniquement si activée et intervalle valide (> 0) + val forecastInterval = if (forecastEnabled) { + forecastDaysText.replace(',', '.').toDoubleOrNull()?.takeIf { it > 0.0 } + } else null return Treatment( id = id, name = name.trim(), @@ -133,6 +146,7 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { eliminationHalfLifeHours = thalf, bioavailabilityFraction = bio.coerceIn(0.01f, 1f), scaleFactor = scale.coerceAtLeast(0.01), + forecastIntervalDays = forecastInterval, reminderEnabled = reminderEnabled, reminderHour = if (reminderEnabled) reminderTime.hour else null, reminderMinute = if (reminderEnabled) reminderTime.minute else null, @@ -319,6 +333,37 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { Spacer(Modifier.height(8.dp)) } + Spacer(Modifier.height(8.dp)) + + // --- Section « Fréquence » : doses prévisionnelles --- + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.freq_section), style = MaterialTheme.typography.titleMedium) + Switch(checked = forecastEnabled, onCheckedChange = { forecastEnabled = it }) + } + if (forecastEnabled) { + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = forecastDaysText, + onValueChange = { forecastDaysText = it }, + label = { Text(stringResource(R.string.freq_interval)) }, + modifier = Modifier.fillMaxWidth() + ) + Text( + stringResource(R.string.freq_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Row( @@ -418,7 +463,15 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { TextButton(onClick = { showTimePicker = false }) { Text(stringResource(R.string.cancel)) } }, title = { Text(stringResource(R.string.reminder_time)) }, - text = { TimePicker(state = state) } + text = { + // Horloge centrée dans le dialog (sinon elle part vers la gauche) + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + TimePicker(state = state) + } + } ) } diff --git a/app/src/main/java/com/hormonetrack/ui/theme/Theme.kt b/app/src/main/java/com/hormonetrack/ui/theme/Theme.kt index f32502a..62c0a3d 100644 --- a/app/src/main/java/com/hormonetrack/ui/theme/Theme.kt +++ b/app/src/main/java/com/hormonetrack/ui/theme/Theme.kt @@ -26,6 +26,18 @@ private val DarkColors = darkColorScheme( tertiary = androidx.compose.ui.graphics.Color(0xFF7CD5C1) ) +/** + * Thème Material 3 de l'app. + * + * NOTE Material Expressive (état au 2026-09) : `MaterialExpressiveTheme` et + * `ExperimentalMaterial3ExpressiveApi` restent **internal** dans la ligne material3 + * pinnée par le BOM 2026.08.00 (vérifié par erreur de compilation : « it is internal »). + * On utilise donc MaterialTheme standard ; les apports Material You viennent quand + * même de la BOM récente (composants, comportements, couleurs). Quand Google rendra + * l'API publique, remplacer MaterialTheme par MaterialExpressiveTheme (mêmes params). + * Le dynamic color (fond d'écran Material You) reste désactivé volontairement pour + * une identité visuelle stable ; passer `dynamicColor = true` pour l'activer. + */ @Composable fun HormoneTrackTheme( darkTheme: Boolean = isSystemInDarkTheme(), diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 427056e..987c68e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -26,9 +26,15 @@ 7 jours 30 jours Analyses - — E2 estimé (pg/mL, axe gauche) - -- T estimée (ng/mL, axe droit) + Prévision + Revenir à maintenant + Fais glisser le graphique vers la droite pour remonter dans le passé. + — E2 · Estrannaise (pg/mL, axe gauche) + -- T · Estrannaise (ng/mL, axe droit) + — E2 · Transfem Science + -- T · Transfem Science ● Résultats de prise de sang + Δ %1$s j Ajouter une dose @@ -86,6 +92,13 @@ Facteur d\'échelle Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle). Calibrer avec les analyses + Calibration automatique + Ajuste automatiquement les facteurs d\'échelle et le modèle T à partir de tes analyses, pour l\'affichage uniquement (les valeurs stockées ne changent pas). + + Fréquence d\'injection + Simuler les doses à venir + Intervalle (jours) + Les doses à venir sont simulées à cet intervalle après la dernière injection enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées. Enregistrer Annuler diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8dc478c..b003e41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,9 +26,15 @@ 7 days 30 days Labs - — E2 estimate (pg/mL, left axis) - -- T estimate (ng/mL, right axis) + Forecast + Back to now + Drag the chart to the right to travel into the past. + — E2 · Estrannaise (pg/mL, left axis) + -- T · Estrannaise (ng/mL, right axis) + — E2 · Transfem Science + -- T · Transfem Science ● Lab results + Δ %1$s d Log a dose @@ -86,6 +92,13 @@ Scale factor The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction). Calibrate from labs + Auto-calibration + Automatically adjusts scale factors and the T model from your labs, for display only (stored values unchanged). + + Injection frequency + Simulate upcoming doses + Interval (days) + Upcoming doses are simulated at this interval after the last logged injection; they appear on the chart (Forecast) but are never saved. Save Cancel