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
a4ec1fa71f
commit
8ab97d4ed4
@ -51,7 +51,7 @@ android {
|
|||||||
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)
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2026.06.01")
|
val composeBom = platform("androidx.compose:compose-bom:2026.08.00")
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.nativeCanvas
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.data.model.LabResult
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.ui.theme.ChartE2
|
import com.hormonetrack.ui.theme.ChartE2
|
||||||
import com.hormonetrack.ui.theme.ChartT
|
import com.hormonetrack.ui.theme.ChartT
|
||||||
import com.hormonetrack.ui.theme.LabDot
|
import com.hormonetrack.ui.theme.LabDot
|
||||||
@ -23,12 +24,32 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.ceil
|
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(
|
data class ChartOptions(
|
||||||
val showT: Boolean = true,
|
val showT: Boolean = true,
|
||||||
val showLabs: Boolean = true,
|
val showLabs: Boolean = true,
|
||||||
val nowMs: Long = System.currentTimeMillis()
|
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 {
|
fun niceCeil(value: Double): Double {
|
||||||
if (value <= 0) return 100.0
|
if (value <= 0) return 100.0
|
||||||
val exp = Math.pow(10.0, Math.floor(Math.log10(value)))
|
val exp = Math.pow(10.0, Math.floor(Math.log10(value)))
|
||||||
@ -43,9 +64,18 @@ fun niceCeil(value: Double): Double {
|
|||||||
return nice * exp
|
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
|
@Composable
|
||||||
fun CurveChart(
|
fun CurveChart(
|
||||||
points: List<LevelPoint>,
|
series: List<ChartSeries>,
|
||||||
e2Labs: List<LabResult>,
|
e2Labs: List<LabResult>,
|
||||||
tLabs: List<LabResult>,
|
tLabs: List<LabResult>,
|
||||||
options: ChartOptions,
|
options: ChartOptions,
|
||||||
@ -56,7 +86,8 @@ fun CurveChart(
|
|||||||
val nowLineColor = MaterialTheme.colorScheme.tertiary
|
val nowLineColor = MaterialTheme.colorScheme.tertiary
|
||||||
|
|
||||||
Canvas(modifier = modifier) {
|
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 padLeft = 42.dp.toPx()
|
||||||
val padRight = 42.dp.toPx()
|
val padRight = 42.dp.toPx()
|
||||||
val padTop = 12.dp.toPx()
|
val padTop = 12.dp.toPx()
|
||||||
@ -66,20 +97,23 @@ fun CurveChart(
|
|||||||
val h = size.height - padTop - padBottom
|
val h = size.height - padTop - padBottom
|
||||||
if (w <= 0 || h <= 0) return@Canvas
|
if (w <= 0 || h <= 0) return@Canvas
|
||||||
|
|
||||||
val t0 = points.first().timestamp
|
// Fenêtre temporelle commune à toutes les séries (min/max)
|
||||||
val t1 = points.last().timestamp
|
val t0 = allPoints.minOf { it.timestamp }
|
||||||
|
val t1 = allPoints.maxOf { it.timestamp }
|
||||||
if (t1 <= t0) return@Canvas
|
if (t1 <= t0) return@Canvas
|
||||||
|
|
||||||
|
val convertedTLabs = tLabs.map { lab ->
|
||||||
|
lab.copy(value = PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit))
|
||||||
|
}
|
||||||
|
|
||||||
val e2DataMax = maxOf(
|
val e2DataMax = maxOf(
|
||||||
points.maxOf { it.e2 },
|
allPoints.maxOf { it.e2 },
|
||||||
if (options.showLabs) e2Labs.maxOfOrNull { it.value } ?: 0.0 else 0.0
|
if (options.showLabs) e2Labs.maxOfOrNull { it.value } ?: 0.0 else 0.0
|
||||||
)
|
)
|
||||||
val e2Max = niceCeil(maxOf(e2DataMax, 50.0))
|
val e2Max = niceCeil(maxOf(e2DataMax, 50.0))
|
||||||
val tDataMax = maxOf(
|
val tDataMax = maxOf(
|
||||||
points.maxOf { it.t },
|
allPoints.maxOf { it.t },
|
||||||
if (options.showLabs) {
|
if (options.showLabs) convertedTLabs.maxOfOrNull { it.value } ?: 0.0 else 0.0
|
||||||
tLabs.maxOfOrNull { com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(it.value, it.unit) } ?: 0.0
|
|
||||||
} else 0.0
|
|
||||||
)
|
)
|
||||||
val tMax = niceCeil(tDataMax * 1.1)
|
val tMax = niceCeil(tDataMax * 1.1)
|
||||||
|
|
||||||
@ -89,33 +123,51 @@ fun CurveChart(
|
|||||||
|
|
||||||
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
|
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
|
||||||
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
|
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)
|
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w)
|
||||||
|
|
||||||
val path = Path()
|
// Une courbe E2 + une courbe T par série
|
||||||
points.forEachIndexed { i, p ->
|
for (s in series) {
|
||||||
val x = xOf(p.timestamp)
|
if (s.points.size < 2) continue
|
||||||
val y = yE2(p.e2)
|
val e2Path = Path()
|
||||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
s.points.forEachIndexed { i, p ->
|
||||||
}
|
|
||||||
drawPath(path, ChartE2, style = Stroke(width = 2.5.dp.toPx()))
|
|
||||||
|
|
||||||
if (options.showT) {
|
|
||||||
val tPath = Path()
|
|
||||||
points.forEachIndexed { i, p ->
|
|
||||||
val x = xOf(p.timestamp)
|
val x = xOf(p.timestamp)
|
||||||
val y = yT(p.t)
|
val y = yE2(p.e2)
|
||||||
if (i == 0) tPath.moveTo(x, y) else tPath.lineTo(x, y)
|
if (i == 0) e2Path.moveTo(x, y) else e2Path.lineTo(x, y)
|
||||||
}
|
}
|
||||||
drawPath(
|
drawPath(
|
||||||
tPath, ChartT,
|
e2Path, s.e2Style.color,
|
||||||
style = Stroke(
|
style = Stroke(
|
||||||
width = 2.dp.toPx(),
|
width = 2.5.dp.toPx(),
|
||||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f))
|
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) {
|
if (options.showLabs) {
|
||||||
e2Labs.forEach { lab ->
|
e2Labs.forEach { lab ->
|
||||||
if (lab.timestamp in t0..t1) {
|
if (lab.timestamp in t0..t1) {
|
||||||
@ -124,21 +176,20 @@ fun CurveChart(
|
|||||||
drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx()))
|
drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tLabs.forEach { lab ->
|
convertedTLabs.forEach { lab ->
|
||||||
if (lab.timestamp in t0..t1) {
|
if (lab.timestamp in t0..t1) {
|
||||||
// labs may be in ng/dL or nmol/L: normalize to the model's ng/mL
|
val c = Offset(xOf(lab.timestamp), yT(lab.value))
|
||||||
val tVal = com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit)
|
|
||||||
val c = Offset(xOf(lab.timestamp), yT(tVal))
|
|
||||||
drawRect(
|
drawRect(
|
||||||
LabDot,
|
LabDot,
|
||||||
topLeft = Offset(c.x - 4.dp.toPx(), c.y - 4.dp.toPx()),
|
topLeft = Offset(c.x - 4.dp.toPx(), c.y - 4.dp.toPx()),
|
||||||
size = Size(8.dp.toPx(), 8.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) {
|
if (options.nowMs in t0..t1) {
|
||||||
drawLine(
|
drawLine(
|
||||||
nowLineColor,
|
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 {
|
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
|
||||||
val c = Color(0xFF6B7280)
|
val c = Color(0xFF6B7280)
|
||||||
return android.graphics.Paint().apply {
|
return android.graphics.Paint().apply {
|
||||||
|
|||||||
@ -91,7 +91,14 @@ fun DateTimeField(
|
|||||||
TextButton(onClick = { showTime = false }) { Text(stringResource(R.string.cancel)) }
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -13,28 +14,57 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.ChartOptions
|
import com.hormonetrack.ui.components.ChartOptions
|
||||||
|
import com.hormonetrack.ui.components.ChartSeries
|
||||||
import com.hormonetrack.ui.components.CurveChart
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChartScreen() {
|
fun ChartScreen() {
|
||||||
@ -44,29 +74,109 @@ fun ChartScreen() {
|
|||||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||||
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
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 rangeHours by remember { mutableLongStateOf(24L) }
|
||||||
|
var panHours by remember { mutableLongStateOf(0L) }
|
||||||
var showT by remember { mutableStateOf(true) }
|
var showT by remember { mutableStateOf(true) }
|
||||||
var showLabs 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
|
||||||
withContext(Dispatchers.Default) {
|
var tick by remember { mutableIntStateOf(0) }
|
||||||
val end = System.currentTimeMillis()
|
LaunchedEffect(Unit) {
|
||||||
value = PharmacokineticEngine.computeCurve(
|
while (true) {
|
||||||
treatments, doseLogs,
|
delay(60_000)
|
||||||
startMs = end - rangeHours * 3_600_000L,
|
tick++
|
||||||
endMs = end,
|
|
||||||
tConfig = tConfig
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val (a, b) = if (curve.size >= 2) {
|
// Traitement + TConfig éventuellement recalibrés à la volée (option auto-calibration) ;
|
||||||
curve.first().timestamp to curve.last().timestamp
|
// 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<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 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
|
} else 0L to 0L
|
||||||
val e2Labs = labResults.filter { it.marker.equals("E2", true) && it.timestamp in a..b }
|
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 }
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -78,6 +188,7 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Plage temporelle
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = rangeHours == 24L,
|
selected = rangeHours == 24L,
|
||||||
@ -98,6 +209,7 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Affichage : T, labs, prévision
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = showT,
|
selected = showT,
|
||||||
@ -109,13 +221,51 @@ fun ChartScreen() {
|
|||||||
onClick = { showLabs = !showLabs },
|
onClick = { showLabs = !showLabs },
|
||||||
label = { Text(stringResource(R.string.show_labs)) }
|
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))
|
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)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
if (curve.isEmpty()) {
|
if (series.isEmpty() || allPoints.size < 2) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.no_data),
|
stringResource(R.string.no_data),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
@ -123,34 +273,65 @@ fun ChartScreen() {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CurveChart(
|
CurveChart(
|
||||||
points = curve,
|
series = series,
|
||||||
e2Labs = e2Labs,
|
e2Labs = e2Labs,
|
||||||
tLabs = tLabs,
|
tLabs = tLabs,
|
||||||
options = ChartOptions(showT = showT, showLabs = showLabs),
|
options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(320.dp)
|
.height(320.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
|
||||||
stringResource(R.string.legend_e2),
|
// Retour au présent dès qu'on a voyagé dans le passé
|
||||||
style = MaterialTheme.typography.labelMedium,
|
if (panHours > 0L) {
|
||||||
color = MaterialTheme.colorScheme.primary
|
TextButton(onClick = { panHours = 0L }) {
|
||||||
)
|
Text(stringResource(R.string.back_to_present))
|
||||||
if (showT) {
|
}
|
||||||
Text(
|
}
|
||||||
stringResource(R.string.legend_t),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// Légende dynamique
|
||||||
color = MaterialTheme.colorScheme.secondary
|
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) {
|
if (showLabs) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_labs),
|
stringResource(R.string.legend_labs),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
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 })
|
}.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") }
|
val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -109,6 +124,15 @@ fun DosesScreen() {
|
|||||||
supportingContent = {
|
supportingContent = {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(time)
|
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 {
|
log.esterType?.takeIf { it.isNotEmpty() && it != "NONE" }?.let {
|
||||||
AssistChip(onClick = {}, label = { Text(it) })
|
AssistChip(onClick = {}, label = { Text(it) })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,11 +50,15 @@ import com.hormonetrack.pk.PharmacokineticEngine
|
|||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.ChartOptions
|
import com.hormonetrack.ui.components.ChartOptions
|
||||||
|
import com.hormonetrack.ui.components.ChartSeries
|
||||||
import com.hormonetrack.ui.components.CurveChart
|
import com.hormonetrack.ui.components.CurveChart
|
||||||
|
import com.hormonetrack.ui.components.CurveStyle
|
||||||
import com.hormonetrack.ui.components.DoseDialog
|
import com.hormonetrack.ui.components.DoseDialog
|
||||||
import com.hormonetrack.ui.components.formatDose
|
import com.hormonetrack.ui.components.formatDose
|
||||||
import com.hormonetrack.ui.theme.TransPink
|
import com.hormonetrack.ui.theme.TransPink
|
||||||
import com.hormonetrack.ui.theme.TransSky
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -81,6 +85,20 @@ fun HomeScreen(
|
|||||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||||
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
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 showLogDialog by remember { mutableStateOf(false) }
|
||||||
var preselectId by remember { mutableStateOf<Long?>(null) }
|
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) {
|
withContext(Dispatchers.Default) {
|
||||||
val end = System.currentTimeMillis()
|
val end = System.currentTimeMillis()
|
||||||
value = PharmacokineticEngine.computeCurve(
|
value = PharmacokineticEngine.computeCurve(
|
||||||
treatments, doseLogs,
|
effective.first, doseLogs,
|
||||||
startMs = end - 24 * HOUR_MS,
|
startMs = end - 24 * HOUR_MS,
|
||||||
endMs = end,
|
endMs = end,
|
||||||
tConfig = tConfig
|
tConfig = effective.second
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,7 +203,14 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
CurveChart(
|
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"),
|
e2Labs = labResults.filterInRange(curve, "E2"),
|
||||||
tLabs = labResults.filterInRange(curve, "T"),
|
tLabs = labResults.filterInRange(curve, "T"),
|
||||||
options = ChartOptions(showT = true, showLabs = true),
|
options = ChartOptions(showT = true, showLabs = true),
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -66,6 +67,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
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 tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||||
@ -245,6 +247,32 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
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 ---
|
// --- Reminders / exact alarms ---
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
|||||||
@ -83,6 +83,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
var reminderEnabled by remember { mutableStateOf(false) }
|
var reminderEnabled by remember { mutableStateOf(false) }
|
||||||
var reminderTime by remember { mutableStateOf(LocalTime.of(12, 0)) }
|
var reminderTime by remember { mutableStateOf(LocalTime.of(12, 0)) }
|
||||||
var active by remember { mutableStateOf(true) }
|
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 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) }
|
||||||
@ -107,6 +111,11 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0)
|
reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0)
|
||||||
active = tr.isActive
|
active = tr.isActive
|
||||||
loadedCreatedAt = tr.createdAt
|
loadedCreatedAt = tr.createdAt
|
||||||
|
// Fréquence de prévision (si configurée)
|
||||||
|
tr.forecastIntervalDays?.let { fi ->
|
||||||
|
forecastEnabled = true
|
||||||
|
forecastDaysText = formatDose(fi)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@ -119,6 +128,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
val bio = bioText.replace(',', '.').toFloatOrNull() ?: 1f
|
val bio = bioText.replace(',', '.').toFloatOrNull() ?: 1f
|
||||||
val scale = scaleText.replace(',', '.').toDoubleOrNull() ?: 1.0
|
val scale = scaleText.replace(',', '.').toDoubleOrNull() ?: 1.0
|
||||||
if (name.isBlank() || dose <= 0.0) return null
|
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(
|
return Treatment(
|
||||||
id = id,
|
id = id,
|
||||||
name = name.trim(),
|
name = name.trim(),
|
||||||
@ -133,6 +146,7 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
eliminationHalfLifeHours = thalf,
|
eliminationHalfLifeHours = thalf,
|
||||||
bioavailabilityFraction = bio.coerceIn(0.01f, 1f),
|
bioavailabilityFraction = bio.coerceIn(0.01f, 1f),
|
||||||
scaleFactor = scale.coerceAtLeast(0.01),
|
scaleFactor = scale.coerceAtLeast(0.01),
|
||||||
|
forecastIntervalDays = forecastInterval,
|
||||||
reminderEnabled = reminderEnabled,
|
reminderEnabled = reminderEnabled,
|
||||||
reminderHour = if (reminderEnabled) reminderTime.hour else null,
|
reminderHour = if (reminderEnabled) reminderTime.hour else null,
|
||||||
reminderMinute = if (reminderEnabled) reminderTime.minute 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Row(
|
Row(
|
||||||
@ -418,7 +463,15 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(R.string.cancel)) }
|
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(R.string.cancel)) }
|
||||||
},
|
},
|
||||||
title = { Text(stringResource(R.string.reminder_time)) },
|
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)
|
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
|
@Composable
|
||||||
fun HormoneTrackTheme(
|
fun HormoneTrackTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
|||||||
@ -26,9 +26,15 @@
|
|||||||
<string name="chart_7j">7 jours</string>
|
<string name="chart_7j">7 jours</string>
|
||||||
<string name="chart_30j">30 jours</string>
|
<string name="chart_30j">30 jours</string>
|
||||||
<string name="show_labs">Analyses</string>
|
<string name="show_labs">Analyses</string>
|
||||||
<string name="legend_e2">— E2 estimé (pg/mL, axe gauche)</string>
|
<string name="show_forecast">Prévision</string>
|
||||||
<string name="legend_t">-- T estimée (ng/mL, axe droit)</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="legend_labs">● Résultats de prise de sang</string>
|
||||||
|
<string name="interval_value">Δ %1$s j</string>
|
||||||
|
|
||||||
<!-- Doses -->
|
<!-- Doses -->
|
||||||
<string name="add_dose">Ajouter une dose</string>
|
<string name="add_dose">Ajouter une dose</string>
|
||||||
@ -86,6 +92,13 @@
|
|||||||
<string name="scale_factor">Facteur d\'échelle</string>
|
<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="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="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="save">Enregistrer</string>
|
||||||
<string name="cancel">Annuler</string>
|
<string name="cancel">Annuler</string>
|
||||||
|
|||||||
@ -26,9 +26,15 @@
|
|||||||
<string name="chart_7j">7 days</string>
|
<string name="chart_7j">7 days</string>
|
||||||
<string name="chart_30j">30 days</string>
|
<string name="chart_30j">30 days</string>
|
||||||
<string name="show_labs">Labs</string>
|
<string name="show_labs">Labs</string>
|
||||||
<string name="legend_e2">— E2 estimate (pg/mL, left axis)</string>
|
<string name="show_forecast">Forecast</string>
|
||||||
<string name="legend_t">-- T estimate (ng/mL, right axis)</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="legend_labs">● Lab results</string>
|
||||||
|
<string name="interval_value">Δ %1$s d</string>
|
||||||
|
|
||||||
<!-- Doses -->
|
<!-- Doses -->
|
||||||
<string name="add_dose">Log a dose</string>
|
<string name="add_dose">Log a dose</string>
|
||||||
@ -86,6 +92,13 @@
|
|||||||
<string name="scale_factor">Scale factor</string>
|
<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="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="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="save">Save</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user