v1.2.2 : prise de sang E2 + T en une entrée, affichage côte à côte, toggle T masque aussi les labs T

- LabDialog : mode création avec deux sections (E2 et T, chacune optionnelle,
  au moins une valeur requise) → insère 1 ou 2 LabResult au même timestamp ;
  mode édition inchangé (un seul marqueur, pré-rempli)
- LabsScreen : regroupement par timestamp (groupLabsForDisplay : E2 avant T,
  autres ensuite, tri desc) → affichage « E2 306 pg/mL · T 45 ng/dL » côte à
  côte ; tap sur une paire → sélecteur « Quelle entrée modifier ? » puis
  édition pré-remplie ; suppression = la prise entière (confirm nommant les
  valeurs) ; formatLabValue préserve les décimales
- ChartScreen : le toggle T masque désormais aussi les labs T (avant : seuls
  les points restaient visibles)
- LabsGroupingTest : 4 tests de regroupement (48 tests verts au total)
- versionCode 5, versionName 1.2.2 ; docs mises à jour
This commit is contained in:
Siphonight 2026-09-05 18:33:19 +02:00
parent 463cdfb8e2
commit b4a81351bf
11 changed files with 356 additions and 120 deletions

View File

@ -9,7 +9,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.2.1 — build Android ✅, **44 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅ - **Statut** : v1.2.2 — build Android ✅, **48 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -29,8 +29,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection** - **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses), (switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché **intervalle en jours entre dosages** affiché
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration, - **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**éditables** (tap sur une ligne dans Analyses) ; unités T : ng/mL, ng/dL, ng/L, nmol/L **côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 4 versionCode = 5
versionName = "1.2.1" versionName = "1.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -2,6 +2,7 @@ package com.hormonetrack.ui.components
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.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -9,6 +10,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -23,20 +25,25 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
/** Valeur de labo saisie pour un marqueur (valeur + unité). */
data class LabValue(val value: Double, val unit: String)
/** /**
* Dialog create-OU-edit pour les résultats de labo (même mécanique que DoseDialog). * Dialog de saisie de prise de sang.
* *
* existing == null → création ; * - **Création** (existing == null) : E2 et T **dans la même entrée**, chacune
* existing != null → édition : tout est pré-rempli (marqueur, valeur, unité, * optionnelle (au moins une valeur requise), date/heure et notes partagées →
* date/heure, notes) et la confirmation appelle onConfirmEdit avec la copie mise à * insère 1 ou 2 LabResult avec le MÊME timestamp (l'écran Analyses les affiche
* jour (id et timestamp préservés sauf modification explicite). * alors côte à côte).
* - **Édition** (existing != null) : un seul marqueur, tout pré-rempli →
* onConfirmEdit avec la copie mise à jour.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabDialog( fun LabDialog(
existing: LabResult? = null, existing: LabResult? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirmCreate: (marker: String, value: Double, unit: String, timestampMs: Long, notes: String) -> Unit, onConfirmCreate: (e2: LabValue?, t: LabValue?, timestampMs: Long, notes: String) -> Unit,
onConfirmEdit: (LabResult) -> Unit onConfirmEdit: (LabResult) -> Unit
) { ) {
val isEdit = existing != null val isEdit = existing != null
@ -50,6 +57,15 @@ fun LabDialog(
} }
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") } var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
var unitExpanded by remember { mutableStateOf(false) } var unitExpanded by remember { mutableStateOf(false) }
// Mode création : deux sections (E2 + T), chacune optionnelle
var e2Text by remember { mutableStateOf("") }
var e2Unit by remember { mutableStateOf("pg/mL") }
var e2UnitExpanded by remember { mutableStateOf(false) }
var tText by remember { mutableStateOf("") }
var tUnit by remember { mutableStateOf("ng/mL") }
var tUnitExpanded by remember { mutableStateOf(false) }
var dateTime by remember(existing) { var dateTime by remember(existing) {
mutableStateOf( mutableStateOf(
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now() existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
@ -60,22 +76,38 @@ fun LabDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(
val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton enabled = if (isEdit) {
if (isEdit && existing != null) { valueText.replace(',', '.').toDoubleOrNull() != null
onConfirmEdit(
existing.copy(
marker = marker.trim().uppercase(),
value = v,
unit = unit.trim(),
timestamp = msFromLocalDateTime(dateTime),
notes = notes.ifBlank { null }
)
)
} else { } else {
onConfirmCreate(marker.trim().uppercase(), v, unit.trim(), msFromLocalDateTime(dateTime), notes) e2Text.replace(',', '.').toDoubleOrNull() != null ||
tText.replace(',', '.').toDoubleOrNull() != null
},
onClick = {
if (isEdit && existing != null) {
val v = valueText.replace(',', '.').toDoubleOrNull()
?: return@TextButton
onConfirmEdit(
existing.copy(
marker = marker.trim().uppercase(),
value = v,
unit = unit.trim(),
timestamp = msFromLocalDateTime(dateTime),
notes = notes.ifBlank { null }
)
)
} else {
val e2Val = e2Text.replace(',', '.').toDoubleOrNull()
val tVal = tText.replace(',', '.').toDoubleOrNull()
onConfirmCreate(
e2Val?.let { LabValue(it, e2Unit.trim()) },
tVal?.let { LabValue(it, tUnit.trim()) },
msFromLocalDateTime(dateTime),
notes
)
}
} }
}) { Text(stringResource(R.string.save)) } ) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
title = { title = {
@ -83,37 +115,85 @@ fun LabDialog(
}, },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) { if (isEdit) {
OutlinedTextField( // Édition : un seul marqueur (l'entrée existante)
value = marker, ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
onValueChange = { marker = it }, OutlinedTextField(
label = { Text(stringResource(R.string.lab_marker)) }, value = marker,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = markerExpanded) }, onValueChange = { marker = it },
modifier = Modifier.menuAnchor().fillMaxWidth() label = { Text(stringResource(R.string.lab_marker)) },
) trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = markerExpanded) },
ExposedDropdownMenu(expanded = markerExpanded, onDismissRequest = { markerExpanded = false }) { modifier = Modifier.menuAnchor().fillMaxWidth()
markerSuggestions.forEach { s -> )
DropdownMenuItem(text = { Text(s) }, onClick = { marker = s; markerExpanded = false }) ExposedDropdownMenu(expanded = markerExpanded, onDismissRequest = { markerExpanded = false }) {
markerSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { marker = s; markerExpanded = false })
}
} }
} }
}
OutlinedTextField(
value = valueText,
onValueChange = { valueText = it },
label = { Text(stringResource(R.string.lab_value)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = unitExpanded, onExpandedChange = { unitExpanded = it }) {
OutlinedTextField( OutlinedTextField(
value = unit, value = valueText,
onValueChange = { unit = it }, onValueChange = { valueText = it },
label = { Text(stringResource(R.string.lab_unit)) }, label = { Text(stringResource(R.string.lab_value)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) }, modifier = Modifier.fillMaxWidth()
modifier = Modifier.menuAnchor().fillMaxWidth()
) )
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) { ExposedDropdownMenuBox(expanded = unitExpanded, onExpandedChange = { unitExpanded = it }) {
unitSuggestions.forEach { s -> OutlinedTextField(
DropdownMenuItem(text = { Text(s) }, onClick = { unit = s; unitExpanded = false }) value = unit,
onValueChange = { unit = it },
label = { Text(stringResource(R.string.lab_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { unit = s; unitExpanded = false })
}
}
}
} else {
// Création : E2 et T ensemble, chacune optionnelle
OutlinedTextField(
value = e2Text,
onValueChange = { e2Text = it },
label = { Text(stringResource(R.string.lab_e2_value)) },
supportingText = { Text(stringResource(R.string.lab_optional)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = e2UnitExpanded, onExpandedChange = { e2UnitExpanded = it }) {
OutlinedTextField(
value = e2Unit,
onValueChange = { e2Unit = it },
label = { Text(stringResource(R.string.lab_e2_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = e2UnitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = e2UnitExpanded, onDismissRequest = { e2UnitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { e2Unit = s; e2UnitExpanded = false })
}
}
}
HorizontalDivider()
OutlinedTextField(
value = tText,
onValueChange = { tText = it },
label = { Text(stringResource(R.string.lab_t_value)) },
supportingText = { Text(stringResource(R.string.lab_optional)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = tUnitExpanded, onExpandedChange = { tUnitExpanded = it }) {
OutlinedTextField(
value = tUnit,
onValueChange = { tUnit = it },
label = { Text(stringResource(R.string.lab_t_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = tUnitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = tUnitExpanded, onDismissRequest = { tUnitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { tUnit = s; tUnitExpanded = false })
}
} }
} }
} }

View File

@ -168,7 +168,10 @@ fun ChartScreen() {
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp } 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 } // Le toggle T masque la courbe T ET les labs T (cohérence d'affichage)
val tLabs = if (showT) {
labResults.filter { it.marker.equals("T", true) && it.timestamp in a..b }
} else emptyList()
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ; // Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
// T Estrannaise = rose plein, T TFS = rose pointillé. // T Estrannaise = rose plein, T TFS = rose pointillé.

View File

@ -3,7 +3,6 @@ package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -36,6 +35,7 @@ import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -43,6 +43,24 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/**
* Regroupe les labs par timestamp (une « prise de sang » = E2 + T prises au même
* moment). Ordre d'affichage dans un groupe : E2 avant T, autres marqueurs ensuite.
* Retour : liste (timestamp, labs du groupe) triée par date décroissante.
*/
fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>> =
labs.groupBy { it.timestamp }
.map { (ts, group) ->
ts to group.sortedBy { lab ->
when (lab.marker.uppercase()) {
"E2" -> 0
"T" -> 1
else -> 2
}
}
}
.sortedByDescending { it.first }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabsScreen() { fun LabsScreen() {
@ -52,11 +70,12 @@ fun LabsScreen() {
var showLabDialog by remember { mutableStateOf(false) } var showLabDialog by remember { mutableStateOf(false) }
var toEdit by remember { mutableStateOf<LabResult?>(null) } var toEdit by remember { mutableStateOf<LabResult?>(null) }
var toDelete by remember { mutableStateOf<LabResult?>(null) } // Groupe en cours de suppression (une prise de sang entière)
var toDeleteGroup by remember { mutableStateOf<List<LabResult>?>(null) }
// Choix du marqueur à éditer quand le groupe contient E2 + T
var chooserGroup by remember { mutableStateOf<List<LabResult>?>(null) }
val grouped = remember(labs) { val grouped = remember(labs) { groupLabsForDisplay(labs) }
labs.sortedBy { it.marker.lowercase() }.groupBy { it.marker.uppercase() }
}
val fmt = remember { val fmt = remember {
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault()) DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
} }
@ -72,92 +91,128 @@ fun LabsScreen() {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.nav_labs)) } title = { Text(stringResource(R.string.nav_labs)) }
) )
if (labs.isEmpty()) { if (grouped.isEmpty()) {
Text( Text(
stringResource(R.string.no_data), stringResource(R.string.no_data),
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
} else { } else {
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
grouped.forEach { (marker, results) -> items(grouped, key = { it.first }) { (ts, group) ->
item(key = "header_$marker") { // Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL »
Text( val headline = group.joinToString(" · ") { lab ->
marker, "${lab.marker.uppercase()} ${formatLabValue(lab.value)} ${lab.unit}"
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
} }
items(results, key = { it.id }) { lab -> ListItem(
ListItem( modifier = Modifier.clickable {
modifier = Modifier.clickable { toEdit = lab }, if (group.size > 1) chooserGroup = group else toEdit = group.first()
headlineContent = { },
Text("${lab.value} ${lab.unit}") headlineContent = { Text(headline) },
}, supportingContent = {
supportingContent = { Column {
Column { Text(fmt.format(Instant.ofEpochMilli(ts)))
Text(fmt.format(Instant.ofEpochMilli(lab.timestamp))) // Notes du groupe (toutes identiques en pratique)
lab.notes?.takeIf { it.isNotBlank() }?.let { group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
trailingContent = {
IconButton(onClick = { toDelete = lab }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
} }
} }
) },
} trailingContent = {
IconButton(onClick = { toDeleteGroup = group }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
}
)
} }
} }
} }
} }
} }
if (showLabDialog || toEdit != null) { if (showLabDialog) {
LabDialog( LabDialog(
existing = toEdit, existing = null,
onDismiss = { onDismiss = { showLabDialog = false },
showLabDialog = false onConfirmCreate = { e2, t, ts, notes ->
toEdit = null
},
onConfirmCreate = { marker, value, unit, ts, notes ->
showLabDialog = false showLabDialog = false
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repo.insertLabResult( e2?.let {
LabResult( repo.insertLabResult(
marker = marker, LabResult(marker = "E2", value = it.value, unit = it.unit, timestamp = ts, notes = notes.ifBlank { null })
value = value,
unit = unit,
timestamp = ts,
notes = notes.ifBlank { null }
) )
) }
t?.let {
repo.insertLabResult(
LabResult(marker = "T", value = it.value, unit = it.unit, timestamp = ts, notes = notes.ifBlank { null })
)
}
} }
}, },
onConfirmEdit = { }
)
}
// Édition d'UNE entrée du groupe (choix E2 / T si paire)
toEdit?.let { lab ->
LabDialog(
existing = lab,
onDismiss = { toEdit = null },
onConfirmCreate = { _, _, _, _ -> },
onConfirmEdit = { updated -> onConfirmEdit = { updated ->
toEdit = null toEdit = null
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch { repo.updateLabResult(updated) }
repo.updateLabResult(updated) }
)
}
// Sélecteur quand la prise contient E2 + T
chooserGroup?.let { group ->
AlertDialog(
onDismissRequest = { chooserGroup = null },
confirmButton = {},
dismissButton = {
TextButton(onClick = { chooserGroup = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.edit_which_lab)) },
text = {
Column {
group.forEach { lab ->
TextButton(onClick = {
chooserGroup = null
toEdit = lab
}) {
Text("${lab.marker.uppercase()} · ${formatLabValue(lab.value)} ${lab.unit}")
}
}
} }
} }
) )
} }
toDelete?.let { lab -> // Suppression : la prise de sang ENTIÈRE (les labs sont pris ensemble)
toDeleteGroup?.let { group ->
AlertDialog( AlertDialog(
onDismissRequest = { toDelete = null }, onDismissRequest = { toDeleteGroup = null },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
toDelete = null toDeleteGroup = null
CoroutineScope(Dispatchers.IO).launch { repo.deleteLabResult(lab) } CoroutineScope(Dispatchers.IO).launch {
group.forEach { repo.deleteLabResult(it) }
}
}) { Text(stringResource(R.string.delete)) } }) { Text(stringResource(R.string.delete)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } TextButton(onClick = { toDeleteGroup = null }) { Text(stringResource(R.string.cancel)) }
}, },
title = { Text(stringResource(R.string.delete)) }, title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) } text = {
Text(
stringResource(
R.string.confirm_delete_blood_draw,
group.joinToString(" · ") { "${it.marker.uppercase()} ${formatLabValue(it.value)}" }
)
)
}
) )
} }
} }

View File

@ -51,8 +51,15 @@
<string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string> <string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string>
<!-- Labs --> <!-- Labs -->
<string name="add_lab">Ajouter un résultat</string> <string name="add_lab">Ajouter une prise de sang</string>
<string name="edit_lab">Modifier le résultat</string> <string name="edit_lab">Modifier le résultat</string>
<string name="lab_e2_value">E2 — valeur œstradiol</string>
<string name="lab_e2_unit">Unité E2</string>
<string name="lab_t_value">T — valeur testostérone</string>
<string name="lab_t_unit">Unité T</string>
<string name="lab_optional">optionnel</string>
<string name="edit_which_lab">Quelle entrée veux-tu modifier ?</string>
<string name="confirm_delete_blood_draw">Supprimer cette prise de sang (%1$s) ?</string>
<string name="lab_marker">Marqueur (ex : E2, T)</string> <string name="lab_marker">Marqueur (ex : E2, T)</string>
<string name="lab_value">Valeur</string> <string name="lab_value">Valeur</string>
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string> <string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>

View File

@ -51,8 +51,15 @@
<string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string> <string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string>
<!-- Labs --> <!-- Labs -->
<string name="add_lab">Add lab result</string> <string name="add_lab">Add a blood test</string>
<string name="edit_lab">Edit lab result</string> <string name="edit_lab">Edit lab result</string>
<string name="lab_e2_value">E2 — estradiol value</string>
<string name="lab_e2_unit">E2 unit</string>
<string name="lab_t_value">T — testosterone value</string>
<string name="lab_t_unit">T unit</string>
<string name="lab_optional">optional</string>
<string name="edit_which_lab">Which entry do you want to edit?</string>
<string name="confirm_delete_blood_draw">Delete this blood draw (%1$s)?</string>
<string name="lab_marker">Marker (e.g. E2, T)</string> <string name="lab_marker">Marker (e.g. E2, T)</string>
<string name="lab_value">Value</string> <string name="lab_value">Value</string>
<string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string> <string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string>

View File

@ -0,0 +1,56 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.groupLabsForDisplay
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
* timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant.
*/
class LabsGroupingTest {
private fun lab(marker: String, ts: Long, value: Double) =
LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts)
@Test
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
val ts = 1_788_621_894_346L
val labs = listOf(lab("E2", ts, 300.0), lab("T", ts, 45.0))
val groups = groupLabsForDisplay(labs)
assertEquals(1, groups.size)
assertEquals(ts, groups[0].first)
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
}
@Test
fun `different timestamps stay separate rows`() {
val labs = listOf(
lab("E2", 1000L, 300.0),
lab("T", 2000L, 45.0)
)
val groups = groupLabsForDisplay(labs)
assertEquals(2, groups.size)
// Tri décroissant : le plus récent (2000) d'abord
assertEquals(2000L, groups[0].first)
assertEquals(1000L, groups[1].first)
}
@Test
fun `labs entered alone also group correctly and E2 sorts before T`() {
val ts = 5000L
val labs = listOf(lab("T", ts, 45.0), lab("E2", ts, 300.0))
val groups = groupLabsForDisplay(labs)
assertEquals(1, groups.size)
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
}
@Test
fun `other markers sort after E2 and T`() {
val ts = 6000L
val labs = listOf(lab("PRL", ts, 12.0), lab("T", ts, 45.0), lab("E2", ts, 300.0))
val groups = groupLabsForDisplay(labs)
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
}
}

View File

@ -1,7 +1,22 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.1`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.2`, …).
## [1.2.2] — 2026-09-05 (versionCode 5)
### Ajouté
- **Prise de sang E2 + T en UNE entrée** : le dialog d'ajout propose les deux
marqueurs (chacun **optionnel** — on peut ne saisir que E2 ou que T), avec
date/heure et notes partagées ; les entrées sont insérées au même timestamp et
s'affichent **côte à côte** dans l'écran Analyses (« E2 306 pg/mL · T 44 ng/dL »).
- **Tap sur une paire → sélecteur** (« Quelle entrée veux-tu modifier ? » E2 / T)
puis édition pré-remplie de l'entrée choisie.
- **Suppression par prise de sang** : la corbeille supprime la prise entière
(confirmation listant les valeurs).
- **Toggle T du graphique** : masque désormais aussi les **labs T** (avant : seule
la courbe T disparaissait, les points restaient).
- 4 tests de regroupement (48 au total).
## [1.2.1] — 2026-09-05 (versionCode 4) ## [1.2.1] — 2026-09-05 (versionCode 4)

View File

@ -297,7 +297,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires ## 8. Tests unitaires
**44 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2. **48 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -334,6 +334,9 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en
unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente. unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente.
**Tout nouvel export utilisateur = un nouveau test de régression.** **Tout nouvel export utilisateur = un nouveau test de régression.**
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant).
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis **Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu. la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
@ -379,13 +382,18 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée = 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T =
masque aussi les labs T** (v1.2.2)
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (création), **tap sur la ligne → - `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
édition** (v1.2.1, même mécanique que les doses), suppression avec confirmation ; deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L **groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri
desc) → « E2 306 pg/mL · T 44 ng/dL » côte à côte ; tap → sélecteur E2/T si paire,
édition unitaire pré-remplie ; suppression = **la prise entière** (confirm nommant
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰, - `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur badge inactif), FAB → éditeur
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ; - `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
@ -632,4 +640,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2) - [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
--- ---
*Doc mise à jour le 5 sept. 2026 (v1.2.1) — build OK, 44/44 tests verts, repo git avec tags v1.1.0/v1.2.0/v1.2.1.* *Doc mise à jour le 5 sept. 2026 (v1.2.2) — build OK, 48/48 tests verts, repo git avec tags v1.1.0→v1.2.2.*

View File

@ -65,10 +65,14 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses - **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de (traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
supprimer/recréer supprimer/recréer
3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL ou ng/dL) 3. **Analyses → +** → une prise de sang complète en une entrée : **E2 et/ou T**
- **Modifier un résultat** : appuie simplement sur sa ligne (même principe que les doses) (chacune optionnelle), date/heure commune, notes — les deux s'affichent côte à côte
dans la liste
- **Modifier** : appuie sur la ligne ; si la prise contient E2 **et** T, un sélecteur
te demande laquelle modifier
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la - Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (ng/mL, ng/dL, ng/L, nmol/L) calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses » 4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods` → calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
5. **Paramètres** : 5. **Paramètres** :