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:
parent
8c3cd0c965
commit
96ac8ea108
5 changed files with 404 additions and 267 deletions
291
app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt
Normal file
291
app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt
Normal 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}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue