Implement Rust-backed Android name search (#101)
This commit is contained in:
1 parent
a06f39af23
commit
c7703d8efd
21 files changed
+910
-27
No files matched your search
@@ -90,7 +90,7 @@ fun PasswordScreen(
|
||||
repository.lock()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (state.revision.isNotEmpty()) repository.refresh()
|
||||
if (!state.loading && state.revision.isNotEmpty()) repository.refresh()
|
||||
}
|
||||
BackHandler(state.selected != null || state.parent != null) {
|
||||
actions = false
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.PasswordRow
|
||||
import de.rfc1437.ironstorage.core.SearchRepository
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
repository: SearchRepository,
|
||||
open: (PasswordRow) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focus = LocalFocusManager.current
|
||||
DisposableEffect(repository) {
|
||||
repository.refresh()
|
||||
onDispose { repository.cancel() }
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.refresh() }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { repository.cancel() }
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.search_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics { heading() }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = repository::query,
|
||||
label = { Text(stringResource(R.string.search_query)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = {
|
||||
keyboard?.hide()
|
||||
focus.clearFocus()
|
||||
repository.refresh()
|
||||
})
|
||||
)
|
||||
FlowRow {
|
||||
TextButton(enabled = state.query.isNotEmpty(), onClick = repository::clear) {
|
||||
Text(stringResource(R.string.search_clear))
|
||||
}
|
||||
TextButton(enabled = state.query.isNotBlank(), onClick = repository::refresh) {
|
||||
Text(stringResource(R.string.search_refresh))
|
||||
}
|
||||
if (state.loading) {
|
||||
TextButton(onClick = repository::cancel) {
|
||||
Text(stringResource(R.string.search_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator()
|
||||
Text(stringResource(R.string.search_loading))
|
||||
} else if (state.error != null) {
|
||||
Text(requireNotNull(state.error), color = MaterialTheme.colorScheme.error)
|
||||
} else if (state.cancelled) {
|
||||
Text(stringResource(R.string.search_cancelled))
|
||||
} else if (state.results.isEmpty()) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.query.isBlank()) R.string.search_prompt else R.string.search_empty
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.search_count, state.results.size))
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.weight(1f).fillMaxSize()) {
|
||||
items(state.results, key = { it.id }) { row ->
|
||||
ListItem(
|
||||
headlineContent = { Text(row.title) },
|
||||
supportingContent = { Text(row.detail) },
|
||||
modifier = Modifier.clickable(
|
||||
onClickLabel = stringResource(R.string.search_open)
|
||||
) {
|
||||
keyboard?.hide()
|
||||
open(row)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,9 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
|
||||
val browsing = ready &&
|
||||
snapshot.selectedTab == ShellTab.Passwords
|
||||
val searching = ready && snapshot.selectedTab == ShellTab.Search
|
||||
val home = ready && snapshot.selectedTab == ShellTab.Home
|
||||
if (!browsing && !home) {
|
||||
if (!browsing && !home && !searching) {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
@@ -98,6 +99,8 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
} else if (browsing) {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f))
|
||||
} else if (searching) {
|
||||
SearchScreen(viewModel.search, viewModel::openSearch, Modifier.weight(1f))
|
||||
} else if (home) {
|
||||
HomeScreen(viewModel.home, Modifier.weight(1f))
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,8 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRow
|
||||
import de.rfc1437.ironstorage.core.SearchRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
import javax.inject.Inject
|
||||
@@ -19,6 +21,7 @@ class ShellViewModel @Inject constructor(
|
||||
val onboarding: OnboardingRepository,
|
||||
val passwords: PasswordRepository,
|
||||
val home: HomeRepository,
|
||||
val search: SearchRepository,
|
||||
val authentication: AuthenticationRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
@@ -36,15 +39,22 @@ class ShellViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
private var passwordsRestored = false
|
||||
fun openSearch(row: PasswordRow) {
|
||||
passwords.openSearch(row.id, row.revision)
|
||||
repository.selectTab(ShellTab.Passwords)
|
||||
}
|
||||
fun openPasswords() {
|
||||
if (!passwordsRestored) {
|
||||
passwordsRestored = true
|
||||
// Preserve live navigation through rotation and pending search handoffs.
|
||||
val current = passwords.state.value
|
||||
if (current.loading || current.revision.isNotEmpty()) return
|
||||
passwords.restore(
|
||||
saved["password.path"] ?: "",
|
||||
saved["password.selection"],
|
||||
saved["password.revision"]
|
||||
)
|
||||
} else {
|
||||
} else if (!passwords.state.value.loading) {
|
||||
passwords.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +102,15 @@
|
||||
<string name="entry_overwrite_enabled">Replacing the existing entry confirmed</string>
|
||||
<string name="entry_confirm">Confirm</string>
|
||||
<string name="entry_copy_stored_hint">Selection copies the stored field. Save edits first to copy changes.</string>
|
||||
<string name="search_title">Search</string>
|
||||
<string name="search_query">Entry name or path</string>
|
||||
<string name="search_clear">Clear search</string>
|
||||
<string name="search_refresh">Refresh results</string>
|
||||
<string name="search_cancel">Cancel search</string>
|
||||
<string name="search_loading">Searching names…</string>
|
||||
<string name="search_prompt">Enter an entry name, folder, or path.</string>
|
||||
<string name="search_empty">No matching entries.</string>
|
||||
<string name="search_count">Results: %1$d</string>
|
||||
<string name="search_open">Open entry</string>
|
||||
<string name="search_cancelled">Search cancelled. Refresh to try again.</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user