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:
parent
0dd48445e1
commit
861621e657
6 changed files with 222 additions and 25 deletions
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
36
app/src/main/java/no/naiv/meddetsamme/ui/Components.kt
Normal file
36
app/src/main/java/no/naiv/meddetsamme/ui/Components.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
166
app/src/main/java/no/naiv/meddetsamme/ui/HistoryScreen.kt
Normal file
166
app/src/main/java/no/naiv/meddetsamme/ui/HistoryScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
app/src/main/res/drawable/ic_history.xml
Normal file
8
app/src/main/res/drawable/ic_history.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue