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:
Siphonight 2026-09-05 16:53:53 +02:00
parent a4ec1fa71f
commit 8ab97d4ed4
11 changed files with 477 additions and 69 deletions

View File

@ -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

View File

@ -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 {

View File

@ -90,10 +90,17 @@ 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 =

View File

@ -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
)
}
}
}
}
}
}

View File

@ -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) })
}

View File

@ -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),

View File

@ -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)) {

View File

@ -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)
}
}
)
}

View File

@ -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(),

View File

@ -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>

View File

@ -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>