feat: Wordlist view screen

This commit is contained in:
Artem Chepurnoy 2024-01-27 13:55:00 +02:00
parent dce9d5d3c6
commit d23b8e1c76
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
18 changed files with 906 additions and 192 deletions

View File

@ -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")
}

View File

@ -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,
)

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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,
)
}

View File

@ -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,
)
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,
)
}

View File

@ -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,
)
}

View File

@ -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)
}
}

View File

@ -108,6 +108,7 @@ fun SearchTextField(
modifier = Modifier
.size(16.dp),
)
}
val textStyle = TextStyle(
fontSize = 20.sp,

View File

@ -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)
}

View File

@ -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(

View File

@ -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>

View File

@ -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;