Adherence history screen: 30 days of dose logs with 7/30-day percentages

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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 17:47:52 +02:00
commit 861621e657
6 changed files with 222 additions and 25 deletions

View file

@ -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<MedWithItem>
@Query("SELECT * FROM medication WHERE id = :id")
suspend fun getRawById(id: Long): Medication?

View file

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

View file

@ -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<List<Pair<LocalDate, List<Pair<DoseLog, String>>>>>(emptyList()) }
var pct7 by remember { mutableStateOf<Int?>(null) }
var pct30 by remember { mutableStateOf<Int?>(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)
}
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
</vector>