diff --git a/README.md b/README.md index 46da2ed..4c42586 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 880b451..f5b34c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt b/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt index d6e82c0..4d901fb 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt @@ -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 }) + } } } } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index 2a5d756..f605d2b 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -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é. diff --git a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt index 5dc69b7..20ed6a1 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt @@ -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): List>> = + 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(null) } - var toDelete by remember { mutableStateOf(null) } + // Groupe en cours de suppression (une prise de sang entière) + var toDeleteGroup by remember { mutableStateOf?>(null) } + // Choix du marqueur à éditer quand le groupe contient E2 + T + var chooserGroup by remember { mutableStateOf?>(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)}" } + ) + ) + } ) } } diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 14bed61..5f73852 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -51,8 +51,15 @@ Supprimer ce traitement ? Son historique de doses sera aussi supprimé. - Ajouter un résultat + Ajouter une prise de sang Modifier le résultat + E2 — valeur œstradiol + Unité E2 + T — valeur testostérone + Unité T + optionnel + Quelle entrée veux-tu modifier ? + Supprimer cette prise de sang (%1$s) ? Marqueur (ex : E2, T) Valeur Unité (ex : pg/mL, ng/mL) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49bb6f4..ba4a5c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,8 +51,15 @@ Delete this treatment? Its dose history will also be deleted. - Add lab result + Add a blood test Edit lab result + E2 — estradiol value + E2 unit + T — testosterone value + T unit + optional + Which entry do you want to edit? + Delete this blood draw (%1$s)? Marker (e.g. E2, T) Value Unit (e.g. pg/mL, ng/mL) diff --git a/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt b/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt new file mode 100644 index 0000000..9d0fa38 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt @@ -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() }) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index adb8230..57569d1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 12a2eab..6e95739 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index 2740224..71c180f 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -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** :