feat: Wordlist view screen
This commit is contained in:
parent
dce9d5d3c6
commit
d23b8e1c76
@ -221,6 +221,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
|
||||
dependsOn("kspCommonMainKotlinMetadata")
|
||||
}
|
||||
}
|
||||
kotlin.compilerOptions {
|
||||
freeCompilerArgs.add("-Xcontext-receivers")
|
||||
}
|
||||
kotlin.sourceSets.commonMain {
|
||||
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
|
||||
}
|
||||
|
@ -55,9 +55,10 @@ import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
|
||||
import com.artemchep.keyguard.feature.auth.common.util.REGEX_DOMAIN
|
||||
import com.artemchep.keyguard.feature.auth.common.util.REGEX_EMAIL
|
||||
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.WordlistRoute
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.list.WordlistListRoute
|
||||
import com.artemchep.keyguard.feature.generator.emailrelay.EmailRelayListRoute
|
||||
import com.artemchep.keyguard.feature.generator.history.GeneratorHistoryRoute
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.add.AddRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.add.LeAddRoute
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
@ -1443,7 +1444,7 @@ fun produceGeneratorState(
|
||||
translator = this@produceScreenState,
|
||||
navigate = ::navigate,
|
||||
)
|
||||
this += WordlistRoute.actionOrNull(
|
||||
this += WordlistsRoute.actionOrNull(
|
||||
translator = this@produceScreenState,
|
||||
navigate = ::navigate,
|
||||
)
|
||||
|
@ -11,7 +11,9 @@ import com.artemchep.keyguard.ui.icons.ChevronIcon
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardWordlist
|
||||
import com.artemchep.keyguard.ui.icons.iconSmall
|
||||
|
||||
object WordlistRoute : Route {
|
||||
object WordlistsRoute : Route {
|
||||
const val ROUTER_NAME = "wordlists"
|
||||
|
||||
fun actionOrNull(
|
||||
translator: TranslatorScope,
|
||||
navigate: (NavigationIntent) -> Unit,
|
||||
@ -30,7 +32,7 @@ object WordlistRoute : Route {
|
||||
ChevronIcon()
|
||||
},
|
||||
onClick = {
|
||||
val route = WordlistRoute
|
||||
val route = WordlistsRoute
|
||||
val intent = NavigationIntent.NavigateToRoute(route)
|
||||
navigate(intent)
|
||||
},
|
||||
@ -38,6 +40,6 @@ object WordlistRoute : Route {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
WordlistScreen()
|
||||
WordlistsScreen()
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.list.WordlistListRoute
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationRouter
|
||||
import com.artemchep.keyguard.feature.twopane.TwoPaneNavigationContent
|
||||
|
||||
@Composable
|
||||
fun WordlistsScreen() {
|
||||
NavigationRouter(
|
||||
id = WordlistsRoute.ROUTER_NAME,
|
||||
initial = WordlistListRoute,
|
||||
) { backStack ->
|
||||
TwoPaneNavigationContent(backStack)
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.artemchep.keyguard.feature.navigation.Route
|
||||
|
||||
object WordlistListRoute : Route {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
WordlistListScreen()
|
||||
}
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.list
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@ -39,21 +42,25 @@ import com.artemchep.keyguard.common.model.flatMap
|
||||
import com.artemchep.keyguard.common.model.getOrNull
|
||||
import com.artemchep.keyguard.feature.EmptyView
|
||||
import com.artemchep.keyguard.feature.ErrorView
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
|
||||
import com.artemchep.keyguard.feature.navigation.LocalNavigationController
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIcon
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.navigationNextEntryOrNull
|
||||
import com.artemchep.keyguard.feature.twopane.LocalHasDetailPane
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.AvatarBuilder
|
||||
import com.artemchep.keyguard.ui.DefaultFab
|
||||
import com.artemchep.keyguard.ui.DefaultSelection
|
||||
import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow
|
||||
import com.artemchep.keyguard.ui.FabState
|
||||
import com.artemchep.keyguard.ui.FlatDropdown
|
||||
import com.artemchep.keyguard.ui.FlatItemLayout
|
||||
import com.artemchep.keyguard.ui.FlatItemTextContent
|
||||
import com.artemchep.keyguard.ui.ScaffoldLazyColumn
|
||||
import com.artemchep.keyguard.ui.icons.ChevronIcon
|
||||
import com.artemchep.keyguard.ui.icons.IconBox
|
||||
import com.artemchep.keyguard.ui.skeleton.SkeletonItem
|
||||
import com.artemchep.keyguard.ui.theme.selectedContainer
|
||||
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.flow.filter
|
||||
@ -61,19 +68,19 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
|
||||
@Composable
|
||||
fun WordlistScreen(
|
||||
fun WordlistListScreen(
|
||||
) {
|
||||
val loadableState = produceWordlistState(
|
||||
val loadableState = produceWordlistListState(
|
||||
)
|
||||
WordlistScreen(
|
||||
WordlistListScreen(
|
||||
loadableState = loadableState,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun WordlistScreen(
|
||||
loadableState: Loadable<WordlistState>,
|
||||
fun WordlistListScreen(
|
||||
loadableState: Loadable<WordlistListState>,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
@ -227,14 +234,36 @@ private fun NoItemsPlaceholder(
|
||||
@Composable
|
||||
private fun WordlistItem(
|
||||
modifier: Modifier,
|
||||
item: WordlistState.Item,
|
||||
item: WordlistListState.Item,
|
||||
) {
|
||||
val selectableState by item.selectableState.collectAsState()
|
||||
|
||||
val onClick = selectableState.onClick
|
||||
// fallback to default
|
||||
?: item.onClick
|
||||
.takeIf { selectableState.can }
|
||||
val onLongClick = selectableState.onLongClick
|
||||
|
||||
val backgroundColor = when {
|
||||
selectableState.selected -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> Color.Unspecified
|
||||
else -> run {
|
||||
if (LocalHasDetailPane.current) {
|
||||
val nextEntry = navigationNextEntryOrNull()
|
||||
val nextRoute = nextEntry?.route as? WordlistViewRoute
|
||||
|
||||
MaterialTheme.colorScheme.selectedContainer
|
||||
.takeIf { LocalHasDetailPane.current }
|
||||
?: Color.Unspecified
|
||||
val selected = nextRoute?.args?.wordlistId == item.wordlistId
|
||||
if (selected) {
|
||||
return@run MaterialTheme.colorScheme.selectedContainer
|
||||
}
|
||||
}
|
||||
|
||||
Color.Unspecified
|
||||
}
|
||||
}
|
||||
FlatDropdown(
|
||||
FlatItemLayout(
|
||||
modifier = modifier,
|
||||
backgroundColor = backgroundColor,
|
||||
leading = {
|
||||
@ -289,20 +318,40 @@ private fun WordlistItem(
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ExpandedIfNotEmptyForRow(
|
||||
selectableState.selected.takeIf { selectableState.selecting },
|
||||
) { selected ->
|
||||
Checkbox(
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(8.dp),
|
||||
)
|
||||
|
||||
val checkbox = when {
|
||||
selectableState.selecting -> true
|
||||
else -> false
|
||||
}
|
||||
Crossfade(
|
||||
modifier = Modifier
|
||||
.size(
|
||||
width = 36.dp,
|
||||
height = 36.dp,
|
||||
),
|
||||
targetState = checkbox,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp),
|
||||
checked = selected,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (it) {
|
||||
Checkbox(
|
||||
checked = selectableState.selected,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
} else {
|
||||
ChevronIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dropdown = item.dropdown,
|
||||
onClick = selectableState.onClick,
|
||||
onLongClick = selectableState.onLongClick,
|
||||
enabled = true,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
@ -13,7 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Immutable
|
||||
data class WordlistState(
|
||||
data class WordlistListState(
|
||||
val content: Loadable<Either<Throwable, Content>>,
|
||||
) {
|
||||
@Immutable
|
||||
@ -32,9 +32,10 @@ data class WordlistState(
|
||||
val title: String,
|
||||
val counter: String,
|
||||
val icon: VaultItemIcon,
|
||||
val wordlistId: Long,
|
||||
val accentLight: Color,
|
||||
val accentDark: Color,
|
||||
val dropdown: ImmutableList<ContextItem>,
|
||||
val selectableState: StateFlow<SelectableItemState>,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.list
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.ViewList
|
||||
import androidx.compose.runtime.Composable
|
||||
import arrow.core.partially1
|
||||
import com.artemchep.keyguard.common.io.launchIn
|
||||
@ -23,6 +24,9 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationResult
|
||||
import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
|
||||
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
|
||||
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
|
||||
@ -47,15 +51,15 @@ import org.kodein.di.compose.localDI
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.di.instance
|
||||
|
||||
private class WordlistUiException(
|
||||
private class WordlistListUiException(
|
||||
msg: String,
|
||||
cause: Throwable,
|
||||
) : RuntimeException(msg, cause)
|
||||
|
||||
@Composable
|
||||
fun produceWordlistState(
|
||||
fun produceWordlistListState(
|
||||
) = with(localDI().direct) {
|
||||
produceWordlistState(
|
||||
produceWordlistListState(
|
||||
addWordlist = instance(),
|
||||
editWordlist = instance(),
|
||||
removeWordlistById = instance(),
|
||||
@ -65,137 +69,31 @@ fun produceWordlistState(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun produceWordlistState(
|
||||
fun produceWordlistListState(
|
||||
addWordlist: AddWordlist,
|
||||
editWordlist: EditWordlist,
|
||||
removeWordlistById: RemoveWordlistById,
|
||||
getWordlists: GetWordlists,
|
||||
numberFormatter: NumberFormatter,
|
||||
): Loadable<WordlistState> = produceScreenState(
|
||||
key = "wordlist",
|
||||
): Loadable<WordlistListState> = produceScreenState(
|
||||
key = "wordlist_list",
|
||||
initial = Loadable.Loading,
|
||||
args = arrayOf(),
|
||||
) {
|
||||
val selectionHandle = selectionHandle("selection")
|
||||
|
||||
fun onEdit(entity: DGeneratorWordlist) {
|
||||
val nameKey = "name"
|
||||
val nameItem = ConfirmationRoute.Args.Item.StringItem(
|
||||
key = nameKey,
|
||||
value = entity.name,
|
||||
title = translate(Res.strings.generic_name),
|
||||
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
|
||||
canBeEmpty = false,
|
||||
)
|
||||
|
||||
val items = listOfNotNull(
|
||||
nameItem,
|
||||
)
|
||||
val route = registerRouteResultReceiver(
|
||||
route = ConfirmationRoute(
|
||||
args = ConfirmationRoute.Args(
|
||||
icon = icon(
|
||||
main = Icons.Outlined.KeyguardWordlist,
|
||||
secondary = Icons.Outlined.Edit,
|
||||
),
|
||||
title = translate(Res.strings.wordlist_edit_wordlist_title),
|
||||
items = items,
|
||||
docUrl = null,
|
||||
),
|
||||
fun onView(entity: DGeneratorWordlist) {
|
||||
val route = WordlistViewRoute(
|
||||
args = WordlistViewRoute.Args(
|
||||
wordlistId = entity.idRaw,
|
||||
),
|
||||
) { result ->
|
||||
if (result is ConfirmationResult.Confirm) {
|
||||
val name = result.data[nameKey] as? String
|
||||
?: return@registerRouteResultReceiver
|
||||
|
||||
val request = EditWordlistRequest(
|
||||
id = entity.idRaw,
|
||||
name = name,
|
||||
)
|
||||
editWordlist(request)
|
||||
.launchIn(appScope)
|
||||
}
|
||||
}
|
||||
val intent = NavigationIntent.NavigateToRoute(route)
|
||||
navigate(intent)
|
||||
}
|
||||
|
||||
fun onNew() {
|
||||
val nameKey = "name"
|
||||
val nameItem = ConfirmationRoute.Args.Item.StringItem(
|
||||
key = nameKey,
|
||||
value = "",
|
||||
title = translate(Res.strings.generic_name),
|
||||
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
|
||||
canBeEmpty = false,
|
||||
)
|
||||
|
||||
val fileKey = "file"
|
||||
val fileItem = ConfirmationRoute.Args.Item.FileItem(
|
||||
key = fileKey,
|
||||
value = null,
|
||||
title = translate(Res.strings.wordlist),
|
||||
)
|
||||
|
||||
val items = listOfNotNull(
|
||||
nameItem,
|
||||
fileItem,
|
||||
)
|
||||
val route = registerRouteResultReceiver(
|
||||
route = ConfirmationRoute(
|
||||
args = ConfirmationRoute.Args(
|
||||
icon = icon(
|
||||
main = Icons.Outlined.KeyguardWordlist,
|
||||
secondary = Icons.Outlined.Add,
|
||||
),
|
||||
title = translate(Res.strings.wordlist_add_wordlist_title),
|
||||
items = items,
|
||||
docUrl = null,
|
||||
),
|
||||
val intent = NavigationIntent.Composite(
|
||||
listOf(
|
||||
NavigationIntent.PopById(WordlistsRoute.ROUTER_NAME),
|
||||
NavigationIntent.NavigateToRoute(route),
|
||||
),
|
||||
) { result ->
|
||||
if (result is ConfirmationResult.Confirm) {
|
||||
val name = result.data[nameKey] as? String
|
||||
?: return@registerRouteResultReceiver
|
||||
val file = result.data[fileKey] as? ConfirmationRoute.Args.Item.FileItem.File
|
||||
?: return@registerRouteResultReceiver
|
||||
|
||||
val wordlist = AddWordlistRequest.Wordlist.FromFile(
|
||||
uri = file.uri,
|
||||
)
|
||||
val request = AddWordlistRequest(
|
||||
name = name,
|
||||
wordlist = wordlist,
|
||||
)
|
||||
addWordlist(request)
|
||||
.launchIn(appScope)
|
||||
}
|
||||
}
|
||||
val intent = NavigationIntent.NavigateToRoute(route)
|
||||
navigate(intent)
|
||||
}
|
||||
|
||||
fun onDeleteByItems(
|
||||
items: List<DGeneratorWordlist>,
|
||||
) {
|
||||
val title = if (items.size > 1) {
|
||||
translate(Res.strings.wordlist_delete_many_confirmation_title)
|
||||
} else {
|
||||
translate(Res.strings.wordlist_delete_one_confirmation_title)
|
||||
}
|
||||
val message = items
|
||||
.joinToString(separator = "\n") { it.name }
|
||||
val intent = createConfirmationDialogIntent(
|
||||
icon = icon(Icons.Outlined.Delete),
|
||||
title = title,
|
||||
message = message,
|
||||
) {
|
||||
val ids = items
|
||||
.map { it.idRaw }
|
||||
.toSet()
|
||||
removeWordlistById(ids)
|
||||
.launchIn(appScope)
|
||||
}
|
||||
)
|
||||
navigate(intent)
|
||||
}
|
||||
|
||||
@ -231,11 +129,26 @@ fun produceWordlistState(
|
||||
}
|
||||
|
||||
val actions = buildContextItems {
|
||||
if (selectedItems.size == 1) {
|
||||
section {
|
||||
val selectedItem = selectedItems.first()
|
||||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Edit,
|
||||
title = translate(Res.strings.edit),
|
||||
onClick = WordlistUtil::onEdit
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(editWordlist)
|
||||
.partially1(selectedItem),
|
||||
)
|
||||
}
|
||||
}
|
||||
section {
|
||||
this += FlatItemAction(
|
||||
leading = icon(Icons.Outlined.Delete),
|
||||
title = translate(Res.strings.delete),
|
||||
onClick = ::onDeleteByItems
|
||||
onClick = WordlistUtil::onDeleteByItems
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(removeWordlistById)
|
||||
.partially1(selectedItems),
|
||||
)
|
||||
}
|
||||
@ -260,22 +173,6 @@ fun produceWordlistState(
|
||||
.map { list ->
|
||||
list
|
||||
.map {
|
||||
val dropdown = buildContextItems {
|
||||
section {
|
||||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Edit,
|
||||
title = translate(Res.strings.edit),
|
||||
onClick = ::onEdit
|
||||
.partially1(it),
|
||||
)
|
||||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Delete,
|
||||
title = translate(Res.strings.delete),
|
||||
onClick = ::onDeleteByItems
|
||||
.partially1(listOf(it)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val icon = VaultItemIcon.TextIcon(
|
||||
run {
|
||||
val words = it.name.split(" ")
|
||||
@ -331,22 +228,24 @@ fun produceWordlistState(
|
||||
quantity,
|
||||
numberFormatter.formatNumber(quantity),
|
||||
)
|
||||
WordlistState.Item(
|
||||
WordlistListState.Item(
|
||||
key = it.id,
|
||||
title = it.name,
|
||||
counter = counter,
|
||||
icon = icon,
|
||||
wordlistId = it.idRaw,
|
||||
accentLight = it.accentColor.light,
|
||||
accentDark = it.accentColor.dark,
|
||||
dropdown = dropdown,
|
||||
selectableState = selectableStateFlow,
|
||||
onClick = ::onView
|
||||
.partially1(it),
|
||||
)
|
||||
}
|
||||
.toPersistentList()
|
||||
}
|
||||
.crashlyticsAttempt { e ->
|
||||
val msg = "Failed to get the wordlist list!"
|
||||
WordlistUiException(
|
||||
WordlistListUiException(
|
||||
msg = msg,
|
||||
cause = e,
|
||||
)
|
||||
@ -357,18 +256,20 @@ fun produceWordlistState(
|
||||
) { selection, itemsResult ->
|
||||
val contentOrException = itemsResult
|
||||
.map { items ->
|
||||
WordlistState.Content(
|
||||
WordlistListState.Content(
|
||||
revision = 0,
|
||||
items = items,
|
||||
selection = selection,
|
||||
primaryAction = ::onNew,
|
||||
primaryAction = WordlistUtil::onNew
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(addWordlist),
|
||||
)
|
||||
}
|
||||
Loadable.Ok(contentOrException)
|
||||
}
|
||||
contentFlow
|
||||
.map { content ->
|
||||
val state = WordlistState(
|
||||
val state = WordlistListState(
|
||||
content = content,
|
||||
)
|
||||
Loadable.Ok(state)
|
@ -0,0 +1,154 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.util
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import com.artemchep.keyguard.common.io.launchIn
|
||||
import com.artemchep.keyguard.common.model.AddWordlistRequest
|
||||
import com.artemchep.keyguard.common.model.DGeneratorWordlist
|
||||
import com.artemchep.keyguard.common.model.EditWordlistRequest
|
||||
import com.artemchep.keyguard.common.usecase.AddWordlist
|
||||
import com.artemchep.keyguard.common.usecase.EditWordlist
|
||||
import com.artemchep.keyguard.common.usecase.RemoveWordlistById
|
||||
import com.artemchep.keyguard.feature.confirmation.ConfirmationResult
|
||||
import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
|
||||
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
|
||||
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardWordlist
|
||||
import com.artemchep.keyguard.ui.icons.icon
|
||||
|
||||
object WordlistUtil {
|
||||
context(RememberStateFlowScope)
|
||||
fun onEdit(
|
||||
editWordlist: EditWordlist,
|
||||
entity: DGeneratorWordlist,
|
||||
) {
|
||||
val nameKey = "name"
|
||||
val nameItem = ConfirmationRoute.Args.Item.StringItem(
|
||||
key = nameKey,
|
||||
value = entity.name,
|
||||
title = translate(Res.strings.generic_name),
|
||||
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
|
||||
canBeEmpty = false,
|
||||
)
|
||||
|
||||
val items = listOfNotNull(
|
||||
nameItem,
|
||||
)
|
||||
val route = registerRouteResultReceiver(
|
||||
route = ConfirmationRoute(
|
||||
args = ConfirmationRoute.Args(
|
||||
icon = icon(
|
||||
main = Icons.Outlined.KeyguardWordlist,
|
||||
secondary = Icons.Outlined.Edit,
|
||||
),
|
||||
title = translate(Res.strings.wordlist_edit_wordlist_title),
|
||||
items = items,
|
||||
docUrl = null,
|
||||
),
|
||||
),
|
||||
) { result ->
|
||||
if (result is ConfirmationResult.Confirm) {
|
||||
val name = result.data[nameKey] as? String
|
||||
?: return@registerRouteResultReceiver
|
||||
|
||||
val request = EditWordlistRequest(
|
||||
id = entity.idRaw,
|
||||
name = name,
|
||||
)
|
||||
editWordlist(request)
|
||||
.launchIn(appScope)
|
||||
}
|
||||
}
|
||||
val intent = NavigationIntent.NavigateToRoute(route)
|
||||
navigate(intent)
|
||||
}
|
||||
|
||||
context(RememberStateFlowScope)
|
||||
fun onNew(
|
||||
addWordlist: AddWordlist,
|
||||
) {
|
||||
val nameKey = "name"
|
||||
val nameItem = ConfirmationRoute.Args.Item.StringItem(
|
||||
key = nameKey,
|
||||
value = "",
|
||||
title = translate(Res.strings.generic_name),
|
||||
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
|
||||
canBeEmpty = false,
|
||||
)
|
||||
|
||||
val fileKey = "file"
|
||||
val fileItem = ConfirmationRoute.Args.Item.FileItem(
|
||||
key = fileKey,
|
||||
value = null,
|
||||
title = translate(Res.strings.wordlist),
|
||||
)
|
||||
|
||||
val items = listOfNotNull(
|
||||
nameItem,
|
||||
fileItem,
|
||||
)
|
||||
val route = registerRouteResultReceiver(
|
||||
route = ConfirmationRoute(
|
||||
args = ConfirmationRoute.Args(
|
||||
icon = icon(
|
||||
main = Icons.Outlined.KeyguardWordlist,
|
||||
secondary = Icons.Outlined.Add,
|
||||
),
|
||||
title = translate(Res.strings.wordlist_add_wordlist_title),
|
||||
items = items,
|
||||
docUrl = null,
|
||||
),
|
||||
),
|
||||
) { result ->
|
||||
if (result is ConfirmationResult.Confirm) {
|
||||
val name = result.data[nameKey] as? String
|
||||
?: return@registerRouteResultReceiver
|
||||
val file = result.data[fileKey] as? ConfirmationRoute.Args.Item.FileItem.File
|
||||
?: return@registerRouteResultReceiver
|
||||
|
||||
val wordlist = AddWordlistRequest.Wordlist.FromFile(
|
||||
uri = file.uri,
|
||||
)
|
||||
val request = AddWordlistRequest(
|
||||
name = name,
|
||||
wordlist = wordlist,
|
||||
)
|
||||
addWordlist(request)
|
||||
.launchIn(appScope)
|
||||
}
|
||||
}
|
||||
val intent = NavigationIntent.NavigateToRoute(route)
|
||||
navigate(intent)
|
||||
}
|
||||
|
||||
context(RememberStateFlowScope)
|
||||
fun onDeleteByItems(
|
||||
removeWordlistById: RemoveWordlistById,
|
||||
items: List<DGeneratorWordlist>,
|
||||
) {
|
||||
val title = if (items.size > 1) {
|
||||
translate(Res.strings.wordlist_delete_many_confirmation_title)
|
||||
} else {
|
||||
translate(Res.strings.wordlist_delete_one_confirmation_title)
|
||||
}
|
||||
val message = items
|
||||
.joinToString(separator = "\n") { it.name }
|
||||
val intent = createConfirmationDialogIntent(
|
||||
icon = icon(Icons.Outlined.Delete),
|
||||
title = title,
|
||||
message = message,
|
||||
) {
|
||||
val ids = items
|
||||
.map { it.idRaw }
|
||||
.toSet()
|
||||
removeWordlistById(ids)
|
||||
.launchIn(appScope)
|
||||
}
|
||||
navigate(intent)
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.view
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.artemchep.keyguard.feature.navigation.Route
|
||||
|
||||
data class WordlistViewRoute(
|
||||
val args: Args,
|
||||
) : Route {
|
||||
data class Args(
|
||||
val wordlistId: Long,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
WordlistViewScreen(args)
|
||||
}
|
||||
}
|
@ -0,0 +1,284 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.view
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.OpenInBrowser
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.artemchep.keyguard.URL_2FA
|
||||
import com.artemchep.keyguard.common.model.Loadable
|
||||
import com.artemchep.keyguard.common.model.flatMap
|
||||
import com.artemchep.keyguard.common.model.getOrNull
|
||||
import com.artemchep.keyguard.feature.EmptySearchView
|
||||
import com.artemchep.keyguard.feature.EmptyView
|
||||
import com.artemchep.keyguard.feature.ErrorView
|
||||
import com.artemchep.keyguard.feature.home.vault.component.SearchTextField
|
||||
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
|
||||
import com.artemchep.keyguard.feature.navigation.LocalNavigationController
|
||||
import com.artemchep.keyguard.feature.navigation.LocalNavigationNodeVisualStack
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIcon
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.tfa.directory.TwoFaServiceListState
|
||||
import com.artemchep.keyguard.feature.tfa.directory.produceTwoFaServiceListState
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.AvatarBuilder
|
||||
import com.artemchep.keyguard.ui.DefaultFab
|
||||
import com.artemchep.keyguard.ui.DefaultProgressBar
|
||||
import com.artemchep.keyguard.ui.DefaultSelection
|
||||
import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow
|
||||
import com.artemchep.keyguard.ui.FabState
|
||||
import com.artemchep.keyguard.ui.FlatDropdown
|
||||
import com.artemchep.keyguard.ui.FlatItem
|
||||
import com.artemchep.keyguard.ui.FlatItemTextContent
|
||||
import com.artemchep.keyguard.ui.OptionsButton
|
||||
import com.artemchep.keyguard.ui.ScaffoldLazyColumn
|
||||
import com.artemchep.keyguard.ui.focus.FocusRequester2
|
||||
import com.artemchep.keyguard.ui.focus.focusRequester2
|
||||
import com.artemchep.keyguard.ui.icons.IconBox
|
||||
import com.artemchep.keyguard.ui.pulltosearch.PullToSearch
|
||||
import com.artemchep.keyguard.ui.skeleton.SkeletonItem
|
||||
import com.artemchep.keyguard.ui.toolbar.CustomToolbar
|
||||
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
|
||||
import com.artemchep.keyguard.ui.toolbar.content.CustomToolbarContent
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
|
||||
@Composable
|
||||
fun WordlistViewScreen(
|
||||
args: WordlistViewRoute.Args,
|
||||
) {
|
||||
val loadableState = produceWordlistViewState(args)
|
||||
WordlistViewScreen(
|
||||
loadableState = loadableState,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun WordlistViewScreen(
|
||||
loadableState: Loadable<WordlistViewState>,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val filterState = run {
|
||||
val filterFlow = loadableState.getOrNull()?.filter
|
||||
remember(filterFlow) {
|
||||
filterFlow ?: MutableStateFlow(null)
|
||||
}.collectAsState()
|
||||
}
|
||||
|
||||
val listRevision =
|
||||
loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.revision
|
||||
val listState = remember {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = 0,
|
||||
firstVisibleItemScrollOffset = 0,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(listRevision) {
|
||||
// TODO: How do you wait till the layout state start to represent
|
||||
// the actual data?
|
||||
val listSize =
|
||||
loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.items?.size
|
||||
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
||||
.withIndex()
|
||||
.filter {
|
||||
it.index > 0 || it.value == listSize
|
||||
}
|
||||
.first()
|
||||
|
||||
listState.scrollToItem(0, 0)
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester2() }
|
||||
// Auto focus the text field
|
||||
// on launch.
|
||||
LaunchedEffect(
|
||||
focusRequester,
|
||||
filterState,
|
||||
) {
|
||||
snapshotFlow { filterState.value }
|
||||
.first { it?.query?.onChange != null }
|
||||
delay(100L)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = false,
|
||||
onRefresh = {
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
)
|
||||
ScaffoldLazyColumn(
|
||||
modifier = Modifier
|
||||
.pullRefresh(pullRefreshState)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topAppBarScrollBehavior = scrollBehavior,
|
||||
topBar = {
|
||||
CustomToolbar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
) {
|
||||
val wordlistState = run {
|
||||
val wordlistFlow = loadableState.getOrNull()?.wordlist
|
||||
remember(wordlistFlow) {
|
||||
wordlistFlow ?: MutableStateFlow(null)
|
||||
}.collectAsState()
|
||||
}
|
||||
Column {
|
||||
val name = wordlistState.value?.wordlist?.name
|
||||
CustomToolbarContent(
|
||||
title = name,
|
||||
icon = {
|
||||
NavigationIcon()
|
||||
},
|
||||
actions = {
|
||||
val actions = wordlistState.value?.actions.orEmpty()
|
||||
OptionsButton(actions = actions)
|
||||
},
|
||||
)
|
||||
|
||||
val query = filterState.value?.query
|
||||
val queryText = query?.state?.value.orEmpty()
|
||||
|
||||
// TODO: I should somehow sync it with the toolbar instead
|
||||
// of assuming the placement of the composable.
|
||||
val visualStack = LocalNavigationNodeVisualStack.current
|
||||
val backVisible = visualStack.size > 1
|
||||
val searchIcon = !backVisible // otherwise it's too much icons in my opinion
|
||||
|
||||
SearchTextField(
|
||||
modifier = Modifier
|
||||
.focusRequester2(focusRequester),
|
||||
text = queryText,
|
||||
placeholder = stringResource(Res.strings.wordlist_word_search_placeholder),
|
||||
searchIcon = searchIcon,
|
||||
leading = {},
|
||||
trailing = {},
|
||||
onTextChange = query?.onChange,
|
||||
onGoClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
pullRefreshState = pullRefreshState,
|
||||
overlay = {
|
||||
val filterRevision = filterState.value?.revision
|
||||
DefaultProgressBar(
|
||||
visible = listRevision != null && filterRevision != null &&
|
||||
listRevision != filterRevision,
|
||||
)
|
||||
|
||||
PullToSearch(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding.value),
|
||||
pullRefreshState = pullRefreshState,
|
||||
)
|
||||
},
|
||||
listState = listState,
|
||||
) {
|
||||
val contentState = loadableState
|
||||
.flatMap { it.content }
|
||||
when (contentState) {
|
||||
is Loadable.Loading -> {
|
||||
for (i in 1..3) {
|
||||
item("skeleton.$i") {
|
||||
SkeletonItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Loadable.Ok -> {
|
||||
contentState.value.fold(
|
||||
ifLeft = { e ->
|
||||
item("error") {
|
||||
ErrorView(
|
||||
text = {
|
||||
Text(text = "Failed to load wordlist!")
|
||||
},
|
||||
exception = e,
|
||||
)
|
||||
}
|
||||
},
|
||||
ifRight = { content ->
|
||||
val items = content.items
|
||||
if (items.isEmpty()) {
|
||||
item("empty") {
|
||||
NoItemsPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = items,
|
||||
key = { it.key },
|
||||
) { item ->
|
||||
AppItem(
|
||||
modifier = Modifier
|
||||
.animateItemPlacement(),
|
||||
item = item,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoItemsPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
EmptySearchView(
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppItem(
|
||||
modifier: Modifier,
|
||||
item: WordlistViewState.Item,
|
||||
) {
|
||||
FlatItem(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(item.name)
|
||||
},
|
||||
onClick = item.onClick,
|
||||
)
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.view
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import arrow.core.Either
|
||||
import com.artemchep.keyguard.common.model.DGeneratorWordlist
|
||||
import com.artemchep.keyguard.common.model.Loadable
|
||||
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
|
||||
import com.artemchep.keyguard.ui.ContextItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Immutable
|
||||
data class WordlistViewState(
|
||||
val wordlist: StateFlow<Wordlist?>,
|
||||
val filter: StateFlow<Filter>,
|
||||
val content: Loadable<Either<Throwable, Content>>,
|
||||
) {
|
||||
@Immutable
|
||||
data class Wordlist(
|
||||
val wordlist: DGeneratorWordlist,
|
||||
val actions: ImmutableList<ContextItem>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Filter(
|
||||
val revision: Int,
|
||||
val query: TextFieldModel2,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Content(
|
||||
val revision: Int,
|
||||
val items: List<Item>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Item(
|
||||
val key: String,
|
||||
val name: AnnotatedString,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
package com.artemchep.keyguard.feature.generator.wordlist.view
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import arrow.core.partially1
|
||||
import com.artemchep.keyguard.common.model.Loadable
|
||||
import com.artemchep.keyguard.common.usecase.EditWordlist
|
||||
import com.artemchep.keyguard.common.usecase.GetWordlistPrimitive
|
||||
import com.artemchep.keyguard.common.usecase.GetWordlists
|
||||
import com.artemchep.keyguard.common.usecase.RemoveWordlistById
|
||||
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
|
||||
import com.artemchep.keyguard.feature.home.vault.search.IndexedText
|
||||
import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf
|
||||
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
|
||||
import com.artemchep.keyguard.feature.search.search.IndexedModel
|
||||
import com.artemchep.keyguard.feature.search.search.mapSearch
|
||||
import com.artemchep.keyguard.feature.search.search.searchFilter
|
||||
import com.artemchep.keyguard.feature.search.search.searchQueryHandle
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.FlatItemAction
|
||||
import com.artemchep.keyguard.ui.buildContextItems
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.kodein.di.compose.localDI
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.di.instance
|
||||
|
||||
private class WordlistViewUiException(
|
||||
msg: String,
|
||||
cause: Throwable,
|
||||
) : RuntimeException(msg, cause)
|
||||
|
||||
@Composable
|
||||
fun produceWordlistViewState(
|
||||
args: WordlistViewRoute.Args,
|
||||
) = with(localDI().direct) {
|
||||
produceWordlistViewState(
|
||||
args = args,
|
||||
editWordlist = instance(),
|
||||
removeWordlistById = instance(),
|
||||
getWordlists = instance(),
|
||||
getWordlistPrimitive = instance(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun produceWordlistViewState(
|
||||
args: WordlistViewRoute.Args,
|
||||
editWordlist: EditWordlist,
|
||||
removeWordlistById: RemoveWordlistById,
|
||||
getWordlists: GetWordlists,
|
||||
getWordlistPrimitive: GetWordlistPrimitive,
|
||||
): Loadable<WordlistViewState> = produceScreenState(
|
||||
key = "wordlist_view",
|
||||
initial = Loadable.Loading,
|
||||
args = arrayOf(
|
||||
args,
|
||||
),
|
||||
) {
|
||||
val queryHandle = searchQueryHandle("query")
|
||||
val queryFlow = searchFilter(queryHandle) { model, revision ->
|
||||
WordlistViewState.Filter(
|
||||
revision = revision,
|
||||
query = model,
|
||||
)
|
||||
}
|
||||
|
||||
val wordlistFlow = getWordlists()
|
||||
.map { wordlists ->
|
||||
val wordlist = wordlists
|
||||
.firstOrNull { it.idRaw == args.wordlistId }
|
||||
if (wordlist != null) {
|
||||
val actions = buildContextItems {
|
||||
section {
|
||||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Edit,
|
||||
title = translate(Res.strings.edit),
|
||||
onClick = WordlistUtil::onEdit
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(editWordlist)
|
||||
.partially1(wordlist),
|
||||
)
|
||||
}
|
||||
section {
|
||||
val wordlistAsItems = listOf(wordlist)
|
||||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Delete,
|
||||
title = translate(Res.strings.delete),
|
||||
onClick = WordlistUtil::onDeleteByItems
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(removeWordlistById)
|
||||
.partially1(wordlistAsItems),
|
||||
)
|
||||
}
|
||||
}
|
||||
WordlistViewState.Wordlist(
|
||||
wordlist = wordlist,
|
||||
actions = actions,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.stateIn(screenScope)
|
||||
// Auto-close the screen if a model
|
||||
// disappears
|
||||
wordlistFlow
|
||||
// We drop the first event, because we don't want to never let
|
||||
// the user open the screen if the model doesn't exist, we want to
|
||||
// close it if the model existed before and a user has seen it.
|
||||
.drop(1)
|
||||
.filter { it == null }
|
||||
// Pop the screen.
|
||||
.onEach { navigatePopSelf() }
|
||||
.launchIn(screenScope)
|
||||
|
||||
fun onClick(model: String) {
|
||||
}
|
||||
|
||||
fun List<String>.toItems(): List<WordlistViewState.Item> {
|
||||
val nameCollisions = mutableMapOf<String, Int>()
|
||||
return this
|
||||
.map { word ->
|
||||
val key = kotlin.run {
|
||||
val newPackageNameCollisionCounter = nameCollisions
|
||||
.getOrDefault(word, 0) + 1
|
||||
nameCollisions[word] =
|
||||
newPackageNameCollisionCounter
|
||||
word + ":" + newPackageNameCollisionCounter
|
||||
}
|
||||
WordlistViewState.Item(
|
||||
key = key,
|
||||
name = AnnotatedString(word),
|
||||
onClick = ::onClick
|
||||
.partially1(word),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val itemsFlow = getWordlistPrimitive(args.wordlistId)
|
||||
.map { words ->
|
||||
words
|
||||
.toItems()
|
||||
// Index for the search.
|
||||
.map { item ->
|
||||
IndexedModel(
|
||||
model = item,
|
||||
indexedText = IndexedText.invoke(item.name.text),
|
||||
)
|
||||
}
|
||||
}
|
||||
.mapSearch(
|
||||
handle = queryHandle,
|
||||
) { item, result ->
|
||||
// Replace the origin text with the one with
|
||||
// search decor applied to it.
|
||||
item.copy(name = result.highlightedText)
|
||||
}
|
||||
val contentFlow = itemsFlow
|
||||
.crashlyticsAttempt { e ->
|
||||
val msg = "Failed to get the wordlist primitive list!"
|
||||
WordlistViewUiException(
|
||||
msg = msg,
|
||||
cause = e,
|
||||
)
|
||||
}
|
||||
.map { result ->
|
||||
val contentOrException = result
|
||||
.map { (items, revision) ->
|
||||
WordlistViewState.Content(
|
||||
revision = revision,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
Loadable.Ok(contentOrException)
|
||||
}
|
||||
contentFlow
|
||||
.map { content ->
|
||||
val state = WordlistViewState(
|
||||
wordlist = wordlistFlow,
|
||||
filter = queryFlow,
|
||||
content = content,
|
||||
)
|
||||
Loadable.Ok(state)
|
||||
}
|
||||
}
|
@ -108,6 +108,7 @@ fun SearchTextField(
|
||||
modifier = Modifier
|
||||
.size(16.dp),
|
||||
)
|
||||
|
||||
}
|
||||
val textStyle = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
|
@ -0,0 +1,15 @@
|
||||
package com.artemchep.keyguard.feature.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun navigationNextEntryOrNull(): NavigationEntry? {
|
||||
val screenId = LocalNavigationEntry.current.id
|
||||
val screenStack = LocalNavigationRouter.current.value
|
||||
|
||||
val backStackIndex = screenStack
|
||||
.indexOfFirst { it.id == screenId }
|
||||
// take the next screen
|
||||
.inc()
|
||||
return screenStack.getOrNull(backStackIndex)
|
||||
}
|
@ -4,9 +4,12 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -14,13 +17,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
|
||||
import com.artemchep.keyguard.ui.skeleton.SkeletonText
|
||||
import com.artemchep.keyguard.ui.theme.Dimens
|
||||
import com.artemchep.keyguard.ui.theme.combineAlpha
|
||||
|
||||
private val toolbarMinHeight = 64.dp
|
||||
|
||||
@Composable
|
||||
fun CustomToolbarContent(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
title: String?,
|
||||
icon: @Composable () -> Unit = {},
|
||||
actions: @Composable () -> Unit = {},
|
||||
) {
|
||||
@ -29,27 +36,41 @@ fun CustomToolbarContent(
|
||||
.heightIn(min = toolbarMinHeight),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.heightIn(min = toolbarMinHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
.widthIn(min = Dimens.horizontalPadding),
|
||||
) {
|
||||
icon()
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = toolbarMinHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
val titleStyle = MaterialTheme.typography.titleLarge
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = titleStyle,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
} else {
|
||||
SkeletonText(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.4f),
|
||||
style = titleStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
|
@ -491,6 +491,7 @@
|
||||
<string name="wordlist_edit_wordlist_title">Edit a wordlist</string>
|
||||
<string name="wordlist_add_wordlist_title">Add a wordlist</string>
|
||||
<string name="wordlist_empty_label">No wordlists</string>
|
||||
<string name="wordlist_word_search_placeholder">Search words</string>
|
||||
|
||||
<string name="urloverride_header_title">URL override</string>
|
||||
<string name="urloverride_list_header_title">URL overrides</string>
|
||||
|
@ -13,7 +13,7 @@ insert {
|
||||
get:
|
||||
SELECT *
|
||||
FROM generatorWordlistWord
|
||||
ORDER BY word DESC;
|
||||
ORDER BY word ASC;
|
||||
|
||||
getPrimitiveByWordlistId:
|
||||
SELECT
|
||||
@ -22,7 +22,7 @@ FROM
|
||||
generatorWordlistWord
|
||||
WHERE
|
||||
wordlistId = :wordlistId
|
||||
ORDER BY word DESC;
|
||||
ORDER BY word ASC;
|
||||
|
||||
deleteAll:
|
||||
DELETE FROM generatorWordlistWord;
|
||||
|
Loading…
x
Reference in New Issue
Block a user