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:
parent
571e346b73
commit
4731e4c159
@ -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
|
||||
> 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)
|
||||
- **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)
|
||||
@ -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**
|
||||
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
||||
**intervalle en jours entre dosages** affiché
|
||||
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration,
|
||||
**éditables** (tap sur une ligne dans Analyses) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
|
||||
**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),
|
||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||
|
||||
@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.hormonetrack"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 4
|
||||
versionName = "1.2.1"
|
||||
versionCode = 5
|
||||
versionName = "1.2.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@ -2,6 +2,7 @@ package com.hormonetrack.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@ -9,6 +10,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@ -23,20 +25,25 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
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 ;
|
||||
* existing != null → édition : tout est pré-rempli (marqueur, valeur, unité,
|
||||
* date/heure, notes) et la confirmation appelle onConfirmEdit avec la copie mise à
|
||||
* jour (id et timestamp préservés sauf modification explicite).
|
||||
* - **Création** (existing == null) : E2 et T **dans la même entrée**, chacune
|
||||
* optionnelle (au moins une valeur requise), date/heure et notes partagées →
|
||||
* insère 1 ou 2 LabResult avec le MÊME timestamp (l'écran Analyses les affiche
|
||||
* alors côte à côte).
|
||||
* - **Édition** (existing != null) : un seul marqueur, tout pré-rempli →
|
||||
* onConfirmEdit avec la copie mise à jour.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabDialog(
|
||||
existing: LabResult? = null,
|
||||
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
|
||||
) {
|
||||
val isEdit = existing != null
|
||||
@ -50,6 +57,15 @@ fun LabDialog(
|
||||
}
|
||||
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
|
||||
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) {
|
||||
mutableStateOf(
|
||||
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
|
||||
@ -60,22 +76,38 @@ fun LabDialog(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton
|
||||
if (isEdit && existing != null) {
|
||||
onConfirmEdit(
|
||||
existing.copy(
|
||||
marker = marker.trim().uppercase(),
|
||||
value = v,
|
||||
unit = unit.trim(),
|
||||
timestamp = msFromLocalDateTime(dateTime),
|
||||
notes = notes.ifBlank { null }
|
||||
)
|
||||
)
|
||||
TextButton(
|
||||
enabled = if (isEdit) {
|
||||
valueText.replace(',', '.').toDoubleOrNull() != null
|
||||
} 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)) } },
|
||||
title = {
|
||||
@ -83,37 +115,85 @@ fun LabDialog(
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = marker,
|
||||
onValueChange = { marker = it },
|
||||
label = { Text(stringResource(R.string.lab_marker)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = markerExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = markerExpanded, onDismissRequest = { markerExpanded = false }) {
|
||||
markerSuggestions.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = { marker = s; markerExpanded = false })
|
||||
if (isEdit) {
|
||||
// Édition : un seul marqueur (l'entrée existante)
|
||||
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = marker,
|
||||
onValueChange = { marker = it },
|
||||
label = { Text(stringResource(R.string.lab_marker)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = markerExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
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(
|
||||
value = unit,
|
||||
onValueChange = { unit = it },
|
||||
label = { Text(stringResource(R.string.lab_unit)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
value = valueText,
|
||||
onValueChange = { valueText = it },
|
||||
label = { Text(stringResource(R.string.lab_value)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) {
|
||||
unitSuggestions.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = { unit = s; unitExpanded = false })
|
||||
ExposedDropdownMenuBox(expanded = unitExpanded, onExpandedChange = { unitExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,7 +168,10 @@ fun ChartScreen() {
|
||||
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 }
|
||||
// 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 ;
|
||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||
|
||||
@ -3,7 +3,6 @@ package com.hormonetrack.ui.screens
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -36,6 +35,7 @@ import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.LabDialog
|
||||
import com.hormonetrack.ui.components.formatLabValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -43,6 +43,24 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
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)
|
||||
@Composable
|
||||
fun LabsScreen() {
|
||||
@ -52,11 +70,12 @@ fun LabsScreen() {
|
||||
|
||||
var showLabDialog by remember { mutableStateOf(false) }
|
||||
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) {
|
||||
labs.sortedBy { it.marker.lowercase() }.groupBy { it.marker.uppercase() }
|
||||
}
|
||||
val grouped = remember(labs) { groupLabsForDisplay(labs) }
|
||||
val fmt = remember {
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
|
||||
}
|
||||
@ -72,92 +91,128 @@ fun LabsScreen() {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_labs)) }
|
||||
)
|
||||
if (labs.isEmpty()) {
|
||||
if (grouped.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.no_data),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
grouped.forEach { (marker, results) ->
|
||||
item(key = "header_$marker") {
|
||||
Text(
|
||||
marker,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
items(grouped, key = { it.first }) { (ts, group) ->
|
||||
// Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL »
|
||||
val headline = group.joinToString(" · ") { lab ->
|
||||
"${lab.marker.uppercase()} ${formatLabValue(lab.value)} ${lab.unit}"
|
||||
}
|
||||
items(results, key = { it.id }) { lab ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { toEdit = lab },
|
||||
headlineContent = {
|
||||
Text("${lab.value} ${lab.unit}")
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(fmt.format(Instant.ofEpochMilli(lab.timestamp)))
|
||||
lab.notes?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { toDelete = lab }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
if (group.size > 1) chooserGroup = group else toEdit = group.first()
|
||||
},
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(fmt.format(Instant.ofEpochMilli(ts)))
|
||||
// Notes du groupe (toutes identiques en pratique)
|
||||
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { toDeleteGroup = group }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabDialog || toEdit != null) {
|
||||
if (showLabDialog) {
|
||||
LabDialog(
|
||||
existing = toEdit,
|
||||
onDismiss = {
|
||||
showLabDialog = false
|
||||
toEdit = null
|
||||
},
|
||||
onConfirmCreate = { marker, value, unit, ts, notes ->
|
||||
existing = null,
|
||||
onDismiss = { showLabDialog = false },
|
||||
onConfirmCreate = { e2, t, ts, notes ->
|
||||
showLabDialog = false
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
repo.insertLabResult(
|
||||
LabResult(
|
||||
marker = marker,
|
||||
value = value,
|
||||
unit = unit,
|
||||
timestamp = ts,
|
||||
notes = notes.ifBlank { null }
|
||||
e2?.let {
|
||||
repo.insertLabResult(
|
||||
LabResult(marker = "E2", value = it.value, unit = it.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 ->
|
||||
toEdit = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
repo.updateLabResult(updated)
|
||||
CoroutineScope(Dispatchers.IO).launch { 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(
|
||||
onDismissRequest = { toDelete = null },
|
||||
onDismissRequest = { toDeleteGroup = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
toDelete = null
|
||||
CoroutineScope(Dispatchers.IO).launch { repo.deleteLabResult(lab) }
|
||||
toDeleteGroup = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
group.forEach { repo.deleteLabResult(it) }
|
||||
}
|
||||
}) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
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)) },
|
||||
text = { Text(stringResource(R.string.confirm_delete)) }
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.confirm_delete_blood_draw,
|
||||
group.joinToString(" · ") { "${it.marker.uppercase()} ${formatLabValue(it.value)}" }
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,8 +51,15 @@
|
||||
<string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string>
|
||||
|
||||
<!-- 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="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_value">Valeur</string>
|
||||
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>
|
||||
|
||||
@ -51,8 +51,15 @@
|
||||
<string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string>
|
||||
|
||||
<!-- 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="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_value">Value</string>
|
||||
<string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string>
|
||||
|
||||
56
app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt
Normal file
56
app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt
Normal 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, 306.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, 306.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, 306.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, 306.0))
|
||||
val groups = groupLabsForDisplay(labs)
|
||||
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,22 @@
|
||||
# Changelog — HormoneTrack
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -297,7 +297,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
|
||||
|
||||
## 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 =
|
||||
`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
|
||||
unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente.
|
||||
**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
|
||||
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
|
||||
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
|
||||
= 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
|
||||
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
|
||||
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
|
||||
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (création), **tap sur la ligne →
|
||||
édition** (v1.2.1, même mécanique que les doses), suppression avec confirmation ;
|
||||
suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
|
||||
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
|
||||
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
|
||||
**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 / ⏰,
|
||||
badge inactif), FAB → éditeur
|
||||
- `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)
|
||||
|
||||
---
|
||||
*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.*
|
||||
|
||||
@ -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
|
||||
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
|
||||
supprimer/recréer
|
||||
3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL ou ng/dL)
|
||||
- **Modifier un résultat** : appuie simplement sur sa ligne (même principe que les doses)
|
||||
3. **Analyses → +** → une prise de sang complète en une entrée : **E2 et/ou T**
|
||||
(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
|
||||
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 »
|
||||
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
|
||||
5. **Paramètres** :
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user