Proper two-pane layout for Duplicates screen

This commit is contained in:
Artem Chepurnoy 2024-01-27 22:31:10 +02:00
parent 96efcfad65
commit 42dab0318b
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
9 changed files with 319 additions and 202 deletions

View File

@ -5,8 +5,12 @@ import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.feature.navigation.Route
data class DuplicatesRoute(
val args: Args = Args(),
val args: Args,
) : Route {
companion object {
const val ROUTER_NAME = "duplicates"
}
data class Args(
val filter: DFilter? = null,
)

View File

@ -1,193 +1,24 @@
package com.artemchep.keyguard.feature.duplicates
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import com.artemchep.keyguard.common.model.fold
import com.artemchep.keyguard.common.model.getOrNull
import com.artemchep.keyguard.feature.EmptySearchView
import com.artemchep.keyguard.feature.home.vault.component.VaultListItem
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.DefaultSelection
import com.artemchep.keyguard.ui.DropdownMenuItemFlat
import com.artemchep.keyguard.ui.DropdownMinWidth
import com.artemchep.keyguard.ui.DropdownScopeImpl
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.ScaffoldLazyColumn
import com.artemchep.keyguard.ui.icons.DropdownIcon
import com.artemchep.keyguard.ui.skeleton.SkeletonItem
import com.artemchep.keyguard.ui.theme.Dimens
import com.artemchep.keyguard.ui.theme.combineAlpha
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
import dev.icerock.moko.resources.compose.stringResource
import com.artemchep.keyguard.feature.duplicates.list.DuplicatesListRoute
import com.artemchep.keyguard.feature.navigation.NavigationRouter
import com.artemchep.keyguard.feature.twopane.TwoPaneNavigationContent
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class,
ExperimentalFoundationApi::class,
)
@Composable
fun DuplicatesScreen(
args: DuplicatesRoute.Args,
) {
val loadableState = produceDuplicatesState(args)
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
ScaffoldLazyColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
LargeToolbar(
title = {
Column() {
Text(
text = stringResource(Res.strings.watchtower_header_title),
style = MaterialTheme.typography.labelSmall,
color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
Text(
text = stringResource(Res.strings.watchtower_item_duplicate_items_title),
style = MaterialTheme.typography.titleMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
navigationIcon = {
NavigationIcon()
},
actions = {
var isAutofillWindowShowing by remember {
mutableStateOf(false)
}
val normalContentColor = LocalContentColor.current
TextButton(
onClick = {
isAutofillWindowShowing = true
},
) {
Column {
Text(text = "Sensitivity")
val textOrNull =
loadableState.getOrNull()?.sensitivity?.name
?.lowercase()
?.capitalize()
ExpandedIfNotEmpty(
valueOrNull = textOrNull,
) { text ->
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = normalContentColor
.combineAlpha(MediumEmphasisAlpha),
)
}
}
Spacer(
modifier = Modifier
.width(Dimens.buttonIconPadding),
)
DropdownIcon()
// Inject the dropdown popup to the bottom of the
// content.
val onDismissRequest = {
isAutofillWindowShowing = false
}
DropdownMenu(
modifier = Modifier
.widthIn(min = DropdownMinWidth),
expanded = isAutofillWindowShowing,
onDismissRequest = onDismissRequest,
) {
val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest)
loadableState.getOrNull()?.sensitivities.orEmpty()
.forEachIndexed { index, action ->
scope.DropdownMenuItemFlat(
action = action,
)
}
}
}
},
scrollBehavior = scrollBehavior,
)
},
bottomBar = {
val screenState = loadableState.getOrNull()
?: return@ScaffoldLazyColumn
val selectionState = screenState.selectionStateFlow.collectAsState()
DefaultSelection(
state = selectionState.value,
)
},
) {
loadableState.fold(
ifLoading = {
for (i in 0..2) {
item(i) {
SkeletonItem()
}
}
},
ifOk = { state ->
val items = state.items
if (items.isEmpty()) {
item("header.empty") {
NoItemsPlaceholder()
}
}
items(
items = items,
key = { model -> model.id },
) { model ->
VaultListItem(
modifier = Modifier
.animateItemPlacement(),
item = model,
)
}
},
val initialRoute = remember(args) {
DuplicatesListRoute(
args = args,
)
}
}
@Composable
private fun NoItemsPlaceholder(
modifier: Modifier = Modifier,
) {
EmptySearchView(
modifier = modifier,
text = {
Text(
text = stringResource(Res.strings.duplicates_empty_label),
)
},
)
NavigationRouter(
id = DuplicatesRoute.ROUTER_NAME,
initial = initialRoute,
) { backStack ->
TwoPaneNavigationContent(backStack)
}
}

View File

@ -0,0 +1,14 @@
package com.artemchep.keyguard.feature.duplicates.list
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.feature.duplicates.DuplicatesRoute
import com.artemchep.keyguard.feature.navigation.Route
data class DuplicatesListRoute(
val args: DuplicatesRoute.Args,
) : Route {
@Composable
override fun Content() {
DuplicatesListScreen(args)
}
}

View File

@ -0,0 +1,234 @@
package com.artemchep.keyguard.feature.duplicates.list
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import com.artemchep.keyguard.common.model.fold
import com.artemchep.keyguard.common.model.getOrNull
import com.artemchep.keyguard.feature.EmptySearchView
import com.artemchep.keyguard.feature.duplicates.DuplicatesRoute
import com.artemchep.keyguard.feature.home.vault.component.VaultListItem
import com.artemchep.keyguard.feature.home.vault.screen.VaultViewRoute
import com.artemchep.keyguard.feature.navigation.LocalNavigationEntry
import com.artemchep.keyguard.feature.navigation.LocalNavigationRouter
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.Compose
import com.artemchep.keyguard.ui.DefaultSelection
import com.artemchep.keyguard.ui.DropdownMenuItemFlat
import com.artemchep.keyguard.ui.DropdownMinWidth
import com.artemchep.keyguard.ui.DropdownScopeImpl
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.ScaffoldLazyColumn
import com.artemchep.keyguard.ui.icons.DropdownIcon
import com.artemchep.keyguard.ui.skeleton.SkeletonItem
import com.artemchep.keyguard.ui.theme.Dimens
import com.artemchep.keyguard.ui.theme.combineAlpha
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
import dev.icerock.moko.resources.compose.stringResource
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class,
ExperimentalFoundationApi::class,
)
@Composable
fun DuplicatesListScreen(
args: DuplicatesRoute.Args,
) {
val loadableState = produceDuplicatesListState(args)
val onSelected = loadableState.getOrNull()?.onSelected
Compose {
val screenId = LocalNavigationEntry.current.id
val screenStack = LocalNavigationRouter.current.value
val childBackStackFlow = remember(
screenId,
screenStack,
) {
snapshotFlow {
val backStack = screenStack
.indexOfFirst { it.id == screenId }
// take the next screen
.inc()
// check if in range
.takeIf { it in 1 until screenStack.size }
?.let { index ->
screenStack.subList(
fromIndex = index,
toIndex = screenStack.size,
)
}
.orEmpty()
backStack
}
}
LaunchedEffect(onSelected, childBackStackFlow) {
childBackStackFlow.collect { backStack ->
val firstRoute = backStack.firstOrNull()?.route as? VaultViewRoute?
onSelected?.invoke(firstRoute?.itemId)
}
}
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
ScaffoldLazyColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
LargeToolbar(
title = {
Column() {
Text(
text = stringResource(Res.strings.watchtower_header_title),
style = MaterialTheme.typography.labelSmall,
color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
Text(
text = stringResource(Res.strings.watchtower_item_duplicate_items_title),
style = MaterialTheme.typography.titleMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
navigationIcon = {
NavigationIcon()
},
actions = {
var isAutofillWindowShowing by remember {
mutableStateOf(false)
}
val normalContentColor = LocalContentColor.current
TextButton(
onClick = {
isAutofillWindowShowing = true
},
) {
Column {
Text(text = "Sensitivity")
val textOrNull =
loadableState.getOrNull()?.sensitivity?.name
?.lowercase()
?.capitalize()
ExpandedIfNotEmpty(
valueOrNull = textOrNull,
) { text ->
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = normalContentColor
.combineAlpha(MediumEmphasisAlpha),
)
}
}
Spacer(
modifier = Modifier
.width(Dimens.buttonIconPadding),
)
DropdownIcon()
// Inject the dropdown popup to the bottom of the
// content.
val onDismissRequest = {
isAutofillWindowShowing = false
}
DropdownMenu(
modifier = Modifier
.widthIn(min = DropdownMinWidth),
expanded = isAutofillWindowShowing,
onDismissRequest = onDismissRequest,
) {
val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest)
loadableState.getOrNull()?.sensitivities.orEmpty()
.forEachIndexed { index, action ->
scope.DropdownMenuItemFlat(
action = action,
)
}
}
}
},
scrollBehavior = scrollBehavior,
)
},
bottomBar = {
val screenState = loadableState.getOrNull()
?: return@ScaffoldLazyColumn
val selectionState = screenState.selectionStateFlow.collectAsState()
DefaultSelection(
state = selectionState.value,
)
},
) {
loadableState.fold(
ifLoading = {
for (i in 0..2) {
item(i) {
SkeletonItem()
}
}
},
ifOk = { state ->
val items = state.items
if (items.isEmpty()) {
item("header.empty") {
NoItemsPlaceholder()
}
}
items(
items = items,
key = { model -> model.id },
) { model ->
VaultListItem(
modifier = Modifier
.animateItemPlacement(),
item = model,
)
}
},
)
}
}
@Composable
private fun NoItemsPlaceholder(
modifier: Modifier = Modifier,
) {
EmptySearchView(
modifier = modifier,
text = {
Text(
text = stringResource(Res.strings.duplicates_empty_label),
)
},
)
}

View File

@ -1,4 +1,4 @@
package com.artemchep.keyguard.feature.duplicates
package com.artemchep.keyguard.feature.duplicates.list
import com.artemchep.keyguard.common.usecase.CipherDuplicatesCheck
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2
@ -6,7 +6,8 @@ import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.Selection
import kotlinx.coroutines.flow.StateFlow
data class DuplicatesState(
data class DuplicatesListState(
val onSelected: (String?) -> Unit,
val items: List<VaultItem2>,
val sensitivity: CipherDuplicatesCheck.Sensitivity,
val sensitivities: List<FlatItemAction>,

View File

@ -1,6 +1,7 @@
package com.artemchep.keyguard.feature.duplicates
package com.artemchep.keyguard.feature.duplicates.list
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Merge
import androidx.compose.runtime.Composable
import arrow.core.partially1
@ -29,8 +30,12 @@ import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.attachments.SelectableItemState
import com.artemchep.keyguard.feature.attachments.SelectableItemStateRaw
import com.artemchep.keyguard.feature.confirmation.elevatedaccess.createElevatedAccessDialogIntent
import com.artemchep.keyguard.feature.duplicates.DuplicatesRoute
import com.artemchep.keyguard.feature.generator.history.mapLatestScoped
import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
import com.artemchep.keyguard.feature.home.vault.component.VaultListItem
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.screen.VaultViewRoute
import com.artemchep.keyguard.feature.home.vault.screen.toVaultListItem
import com.artemchep.keyguard.feature.home.vault.screen.verify
@ -84,10 +89,10 @@ private data class SelectionData(
)
@Composable
fun produceDuplicatesState(
fun produceDuplicatesListState(
args: DuplicatesRoute.Args,
) = with(localDI().direct) {
produceDuplicatesState(
produceDuplicatesListState(
directDI = this,
args = args,
clipboardService = instance(),
@ -105,7 +110,7 @@ fun produceDuplicatesState(
}
@Composable
fun produceDuplicatesState(
fun produceDuplicatesListState(
directDI: DirectDI,
args: DuplicatesRoute.Args,
clipboardService: ClipboardService,
@ -119,8 +124,8 @@ fun produceDuplicatesState(
getCanWrite: GetCanWrite,
cipherToolbox: CipherToolbox,
cipherDuplicatesCheck: CipherDuplicatesCheck,
): Loadable<DuplicatesState> = produceScreenState(
key = "duplicates",
): Loadable<DuplicatesListState> = produceScreenState(
key = "duplicates_list",
initial = Loadable.Loading,
args = arrayOf(
getOrganizations,
@ -132,6 +137,7 @@ fun produceDuplicatesState(
val sensitivitySink = mutablePersistedFlow("sensitivity") {
CipherDuplicatesCheck.Sensitivity.NORMAL
}
val itemSink = mutablePersistedFlow("item") { "" }
val selectionHandle = selectionHandle("selection")
val selectionGroupSink = mutablePersistedFlow("selection_group_id") {
""
@ -149,10 +155,14 @@ fun produceDuplicatesState(
fun onClickCipher(
cipher: DSecret,
) {
val intent = NavigationIntent.NavigateToRoute(
VaultViewRoute(
itemId = cipher.id,
accountId = cipher.accountId,
val route = VaultViewRoute(
itemId = cipher.id,
accountId = cipher.accountId,
)
val intent = NavigationIntent.Composite(
listOf(
NavigationIntent.PopById(DuplicatesRoute.ROUTER_NAME),
NavigationIntent.NavigateToRoute(route),
),
)
navigate(intent)
@ -257,7 +267,7 @@ fun produceDuplicatesState(
onLongClick = onLongClick,
)
}
val openedStateFlow = flowOf("")
val openedStateFlow = itemSink
.map {
val isOpened = it == cipher.id
VaultItem2.Item.OpenedState(isOpened)
@ -304,7 +314,7 @@ fun produceDuplicatesState(
allItems += VaultItem2.Button(
id = "merge." + group.id,
title = translate(Res.strings.ciphers_action_merge_title),
leading = icon(Icons.Outlined.Merge),
leading = icon(Icons.Outlined.Merge, Icons.Outlined.Add),
onClick = {
val ciphers = groupedItems
.map { it.source }
@ -329,7 +339,10 @@ fun produceDuplicatesState(
itemsFlow
.combine(sensitivitySink) { items, sensitivity ->
val state = DuplicatesState(
val state = DuplicatesListState(
onSelected = { key ->
itemSink.value = key.orEmpty()
},
items = items,
sensitivity = sensitivity,
sensitivities = CipherDuplicatesCheck.Sensitivity

View File

@ -76,6 +76,7 @@ import com.artemchep.keyguard.ui.DisabledEmphasisAlpha
import com.artemchep.keyguard.ui.DropdownMenuItemFlat
import com.artemchep.keyguard.ui.DropdownMinWidth
import com.artemchep.keyguard.ui.DropdownScopeImpl
import com.artemchep.keyguard.ui.FlatItem
import com.artemchep.keyguard.ui.FlatItemTextContent
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.icons.ChevronIcon
@ -149,12 +150,31 @@ fun VaultListItemButton(
modifier: Modifier = Modifier,
item: VaultItem2.Button,
) {
VaultViewButtonItem(
val contentColor = MaterialTheme.colorScheme.primary
FlatItem(
modifier = modifier,
leading = {
item.leading?.invoke()
CompositionLocalProvider(
LocalContentColor provides contentColor,
) {
val leading = item.leading
if (leading != null) {
Row(
modifier = Modifier
.widthIn(min = 36.dp),
horizontalArrangement = Arrangement.Center,
) {
leading.invoke()
}
}
}
},
title = {
Text(
text = item.title,
color = contentColor,
)
},
text = item.title,
onClick = item.onClick,
)
}

View File

@ -74,7 +74,7 @@ import com.artemchep.keyguard.feature.decorator.ItemDecorator
import com.artemchep.keyguard.feature.decorator.ItemDecoratorDate
import com.artemchep.keyguard.feature.decorator.ItemDecoratorNone
import com.artemchep.keyguard.feature.decorator.ItemDecoratorTitle
import com.artemchep.keyguard.feature.duplicates.createCipherSelectionFlow
import com.artemchep.keyguard.feature.duplicates.list.createCipherSelectionFlow
import com.artemchep.keyguard.feature.generator.history.mapLatestScoped
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.add.AddRoute
@ -134,7 +134,6 @@ import org.kodein.di.DirectDI
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@LeParcelize

View File

@ -19,6 +19,7 @@ import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.crashlytics.crashlyticsMap
import com.artemchep.keyguard.feature.duplicates.DuplicatesRoute
import com.artemchep.keyguard.feature.duplicates.list.DuplicatesListRoute
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.folders.FoldersRoute
import com.artemchep.keyguard.feature.home.vault.screen.FilterParams