From 2e7eadeb391841a6a6f56bd38a03ba36663ccc2b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 10 Jun 2026 14:22:29 +0200 Subject: [PATCH] UI buildout: today screen, med editor with FEST autocomplete, schedule editor, settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/no/naiv/meddetsamme/MainActivity.kt | 73 +-- .../meddetsamme/alarm/DoseActionReceiver.kt | 32 +- .../no/naiv/meddetsamme/domain/DoseActions.kt | 67 +++ .../naiv/meddetsamme/pdf/DoctorSummaryPdf.kt | 16 +- .../meddetsamme/settings/SettingsStore.kt | 5 + .../no/naiv/meddetsamme/ui/MedEditScreen.kt | 529 ++++++++++++++++++ .../no/naiv/meddetsamme/ui/MedListScreen.kt | 99 ++++ .../main/java/no/naiv/meddetsamme/ui/Nav.kt | 49 ++ .../no/naiv/meddetsamme/ui/SettingsScreen.kt | 300 ++++++++++ .../no/naiv/meddetsamme/ui/TodayScreen.kt | 290 ++++++++++ app/src/main/res/drawable/ic_back.xml | 10 + app/src/main/res/drawable/ic_meds.xml | 10 + app/src/main/res/drawable/ic_settings.xml | 10 + 13 files changed, 1390 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/drawable/ic_meds.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml diff --git a/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt b/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt index cffb0ff..ad64112 100644 --- a/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt +++ b/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt @@ -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() } } } diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt index 96dd8ac..91e3c5c 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt @@ -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) } } } diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt new file mode 100644 index 0000000..cd93d5a --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt @@ -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) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt index 6a2ca71..88c211c 100644 --- a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt +++ b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt @@ -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") } } diff --git a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt index 4fa6a15..94dad0b 100644 --- a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt +++ b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt @@ -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() diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt new file mode 100644 index 0000000..8a8ee95 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt @@ -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(null) } + var refills by remember { mutableStateOf("") } + var atc by remember { mutableStateOf(null) } + var suggestions by remember { mutableStateOf>(emptyList()) } + var suppressSuggestions by remember { mutableStateOf(false) } + var doseTimes by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(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, onChanged: () -> Unit) { + val context = LocalContext.current + val db = remember { MedDatabase.get(context) } + val scope = rememberCoroutineScope() + var editing by remember { mutableStateOf(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] diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt new file mode 100644 index 0000000..b44752a --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt @@ -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, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt new file mode 100644 index 0000000..f4b6095 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt @@ -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) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt new file mode 100644 index 0000000..9901332 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt @@ -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(null) } + var importUri by remember { mutableStateOf(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") } }, + ) +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt new file mode 100644 index 0000000..f99dae1 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -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>(emptyList()) } + var warnings by remember { mutableStateOf>(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() + val warn = mutableListOf() + 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å") } + }, + ) +} diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..41acc1a --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_meds.xml b/app/src/main/res/drawable/ic_meds.xml new file mode 100644 index 0000000..5e71103 --- /dev/null +++ b/app/src/main/res/drawable/ic_meds.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..d73ff74 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + +