UI buildout: today screen, med editor with FEST autocomplete, schedule editor, settings

Milestone 9. Hand-rolled back-stack navigation (4 screens don't justify
navigation-compose). DoseActions is now the single implementation of
Taken/Snooze/Skip/Undo shared by notification receiver and UI — two
code paths for 'taken' would eventually disagree about inventory.
Screens read DAO Flows directly; every schedule mutation re-arms
alarms via armAll() (idempotent, OS-deduplicated). One-time battery
prompt + POST_NOTIFICATIONS request on the today screen. Settings:
S3/Garage config, typed-passphrase export/import (replace-all import
re-arms alarms), FEST refresh, PDF share. TalkBack: merged semantics
with full descriptions on dose cards, content descriptions on icon
buttons and day chips. Doctor PDF locale pinned to Bokmål — verified
on emulator after catching English month names on an en-US device.

Emulator-verified end-to-end: battery prompt → system dialog, med
created via editor (validation catches bad input), dose time added →
exact alarm armed immediately, Ta nå → TAKEN + inventory 20→19 +
reactive UI update, Angre → PENDING + inventory restored, settings
renders, PDF generated and share sheet opened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 14:22:29 +02:00
commit 2e7eadeb39
13 changed files with 1390 additions and 100 deletions

View file

@ -1,88 +1,19 @@
package no.naiv.meddetsamme
import android.content.pm.ApplicationInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDateTime
import java.time.ZoneId
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
import no.naiv.meddetsamme.ui.AppRoot
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val debuggable = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
setContent {
MaterialTheme {
AppRoot(showDebugSeed = debuggable, onSeed = ::seedTestDose)
}
}
}
/**
* Debug-only: insert a med with a dose due in 2 minutes and arm it, so the
* fire escalate Taken/Snooze path can be verified on an emulator before
* the real UI exists (milestone 9 replaces this screen entirely).
*/
private fun seedTestDose() {
val app = application as MedDetSammeApp
app.appScope.launch {
val db = MedDatabase.get(this@MainActivity)
val due = LocalDateTime.now().plusMinutes(2)
val medId = db.medicationDao().insert(
Medication(
name = "Testmedisin",
strength = "500 mg",
unit = "tablett",
form = MedForm.TABLET,
inventoryUnits = 10.0,
),
)
val doseTimeId = db.doseTimeDao().insert(
DoseTime(
medId = medId,
minuteOfDay = due.hour * 60 + due.minute,
amount = 1.0,
),
)
AlarmScheduler(this@MainActivity).armDoseTime(doseTimeId)
}
}
}
@Composable
private fun AppRoot(showDebugSeed: Boolean, onSeed: () -> Unit) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Med det samme",
style = MaterialTheme.typography.headlineMedium,
)
if (showDebugSeed) {
Button(onClick = onSeed) {
Text("Seed testdose (+2 min)")
}
AppRoot()
}
}
}

View file

@ -6,9 +6,7 @@ import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.notify.Notifications
import no.naiv.meddetsamme.domain.DoseActions
/**
* Handles the notification actions (and later, the same actions from the UI).
@ -42,31 +40,11 @@ class DoseActionReceiver : BroadcastReceiver() {
}
private suspend fun handle(context: Context, action: String, logId: Long) {
val db = MedDatabase.get(context)
val scheduler = AlarmScheduler(context)
val log = db.doseLogDao().getById(logId) ?: return
val now = System.currentTimeMillis()
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> {
// Idempotence: double-tap on a laggy notification must not
// decrement stock twice.
if (log.status == DoseStatus.TAKEN) return
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, now)
db.medicationDao().decrementInventory(log.medId, log.amount)
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
ACTION_SNOOZE -> {
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, now)
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
ACTION_SKIP -> {
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, now)
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
ACTION_TAKEN -> actions.take(logId)
ACTION_SNOOZE -> actions.snooze(logId)
ACTION_SKIP -> actions.skip(logId)
}
}
}

View file

@ -0,0 +1,67 @@
package no.naiv.meddetsamme.domain
import android.content.Context
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.notify.Notifications
/**
* The one implementation of Taken/Snooze/Skip, shared by the notification
* receiver and the UI. Two separate code paths for "taken" would eventually
* disagree about inventory or escalation this class makes that impossible.
*/
class DoseActions(private val context: Context) {
private val db get() = MedDatabase.get(context)
private val scheduler get() = AlarmScheduler(context)
/**
* Find-or-create the log for an occurrence lets the UI act on a dose
* before its alarm has fired (e.g. taking the 20:00 dose at 19:30).
*/
suspend fun ensureLog(doseTimeId: Long, scheduledAtMillis: Long): DoseLog {
db.doseLogDao().getForOccurrence(doseTimeId, scheduledAtMillis)?.let { return it }
val doseTime = checkNotNull(db.doseTimeDao().getById(doseTimeId)) { "Ukjent dosetid" }
val id = db.doseLogDao().insert(
DoseLog(
medId = doseTime.medId,
doseTimeId = doseTimeId,
scheduledAtMillis = scheduledAtMillis,
amount = doseTime.amount,
),
)
return db.doseLogDao().getById(id)!!
}
suspend fun take(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis())
db.medicationDao().decrementInventory(log.medId, log.amount)
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
suspend fun snooze(logId: Long) {
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
suspend fun skip(logId: Long) {
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
/** Undo a mis-tap: back to PENDING, restoring inventory if it was TAKEN. */
suspend fun undo(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
if (log.status == DoseStatus.TAKEN) {
db.medicationDao().addInventory(log.medId, log.amount)
}
db.doseLogDao().setStatus(logId, DoseStatus.PENDING, null)
}
}

View file

@ -31,7 +31,19 @@ class DoctorSummaryPdf(private val context: Context) {
const val ADHERENCE_WINDOW_DAYS = 30L
}
private val dateFmt = DateTimeFormatter.ofPattern("d. MMMM yyyy")
// Locale pinned to Bokmål: the system formatter would print English month
// names on a non-Norwegian device, and this document goes to a Norwegian GP.
private val dateFmt = DateTimeFormatter.ofPattern("d. MMMM yyyy", java.util.Locale.forLanguageTag("nb"))
private val formLabels = mapOf(
no.naiv.meddetsamme.data.MedForm.TABLET to "Tablett",
no.naiv.meddetsamme.data.MedForm.CAPSULE to "Kapsel",
no.naiv.meddetsamme.data.MedForm.LIQUID to "Flytende",
no.naiv.meddetsamme.data.MedForm.INJECTION to "Injeksjon",
no.naiv.meddetsamme.data.MedForm.DROPS to "Dråper",
no.naiv.meddetsamme.data.MedForm.SPRAY to "Spray",
no.naiv.meddetsamme.data.MedForm.OTHER to "Annet",
)
/** Generates the PDF and returns a ready-to-launch share chooser intent. */
suspend fun buildShareIntent(): Intent {
@ -91,7 +103,7 @@ class DoctorSummaryPdf(private val context: Context) {
line("${med.name} ${med.strength}".trim(), heading, 3f)
val formLine = buildString {
append(med.form.name.lowercase().replaceFirstChar { it.uppercase() })
append(formLabels[med.form] ?: med.form.name)
if (med.withFood) append(" — tas med mat")
med.atcCode?.let { append(" · ATC $it") }
}

View file

@ -39,6 +39,11 @@ class SettingsStore(context: Context) {
get() = prefs.getBoolean("battery_prompt_shown", false)
set(v) = prefs.edit().putBoolean("battery_prompt_shown", v).apply()
/** FEST dataset URL — not a secret, plaintext pref. */
var festUrl: String?
get() = prefs.getString("fest_url", null)
set(v) = prefs.edit().putString("fest_url", v).apply()
val isBackupConfigured: Boolean
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
!s3AccessKey.isNullOrBlank() && !s3SecretKey.isNullOrBlank()

View file

@ -0,0 +1,529 @@
package no.naiv.meddetsamme.ui
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
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
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.rememberCoroutineScope
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.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
import no.naiv.meddetsamme.domain.ScheduleText
import no.naiv.meddetsamme.fest.FestEntry
import no.naiv.meddetsamme.fest.FestRepository
private val FORM_LABELS = mapOf(
MedForm.TABLET to "Tablett", MedForm.CAPSULE to "Kapsel", MedForm.LIQUID to "Flytende",
MedForm.INJECTION to "Injeksjon", MedForm.DROPS to "Dråper", MedForm.SPRAY to "Spray",
MedForm.OTHER to "Annet",
)
/** Best-effort mapping of FEST's free-form text to our enum; user can override. */
private fun guessForm(festForm: String): MedForm = when {
"tablett" in festForm.lowercase() -> MedForm.TABLET
"kapsel" in festForm.lowercase() -> MedForm.CAPSULE
"mikst" in festForm.lowercase() || "oppløsning" in festForm.lowercase() -> MedForm.LIQUID
"inj" in festForm.lowercase() -> MedForm.INJECTION
"dråp" in festForm.lowercase() -> MedForm.DROPS
"spray" in festForm.lowercase() -> MedForm.SPRAY
else -> MedForm.OTHER
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MedEditScreen(nav: Nav, initialMedId: Long?) {
val context = LocalContext.current
val db = remember { MedDatabase.get(context) }
val fest = remember { FestRepository(context) }
val scope = rememberCoroutineScope()
// null until first save for a new med; schedule editing needs the row id.
var medId by remember { mutableStateOf(initialMedId) }
var name by remember { mutableStateOf("") }
var strength by remember { mutableStateOf("") }
var unit by remember { mutableStateOf("tablett") }
var form by remember { mutableStateOf(MedForm.TABLET) }
var withFood by remember { mutableStateOf(false) }
var notes by remember { mutableStateOf("") }
var inventory by remember { mutableStateOf("0") }
var packageSize by remember { mutableStateOf("") }
var leadDays by remember { mutableStateOf("7") }
var rxExpiry by remember { mutableStateOf<LocalDate?>(null) }
var refills by remember { mutableStateOf("") }
var atc by remember { mutableStateOf<String?>(null) }
var suggestions by remember { mutableStateOf<List<FestEntry>>(emptyList()) }
var suppressSuggestions by remember { mutableStateOf(false) }
var doseTimes by remember { mutableStateOf<List<DoseTime>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
var confirmDelete by remember { mutableStateOf(false) }
suspend fun reloadDoseTimes() {
medId?.let { doseTimes = db.doseTimeDao().getForMed(it) }
}
LaunchedEffect(initialMedId) {
initialMedId?.let { id ->
db.medicationDao().getById(id)?.let { m ->
name = m.name; strength = m.strength; unit = m.unit; form = m.form
withFood = m.withFood; notes = m.notes
inventory = ScheduleText.amountText(m.inventoryUnits, "").trim()
packageSize = m.packageSize?.let { p -> ScheduleText.amountText(p, "").trim() } ?: ""
leadDays = m.lowStockLeadDays.toString()
rxExpiry = m.rxExpiryEpochDay?.let(LocalDate::ofEpochDay)
refills = m.refillsRemaining?.toString() ?: ""
atc = m.atcCode
}
reloadDoseTimes()
}
suppressSuggestions = initialMedId != null
}
fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull()
fun save() {
val inv = parseAmount(inventory)
if (name.isBlank()) { error = "Navn mangler"; return }
if (inv == null || inv < 0) { error = "Ugyldig lagerbeholdning"; return }
val med = Medication(
id = medId ?: 0,
name = name.trim(),
strength = strength.trim(),
unit = unit.trim().ifBlank { "dose" },
form = form,
withFood = withFood,
notes = notes.trim(),
inventoryUnits = inv,
packageSize = parseAmount(packageSize),
lowStockLeadDays = leadDays.toIntOrNull() ?: 7,
rxExpiryEpochDay = rxExpiry?.toEpochDay(),
refillsRemaining = refills.trim().toIntOrNull(),
atcCode = atc,
)
scope.launch {
medId = if (medId == null) {
db.medicationDao().insert(med)
} else {
db.medicationDao().update(med); medId
}
AlarmScheduler(context).armAll()
error = null
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (initialMedId == null) "Ny medisin" else "Rediger medisin") },
navigationIcon = {
IconButton(onClick = { nav.pop() }) {
Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = name,
onValueChange = {
name = it
suppressSuggestions = false
scope.launch(Dispatchers.IO) {
val hits = fest.search(it)
withContext(Dispatchers.Main) { suggestions = hits }
}
},
label = { Text("Navn") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (!suppressSuggestions && suggestions.isNotEmpty()) {
Card(Modifier.fillMaxWidth().semantics { contentDescription = "Forslag fra FEST" }) {
Column {
suggestions.take(6).forEach { s ->
Text(
"${s.name} ${s.strength}".trim(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.clickable {
name = s.name
strength = s.strength
if (s.unit.isNotBlank()) unit = s.unit
form = guessForm(s.form)
atc = s.atc
s.packageSize?.let { p ->
packageSize = ScheduleText.amountText(p, "").trim()
}
suggestions = emptyList()
suppressSuggestions = true
}
.padding(12.dp),
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = strength, onValueChange = { strength = it },
label = { Text("Styrke") }, singleLine = true, modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = unit, onValueChange = { unit = it },
label = { Text("Enhet") }, singleLine = true, modifier = Modifier.weight(1f),
)
}
FormDropdown(form) { form = it }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { withFood = !withFood },
) {
Checkbox(checked = withFood, onCheckedChange = { withFood = it })
Text("Tas med mat")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = inventory, onValueChange = { inventory = it },
label = { Text("Lager") }, singleLine = true, modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = packageSize, onValueChange = { packageSize = it },
label = { Text("Pakningsstr.") }, singleLine = true, modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = leadDays, onValueChange = { leadDays = it },
label = { Text("Varsle v/ dager") }, singleLine = true, modifier = Modifier.weight(1f),
)
}
RxExpiryField(rxExpiry) { rxExpiry = it }
OutlinedTextField(
value = refills, onValueChange = { refills = it },
label = { Text("Reit igjen") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = notes, onValueChange = { notes = it },
label = { Text("Merknader") }, modifier = Modifier.fillMaxWidth(),
)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = ::save, modifier = Modifier.fillMaxWidth()) {
Text(if (medId == null) "Lagre medisin" else "Lagre endringer")
}
medId?.let { id ->
ScheduleSection(
medId = id,
doseTimes = doseTimes,
onChanged = {
scope.launch {
reloadDoseTimes()
AlarmScheduler(context).armAll()
}
},
)
if (initialMedId != null) {
OutlinedButton(
onClick = {
scope.launch {
db.medicationDao().deactivate(id)
AlarmScheduler(context).armAll()
nav.pop()
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Avslutt medisin (beholder historikk)") }
TextButton(onClick = { confirmDelete = true }, modifier = Modifier.fillMaxWidth()) {
Text("Slett permanent", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Slette ${name.ifBlank { "medisinen" }}?") },
text = { Text("Sletter også all dosehistorikk. Dette kan ikke angres. «Avslutt medisin» beholder historikken.") },
confirmButton = {
TextButton(onClick = {
confirmDelete = false
scope.launch {
medId?.let { db.medicationDao().delete(it) }
AlarmScheduler(context).armAll()
nav.pop()
}
}) { Text("Slett", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Avbryt") } },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FormDropdown(value: MedForm, onChange: (MedForm) -> Unit) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = FORM_LABELS.getValue(value),
onValueChange = {},
readOnly = true,
label = { Text("Form") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
MedForm.entries.forEach { f ->
DropdownMenuItem(
text = { Text(FORM_LABELS.getValue(f)) },
onClick = { onChange(f); expanded = false },
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RxExpiryField(value: LocalDate?, onChange: (LocalDate?) -> Unit) {
var showPicker by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = value?.toString() ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Resept utløper") },
modifier = Modifier.weight(1f).clickable { showPicker = true },
)
TextButton(onClick = { showPicker = true }) { Text("Velg dato") }
if (value != null) {
TextButton(onClick = { onChange(null) }) { Text("Fjern") }
}
}
if (showPicker) {
val state = rememberDatePickerState(
initialSelectedDateMillis = value?.toEpochDay()?.times(86_400_000),
)
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { onChange(LocalDate.ofEpochDay(it / 86_400_000)) }
showPicker = false
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Avbryt") } },
) {
DatePicker(state = state)
}
}
}
// ---- schedule (dose times) -------------------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: () -> Unit) {
val context = LocalContext.current
val db = remember { MedDatabase.get(context) }
val scope = rememberCoroutineScope()
var editing by remember { mutableStateOf<DoseTime?>(null) }
var showEditor by remember { mutableStateOf(false) }
Text("Doseringstider", style = MaterialTheme.typography.titleMedium)
doseTimes.forEach { dt ->
Card(
Modifier
.fillMaxWidth()
.clickable { editing = dt; showEditor = true }
.semantics(mergeDescendants = true) {
contentDescription = "Dosetid ${ScheduleText.describe(dt)}. Trykk for å redigere."
},
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(ScheduleText.describe(dt), Modifier.weight(1f))
TextButton(onClick = {
scope.launch {
db.doseTimeDao().delete(dt.id)
onChanged()
}
}) { Text("Fjern") }
}
}
}
OutlinedButton(onClick = { editing = null; showEditor = true }, modifier = Modifier.fillMaxWidth()) {
Text("Legg til doseringstid")
}
if (showEditor) {
DoseTimeEditorDialog(
medId = medId,
existing = editing,
onDismiss = { showEditor = false },
onSaved = { showEditor = false; onChanged() },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DoseTimeEditorDialog(
medId: Long,
existing: DoseTime?,
onDismiss: () -> Unit,
onSaved: () -> Unit,
) {
val context = LocalContext.current
val db = remember { MedDatabase.get(context) }
val scope = rememberCoroutineScope()
val timeState = rememberTimePickerState(
initialHour = (existing?.minuteOfDay ?: 8 * 60) / 60,
initialMinute = (existing?.minuteOfDay ?: 8 * 60) % 60,
is24Hour = true, // 24h everywhere, always
)
var amount by remember { mutableStateOf(existing?.amount?.let { ScheduleText.amountText(it, "").trim() } ?: "1") }
var intervalMode by remember { mutableStateOf((existing?.intervalDays ?: 0) > 1) }
var mask by remember { mutableStateOf(existing?.daysOfWeekMask ?: 0x7F) }
var intervalDays by remember { mutableStateOf((existing?.intervalDays ?: 2).coerceAtLeast(2).toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
TimePicker(state = timeState)
OutlinedTextField(
value = amount, onValueChange = { amount = it },
label = { Text("Mengde per dose") }, singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = !intervalMode, onClick = { intervalMode = false },
label = { Text("Ukedager") },
)
FilterChip(
selected = intervalMode, onClick = { intervalMode = true },
label = { Text("Hver N. dag") },
)
}
if (intervalMode) {
OutlinedTextField(
value = intervalDays, onValueChange = { intervalDays = it },
label = { Text("Antall dager mellom doser") }, singleLine = true,
)
} else {
// Monday-first row of toggles; bit 0 = Sunday underneath.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
listOf(1 to "M", 2 to "T", 3 to "O", 4 to "T", 5 to "F", 6 to "L", 0 to "S")
.forEach { (bit, label) ->
val selected = mask and (1 shl bit) != 0
FilterChip(
selected = selected,
onClick = { mask = mask xor (1 shl bit) },
label = { Text(label) },
modifier = Modifier.semantics {
contentDescription = dayName(bit) + if (selected) ", valgt" else ", ikke valgt"
},
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = {
val amt = amount.trim().replace(',', '.').toDoubleOrNull() ?: return@TextButton
val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
val dt = DoseTime(
id = existing?.id ?: 0,
medId = medId,
minuteOfDay = timeState.hour * 60 + timeState.minute,
amount = amt,
daysOfWeekMask = if (intervalMode) 0 else mask,
intervalDays = interval,
anchorEpochDay = if (intervalMode) {
existing?.anchorEpochDay ?: LocalDate.now().toEpochDay()
} else null,
)
scope.launch {
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
onSaved()
}
}) { Text("Lagre") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
)
}
private fun dayName(bit: Int): String =
listOf("søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag")[bit]

View file

@ -0,0 +1,99 @@
package no.naiv.meddetsamme.ui
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
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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 no.naiv.meddetsamme.R
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.domain.ScheduleText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MedListScreen(nav: Nav) {
val context = LocalContext.current
val db = remember { MedDatabase.get(context) }
val meds by db.medicationDao().observeActive().collectAsState(initial = emptyList())
Scaffold(
topBar = {
TopAppBar(
title = { Text("Medisiner") },
navigationIcon = {
IconButton(onClick = { nav.pop() }) {
Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { nav.push(Screen.MedEdit(medId = null)) },
text = { Text("Ny medisin") },
icon = { Text("+", style = MaterialTheme.typography.titleLarge) },
modifier = Modifier.semantics { contentDescription = "Legg til ny medisin" },
)
},
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (meds.isEmpty()) {
item {
Text(
"Ingen medisiner ennå.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 24.dp),
)
}
}
items(meds, key = { it.id }) { med ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { nav.push(Screen.MedEdit(med.id)) }
.semantics(mergeDescendants = true) {
contentDescription =
"${med.name} ${med.strength}, lager ${ScheduleText.amountText(med.inventoryUnits, med.unit)}. Trykk for å redigere."
},
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${med.name} ${med.strength}".trim(), style = MaterialTheme.typography.titleMedium)
Text(
"Lager: ${ScheduleText.amountText(med.inventoryUnits, med.unit)}",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,49 @@
package no.naiv.meddetsamme.ui
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* Hand-rolled navigation: four screens don't justify the navigation-compose
* dependency (brief: smallest thing that works). A simple back-stack of
* screen values; system back pops it.
*/
sealed interface Screen {
data object Today : Screen
data object MedList : Screen
/** medId == null → create new. */
data class MedEdit(val medId: Long?) : Screen
data object Settings : Screen
}
class Nav(initial: Screen) {
private var stack by mutableStateOf(listOf(initial))
val current: Screen get() = stack.last()
val canGoBack: Boolean get() = stack.size > 1
fun push(screen: Screen) {
stack = stack + screen
}
fun pop() {
if (canGoBack) stack = stack.dropLast(1)
}
}
@Composable
fun AppRoot() {
val nav = remember { Nav(Screen.Today) }
BackHandler(enabled = nav.canGoBack) { nav.pop() }
when (val screen = nav.current) {
is Screen.Today -> TodayScreen(nav)
is Screen.MedList -> MedListScreen(nav)
is Screen.MedEdit -> MedEditScreen(nav, screen.medId)
is Screen.Settings -> SettingsScreen(nav)
}
}

View file

@ -0,0 +1,300 @@
package no.naiv.meddetsamme.ui
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.backup.BackupManager
import no.naiv.meddetsamme.fest.FestRepository
import no.naiv.meddetsamme.pdf.DoctorSummaryPdf
import no.naiv.meddetsamme.settings.SettingsStore
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(nav: Nav) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
val scope = rememberCoroutineScope()
fun toast(msg: String) = Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
var endpoint by remember { mutableStateOf(settings.s3Endpoint ?: "") }
var region by remember { mutableStateOf(settings.s3Region ?: "garage") }
var bucket by remember { mutableStateOf(settings.s3Bucket ?: "") }
var accessKey by remember { mutableStateOf(settings.s3AccessKey ?: "") }
var secretKey by remember { mutableStateOf(settings.s3SecretKey ?: "") }
var autoPassphrase by remember { mutableStateOf(settings.autoBackupPassphrase ?: "") }
var festUrl by remember { mutableStateOf(settings.festUrl ?: "") }
// Export/import passphrase dialogs hold the pending document URI.
var exportUri by remember { mutableStateOf<Uri?>(null) }
var importUri by remember { mutableStateOf<Uri?>(null) }
val exportPicker = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> exportUri = uri }
val importPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> importUri = uri }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Innstillinger") },
navigationIcon = {
IconButton(onClick = { nav.pop() }) {
Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Sikkerhetskopi (S3/Garage)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = endpoint, onValueChange = { endpoint = it },
label = { Text("Endepunkt (https://…)") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = region, onValueChange = { region = it },
label = { Text("Region") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bucket, onValueChange = { bucket = it },
label = { Text("Bøtte") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = accessKey, onValueChange = { accessKey = it },
label = { Text("Access key") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = secretKey, onValueChange = { secretKey = it },
label = { Text("Secret key") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = autoPassphrase, onValueChange = { autoPassphrase = it },
label = { Text("Passordfrase for autobackup") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = {
Text(
"Lagres på enheten slik at nattlig backup kan kjøre uten deg — " +
"beskytter bare mot at bøtta kommer på avveie. Tom = ukryptert backup.",
)
},
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
settings.s3Endpoint = endpoint.trim().ifBlank { null }
settings.s3Region = region.trim().ifBlank { null }
settings.s3Bucket = bucket.trim().ifBlank { null }
settings.s3AccessKey = accessKey.trim().ifBlank { null }
settings.s3SecretKey = secretKey.trim().ifBlank { null }
settings.autoBackupPassphrase = autoPassphrase.ifBlank { null }
toast("Lagret")
},
modifier = Modifier.fillMaxWidth(),
) { Text("Lagre backupinnstillinger") }
OutlinedButton(
onClick = {
scope.launch(Dispatchers.IO) {
try {
val key = BackupManager(context).runAutoBackup()
withContext(Dispatchers.Main) { toast("Lastet opp: $key") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Backup feilet: ${e.message}") }
}
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Test backup nå") }
HorizontalDivider()
Text("Eksport og import", style = MaterialTheme.typography.titleMedium)
OutlinedButton(
onClick = { exportPicker.launch("medisinbackup-${LocalDate.now()}.age") },
modifier = Modifier.fillMaxWidth(),
) { Text("Eksporter til fil …") }
OutlinedButton(
onClick = { importPicker.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
) { Text("Importer fra fil …") }
HorizontalDivider()
Text("FEST-legemiddeldata", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = festUrl, onValueChange = { festUrl = it },
label = { Text("URL til slim.json") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
settings.festUrl = festUrl.trim().ifBlank { null }
val url = settings.festUrl ?: run { toast("Sett URL først"); return@OutlinedButton }
scope.launch(Dispatchers.IO) {
try {
FestRepository(context).refresh(url)
withContext(Dispatchers.Main) { toast("FEST-data oppdatert") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Oppdatering feilet: ${e.message}") }
}
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Last ned/oppdater nå") }
HorizontalDivider()
Text("Legeoversikt", style = MaterialTheme.typography.titleMedium)
OutlinedButton(
onClick = {
scope.launch {
try {
context.startActivity(DoctorSummaryPdf(context).buildShareIntent())
} catch (e: Exception) {
toast("Kunne ikke lage PDF: ${e.message}")
}
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Del medisinoversikt (PDF)") }
}
}
exportUri?.let { uri ->
PassphraseDialog(
title = "Krypter eksport",
text = "Skriv en passordfrase (lagres ikke). La stå tom for ukryptert JSON.",
allowEmpty = true,
onDismiss = { exportUri = null },
) { passphrase ->
exportUri = null
scope.launch(Dispatchers.IO) {
try {
val manager = BackupManager(context)
val bytes = if (passphrase.isEmpty()) {
manager.exportJson().toByteArray()
} else {
manager.exportEncrypted(passphrase)
}
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
?: error("Fikk ikke åpnet filen")
withContext(Dispatchers.Main) { toast("Eksportert") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Eksport feilet: ${e.message}") }
}
}
}
}
importUri?.let { uri ->
PassphraseDialog(
title = "Importer sikkerhetskopi",
text = "ERSTATTER alle medisiner og all historikk på enheten. Passordfrase trengs bare for krypterte filer.",
allowEmpty = true,
onDismiss = { importUri = null },
) { passphrase ->
importUri = null
scope.launch(Dispatchers.IO) {
try {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Fikk ikke lest filen")
val count = BackupManager(context)
.import(bytes, passphrase.takeIf { it.isNotEmpty() })
AlarmScheduler(context).armAll() // restored schedules need alarms
withContext(Dispatchers.Main) { toast("Importerte $count medisiner") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Import feilet: ${e.message}") }
}
}
}
}
}
@Composable
private fun PassphraseDialog(
title: String,
text: String,
allowEmpty: Boolean,
onDismiss: () -> Unit,
onConfirm: (CharArray) -> Unit,
) {
var value by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text)
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text("Passordfrase") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(value.toCharArray()) },
enabled = allowEmpty || value.isNotEmpty(),
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
)
}

View file

@ -0,0 +1,290 @@
package no.naiv.meddetsamme.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 androidx.core.content.ContextCompat
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.BatteryOptimization
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.Medication
import no.naiv.meddetsamme.domain.DoseActions
import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.domain.ScheduleText
import no.naiv.meddetsamme.settings.SettingsStore
/** One row on the today list: an occurrence joined with its log (if any). */
private data class TodayDose(
val med: Medication,
val doseTime: DoseTime,
val at: LocalDateTime,
val log: DoseLog?,
) {
val status: DoseStatus? get() = log?.status
val atMillis: Long get() = at.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodayScreen(nav: Nav) {
val context = LocalContext.current
val db = remember { MedDatabase.get(context) }
val scope = rememberCoroutineScope()
val actions = remember { DoseActions(context) }
NotificationPermissionRequest()
BatteryExemptionPrompt()
val today = remember { LocalDate.now() }
val zone = ZoneId.systemDefault()
val dayStart = today.atStartOfDay(zone).toInstant().toEpochMilli()
val dayEnd = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
var doses by remember { mutableStateOf<List<TodayDose>>(emptyList()) }
var warnings by remember { mutableStateOf<List<String>>(emptyList()) }
// Re-derive the list whenever meds or today's logs change. Dose times are
// fetched inside because they only change together with med edits, which
// bump the meds flow anyway (screen re-enters on back-navigation too).
LaunchedEffect(Unit) {
combine(
db.medicationDao().observeActive(),
db.doseLogDao().observeRange(dayStart, dayEnd),
) { meds, logs -> meds to logs }.collect { (meds, logs) ->
val rows = mutableListOf<TodayDose>()
val warn = mutableListOf<String>()
for (med in meds) {
val doseTimes = db.doseTimeDao().getForMed(med.id)
for ((dt, at) in ScheduleEngine.occurrencesOn(doseTimes, today)) {
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
}
val rate = ScheduleEngine.dailyConsumption(doseTimes)
if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) {
val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate)?.toInt() ?: 0
warn += "${med.name}: lager for $days dager"
}
if (ScheduleEngine.needsRenewal(med.rxExpiryEpochDay, today)) {
warn += "${med.name}: resept må fornyes"
}
}
doses = rows.sortedBy { it.at }
warnings = warn
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("I dag") },
actions = {
IconButton(onClick = { nav.push(Screen.MedList) }) {
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
}
IconButton(onClick = { nav.push(Screen.Settings) }) {
Icon(painterResource(R.drawable.ic_settings), contentDescription = "Innstillinger")
}
},
)
},
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (warnings.isNotEmpty()) {
item {
Card(modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Varsler om lager og resept" }) {
Column(Modifier.padding(12.dp)) {
warnings.forEach { Text(it, style = MaterialTheme.typography.bodyMedium) }
}
}
}
}
if (doses.isEmpty()) {
item {
Text(
"Ingen doser i dag. Legg til medisiner via pilleikonet øverst.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 24.dp),
)
}
}
items(doses, key = { "${it.doseTime.id}-${it.atMillis}" }) { dose ->
DoseCard(
dose = dose,
onTake = {
scope.launch {
val log = actions.ensureLog(dose.doseTime.id, dose.atMillis)
actions.take(log.id)
}
},
onSkip = {
scope.launch {
val log = actions.ensureLog(dose.doseTime.id, dose.atMillis)
actions.skip(log.id)
}
},
onSnooze = dose.log?.let { log ->
{ scope.launch { actions.snooze(log.id) } }
},
onUndo = dose.log?.let { log ->
{ scope.launch { actions.undo(log.id) } }
},
)
}
}
}
}
@Composable
private fun DoseCard(
dose: TodayDose,
onTake: () -> Unit,
onSkip: () -> Unit,
onSnooze: (() -> Unit)?,
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 resolved = dose.status == DoseStatus.TAKEN || dose.status == DoseStatus.SKIPPED
Card(
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
contentDescription = buildString {
append("$time, ${dose.med.name} ${dose.med.strength}, ")
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.unit))
statusText?.let { append(", status: $it") }
}
},
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(time, style = MaterialTheme.typography.titleLarge)
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text("${dose.med.name} ${dose.med.strength}".trim(), style = MaterialTheme.typography.titleMedium)
val withFood = if (dose.med.withFood) " — tas med mat" else ""
Text(
ScheduleText.amountText(dose.doseTime.amount, dose.med.unit) + withFood,
style = MaterialTheme.typography.bodyMedium,
)
}
statusText?.let { Text(it, style = MaterialTheme.typography.labelLarge) }
}
if (!resolved) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onTake) { Text("Ta nå") }
OutlinedButton(onClick = onSkip) { Text("Hopp over") }
if (onSnooze != null && dose.status != null) {
OutlinedButton(onClick = onSnooze) { Text("Utsett 15 min") }
}
}
} else if (onUndo != null) {
TextButton(onClick = onUndo) { Text("Angre") }
}
}
}
}
/** API 33+ runtime notification permission; asked once per app start if missing. */
@Composable
private fun NotificationPermissionRequest() {
if (Build.VERSION.SDK_INT < 33) return
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
/**
* One-time battery-optimisation exemption prompt (decision #4). Asked exactly
* once; declining is respected forever (re-enable via system settings).
*/
@Composable
private fun BatteryExemptionPrompt() {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
var show by remember {
mutableStateOf(!settings.batteryPromptShown && !BatteryOptimization.isExempt(context))
}
if (!show) return
AlertDialog(
onDismissRequest = {
settings.batteryPromptShown = true
show = false
},
title = { Text("Pålitelige påminnelser") },
text = {
Text(
"For at dosevarsler skal komme presist må appen unntas fra " +
"batterioptimalisering. Uten dette kan varsler forsinkes eller utebli.",
)
},
confirmButton = {
TextButton(onClick = {
settings.batteryPromptShown = true
show = false
context.startActivity(BatteryOptimization.requestExemptionIntent(context))
}) { Text("Tillat") }
},
dismissButton = {
TextButton(onClick = {
settings.batteryPromptShown = true
show = false
}) { Text("Ikke nå") }
},
)
}

View file

@ -0,0 +1,10 @@
<?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"
android:autoMirrored="true"
>
<path
android:fillColor="#FF000000"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View file

@ -0,0 +1,10 @@
<?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"
>
<!-- Material "medication" pill outline -->
<path
android:fillColor="#FF000000"
android:pathData="M4.22,11.29l7.07,-7.07c2.34,-2.34 6.14,-2.34 8.49,0s2.34,6.14 0,8.49l-7.07,7.07c-2.34,2.34 -6.14,2.34 -8.49,0S1.88,13.64 4.22,11.29zM5.64,12.71c-1.56,1.56 -1.56,4.09 0,5.66s4.09,1.56 5.66,0l2.83,-2.83 -5.66,-5.66L5.64,12.71zM18.36,5.64c-1.56,-1.56 -4.09,-1.56 -5.66,0L9.88,8.46l5.66,5.66 2.83,-2.83C19.93,9.73 19.93,7.2 18.36,5.64z" />
</vector>

View file

@ -0,0 +1,10 @@
<?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"
>
<!-- Material "settings" gear -->
<path
android:fillColor="#FF000000"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>