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:
parent
c4d0ed9c3b
commit
2e7eadeb39
13 changed files with 1390 additions and 100 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt
Normal file
67
app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
529
app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt
Normal file
529
app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt
Normal 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]
|
||||
99
app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt
Normal file
99
app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt
Normal file
49
app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
300
app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt
Normal file
300
app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt
Normal 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") } },
|
||||
)
|
||||
}
|
||||
290
app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt
Normal file
290
app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt
Normal 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å") }
|
||||
},
|
||||
)
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_back.xml
Normal file
10
app/src/main/res/drawable/ic_back.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_meds.xml
Normal file
10
app/src/main/res/drawable/ic_meds.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_settings.xml
Normal file
10
app/src/main/res/drawable/ic_settings.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue