Backup gets its own settings page

Seven fields and three buttons drowned everything else in Settings.
S3/Garage config, test, restore-from-bucket and file export/import now
live on a dedicated Sikkerhetskopi page; main settings shows a status
card (red 'ikke satt opp' until configured) plus Varsler, FEST and
Legeoversikt. The Today-screen backup nudge deep-links straight to the
new page. PassphraseDialog moved to shared Components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 19:00:50 +02:00
commit 96ac8ea108
5 changed files with 404 additions and 267 deletions

View file

@ -0,0 +1,291 @@
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.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.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.settings.SettingsStore
/**
* Everything backup: S3/Garage credentials, the stored auto-backup
* passphrase, manual test, restore-from-bucket, and file export/import.
* Its own screen seven fields and three buttons drowned the rest of
* Settings when this lived inline.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackupSettingsScreen(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 autoPassphraseConfirm by remember { mutableStateOf(settings.autoBackupPassphrase ?: "") }
// Export/import passphrase dialogs hold the pending document URI.
var exportUri by remember { mutableStateOf<Uri?>(null) }
var importUri by remember { mutableStateOf<Uri?>(null) }
// Downloaded-from-bucket backup awaiting confirmation: key + bytes.
var bucketRestore by remember { mutableStateOf<Pair<String, ByteArray>?>(null) }
var fetchingRestore by remember { mutableStateOf(false) }
val exportPicker = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> exportUri = uri }
val importPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> importUri = uri }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Sikkerhetskopi") },
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("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(),
)
// Confirmation field: a typo here would encrypt every backup with a
// passphrase the user can't reproduce — unrecoverable by design.
val passphraseMismatch = autoPassphrase != autoPassphraseConfirm
OutlinedTextField(
value = autoPassphraseConfirm, onValueChange = { autoPassphraseConfirm = it },
label = { Text("Gjenta passordfrase") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = passphraseMismatch,
supportingText = { if (passphraseMismatch) Text("Passordfrasene er ikke like") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
if (passphraseMismatch) {
toast("Passordfrasene er ikke like")
return@Button
}
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")
},
enabled = !passphraseMismatch,
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å") }
OutlinedButton(
onClick = {
fetchingRestore = true
scope.launch(Dispatchers.IO) {
try {
val latest = BackupManager(context).fetchLatestBackup()
withContext(Dispatchers.Main) {
if (latest == null) toast("Ingen backuper i bøtta") else bucketRestore = latest
}
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Henting feilet: ${e.message}") }
} finally {
withContext(Dispatchers.Main) { fetchingRestore = false }
}
}
},
enabled = !fetchingRestore,
modifier = Modifier.fillMaxWidth(),
) { Text(if (fetchingRestore) "Henter …" else "Gjenopprett siste backup fra bøtta …") }
HorizontalDivider()
Text("Eksport og import (fil)", 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 …") }
}
}
bucketRestore?.let { (key, data) ->
PassphraseDialog(
title = "Gjenopprett fra bøtta",
text = "Fant ${key.removePrefix("meddetsamme/")}. ERSTATTER alle medisiner og all " +
"historikk på enheten. Passordfrase trengs bare for krypterte backuper.",
allowEmpty = true,
onDismiss = { bucketRestore = null },
) { passphrase ->
bucketRestore = null
scope.launch(Dispatchers.IO) {
try {
val count = BackupManager(context)
.import(data, passphrase.takeIf { it.isNotEmpty() })
AlarmScheduler(context).armAll()
withContext(Dispatchers.Main) { toast("Gjenopprettet $count medisiner") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Gjenoppretting feilet: ${e.message}") }
}
}
}
}
exportUri?.let { uri ->
PassphraseDialog(
title = "Krypter eksport",
text = "Skriv en passordfrase (lagres ikke). La stå tom for ukryptert JSON.",
allowEmpty = true,
requireConfirmation = true, // a typo here = an unrecoverable export
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}") }
}
}
}
}
}

View file

@ -1,10 +1,22 @@
package no.naiv.meddetsamme.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import no.naiv.meddetsamme.data.DoseStatus
@ -16,6 +28,61 @@ val DoseStatus.label: String
DoseStatus.PENDING -> "Venter"
}
/**
* Passphrase prompt shared by export/import/restore flows. With
* [requireConfirmation], a matching repeat is demanded for write-only
* passphrases where a typo means unrecoverable data.
*/
@Composable
fun PassphraseDialog(
title: String,
text: String,
allowEmpty: Boolean,
requireConfirmation: Boolean = false,
onDismiss: () -> Unit,
onConfirm: (CharArray) -> Unit,
) {
var value by remember { mutableStateOf("") }
var repeated by remember { mutableStateOf("") }
val mismatch = requireConfirmation && value != repeated
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),
)
if (requireConfirmation) {
OutlinedTextField(
value = repeated,
onValueChange = { repeated = it },
label = { Text("Gjenta passordfrase") },
singleLine = true,
isError = mismatch,
supportingText = { if (mismatch) Text("Passordfrasene er ikke like") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(value.toCharArray()) },
enabled = (allowEmpty || value.isNotEmpty()) && !mismatch,
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
)
}
/** Tonal status chip shared by the today and history screens. */
@Composable
fun DoseStatusBadge(status: DoseStatus) {

View file

@ -21,6 +21,7 @@ sealed interface Screen {
/** itemId == null → create new. */
data class ItemEdit(val itemId: Long?) : Screen
data object Settings : Screen
data object BackupSettings : Screen
data object SummaryPreview : Screen
data object History : Screen
}
@ -52,6 +53,7 @@ fun AppRoot() {
is Screen.Inventory -> InventoryScreen(nav)
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
is Screen.Settings -> SettingsScreen(nav)
is Screen.BackupSettings -> BackupSettingsScreen(nav)
is Screen.SummaryPreview -> SummaryPreviewScreen(nav)
is Screen.History -> HistoryScreen(nav)
}

View file

@ -1,11 +1,10 @@
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.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
@ -13,7 +12,7 @@ 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.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -24,26 +23,28 @@ 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.rememberTimePickerState
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.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.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.settings.SettingsStore
@ -56,29 +57,8 @@ fun SettingsScreen(nav: Nav) {
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 autoPassphraseConfirm 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) }
// Downloaded-from-bucket backup awaiting confirmation: key + bytes.
var bucketRestore by remember { mutableStateOf<Pair<String, ByteArray>?>(null) }
var fetchingRestore by remember { mutableStateOf(false) }
val exportPicker = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> exportUri = uri }
val importPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> importUri = uri }
Scaffold(
topBar = {
TopAppBar(
@ -99,118 +79,39 @@ fun SettingsScreen(nav: Nav) {
.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(),
)
// Confirmation field: a typo here would encrypt every backup with a
// passphrase the user can't reproduce — unrecoverable by design.
val passphraseMismatch = autoPassphrase != autoPassphraseConfirm
OutlinedTextField(
value = autoPassphraseConfirm, onValueChange = { autoPassphraseConfirm = it },
label = { Text("Gjenta passordfrase") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = passphraseMismatch,
supportingText = { if (passphraseMismatch) Text("Passordfrasene er ikke like") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
if (passphraseMismatch) {
toast("Passordfrasene er ikke like")
return@Button
}
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")
},
enabled = !passphraseMismatch,
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}") }
// Backup got its own page — seven fields drowned everything else here.
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { nav.push(Screen.BackupSettings) }
.semantics(mergeDescendants = true) {
contentDescription = if (settings.isBackupConfigured) {
"Sikkerhetskopi, konfigurert. Trykk for å åpne."
} else {
"Sikkerhetskopi, ikke satt opp. Trykk for å sette opp."
}
},
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Sikkerhetskopi", style = MaterialTheme.typography.titleMedium)
Text(
if (settings.isBackupConfigured) {
"S3/Garage konfigurert · eksport og import"
} else {
"Ikke satt opp — helsedata finnes bare på denne enheten"
},
style = MaterialTheme.typography.bodyMedium,
color = if (settings.isBackupConfigured) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
},
)
}
},
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 …") }
OutlinedButton(
onClick = {
fetchingRestore = true
scope.launch(Dispatchers.IO) {
try {
val latest = BackupManager(context).fetchLatestBackup()
withContext(Dispatchers.Main) {
if (latest == null) toast("Ingen backuper i bøtta") else bucketRestore = latest
}
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Henting feilet: ${e.message}") }
} finally {
withContext(Dispatchers.Main) { fetchingRestore = false }
}
}
},
enabled = !fetchingRestore,
modifier = Modifier.fillMaxWidth(),
) { Text(if (fetchingRestore) "Henter …" else "Gjenopprett siste backup fra bøtta …") }
Text("", style = MaterialTheme.typography.headlineSmall)
}
}
HorizontalDivider()
Text("Varsler", style = MaterialTheme.typography.titleMedium)
@ -220,7 +121,7 @@ fun SettingsScreen(nav: Nav) {
Text("FEST-legemiddeldata", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = festUrl, onValueChange = { festUrl = it },
label = { Text("URL til slim.json") }, singleLine = true,
label = { Text("URL til slim.json (valgfritt — bundlet data brukes ellers)") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
@ -248,78 +149,6 @@ fun SettingsScreen(nav: Nav) {
) { Text("Vis medisinoversikt (PDF)") }
}
}
exportUri?.let { uri ->
PassphraseDialog(
title = "Krypter eksport",
text = "Skriv en passordfrase (lagres ikke). La stå tom for ukryptert JSON.",
allowEmpty = true,
requireConfirmation = true, // a typo here = an unrecoverable export
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}") }
}
}
}
}
bucketRestore?.let { (key, data) ->
PassphraseDialog(
title = "Gjenopprett fra bøtta",
text = "Fant ${key.removePrefix("meddetsamme/")}. ERSTATTER alle medisiner og all " +
"historikk på enheten. Passordfrase trengs bare for krypterte backuper.",
allowEmpty = true,
onDismiss = { bucketRestore = null },
) { passphrase ->
bucketRestore = null
scope.launch(Dispatchers.IO) {
try {
val count = BackupManager(context)
.import(data, passphrase.takeIf { it.isNotEmpty() })
AlarmScheduler(context).armAll()
withContext(Dispatchers.Main) { toast("Gjenopprettet $count medisiner") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Gjenoppretting 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}") }
}
}
}
}
}
/** Daily low-stock/rx notification time; re-arms the (replacing) alarm on change. */
@ -331,9 +160,7 @@ private fun SupplyCheckTimeRow(settings: SettingsStore) {
var minute by remember { mutableStateOf(settings.supplyCheckMinuteOfDay) }
var showPicker by remember { mutableStateOf(false) }
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Daglig varsel om lager og resept: %02d:%02d".format(minute / 60, minute % 60),
modifier = Modifier.weight(1f),
@ -342,7 +169,7 @@ private fun SupplyCheckTimeRow(settings: SettingsStore) {
}
if (showPicker) {
val state = androidx.compose.material3.rememberTimePickerState(
val state = rememberTimePickerState(
initialHour = minute / 60,
initialMinute = minute % 60,
is24Hour = true,
@ -350,7 +177,7 @@ private fun SupplyCheckTimeRow(settings: SettingsStore) {
AlertDialog(
onDismissRequest = { showPicker = false },
title = { Text("Tidspunkt for daglig varsel") },
text = { androidx.compose.material3.TimePicker(state = state) },
text = { TimePicker(state = state) },
confirmButton = {
TextButton(onClick = {
minute = state.hour * 60 + state.minute
@ -363,53 +190,3 @@ private fun SupplyCheckTimeRow(settings: SettingsStore) {
)
}
}
@Composable
private fun PassphraseDialog(
title: String,
text: String,
allowEmpty: Boolean,
requireConfirmation: Boolean = false,
onDismiss: () -> Unit,
onConfirm: (CharArray) -> Unit,
) {
var value by remember { mutableStateOf("") }
var repeated by remember { mutableStateOf("") }
val mismatch = requireConfirmation && value != repeated
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),
)
if (requireConfirmation) {
OutlinedTextField(
value = repeated,
onValueChange = { repeated = it },
label = { Text("Gjenta passordfrase") },
singleLine = true,
isError = mismatch,
supportingText = { if (mismatch) Text("Passordfrasene er ikke like") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(value.toCharArray()) },
enabled = (allowEmpty || value.isNotEmpty()) && !mismatch,
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
)
}

View file

@ -167,7 +167,7 @@ fun TodayScreen(nav: Nav) {
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item { BackupNudge(onSetup = { nav.push(Screen.Settings) }) }
item { BackupNudge(onSetup = { nav.push(Screen.BackupSettings) }) }
if (warnings.isNotEmpty()) {
item {
Card(