diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt new file mode 100644 index 0000000..1ccd1d5 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt @@ -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(null) } + var importUri by remember { mutableStateOf(null) } + // Downloaded-from-bucket backup awaiting confirmation: key + bytes. + var bucketRestore by remember { mutableStateOf?>(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}") } + } + } + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt index eeaf6a8..4f31b22 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Components.kt @@ -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) { diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt index 6540d8f..acefd22 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt @@ -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) } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt index 376bde8..6edf937 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt @@ -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(null) } - var importUri by remember { mutableStateOf(null) } - // Downloaded-from-bucket backup awaiting confirmation: key + bytes. - var bucketRestore by remember { mutableStateOf?>(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") } }, - ) -} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt index 0934131..cc28ae9 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -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(