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
|
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.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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 androidx.compose.ui.unit.dp
|
||||||
import no.naiv.meddetsamme.data.DoseStatus
|
import no.naiv.meddetsamme.data.DoseStatus
|
||||||
|
|
||||||
|
|
@ -16,6 +28,61 @@ val DoseStatus.label: String
|
||||||
DoseStatus.PENDING -> "Venter"
|
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. */
|
/** Tonal status chip shared by the today and history screens. */
|
||||||
@Composable
|
@Composable
|
||||||
fun DoseStatusBadge(status: DoseStatus) {
|
fun DoseStatusBadge(status: DoseStatus) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ sealed interface Screen {
|
||||||
/** itemId == null → create new. */
|
/** itemId == null → create new. */
|
||||||
data class ItemEdit(val itemId: Long?) : Screen
|
data class ItemEdit(val itemId: Long?) : Screen
|
||||||
data object Settings : Screen
|
data object Settings : Screen
|
||||||
|
data object BackupSettings : Screen
|
||||||
data object SummaryPreview : Screen
|
data object SummaryPreview : Screen
|
||||||
data object History : Screen
|
data object History : Screen
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +53,7 @@ fun AppRoot() {
|
||||||
is Screen.Inventory -> InventoryScreen(nav)
|
is Screen.Inventory -> InventoryScreen(nav)
|
||||||
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
|
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
|
||||||
is Screen.Settings -> SettingsScreen(nav)
|
is Screen.Settings -> SettingsScreen(nav)
|
||||||
|
is Screen.BackupSettings -> BackupSettingsScreen(nav)
|
||||||
is Screen.SummaryPreview -> SummaryPreviewScreen(nav)
|
is Screen.SummaryPreview -> SummaryPreviewScreen(nav)
|
||||||
is Screen.History -> HistoryScreen(nav)
|
is Screen.History -> HistoryScreen(nav)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package no.naiv.meddetsamme.ui
|
package no.naiv.meddetsamme.ui
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -24,26 +23,28 @@ import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
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.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.LocalDate
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import no.naiv.meddetsamme.R
|
import no.naiv.meddetsamme.R
|
||||||
import no.naiv.meddetsamme.alarm.AlarmScheduler
|
import no.naiv.meddetsamme.alarm.AlarmScheduler
|
||||||
import no.naiv.meddetsamme.backup.BackupManager
|
|
||||||
import no.naiv.meddetsamme.fest.FestRepository
|
import no.naiv.meddetsamme.fest.FestRepository
|
||||||
import no.naiv.meddetsamme.settings.SettingsStore
|
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()
|
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 ?: "") }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -99,118 +79,39 @@ fun SettingsScreen(nav: Nav) {
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text("Sikkerhetskopi (S3/Garage)", style = MaterialTheme.typography.titleMedium)
|
// Backup got its own page — seven fields drowned everything else here.
|
||||||
OutlinedTextField(
|
Card(
|
||||||
value = endpoint, onValueChange = { endpoint = it },
|
modifier = Modifier
|
||||||
label = { Text("Endepunkt (https://…)") }, singleLine = true,
|
.fillMaxWidth()
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
.clickable { nav.push(Screen.BackupSettings) }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.semantics(mergeDescendants = true) {
|
||||||
)
|
contentDescription = if (settings.isBackupConfigured) {
|
||||||
OutlinedTextField(
|
"Sikkerhetskopi, konfigurert. Trykk for å åpne."
|
||||||
value = region, onValueChange = { region = it },
|
} else {
|
||||||
label = { Text("Region") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
"Sikkerhetskopi, ikke satt opp. Trykk for å sette opp."
|
||||||
)
|
}
|
||||||
OutlinedTextField(
|
},
|
||||||
value = bucket, onValueChange = { bucket = it },
|
) {
|
||||||
label = { Text("Bøtte") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
)
|
Column(Modifier.weight(1f)) {
|
||||||
OutlinedTextField(
|
Text("Sikkerhetskopi", style = MaterialTheme.typography.titleMedium)
|
||||||
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(
|
Text(
|
||||||
"Lagres på enheten slik at nattlig backup kan kjøre uten deg — " +
|
if (settings.isBackupConfigured) {
|
||||||
"beskytter bare mot at bøtta kommer på avveie. Tom = ukryptert backup.",
|
"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(),
|
|
||||||
)
|
|
||||||
// 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 }
|
Text("›", style = MaterialTheme.typography.headlineSmall)
|
||||||
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å") }
|
|
||||||
|
|
||||||
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 …") }
|
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
@ -220,7 +121,7 @@ fun SettingsScreen(nav: Nav) {
|
||||||
Text("FEST-legemiddeldata", style = MaterialTheme.typography.titleMedium)
|
Text("FEST-legemiddeldata", style = MaterialTheme.typography.titleMedium)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = festUrl, onValueChange = { festUrl = it },
|
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),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
@ -248,78 +149,6 @@ fun SettingsScreen(nav: Nav) {
|
||||||
) { Text("Vis medisinoversikt (PDF)") }
|
) { 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. */
|
/** 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 minute by remember { mutableStateOf(settings.supplyCheckMinuteOfDay) }
|
||||||
var showPicker by remember { mutableStateOf(false) }
|
var showPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
androidx.compose.foundation.layout.Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
"Daglig varsel om lager og resept: %02d:%02d".format(minute / 60, minute % 60),
|
"Daglig varsel om lager og resept: %02d:%02d".format(minute / 60, minute % 60),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
@ -342,7 +169,7 @@ private fun SupplyCheckTimeRow(settings: SettingsStore) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showPicker) {
|
if (showPicker) {
|
||||||
val state = androidx.compose.material3.rememberTimePickerState(
|
val state = rememberTimePickerState(
|
||||||
initialHour = minute / 60,
|
initialHour = minute / 60,
|
||||||
initialMinute = minute % 60,
|
initialMinute = minute % 60,
|
||||||
is24Hour = true,
|
is24Hour = true,
|
||||||
|
|
@ -350,7 +177,7 @@ private fun SupplyCheckTimeRow(settings: SettingsStore) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showPicker = false },
|
onDismissRequest = { showPicker = false },
|
||||||
title = { Text("Tidspunkt for daglig varsel") },
|
title = { Text("Tidspunkt for daglig varsel") },
|
||||||
text = { androidx.compose.material3.TimePicker(state = state) },
|
text = { TimePicker(state = state) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
minute = state.hour * 60 + state.minute
|
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),
|
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
item { BackupNudge(onSetup = { nav.push(Screen.Settings) }) }
|
item { BackupNudge(onSetup = { nav.push(Screen.BackupSettings) }) }
|
||||||
if (warnings.isNotEmpty()) {
|
if (warnings.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Card(
|
Card(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue