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")
|
@Query("SELECT * FROM medication WHERE id = :id")
|
||||||
suspend fun getById(id: Long): MedWithItem?
|
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")
|
@Query("SELECT * FROM medication WHERE id = :id")
|
||||||
suspend fun getRawById(id: Long): Medication?
|
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 class ItemEdit(val itemId: Long?) : Screen
|
||||||
data object Settings : Screen
|
data object Settings : Screen
|
||||||
data object SummaryPreview : Screen
|
data object SummaryPreview : Screen
|
||||||
|
data object History : Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
class Nav(initial: Screen) {
|
class Nav(initial: Screen) {
|
||||||
|
|
@ -52,5 +53,6 @@ fun AppRoot() {
|
||||||
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
|
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
|
||||||
is Screen.Settings -> SettingsScreen(nav)
|
is Screen.Settings -> SettingsScreen(nav)
|
||||||
is Screen.SummaryPreview -> SummaryPreviewScreen(nav)
|
is Screen.SummaryPreview -> SummaryPreviewScreen(nav)
|
||||||
|
is Screen.History -> HistoryScreen(nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,9 @@ fun TodayScreen(nav: Nav) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = { nav.push(Screen.History) }) {
|
||||||
|
Icon(painterResource(R.drawable.ic_history), contentDescription = "Historikk")
|
||||||
|
}
|
||||||
IconButton(onClick = { nav.push(Screen.MedList) }) {
|
IconButton(onClick = { nav.push(Screen.MedList) }) {
|
||||||
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
|
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
|
||||||
}
|
}
|
||||||
|
|
@ -243,13 +246,7 @@ private fun DoseCard(
|
||||||
onUndo: (() -> Unit)?,
|
onUndo: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val time = "%02d:%02d".format(dose.doseTime.minuteOfDay / 60, dose.doseTime.minuteOfDay % 60)
|
val time = "%02d:%02d".format(dose.doseTime.minuteOfDay / 60, dose.doseTime.minuteOfDay % 60)
|
||||||
val statusText = when (dose.status) {
|
val statusText = dose.status?.label
|
||||||
DoseStatus.TAKEN -> "Tatt"
|
|
||||||
DoseStatus.SKIPPED -> "Hoppet over"
|
|
||||||
DoseStatus.SNOOZED -> "Utsatt"
|
|
||||||
DoseStatus.PENDING -> "Venter"
|
|
||||||
null -> null
|
|
||||||
}
|
|
||||||
val resolved = dose.status == DoseStatus.TAKEN || dose.status == DoseStatus.SKIPPED
|
val resolved = dose.status == DoseStatus.TAKEN || dose.status == DoseStatus.SKIPPED
|
||||||
val overdue = !resolved && dose.at.isBefore(LocalDateTime.now())
|
val overdue = !resolved && dose.at.isBefore(LocalDateTime.now())
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
@ -298,7 +295,7 @@ private fun DoseCard(
|
||||||
color = scheme.onSurfaceVariant,
|
color = scheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
statusText?.let { StatusBadge(it, dose.status!!) }
|
dose.status?.let { DoseStatusBadge(it) }
|
||||||
}
|
}
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
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
|
* 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