From 861621e657a53f5e7c6e11d6b11a32adfaf4b6f1 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 11 Jun 2026 17:47:52 +0200 Subject: [PATCH] Adherence history screen: 30 days of dose logs with 7/30-day percentages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brief promised 'dose logging → adherence history'; the data was always there, now it has a screen. Read-only by design — corrections happen on the Today screen while fresh, history is the record. Days grouped newest-first, discontinued meds still named (getAllWithItems includes inactive), and today's not-yet-due doses are excluded from the percentages so they don't drag the score down. DoseStatusBadge moved to shared Components.kt, reused by both screens. Co-Authored-By: Claude Fable 5 --- .../no/naiv/meddetsamme/data/MedicationDao.kt | 5 + .../java/no/naiv/meddetsamme/ui/Components.kt | 36 ++++ .../no/naiv/meddetsamme/ui/HistoryScreen.kt | 166 ++++++++++++++++++ .../main/java/no/naiv/meddetsamme/ui/Nav.kt | 2 + .../no/naiv/meddetsamme/ui/TodayScreen.kt | 30 +--- app/src/main/res/drawable/ic_history.xml | 8 + 6 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/Components.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/HistoryScreen.kt create mode 100644 app/src/main/res/drawable/ic_history.xml diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt index 2b14697..c0a4b41 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt @@ -30,6 +30,11 @@ interface MedicationDao { @Query("SELECT * FROM medication WHERE id = :id") suspend fun getById(id: Long): MedWithItem? + /** Including inactive — history must name discontinued meds too. */ + @Transaction + @Query("SELECT * FROM medication") + suspend fun getAllWithItems(): List + @Query("SELECT * FROM medication WHERE id = :id") suspend fun getRawById(id: Long): Medication? diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt new file mode 100644 index 0000000..eeaf6a8 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt @@ -0,0 +1,36 @@ +package no.naiv.meddetsamme.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import no.naiv.meddetsamme.data.DoseStatus + +val DoseStatus.label: String + get() = when (this) { + DoseStatus.TAKEN -> "Tatt" + DoseStatus.SKIPPED -> "Hoppet over" + DoseStatus.SNOOZED -> "Utsatt" + DoseStatus.PENDING -> "Venter" + } + +/** Tonal status chip shared by the today and history screens. */ +@Composable +fun DoseStatusBadge(status: DoseStatus) { + val scheme = MaterialTheme.colorScheme + val (bg, fg) = when (status) { + DoseStatus.TAKEN -> scheme.primary to scheme.onPrimary + DoseStatus.SNOOZED -> scheme.tertiaryContainer to scheme.onTertiaryContainer + DoseStatus.SKIPPED -> scheme.surfaceVariant to scheme.onSurfaceVariant + DoseStatus.PENDING -> scheme.secondaryContainer to scheme.onSecondaryContainer + } + Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) { + Text( + status.label, + style = MaterialTheme.typography.labelMedium, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/HistoryScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/HistoryScreen.kt new file mode 100644 index 0000000..f2a9efa --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/HistoryScreen.kt @@ -0,0 +1,166 @@ +package no.naiv.meddetsamme.ui + +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 +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import no.naiv.meddetsamme.R +import no.naiv.meddetsamme.data.DoseLog +import no.naiv.meddetsamme.data.MedDatabase +import no.naiv.meddetsamme.domain.ScheduleEngine + +private const val HISTORY_DAYS = 30L + +/** + * Adherence history: the last 30 days of dose logs, newest first, with + * 7- and 30-day adherence percentages on top. Read-only — corrections happen + * on the Today screen (Angre), history is the record. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HistoryScreen(nav: Nav) { + val context = LocalContext.current + val db = remember { MedDatabase.get(context) } + val zone = ZoneId.systemDefault() + + var days by remember { mutableStateOf>>>>(emptyList()) } + var pct7 by remember { mutableStateOf(null) } + var pct30 by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + val now = System.currentTimeMillis() + val from = now - HISTORY_DAYS * 86_400_000 + val names = db.medicationDao().getAllWithItems().associate { it.med.id to it.displayName } + // Only the past is history: today's not-yet-due doses would drag the + // percentages down for no reason. + val logs = db.doseLogDao().getRange(from, now) + pct30 = ScheduleEngine.adherencePercent(logs) + pct7 = ScheduleEngine.adherencePercent(logs.filter { it.scheduledAtMillis >= now - 7 * 86_400_000 }) + days = logs + .sortedByDescending { it.scheduledAtMillis } + .groupBy { Instant.ofEpochMilli(it.scheduledAtMillis).atZone(zone).toLocalDate() } + .map { (date, dayLogs) -> date to dayLogs.map { it to (names[it.medId] ?: "Slettet medisin") } } + } + + val dayFmt = remember { DateTimeFormatter.ofPattern("EEEE d. MMMM", Locale.forLanguageTag("nb")) } + val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Historikk") }, + navigationIcon = { + IconButton(onClick = { nav.pop() }) { + Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) { + contentDescription = "Etterlevelse: ${pct7?.let { "$it prosent siste 7 dager" } ?: "ingen data siste 7 dager"}, " + + (pct30?.let { "$it prosent siste 30 dager" } ?: "ingen data siste 30 dager") + }, + ) { + Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceEvenly) { + AdherenceStat("Siste 7 dager", pct7, Modifier.weight(1f)) + AdherenceStat("Siste 30 dager", pct30, Modifier.weight(1f)) + } + } + } + if (days.isEmpty()) { + item { + Text( + "Ingen doser logget ennå.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp), + ) + } + } + days.forEach { (date, dayLogs) -> + item(key = date.toEpochDay()) { + Text( + dayFmt.format(date).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 10.dp, bottom = 2.dp), + ) + } + items(dayLogs, key = { it.first.id }) { (log, medName) -> + val time = timeFmt.format(Instant.ofEpochMilli(log.scheduledAtMillis).atZone(zone)) + Card( + modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) { + contentDescription = "$time, $medName, ${log.status.label}" + }, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(time, style = MaterialTheme.typography.titleSmall) + Text( + medName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 12.dp).weight(1f), + ) + DoseStatusBadge(log.status) + } + } + } + } + } + } +} + +@Composable +private fun AdherenceStat(label: String, pct: Int?, modifier: Modifier = Modifier) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + pct?.let { "$it %" } ?: "–", + style = MaterialTheme.typography.headlineMedium, + ) + Text(label, style = MaterialTheme.typography.labelMedium) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt index 72f9507..6540d8f 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt @@ -22,6 +22,7 @@ sealed interface Screen { data class ItemEdit(val itemId: Long?) : Screen data object Settings : Screen data object SummaryPreview : Screen + data object History : Screen } class Nav(initial: Screen) { @@ -52,5 +53,6 @@ fun AppRoot() { is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId) is Screen.Settings -> SettingsScreen(nav) is Screen.SummaryPreview -> SummaryPreviewScreen(nav) + is Screen.History -> HistoryScreen(nav) } } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt index 77b830c..0934131 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -147,6 +147,9 @@ fun TodayScreen(nav: Nav) { } }, actions = { + IconButton(onClick = { nav.push(Screen.History) }) { + Icon(painterResource(R.drawable.ic_history), contentDescription = "Historikk") + } IconButton(onClick = { nav.push(Screen.MedList) }) { Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner") } @@ -243,13 +246,7 @@ private fun DoseCard( onUndo: (() -> Unit)?, ) { val time = "%02d:%02d".format(dose.doseTime.minuteOfDay / 60, dose.doseTime.minuteOfDay % 60) - val statusText = when (dose.status) { - DoseStatus.TAKEN -> "Tatt" - DoseStatus.SKIPPED -> "Hoppet over" - DoseStatus.SNOOZED -> "Utsatt" - DoseStatus.PENDING -> "Venter" - null -> null - } + val statusText = dose.status?.label val resolved = dose.status == DoseStatus.TAKEN || dose.status == DoseStatus.SKIPPED val overdue = !resolved && dose.at.isBefore(LocalDateTime.now()) val scheme = MaterialTheme.colorScheme @@ -298,7 +295,7 @@ private fun DoseCard( color = scheme.onSurfaceVariant, ) } - statusText?.let { StatusBadge(it, dose.status!!) } + dose.status?.let { DoseStatusBadge(it) } } if (!resolved) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -315,23 +312,6 @@ private fun DoseCard( } } -@Composable -private fun StatusBadge(text: String, status: DoseStatus) { - val scheme = MaterialTheme.colorScheme - val (bg, fg) = when (status) { - DoseStatus.TAKEN -> scheme.primary to scheme.onPrimary - DoseStatus.SNOOZED -> scheme.tertiaryContainer to scheme.onTertiaryContainer - DoseStatus.SKIPPED -> scheme.surfaceVariant to scheme.onSurfaceVariant - DoseStatus.PENDING -> scheme.secondaryContainer to scheme.onSecondaryContainer - } - Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) { - Text( - text, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - } -} /** * Quiet one-time card while backup is unconfigured: health data existing only diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..921d975 --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,8 @@ + + + +