feat(android): add structured entry editor and encrypted draft recovery (#103)
This commit is contained in:
1 parent
d22d71eb5e
commit
06575363a2
23 files changed
+2330
-21
No files matched your search
+457
@@ -0,0 +1,457 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.EditorFieldKind
|
||||
import de.rfc1437.ironstorage.core.EditorRepository
|
||||
|
||||
@Composable
|
||||
fun EntryEditorScreen(repository: EditorRepository, authentication: AuthenticationRepository) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val lease by authentication.state.collectAsStateWithLifecycle()
|
||||
var manual by remember { mutableStateOf(false) }
|
||||
var adding by remember { mutableStateOf<EditorFieldKind?>(null) }
|
||||
var generating by remember { mutableStateOf(false) }
|
||||
var revealed by remember { mutableStateOf(emptySet<ULong>()) }
|
||||
val focus = LocalFocusManager.current
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
BackHandler { repository.requestClose() }
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().imePadding().testTag("entry-editor"),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
state.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
}
|
||||
)
|
||||
Text(state.path)
|
||||
FlowRow {
|
||||
TextButton(onClick = {
|
||||
repository.requestClose()
|
||||
}) { Text(stringResource(R.string.editor_cancel)) }
|
||||
Button(
|
||||
enabled =
|
||||
!state.busy && !state.locked && state.dirty && state.fields.isNotEmpty() &&
|
||||
!lease.busy,
|
||||
onClick = {
|
||||
focus.clearFocus()
|
||||
repository.save()
|
||||
}
|
||||
) { Text(stringResource(R.string.editor_save)) }
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
repository.requestClose(authentication::lock)
|
||||
}) {
|
||||
Text(stringResource(R.string.password_lock))
|
||||
}
|
||||
}
|
||||
if (state.busy || lease.busy) CircularProgressIndicator()
|
||||
state.error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("editor-error")
|
||||
)
|
||||
}
|
||||
if (state.locked || state.fields.isEmpty()) {
|
||||
Text(stringResource(R.string.editor_locked))
|
||||
Button(enabled = !state.busy && !lease.busy, onClick = { repository.resume("") }) {
|
||||
Text(stringResource(R.string.password_unlock))
|
||||
}
|
||||
TextButton(enabled = !state.busy && !lease.busy, onClick = { manual = true }) {
|
||||
Text(stringResource(R.string.auth_manual))
|
||||
}
|
||||
} else {
|
||||
FlowRow {
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
adding = EditorFieldKind.Field
|
||||
}) { Text(stringResource(R.string.editor_add_field)) }
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
repository.add(EditorFieldKind.Note, null, "")
|
||||
}) { Text(stringResource(R.string.editor_add_notes)) }
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
adding = EditorFieldKind.OtpUri
|
||||
}) { Text(stringResource(R.string.editor_add_otp)) }
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
generating = true
|
||||
}) { Text(stringResource(R.string.editor_generate)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(state.fields, key = { _, field -> field.id.toString() }) { index, field ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().widthIn(max = 800.dp).padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (field.nameEditable) {
|
||||
OutlinedTextField(
|
||||
value = field.name.orEmpty(),
|
||||
onValueChange = { repository.change(field.id, it, field.value) },
|
||||
enabled = !state.busy && !lease.busy,
|
||||
label = { Text(stringResource(R.string.editor_field_name)) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("editor-name-${field.id}"),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
|
||||
})
|
||||
)
|
||||
}
|
||||
val shown = field.id in revealed
|
||||
OutlinedTextField(
|
||||
value = field.value.orEmpty(),
|
||||
onValueChange = { repository.change(field.id, field.name, it) },
|
||||
enabled = !state.busy && !lease.busy && field.value != null,
|
||||
label = { Text(field.label) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("editor-value-${field.id}"),
|
||||
visualTransformation = if (shown) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
singleLine = !field.multiline,
|
||||
minLines = if (field.multiline) 3 else 1,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (shown) KeyboardType.Text else KeyboardType.Password,
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = if (field.multiline) ImeAction.Default else ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
|
||||
})
|
||||
)
|
||||
field.diagnostic?.let { Text(it) }
|
||||
FlowRow {
|
||||
TextButton(onClick = {
|
||||
revealed =
|
||||
if (shown) revealed - field.id else revealed + field.id
|
||||
}) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (shown) R.string.editor_hide else R.string.editor_reveal
|
||||
)
|
||||
)
|
||||
}
|
||||
if (field.removable) {
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
repository.remove(field.id)
|
||||
}, modifier = Modifier.testTag("editor-remove-${field.id}")) {
|
||||
Text(stringResource(R.string.editor_remove))
|
||||
}
|
||||
}
|
||||
if (field.reorderable) {
|
||||
TextButton(
|
||||
enabled =
|
||||
!state.busy && index > 0 && state.fields[index - 1].reorderable,
|
||||
onClick = {
|
||||
repository.reorder(
|
||||
field.id,
|
||||
(
|
||||
index -
|
||||
1
|
||||
).toUInt()
|
||||
)
|
||||
},
|
||||
modifier = Modifier.testTag("editor-up-${field.id}")
|
||||
) {
|
||||
Text(stringResource(R.string.editor_up))
|
||||
}
|
||||
val canMoveDown = !state.busy && index < state.fields.lastIndex
|
||||
TextButton(enabled = canMoveDown, onClick = {
|
||||
repository.reorder(
|
||||
field.id,
|
||||
(
|
||||
index +
|
||||
1
|
||||
).toUInt()
|
||||
)
|
||||
}, modifier = Modifier.testTag("editor-down-${field.id}")) {
|
||||
Text(stringResource(R.string.editor_down))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manual) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_manual),
|
||||
stringResource(R.string.editor_unlock_detail),
|
||||
authentication.presentation,
|
||||
onCancel = { manual = false },
|
||||
onSubmit = {
|
||||
manual = false
|
||||
repository.resume(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
adding?.let { kind ->
|
||||
EditorAddDialog(repository, authentication, kind) { adding = null }
|
||||
}
|
||||
if (generating) {
|
||||
EditorGenerateDialog(repository, authentication) { generating = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditorDiscardDialog(repository: EditorRepository) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
if (!state.discardRequested) return
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = repository::keepEditing,
|
||||
title = { Text(stringResource(R.string.editor_discard_title)) },
|
||||
text = { Text(stringResource(R.string.editor_discard_detail)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = repository::confirmDiscard) {
|
||||
Text(stringResource(R.string.editor_discard))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = repository::keepEditing) {
|
||||
Text(stringResource(R.string.editor_keep))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorAddDialog(
|
||||
repository: EditorRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
kind: EditorFieldKind,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var submitted by remember { mutableStateOf<Long?>(null) }
|
||||
LaunchedEffect(state.busy, state.completedOperations) {
|
||||
if (submitted != null && !state.busy && state.completedOperations != submitted) close()
|
||||
}
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
var value by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (kind ==
|
||||
EditorFieldKind.OtpUri
|
||||
) {
|
||||
R.string.editor_add_otp
|
||||
} else {
|
||||
R.string.editor_add_field
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
Column {
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (kind ==
|
||||
EditorFieldKind.Field
|
||||
) {
|
||||
OutlinedTextField(name, {
|
||||
name = it
|
||||
}, label = {
|
||||
Text(stringResource(R.string.editor_field_name))
|
||||
}, modifier = Modifier.testTag("editor-add-name"), singleLine = true)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value,
|
||||
{
|
||||
value = it
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.editor_field_value))
|
||||
},
|
||||
modifier = Modifier.testTag("editor-add-value"),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
submitted = state.completedOperations
|
||||
repository.add(
|
||||
kind,
|
||||
if (kind ==
|
||||
EditorFieldKind.Field
|
||||
) {
|
||||
name
|
||||
} else {
|
||||
null
|
||||
},
|
||||
value
|
||||
)
|
||||
}) { Text(stringResource(R.string.editor_add)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorGenerateDialog(
|
||||
repository: EditorRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var length by remember { mutableStateOf(state.defaultLength.toString()) }
|
||||
var noSymbols by remember { mutableStateOf(false) }
|
||||
val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = { Text(stringResource(R.string.editor_generate)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.editor_generate_detail))
|
||||
OutlinedTextField(
|
||||
length,
|
||||
{
|
||||
length = it
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.editor_length))
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
stringResource(R.string.editor_length_help, state.maximumLength.toInt())
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
)
|
||||
)
|
||||
FlowRow {
|
||||
Checkbox(
|
||||
noSymbols,
|
||||
{ noSymbols = it },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
noSymbolsLabel
|
||||
}
|
||||
)
|
||||
Text(stringResource(R.string.editor_no_symbols))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
repository.generate(
|
||||
if (length.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
length.toUIntOrNull()
|
||||
?: 0u
|
||||
},
|
||||
noSymbols
|
||||
)
|
||||
close()
|
||||
}) { Text(stringResource(R.string.editor_generate_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EditorCreationDialog(
|
||||
authentication: AuthenticationRepository,
|
||||
folder: Boolean,
|
||||
close: () -> Unit,
|
||||
create: (String) -> Unit
|
||||
) {
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (folder) R.string.editor_new_folder else R.string.editor_new_entry
|
||||
)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{
|
||||
name = it
|
||||
},
|
||||
label = { Text(stringResource(R.string.editor_name)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { create(name) })
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { create(name) }) { Text(stringResource(R.string.editor_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -47,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.EditorRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
@@ -57,7 +59,8 @@ fun PasswordScreen(
|
||||
repository: PasswordRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
modifier: Modifier = Modifier,
|
||||
leaveOnDispose: Boolean = true
|
||||
leaveOnDispose: Boolean = true,
|
||||
editor: EditorRepository? = null
|
||||
) {
|
||||
DisposableEffect(repository, authentication) {
|
||||
onDispose {
|
||||
@@ -67,6 +70,8 @@ fun PasswordScreen(
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
var actions by remember { mutableStateOf(false) }
|
||||
var createFolder by remember { mutableStateOf<Boolean?>(null) }
|
||||
val editorState = editor?.state?.collectAsStateWithLifecycle()?.value
|
||||
val lease by authentication.state.collectAsStateWithLifecycle()
|
||||
var manual by remember { mutableStateOf(false) }
|
||||
var requested by remember { mutableStateOf(false) }
|
||||
@@ -110,6 +115,23 @@ fun PasswordScreen(
|
||||
Text(stringResource(R.string.password_refresh))
|
||||
}
|
||||
}
|
||||
if (editor != null) {
|
||||
FlowRow {
|
||||
TextButton(enabled = !state.loading && editorState?.busy != true, onClick = {
|
||||
createFolder =
|
||||
false
|
||||
}) {
|
||||
Text(stringResource(R.string.editor_new_entry))
|
||||
}
|
||||
TextButton(enabled = !state.loading && editorState?.busy != true, onClick = {
|
||||
createFolder =
|
||||
true
|
||||
}) {
|
||||
Text(stringResource(R.string.editor_new_folder))
|
||||
}
|
||||
}
|
||||
editorState?.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
@@ -255,6 +277,15 @@ fun PasswordScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (editor != null) {
|
||||
item {
|
||||
TextButton(enabled = !state.loading, onClick = {
|
||||
editor.begin(entry.path)
|
||||
}) {
|
||||
Text(stringResource(R.string.editor_edit_entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
entryViewer(state, repository)
|
||||
}
|
||||
}
|
||||
@@ -263,6 +294,26 @@ fun PasswordScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
createFolder?.let { folder ->
|
||||
if (editor != null) {
|
||||
EditorCreationDialog(
|
||||
authentication,
|
||||
folder,
|
||||
close = { createFolder = null },
|
||||
create = { name ->
|
||||
createFolder = null
|
||||
if (folder) {
|
||||
editor.createFolder(
|
||||
state.path,
|
||||
name
|
||||
)
|
||||
} else {
|
||||
editor.create(state.path, name)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(lease.failure, state.loading) {
|
||||
if (requested && !state.loading && !lease.busy) {
|
||||
requested = false
|
||||
|
||||
@@ -57,6 +57,7 @@ private enum class ShellRoute(val tab: ShellTab?) {
|
||||
|
||||
@Composable
|
||||
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
EditorDiscardDialog(viewModel.editor)
|
||||
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
||||
val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle()
|
||||
fun selectTab(tab: ShellTab) {
|
||||
@@ -149,12 +150,20 @@ private fun ShellDestination(
|
||||
viewModel: ShellViewModel,
|
||||
setupForm: SetupFormViewModel
|
||||
) {
|
||||
val setup by viewModel.onboarding.setup.collectAsStateWithLifecycle()
|
||||
val editor by viewModel.editor.state.collectAsStateWithLifecycle()
|
||||
val ready =
|
||||
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
||||
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
|
||||
(
|
||||
snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked ||
|
||||
(snapshot.state == ShellState.Empty && setup.configured)
|
||||
)
|
||||
when {
|
||||
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
||||
(snapshot.state == ShellState.Empty || route == ShellRoute.Preferences) -> {
|
||||
(
|
||||
(snapshot.state == ShellState.Empty && !setup.configured) ||
|
||||
route == ShellRoute.Preferences
|
||||
) -> {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
@@ -176,12 +185,17 @@ private fun ShellDestination(
|
||||
}
|
||||
ready && route == ShellRoute.Passwords -> {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(
|
||||
viewModel.passwords,
|
||||
viewModel.authentication,
|
||||
Modifier.fillMaxSize(),
|
||||
leaveOnDispose = false
|
||||
)
|
||||
if (editor.open) {
|
||||
EntryEditorScreen(viewModel.editor, viewModel.authentication)
|
||||
} else {
|
||||
PasswordScreen(
|
||||
viewModel.passwords,
|
||||
viewModel.authentication,
|
||||
Modifier.fillMaxSize(),
|
||||
leaveOnDispose = false,
|
||||
editor = viewModel.editor
|
||||
)
|
||||
}
|
||||
}
|
||||
ready && route == ShellRoute.Search -> SearchScreen(
|
||||
viewModel.search,
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.EditorRepository
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
@@ -26,6 +27,7 @@ class ShellViewModel @Inject constructor(
|
||||
val home: HomeRepository,
|
||||
val search: SearchRepository,
|
||||
val authentication: AuthenticationRepository,
|
||||
val editor: EditorRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
@@ -45,6 +47,12 @@ class ShellViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
if (saved.get<Boolean>("editor.wasOpen") == true && !editor.state.value.open) {
|
||||
editor.processRecreated()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
editor.state.collect { saved["editor.wasOpen"] = it.open }
|
||||
}
|
||||
val restored = saved.get<String>("shell.tab")?.let { name ->
|
||||
ShellTab.entries.firstOrNull { it.name == name }
|
||||
}
|
||||
@@ -84,6 +92,10 @@ class ShellViewModel @Inject constructor(
|
||||
}
|
||||
fun refreshShell() = repository.refresh()
|
||||
fun selectTab(tab: ShellTab) {
|
||||
if (tab != snapshot.value.selectedTab && editor.state.value.open) {
|
||||
editor.requestClose { selectTab(tab) }
|
||||
return
|
||||
}
|
||||
if (tab != snapshot.value.selectedTab) leaveDestination()
|
||||
saved["shell.tab"] = tab.name
|
||||
repository.selectTab(tab)
|
||||
|
||||
@@ -118,4 +118,34 @@
|
||||
<string name="shell_appearance_light">Light</string>
|
||||
<string name="shell_appearance_dark">Dark</string>
|
||||
<string name="shell_refresh">Reload state</string>
|
||||
<string name="editor_cancel">Cancel</string>
|
||||
<string name="editor_save">Save entry</string>
|
||||
<string name="editor_locked">The draft is protected. Unlock to resume editing. No plaintext draft is saved on disk.</string>
|
||||
<string name="editor_unlock_detail">Unlock the GPG key to resume this in-memory draft.</string>
|
||||
<string name="editor_add_field">Add named field</string>
|
||||
<string name="editor_add_notes">Add notes</string>
|
||||
<string name="editor_add_otp">Add OTP URI</string>
|
||||
<string name="editor_field_name">Field name</string>
|
||||
<string name="editor_field_value">Field value</string>
|
||||
<string name="editor_generate">Generate password</string>
|
||||
<string name="editor_generate_detail">Generate a replacement password in the draft. Save the entry to apply it.</string>
|
||||
<string name="editor_length">Password length</string>
|
||||
<string name="editor_no_symbols">Use letters and numbers only</string>
|
||||
<string name="editor_generate_confirm">Generate replacement</string>
|
||||
<string name="editor_reveal">Reveal value</string>
|
||||
<string name="editor_hide">Hide value</string>
|
||||
<string name="editor_remove">Remove field</string>
|
||||
<string name="editor_up">Move up</string>
|
||||
<string name="editor_down">Move down</string>
|
||||
<string name="editor_add">Add field</string>
|
||||
<string name="editor_discard_title">Discard unsaved changes?</string>
|
||||
<string name="editor_discard_detail">The entry draft will be discarded without changing the password store.</string>
|
||||
<string name="editor_discard">Discard changes</string>
|
||||
<string name="editor_keep">Keep editing</string>
|
||||
<string name="editor_new_entry">New entry</string>
|
||||
<string name="editor_new_folder">New folder</string>
|
||||
<string name="editor_edit_entry">Edit entry</string>
|
||||
<string name="editor_name">Name</string>
|
||||
<string name="editor_create">Create</string>
|
||||
<string name="editor_length_help">Leave blank for the default. Maximum: %1$d.</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user