UI v1.2.0 : graphique multi-séries panoramique, toggles modèles/prévision, intervalles de dosage, TimePicker centré
- 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
This commit is contained in:
parent
693ab24f3d
commit
4bc82226e3
@ -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
|
||||
|
||||
@ -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<LevelPoint>,
|
||||
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<LevelPoint>,
|
||||
series: List<ChartSeries>,
|
||||
e2Labs: List<LabResult>,
|
||||
tLabs: List<LabResult>,
|
||||
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 ->
|
||||
// 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 = yE2(p.e2)
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
if (i == 0) e2Path.moveTo(x, y) else e2Path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, ChartE2, style = Stroke(width = 2.5.dp.toPx()))
|
||||
|
||||
drawPath(
|
||||
e2Path, s.e2Style.color,
|
||||
style = Stroke(
|
||||
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()
|
||||
points.forEachIndexed { i, p ->
|
||||
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, ChartT,
|
||||
tPath, tStyle.color,
|
||||
style = Stroke(
|
||||
width = 2.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f))
|
||||
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 {
|
||||
|
||||
@ -90,11 +90,18 @@ fun DateTimeField(
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTime = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun localDateTimeFromMs(ms: Long): LocalDateTime =
|
||||
Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()).toLocalDateTime()
|
||||
|
||||
@ -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<List<LevelPoint>>(emptyList(), treatments, doseLogs, tConfig, rangeHours) {
|
||||
// « Maintenant » rafraîchi chaque minute pour le repère vertical
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(60_000)
|
||||
tick++
|
||||
}
|
||||
}
|
||||
|
||||
// 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 end = System.currentTimeMillis()
|
||||
value = PharmacokineticEngine.computeCurve(
|
||||
treatments, doseLogs,
|
||||
startMs = end - rangeHours * 3_600_000L,
|
||||
endMs = end,
|
||||
tConfig = tConfig
|
||||
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<Pair<String, List<LevelPoint>>>(),
|
||||
effective.first, effective.second, doseLogs, startMs, endMs, showEse, showTfs, showForecast
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||
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 (a, b) = if (curve.size >= 2) {
|
||||
curve.first().timestamp to curve.last().timestamp
|
||||
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,36 +273,67 @@ 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))
|
||||
|
||||
// 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_e2),
|
||||
stringResource(R.string.legend_ese_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = ChartE2
|
||||
)
|
||||
if (showT) {
|
||||
Text(
|
||||
stringResource(R.string.legend_t),
|
||||
stringResource(R.string.legend_ese_t),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Long, Double> {
|
||||
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) })
|
||||
}
|
||||
|
||||
@ -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<Long?>(null) }
|
||||
@ -100,14 +118,14 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), treatments, doseLogs, tConfig, tick) {
|
||||
val curve by produceState<List<LevelPoint>>(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),
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -26,9 +26,15 @@
|
||||
<string name="chart_7j">7 jours</string>
|
||||
<string name="chart_30j">30 jours</string>
|
||||
<string name="show_labs">Analyses</string>
|
||||
<string name="legend_e2">— E2 estimé (pg/mL, axe gauche)</string>
|
||||
<string name="legend_t">-- T estimée (ng/mL, axe droit)</string>
|
||||
<string name="show_forecast">Prévision</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="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>
|
||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
|
||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||
<string name="legend_labs">● Résultats de prise de sang</string>
|
||||
<string name="interval_value">Δ %1$s j</string>
|
||||
|
||||
<!-- Doses -->
|
||||
<string name="add_dose">Ajouter une dose</string>
|
||||
@ -86,6 +92,13 @@
|
||||
<string name="scale_factor">Facteur d\'échelle</string>
|
||||
<string name="calibration_hint">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).</string>
|
||||
<string name="calibrate_from_labs">Calibrer avec les analyses</string>
|
||||
<string name="auto_calibrate">Calibration automatique</string>
|
||||
<string name="auto_calibrate_hint">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).</string>
|
||||
|
||||
<string name="freq_section">Fréquence d\'injection</string>
|
||||
<string name="freq_enable">Simuler les doses à venir</string>
|
||||
<string name="freq_interval">Intervalle (jours)</string>
|
||||
<string name="freq_hint">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.</string>
|
||||
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
|
||||
@ -26,9 +26,15 @@
|
||||
<string name="chart_7j">7 days</string>
|
||||
<string name="chart_30j">30 days</string>
|
||||
<string name="show_labs">Labs</string>
|
||||
<string name="legend_e2">— E2 estimate (pg/mL, left axis)</string>
|
||||
<string name="legend_t">-- T estimate (ng/mL, right axis)</string>
|
||||
<string name="show_forecast">Forecast</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="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>
|
||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
|
||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||
<string name="legend_labs">● Lab results</string>
|
||||
<string name="interval_value">Δ %1$s d</string>
|
||||
|
||||
<!-- Doses -->
|
||||
<string name="add_dose">Log a dose</string>
|
||||
@ -86,6 +92,13 @@
|
||||
<string name="scale_factor">Scale factor</string>
|
||||
<string name="calibration_hint">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).</string>
|
||||
<string name="calibrate_from_labs">Calibrate from labs</string>
|
||||
<string name="auto_calibrate">Auto-calibration</string>
|
||||
<string name="auto_calibrate_hint">Automatically adjusts scale factors and the T model from your labs, for display only (stored values unchanged).</string>
|
||||
|
||||
<string name="freq_section">Injection frequency</string>
|
||||
<string name="freq_enable">Simulate upcoming doses</string>
|
||||
<string name="freq_interval">Interval (days)</string>
|
||||
<string name="freq_hint">Upcoming doses are simulated at this interval after the last logged injection; they appear on the chart (Forecast) but are never saved.</string>
|
||||
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user