Updated on 2026-08-14

This commit is contained in:
Tangem 2025-01-27 15:02:37 +03:00
parent 8cb8795f92
commit dbdffaa974
16 changed files with 379 additions and 111 deletions

View file

@ -153,7 +153,7 @@ internal object WalletScreenPreviewData {
singleWalletLockedState,
multiWalletState,
),
onWalletChange = {},
onWalletChange = { _, _ -> },
event = consumedEvent(),
isHidingMode = false,
showMarketsOnboarding = false,

View file

@ -91,7 +91,7 @@ internal class WalletStateController @Inject constructor() {
topBarConfig = WalletTopBarConfig(onDetailsClick = {}),
selectedWalletIndex = NOT_INITIALIZED_WALLET_INDEX,
wallets = persistentListOf(),
onWalletChange = {},
onWalletChange = { _, _ -> },
event = consumedEvent(),
isHidingMode = false,
showMarketsOnboarding = false,

View file

@ -6,7 +6,8 @@ import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class WalletEvent {
data class ChangeWallet(val index: Int) : WalletEvent()
/** Change wallet with animation. Using [prevIndex] and [newIndex] to calculate offset */
data class ChangeWallet(val prevIndex: Int, val newIndex: Int) : WalletEvent()
data class ShowError(val text: TextReference) : WalletEvent()

View file

@ -10,7 +10,7 @@ internal data class WalletScreenState(
val topBarConfig: WalletTopBarConfig,
val selectedWalletIndex: Int,
val wallets: ImmutableList<WalletState>,
val onWalletChange: (Int) -> Unit,
val onWalletChange: (index: Int, onlyState: Boolean) -> Unit,
val event: StateEvent<WalletEvent>,
val isHidingMode: Boolean,
val showMarketsOnboarding: Boolean,

View file

@ -13,7 +13,6 @@ internal class AddWalletTransformer(
private val clickIntents: WalletClickIntents,
private val walletImageResolver: WalletImageResolver,
private val walletFeatureToggles: WalletFeatureToggles,
private val selectedWalletIndex: Int,
) : WalletScreenStateTransformer {
private val walletLoadingStateFactory by lazy {
@ -27,7 +26,6 @@ internal class AddWalletTransformer(
override fun transform(prevState: WalletScreenState): WalletScreenState {
return prevState.copy(
wallets = (prevState.wallets + walletLoadingStateFactory.create(userWallet)).toImmutableList(),
selectedWalletIndex = selectedWalletIndex,
)
}
}

View file

@ -7,7 +7,8 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenSta
import com.tangem.utils.Provider
internal class ScrollToWalletTransformer(
private val index: Int,
private val prevIndex: Int,
private val newIndex: Int,
private val currentStateProvider: Provider<WalletScreenState>,
private val stateUpdater: (WalletScreenState) -> Unit,
private val onConsume: () -> Unit = {},
@ -16,13 +17,10 @@ internal class ScrollToWalletTransformer(
override fun transform(prevState: WalletScreenState): WalletScreenState {
return prevState.copy(
event = triggeredEvent(
data = WalletEvent.ChangeWallet(index),
data = WalletEvent.ChangeWallet(prevIndex = prevIndex, newIndex = newIndex),
onConsume = {
stateUpdater(
currentStateProvider().copy(
selectedWalletIndex = index,
event = consumedEvent(),
),
currentStateProvider().copy(event = consumedEvent()),
)
onConsume()

View file

@ -16,15 +16,12 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.ui.utils.ReviewManagerRequester
import com.tangem.feature.wallet.presentation.wallet.ui.utils.animateScrollByIndex
import com.tangem.feature.wallet.presentation.wallet.ui.utils.demonstrateScrolling
import kotlinx.coroutines.delay
@Suppress("LongParameterList")
@Composable
internal fun WalletEventEffect(
walletsListState: LazyListState,
snackbarHostState: SnackbarHostState,
event: StateEvent<WalletEvent>,
selectedWalletIndex: Int,
onAutoScrollSet: () -> Unit,
onAlertConfigSet: (WalletAlertState) -> Unit,
) {
@ -36,9 +33,8 @@ internal fun WalletEventEffect(
onTrigger = { value ->
when (value) {
is WalletEvent.ChangeWallet -> {
delay(timeMillis = 1000) // to let compose time to start drawing otherwise animation doesnt work
onAutoScrollSet()
walletsListState.animateScrollByIndex(prevIndex = selectedWalletIndex, newIndex = value.index)
walletsListState.animateScrollByIndex(prevIndex = value.prevIndex, newIndex = value.newIndex)
}
is WalletEvent.ShowError -> {
snackbarHostState.showSnackbar(message = value.text.resolveReference(resources))

View file

@ -51,6 +51,7 @@ import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBot
import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.rememberIsKeyboardVisible
import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar
@ -58,7 +59,6 @@ import com.tangem.core.ui.components.snackbar.TangemSnackbar
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
@ -119,12 +119,11 @@ internal fun WalletScreen(state: WalletScreenState, marketsEntryComponent: Marke
)
WalletEventEffect(
event = state.event,
selectedWalletIndex = state.selectedWalletIndex,
walletsListState = walletsListState,
snackbarHostState = snackbarHostState,
onAlertConfigSet = { alertConfig = it },
event = state.event,
onAutoScrollSet = { isAutoScroll.value = true },
onAlertConfigSet = { alertConfig = it },
)
}
@ -139,7 +138,11 @@ private fun WalletContent(
alertConfig: WalletAlertState?,
onAutoScrollReset: () -> Unit,
) {
var selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) }
/*
* Don't pass key to remember, because it will brake scroll animation.
* selectedWalletIndex will be changed in WalletsListEffects.
*/
val selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) }
val selectedWallet = state.wallets.getOrElse(selectedWalletIndex) { state.wallets[state.selectedWalletIndex] }
val listState = rememberLazyListState()
@ -251,10 +254,15 @@ private fun WalletContent(
WalletsListEffects(
lazyListState = walletsListState,
selectedWalletIndex = selectedWalletIndex,
onWalletChange = state.onWalletChange,
onSelectedWalletIndexSet = { selectedWalletIndex = it },
isAutoScroll = isAutoScroll,
onAutoScrollReset = onAutoScrollReset,
onUserScroll = onAutoScrollReset,
onIndexChange = { index ->
// Auto scroll must not change wallet
if (isAutoScroll.value) {
state.onWalletChange(index, true)
} else {
state.onWalletChange(index, false)
}
},
)
}

View file

@ -3,48 +3,44 @@ package com.tangem.feature.wallet.presentation.wallet.ui
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.snapshotFlow
import com.tangem.feature.wallet.presentation.wallet.ui.utils.CenterOfItemScrollingDetector
import com.tangem.feature.wallet.presentation.wallet.ui.utils.LazyListItemData
import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollector
import com.tangem.feature.wallet.presentation.wallet.ui.utils.WalletsListInteractionsCollector
@Suppress("LongParameterList")
/**
* Wallets list effects
*
* @param lazyListState wallets list [LazyListState]
* @param selectedWalletIndex selected wallet index
* @param onUserScroll lambda be invoked when user scrolls the list
* @param onIndexChange lambda be invoked when index is changed
*/
@Composable
internal fun WalletsListEffects(
lazyListState: LazyListState,
selectedWalletIndex: Int,
onWalletChange: (Int) -> Unit,
onSelectedWalletIndexSet: (Int) -> Unit,
isAutoScroll: State<Boolean>,
onAutoScrollReset: () -> Unit,
onUserScroll: () -> Unit,
onIndexChange: (Int) -> Unit,
) {
LaunchedEffect(key1 = lazyListState, key2 = onWalletChange) {
LaunchedEffect(key1 = lazyListState) {
snapshotFlow {
lazyListState.layoutInfo.visibleItemsInfo.map {
LazyListItemData(it.index, it.size, it.offset)
}
}
.collect(
collector = ScrollOffsetCollector(
selectedWalletIndex = selectedWalletIndex,
collector = CenterOfItemScrollingDetector(
initialIndex = selectedWalletIndex,
lazyListState = lazyListState,
onWalletChange = { newIndex ->
// Auto scroll must not change wallet
if (isAutoScroll.value) {
onSelectedWalletIndexSet(newIndex)
} else {
onSelectedWalletIndexSet(newIndex)
onWalletChange(newIndex)
}
},
onIndexChange = { newIndex -> onIndexChange(newIndex) },
),
)
}
LaunchedEffect(Unit) {
lazyListState.interactionSource.interactions.collect(
collector = WalletsListInteractionsCollector(onDragStart = onAutoScrollReset),
collector = WalletsListInteractionsCollector(onDragStart = onUserScroll),
)
}
}

View file

@ -58,7 +58,8 @@ internal fun WalletsList(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
flingBehavior = rememberWalletsFlingBehaviour(lazyListState = lazyListState, itemWidth = itemWidth),
) {
items(items = wallets, contentType = { it.id.stringValue }) { state ->
// Using key is required to guarantee the correct wallet selection
items(items = wallets, key = { it.id.stringValue }, contentType = { "wallet_card" }) { state ->
WalletCard(
state = state,
isBalanceHidden = isBalanceHidden,

View file

@ -5,26 +5,26 @@ import kotlinx.coroutines.flow.FlowCollector
import kotlin.math.abs
/**
* Flow collector for scroll items tracking.
* Scrolling detector that changes the current element when scrolling the center of the element.
* If first visible item offset is greater than half item size, then change selected wallet index.
* If last visible item offset is greater than half item size, then change selected wallet index.
*
* @param selectedWalletIndex selected wallet index
* @property lazyListState lazy list state
* @property onWalletChange callback that will be invoked on wallet change
* @param initialIndex initial index
* @property lazyListState lazy list state
* @property onIndexChange lambda be invoked when index is changed
*
[REDACTED_AUTHOR]
*/
internal class ScrollOffsetCollector(
selectedWalletIndex: Int,
internal class CenterOfItemScrollingDetector(
initialIndex: Int,
private val lazyListState: LazyListState,
private val onWalletChange: (Int) -> Unit,
private val onIndexChange: (Int) -> Unit,
) : FlowCollector<List<LazyListItemData>> {
private val LazyListItemData.halfItemSize
get() = size.div(other = 2)
private var currentIndex = selectedWalletIndex
private var currentIndex = initialIndex
override suspend fun emit(value: List<LazyListItemData>) {
if (!lazyListState.isScrollInProgress || value.size <= 1) return
@ -33,22 +33,18 @@ internal class ScrollOffsetCollector(
val lastItem = value.lastOrNull() ?: return
if (abs(firstItem.offset) > firstItem.halfItemSize) {
selectIndex(newIndex = firstItem.index + 1)
changeIndex(newIndex = firstItem.index + 1)
} else if (abs(lastItem.offset) > lastItem.halfItemSize) {
selectIndex(newIndex = lastItem.index - 1)
changeIndex(newIndex = lastItem.index - 1)
}
}
private fun selectIndex(newIndex: Int) {
private fun changeIndex(newIndex: Int) {
if (currentIndex != newIndex) {
currentIndex = newIndex
onWalletChange(newIndex)
onIndexChange(newIndex)
}
}
}
internal data class LazyListItemData(
val index: Int,
val size: Int,
val offset: Int,
)
internal data class LazyListItemData(val index: Int, val size: Int, val offset: Int)

View file

@ -35,7 +35,6 @@ import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.indexOfFirstOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@ -204,8 +203,6 @@ internal class WalletViewModel @Inject constructor(
selectedWalletAnalyticsSender.send(selectedWallet)
}
changeSelectedWalletState(selectedWalletId = selectedWallet.walletId)
walletDeepLinksHandler.registerForWallet(viewModel = this, userWallet = selectedWallet)
}
.flowOn(dispatchers.main)
@ -213,22 +210,6 @@ internal class WalletViewModel @Inject constructor(
}
}
/** Change selected wallet state if selected wallet [selectedWalletId] was changed in the background */
private suspend fun changeSelectedWalletState(selectedWalletId: UserWalletId) {
if (!stateHolder.isInitialized) return
if (screenLifecycleProvider.isBackgroundState.value && selectedWalletId != stateHolder.getSelectedWalletId()) {
stateHolder.value.wallets
.indexOfFirstOrNull { prevState -> prevState.walletCardState.id == selectedWalletId }
?.let { selectedIndex ->
Timber.e("Selected wallet changed from background state: $selectedWalletId")
delay(timeMillis = 1000)
scrollToWallet(selectedIndex)
}
}
}
// We need to update the current wallet quotes if the application was in the background for more than 10 seconds
// and then returned to the foreground
private fun subscribeToScreenBackgroundState() {
@ -383,11 +364,14 @@ internal class WalletViewModel @Inject constructor(
clickIntents = clickIntents,
walletImageResolver = walletImageResolver,
walletFeatureToggles = walletFeatureToggles,
selectedWalletIndex = action.selectedWalletIndex,
),
)
scrollToWallet(index = action.selectedWalletIndex)
scrollToWallet(prevIndex = action.prevWalletIndex, newIndex = action.selectedWalletIndex) {
stateHolder.update {
it.copy(selectedWalletIndex = action.selectedWalletIndex)
}
}
}
private suspend fun deleteWallet(action: WalletsUpdateActionResolver.Action.DeleteWallet) {
@ -400,27 +384,42 @@ internal class WalletViewModel @Inject constructor(
coroutineScope = viewModelScope,
)
val newSelectedWalletIndex = if (action.selectedWalletIndex - action.deletedWalletIndex == 1) {
action.deletedWalletIndex
} else {
action.selectedWalletIndex
}
/*
* Should not show scroll animation if WalletScreen isn't in the background.
* Example, reset card
*/
if (screenLifecycleProvider.isBackgroundState.value) {
updateStateByDeleteWalletTransformer(action)
updateStateByDeleteWalletTransformer(
selectedWalletIndex = newSelectedWalletIndex,
deletedWalletId = action.deletedWalletId,
)
} else {
withContext(dispatchers.io) { delay(timeMillis = 1000) }
scrollToWallet(
index = action.selectedWalletIndex,
onConsume = { updateStateByDeleteWalletTransformer(action) },
prevIndex = action.deletedWalletIndex,
newIndex = action.selectedWalletIndex,
onConsume = {
updateStateByDeleteWalletTransformer(
selectedWalletIndex = newSelectedWalletIndex,
deletedWalletId = action.deletedWalletId,
)
},
)
}
}
private fun updateStateByDeleteWalletTransformer(action: WalletsUpdateActionResolver.Action.DeleteWallet) {
private fun updateStateByDeleteWalletTransformer(selectedWalletIndex: Int, deletedWalletId: UserWalletId) {
stateHolder.update(
DeleteWalletTransformer(
selectedWalletIndex = action.selectedWalletIndex,
deletedWalletId = action.deletedWalletId,
selectedWalletIndex = selectedWalletIndex,
deletedWalletId = deletedWalletId,
),
)
}
@ -444,10 +443,11 @@ internal class WalletViewModel @Inject constructor(
)
}
private fun scrollToWallet(index: Int, onConsume: () -> Unit = {}) {
private fun scrollToWallet(prevIndex: Int, newIndex: Int, onConsume: () -> Unit = {}) {
stateHolder.update(
ScrollToWalletTransformer(
index = index,
prevIndex = prevIndex,
newIndex = newIndex,
currentStateProvider = Provider(action = stateHolder::value),
stateUpdater = { newState -> stateHolder.update { newState } },
onConsume = onConsume,

View file

@ -90,15 +90,19 @@ internal class WalletsUpdateActionResolver @Inject constructor(
return when {
prevWalletsSize > wallets.size -> {
val deletedWalletId = state.wallets.getDeletedWalletId(wallets)
Action.DeleteWallet(
selectedWallet = selectedWallet,
selectedWalletIndex = wallets.indexOfWallet(id = selectedWallet.walletId),
deletedWalletId = state.wallets.getDeletedWalletId(wallets),
deletedWalletId = deletedWalletId,
deletedWalletIndex = state.selectedWalletIndex,
)
}
prevWalletsSize < wallets.size -> {
val newUserWallet = state.wallets.getAddedWallet(wallets)
Action.AddWallet(
prevWalletIndex = state.selectedWalletIndex,
selectedWalletIndex = wallets.indexOfWallet(id = newUserWallet.walletId),
selectedWallet = newUserWallet,
)
@ -266,6 +270,7 @@ internal class WalletsUpdateActionResolver @Inject constructor(
val selectedWallet: UserWallet,
val selectedWalletIndex: Int,
val deletedWalletId: UserWalletId,
val deletedWalletIndex: Int,
) : Action() {
override fun toString(): String {
@ -273,19 +278,25 @@ internal class WalletsUpdateActionResolver @Inject constructor(
DeleteWallet(
selectedWallet = ${selectedWallet.walletId},
selectedWalletIndex = $selectedWalletIndex,
deletedWalletId = $deletedWalletId
deletedWalletId = $deletedWalletId,
deletedWalletIndex = $deletedWalletIndex,
)
""".trimIndent()
}
}
data class AddWallet(val selectedWalletIndex: Int, val selectedWallet: UserWallet) : Action() {
data class AddWallet(
val prevWalletIndex: Int,
val selectedWalletIndex: Int,
val selectedWallet: UserWallet,
) : Action() {
override fun toString(): String {
return """
AddWallet(
prevWalletIndex = $prevWalletIndex,
selectedWalletIndex = $selectedWalletIndex,
selectedWallet = ${selectedWallet.walletId}
selectedWallet = ${selectedWallet.walletId},
)
""".trimIndent()
}

View file

@ -67,9 +67,14 @@ internal class WalletClickIntents @Inject constructor(
pushPermissionClickIntentsImplementor.initialize(router, coroutineScope)
}
fun onWalletChange(index: Int) {
viewModelScope.launch(dispatchers.main) {
launch(dispatchers.main) { neverToShowWalletsScrollPreview() }
fun onWalletChange(index: Int, onlyState: Boolean) {
if (onlyState) {
stateHolder.update { it.copy(selectedWalletIndex = index) }
return
}
viewModelScope.launch {
launch { neverToShowWalletsScrollPreview() }
val maybeUserWallet = selectWalletUseCase(
userWalletId = stateHolder.value.wallets[index].walletCardState.id,