Updated on 2026-08-14

This commit is contained in:
Tangem 2022-12-01 14:40:31 +03:00
parent d3e3f5cfd5
commit a0f1a6591c
28 changed files with 905 additions and 44 deletions

View file

@ -4,12 +4,12 @@ import com.tangem.common.CompletionResult
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
import com.tangem.common.map
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.builders.UserWalletBuilder
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
@ -65,10 +65,9 @@ internal class SaveWalletMiddleware {
?: return
scope.launch {
val userWallet = UserWallet(
scanResponse = scanResponse,
backupCardsIds = state.additionalInfo?.backupCardsIds,
)
val userWallet = UserWalletBuilder(scanResponse)
.setBackupCardsIds(backupCardsIds = state.additionalInfo?.backupCardsIds)
.build()
userWalletsListManager.save(userWallet)
.flatMap {
@ -108,14 +107,6 @@ internal class SaveWalletMiddleware {
cardsIds = userWallet.cardsInWallet,
)
}
userWallet.hasAccessCode -> {
tangemSdkManager
.scanCard(
cardId = userWallet.cardId,
useBiometricsForAccessCode = true,
)
.map { /* no-op */ }
}
else -> {
CompletionResult.Success(Unit)
}

View file

@ -0,0 +1,22 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.tap.domain.model.TotalFiatBalance
data class UserWalletModel(
val id: String,
val name: String,
val artworkUrl: String,
val type: Type,
val fiatBalance: TotalFiatBalance,
) {
sealed interface Type {
data class SingleCurrency(
val blockchainName: String?,
) : Type
data class MultiCurrency(
val tokensCount: Int,
val cardsInWallet: Int,
) : Type
}
}

View file

@ -1,9 +1,33 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.common.core.TangemError
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.WalletStoreModel
import org.rekotlin.Action
internal sealed interface WalletSelectorAction : Action {
data class UserWalletsLoaded(
val userWallets: List<UserWallet>,
) : WalletSelectorAction
data class SelectedWalletChanged(
val selectedWallet: UserWallet,
) : WalletSelectorAction
data class IsLockedChanged(
val isLocked: Boolean,
) : WalletSelectorAction
data class WalletStoresChanged(
val walletsStores: Map<UserWalletId, List<WalletStoreModel>>,
) : WalletSelectorAction
data class BalanceLoaded(
val userWalletModel: UserWalletModel,
) : WalletSelectorAction
object UnlockWithBiometry : WalletSelectorAction {
object Success : WalletSelectorAction
data class Error(val error: TangemError) : WalletSelectorAction
@ -27,5 +51,10 @@ internal sealed interface WalletSelectorAction : Action {
data class Error(val error: TangemError) : WalletSelectorAction
}
data class ChangeAppCurrency(
val fiatCurrency: FiatCurrency,
) : WalletSelectorAction
data class HandleError(val error: TangemError) : WalletSelectorAction
object CloseError : WalletSelectorAction
}

View file

@ -0,0 +1,212 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
import com.tangem.common.map
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.*
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.WalletStoreModel
import com.tangem.tap.domain.model.builders.UserWalletBuilder
import com.tangem.tap.domain.scanCard.ScanCardProcessor
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.rekotlin.Middleware
import timber.log.Timber
internal class WalletSelectorMiddleware {
val middleware: Middleware<AppState> = { _, appStateProvider ->
{ next ->
{ action ->
val appState = appStateProvider()
if (action is WalletSelectorAction && appState != null) {
handleAction(action, appState.walletSelectorState)
}
next(action)
}
}
}
private fun handleAction(action: WalletSelectorAction, state: WalletSelectorState) {
when (action) {
is WalletSelectorAction.UserWalletsLoaded -> {
fetchWalletStores(action.userWallets)
}
is WalletSelectorAction.WalletStoresChanged -> {
updateBalances(action.walletsStores, state)
}
is WalletSelectorAction.UnlockWithBiometry -> {
unlockWalletsWithBiometry()
}
is WalletSelectorAction.AddWallet -> {
addWallet()
}
is WalletSelectorAction.SelectWallet -> {
selectWallet(action.walletId)
}
is WalletSelectorAction.RemoveWallets -> {
removeWallets(action.walletIdsToRemove, state)
}
is WalletSelectorAction.RenameWallet -> {
renameWallet(action.walletId, action.newName)
}
is WalletSelectorAction.ChangeAppCurrency,
is WalletSelectorAction.AddWallet.Success,
is WalletSelectorAction.AddWallet.Error,
is WalletSelectorAction.SelectedWalletChanged,
is WalletSelectorAction.UnlockWithBiometry.Error,
is WalletSelectorAction.UnlockWithBiometry.Success,
is WalletSelectorAction.BalanceLoaded,
is WalletSelectorAction.IsLockedChanged,
is WalletSelectorAction.HandleError,
is WalletSelectorAction.CloseError,
-> Unit
}
}
private fun fetchWalletStores(userWallets: List<UserWallet>) {
scope.launch {
walletStoresManager.fetch(userWallets)
.doOnFailure { error ->
Timber.e(error, "Unable to fetch wallet stores")
store.dispatchOnMain(WalletSelectorAction.HandleError(error))
}
}
}
private fun updateBalances(walletStores: Map<UserWalletId, List<WalletStoreModel>>, state: WalletSelectorState) {
walletStores.forEach { (walletId, walletStores) ->
scope.launch {
val updatedWallet = state.wallets
.find { it.id == walletId.stringValue }
?.let { wallet ->
wallet.copy(
type = when (val type = wallet.type) {
is UserWalletModel.Type.MultiCurrency -> type.copy(
tokensCount = walletStores.flatMap { it.walletsData }.size,
)
is UserWalletModel.Type.SingleCurrency -> type.copy(
blockchainName = walletStores
.firstOrNull()
?.blockchainNetwork
?.blockchain
?.fullName,
)
},
fiatBalance = totalFiatBalanceCalculator.calculate(
prevAmount = wallet.fiatBalance.amount,
walletStores = walletStores,
),
)
}
if (updatedWallet != null) {
store.dispatchOnMain(WalletSelectorAction.BalanceLoaded(updatedWallet))
}
}
}
}
private fun unlockWalletsWithBiometry() {
scope.launch {
userWalletsListManager.unlockWithBiometry()
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.UnlockWithBiometry.Error(error))
}
.doOnSuccess {
store.dispatchOnMain(WalletSelectorAction.UnlockWithBiometry.Success)
}
}
}
private fun addWallet() = scope.launch {
scanCardInternal { scanResponse ->
val userWallet = UserWalletBuilder(scanResponse).build()
userWalletsListManager.save(userWallet)
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
}
.doOnSuccess {
val selectedWallet = userWalletsListManager.selectedUserWallet.first()
val isSavedWalletSelected = userWallet == selectedWallet
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
if (isSavedWalletSelected) {
store.dispatchOnMain(NavigationAction.PopBackTo())
store.onUserWalletSelected(selectedWallet)
}
}
}
}
private fun selectWallet(id: String) {
scope.launch {
userWalletsListManager.selectWallet(UserWalletId(id))
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.HandleError(error))
}
.doOnSuccess { selectedWallet ->
store.dispatchOnMain(NavigationAction.PopBackTo())
store.onUserWalletSelected(selectedWallet)
}
}
}
private fun removeWallets(walletIdsToRemove: List<String>, state: WalletSelectorState) {
scope.launch {
when (walletIdsToRemove.size) {
state.wallets.size -> { // Removing all wallets
userWalletsListManager.clear()
.flatMap { walletStoresManager.clear() }
.doOnSuccess {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Home))
}
}
else -> {
userWalletsListManager.delete(walletIdsToRemove.map { UserWalletId(it) })
.flatMap { walletStoresManager.delete(walletIdsToRemove) }
}
}
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.HandleError(error))
}
}
}
private fun renameWallet(walletId: String, newName: String) {
scope.launch {
userWalletsListManager.get(walletId = UserWalletId(walletId))
.map { it.copy(name = newName) }
.flatMap { userWalletsListManager.update(it) }
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.HandleError(error))
}
}
}
private suspend inline fun scanCardInternal(
crossinline onCardScanned: suspend (ScanResponse) -> Unit,
) {
ScanCardProcessor.scan(
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes,
onSuccess = {
onCardScanned(it)
},
onFailure = { error ->
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
},
onWalletNotCreated = {
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
store.dispatchOnMain(NavigationAction.PopBackTo())
},
)
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.domain.common.CardDTO
import com.tangem.tap.common.extensions.replaceByOrAdd
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.extensions.isMultiwalletAllowed
import com.tangem.tap.domain.model.TotalFiatBalance
import com.tangem.tap.domain.model.UserWallet
import org.rekotlin.Action
internal object WalletSelectorReducer {
fun reduce(action: Action, state: AppState): WalletSelectorState {
return if (action is WalletSelectorAction) {
internalReduce(action, state.walletSelectorState)
} else state.walletSelectorState
}
private fun internalReduce(action: WalletSelectorAction, state: WalletSelectorState): WalletSelectorState {
return when (action) {
is WalletSelectorAction.UserWalletsLoaded -> state.copy(
wallets = action.userWallets.updateWalletsModels(state.wallets),
)
is WalletSelectorAction.SelectedWalletChanged -> state.copy(
selectedWalletId = action.selectedWallet.walletId.stringValue,
)
is WalletSelectorAction.IsLockedChanged -> state.copy(
isLocked = action.isLocked,
)
is WalletSelectorAction.BalanceLoaded -> state.copy(
wallets = state.wallets.updateWithBalance(action.userWalletModel),
)
is WalletSelectorAction.HandleError -> state.copy(error = action.error)
is WalletSelectorAction.CloseError -> state.copy(error = null)
is WalletSelectorAction.UnlockWithBiometry -> state.copy(
isUnlockInProgress = true,
)
is WalletSelectorAction.UnlockWithBiometry.Error -> state.copy(
isUnlockInProgress = false,
error = action.error,
)
is WalletSelectorAction.UnlockWithBiometry.Success -> state.copy(
isUnlockInProgress = false,
)
is WalletSelectorAction.AddWallet -> state.copy(
isCardSavingInProgress = true,
)
is WalletSelectorAction.AddWallet.Error -> state.copy(
isCardSavingInProgress = false,
error = action.error,
)
is WalletSelectorAction.AddWallet.Success -> state.copy(
isCardSavingInProgress = false,
)
is WalletSelectorAction.ChangeAppCurrency -> state.copy(
fiatCurrency = action.fiatCurrency,
)
is WalletSelectorAction.WalletStoresChanged,
is WalletSelectorAction.SelectWallet,
is WalletSelectorAction.RemoveWallets,
is WalletSelectorAction.RenameWallet,
-> state
}
}
private fun List<UserWallet>.updateWalletsModels(prevWallets: List<UserWalletModel>): List<UserWalletModel> {
return this.map { userWallet ->
prevWallets
.find { it.id == userWallet.walletId.stringValue }
?.copy(
name = userWallet.name,
artworkUrl = userWallet.artworkUrl,
)
?: with(userWallet) {
UserWalletModel(
id = walletId.stringValue,
name = name,
artworkUrl = artworkUrl,
type = getType(),
fiatBalance = TotalFiatBalance.Loading,
)
}
}
}
private fun List<UserWalletModel>.updateWithBalance(
userWalletModel: UserWalletModel,
): List<UserWalletModel> {
return ArrayList(this).apply {
replaceByOrAdd(userWalletModel) { it.id == userWalletModel.id }
}
}
private fun UserWallet.getType(): UserWalletModel.Type {
return if (scanResponse.card.isMultiwalletAllowed) {
UserWalletModel.Type.MultiCurrency(
tokensCount = 0,
cardsInWallet = (scanResponse.card.backupStatus as? CardDTO.BackupStatus.Active)
?.cardCount?.inc()
?: 1,
)
} else {
UserWalletModel.Type.SingleCurrency(
blockchainName = null,
)
}
}
}

View file

@ -1,5 +1,15 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.common.core.TangemError
import com.tangem.tap.common.entities.FiatCurrency
import org.rekotlin.StateType
class WalletSelectorState : StateType
data class WalletSelectorState(
val wallets: List<UserWalletModel> = emptyList(),
val selectedWalletId: String? = null,
val isLocked: Boolean = false,
val fiatCurrency: FiatCurrency = FiatCurrency.Default,
val isCardSavingInProgress: Boolean = false,
val isUnlockInProgress: Boolean = false,
val error: TangemError? = null,
) : StateType

View file

@ -0,0 +1,76 @@
package com.tangem.tap.features.walletSelector.ui
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.extensions.toFormattedFiatValue
import com.tangem.tap.domain.model.TotalFiatBalance
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.walletSelector.redux.UserWalletModel
import com.tangem.tap.features.walletSelector.redux.WalletSelectorState
import com.tangem.tap.features.walletSelector.ui.model.MultiCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.SingleCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.UserWalletItem
internal fun WalletSelectorScreenState.updateWithNewState(
newState: WalletSelectorState,
): WalletSelectorScreenState {
val walletsUi = newState.wallets.toUiModels(newState.fiatCurrency)
val walletsIds = walletsUi.map { it.id }
val multiCurrencyWallets = arrayListOf<MultiCurrencyUserWalletItem>()
val singleCurrencyWallets = arrayListOf<SingleCurrencyUserWalletItem>()
walletsUi.forEach { wallet ->
when (wallet) {
is MultiCurrencyUserWalletItem -> {
multiCurrencyWallets.add(wallet)
}
is SingleCurrencyUserWalletItem -> {
singleCurrencyWallets.add(wallet)
}
}
}
return this.copy(
multiCurrencyWallets = multiCurrencyWallets,
singleCurrencyWallets = singleCurrencyWallets,
selectedWalletId = newState.selectedWalletId,
editingWalletsIds = editingWalletsIds.filter { it in walletsIds },
isLocked = newState.isLocked,
showUnlockProgress = newState.isUnlockInProgress,
showAddCardProgress = newState.isCardSavingInProgress,
error = newState.error
?.takeUnless { it.silent }
?.let { error ->
error.messageResId?.let { TextReference.Res(it) }
?: TextReference.Str(error.customMessage)
},
)
}
private fun List<UserWalletModel>.toUiModels(
appCurrency: FiatCurrency,
): Sequence<UserWalletItem> {
return this.asSequence().map { userWalletModel ->
with(userWalletModel) {
val balance = UserWalletItem.Balance(
amount = fiatBalance.amount.toFormattedFiatValue(appCurrency.symbol),
isLoading = fiatBalance is TotalFiatBalance.Loading,
)
when (type) {
is UserWalletModel.Type.MultiCurrency -> MultiCurrencyUserWalletItem(
id = id,
name = name,
imageUrl = artworkUrl,
balance = balance,
cardsInWallet = type.cardsInWallet,
tokensCount = type.tokensCount,
)
is UserWalletModel.Type.SingleCurrency -> SingleCurrencyUserWalletItem(
id = id,
name = name,
imageUrl = artworkUrl,
balance = balance,
tokenName = type.blockchainName ?: "",
)
}
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.viewModels
import com.tangem.core.ui.fragments.ComposeBottomSheetFragment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.tap.features.walletSelector.ui.components.RenameWalletDialogContent
import com.tangem.tap.features.walletSelector.ui.components.WalletSelectorScreenContent
import com.tangem.tap.features.walletSelector.ui.model.RenameWalletDialog
@ -33,7 +34,7 @@ internal class WalletSelectorBottomSheetFragment : ComposeBottomSheetFragment<Wa
state: WalletSelectorScreenState,
) {
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage by rememberUpdatedState(newValue = state.error?.customMessage)
val errorMessage by rememberUpdatedState(newValue = state.error?.resolveReference())
val renameWalletDialog by rememberUpdatedState(newValue = state.renameWalletDialog)
Box(

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.walletSelector.ui
import androidx.compose.runtime.Immutable
import com.tangem.common.core.TangemError
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.walletSelector.ui.model.MultiCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.RenameWalletDialog
import com.tangem.tap.features.walletSelector.ui.model.SingleCurrencyUserWalletItem
@ -16,5 +16,5 @@ internal data class WalletSelectorScreenState(
val renameWalletDialog: RenameWalletDialog? = null,
val showAddCardProgress: Boolean = false,
val showUnlockProgress: Boolean = false,
val error: TangemError? = null,
val error: TextReference? = null,
)

View file

@ -1,12 +1,18 @@
package com.tangem.tap.features.walletSelector.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.features.walletSelector.redux.WalletSelectorAction
import com.tangem.tap.features.walletSelector.redux.WalletSelectorState
import com.tangem.tap.features.walletSelector.ui.model.RenameWalletDialog
import com.tangem.tap.store
import com.tangem.tap.userWalletsListManager
import com.tangem.tap.walletStoresManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import org.rekotlin.StoreSubscriber
@ -18,6 +24,7 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
subscribeToStoreChanges()
bootstrapWalletListChanges()
bootstrapWalletsStoresChanges()
bootstrapAppFiatCurrency()
}
fun unlock() {
@ -104,7 +111,9 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
}
override fun newState(state: WalletSelectorState) {
// TODO: Update UI state
stateInternal.update { prevState ->
prevState.updateWithNewState(state)
}
}
override fun onCleared() {
@ -135,10 +144,38 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
}
private fun bootstrapWalletListChanges() {
// TODO
userWalletsListManager.userWallets
.onEach {
store.dispatchOnMain(WalletSelectorAction.UserWalletsLoaded(userWallets = it))
}
.launchIn(viewModelScope)
userWalletsListManager.selectedUserWallet
.onEach {
store.dispatchOnMain(WalletSelectorAction.SelectedWalletChanged(selectedWallet = it))
}
.launchIn(viewModelScope)
userWalletsListManager.isLocked
.onEach {
store.dispatchOnMain(WalletSelectorAction.IsLockedChanged(isLocked = it))
}
.launchIn(viewModelScope)
}
private fun bootstrapWalletsStoresChanges() {
// TODO
walletStoresManager.getAll()
.onEach { walletStores ->
store.dispatchOnMain(WalletSelectorAction.WalletStoresChanged(walletStores))
}
.launchIn(viewModelScope)
}
private fun bootstrapAppFiatCurrency() {
store.dispatch(
WalletSelectorAction.ChangeAppCurrency(
fiatCurrency = store.state.globalState.appCurrency,
),
)
}
}

View file

@ -8,7 +8,7 @@ import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.builders.UserWalletBuilder
import com.tangem.tap.domain.scanCard.ScanCardProcessor
import com.tangem.tap.scope
import com.tangem.tap.store
@ -58,7 +58,7 @@ internal class WelcomeMiddleware {
private fun proceedWithCard() = scope.launch {
scanCardInternal { scanResponse ->
val userWallet = UserWallet(scanResponse)
val userWallet = UserWalletBuilder(scanResponse).build()
userWalletsListManager.unlockWithCard(userWallet)
.doOnFailure { error ->