Updated on 2026-08-14

This commit is contained in:
Tangem 2023-04-13 16:19:43 +09:00
parent abec0f1ee8
commit bfb2338a1a
17 changed files with 613 additions and 516 deletions

View file

@ -0,0 +1,37 @@
package com.tangem.tap.features.tokens.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.tap.features.tokens.data.DefaultTokensListRepository
import com.tangem.tap.features.tokens.domain.TokensListRepository
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
[REDACTED_AUTHOR]
*/
@Module
@InstallIn(SingletonComponent::class)
internal object TokensListRepositoryModule {
@Provides
@Singleton
fun providesTokensListRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
reduxStateHolder: AppStateHolder,
testnetTokensStorage: TestnetTokensStorage,
): TokensListRepository {
return DefaultTokensListRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
reduxStateHolder = reduxStateHolder,
testnetTokensStorage = testnetTokensStorage,
)
}
}

View file

@ -5,13 +5,12 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.transition.TransitionInflater
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.presentation.ui.AddTokensScreen
import com.tangem.tap.features.tokens.presentation.ui.TokensListScreen
import com.tangem.tap.features.tokens.presentation.viewmodels.TokensListViewModel
import com.tangem.wallet.R
import dagger.hilt.android.AndroidEntryPoint
@ -36,12 +35,10 @@ internal class TokensListFragment : Fragment() {
setContent {
isTransitionGroup = true
val viewModel = hiltViewModel<TokensListViewModel>().apply {
subscribeOnReduxEvents(lifecycle = LocalLifecycleOwner.current.lifecycle)
}
val viewModel = hiltViewModel<TokensListViewModel>()
TangemTheme {
AddTokensScreen(stateHolder = viewModel.uiState)
TokensListScreen(stateHolder = viewModel.uiState)
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.tap.features.tokens.presentation.models
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.tokens.redux.TokenWithBlockchain
import com.tangem.tap.store
/**
* Required data for tokens list screen
* FIXME("Necessary to avoid using redux state")
*
[REDACTED_AUTHOR]
*/
class TokensListArgs {
/** Tokens list screen mode */
val isManageAccess: Boolean get() = store.state.tokensState.allowToAdd
/** Tokens list that accessible from the main screen */
val mainScreenTokenList: List<TokenWithBlockchain> get() = store.state.tokensState.addedTokens
/** Blockchains list that accessible from the main screen */
val mainScreenBlockchainList: List<Blockchain> get() = store.state.tokensState.addedBlockchains
}

View file

@ -1,5 +1,9 @@
package com.tangem.tap.features.tokens.presentation.states
import androidx.compose.runtime.MutableState
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.tap.common.extensions.getGreyedOutIconRes
import com.tangem.tap.features.tokens.redux.ContractAddress
/**
@ -16,7 +20,7 @@ sealed interface NetworkItemState {
val protocolName: String
/** Network icon id from resources */
val iconResId: Int
val iconResId: MutableState<Int>
/** Flag that determines if the network is the main network for the token */
val isMainNetwork: Boolean
@ -29,35 +33,51 @@ sealed interface NetworkItemState {
* @property iconResId network icon id from resources
* @property isMainNetwork flag that determines if the network is the main network for the token
*/
data class ReadAccess(
data class ReadContent(
override val name: String,
override val protocolName: String,
override val iconResId: Int,
override val iconResId: MutableState<Int>,
override val isMainNetwork: Boolean,
) : NetworkItemState
/**
* Network item state that is available for read and edit
*
* @property name network name
* @property protocolName network protocol name
* @property iconResId network icon id from resources
* @property isMainNetwork flag that determines if the network is the main network for the token
* @property isAdded flag that determines if the user has saved the token
* @property networkId network id
* @property contractAddress contract address
* @property onToggleClick lambda be invoked when switch is been toggled
* @property onNetworkClick lambda be invoked when network item is been clicked
* @property name network name
* @property protocolName network protocol name
* @property iconResId network icon id from resources
* @property isMainNetwork flag that determines if the network is the main network for the token
* @property isAdded flag that determines if the user has saved the token
* @property id network id
* @property address contract address
* @property decimalCount decimal count
* @property blockchain blockchain
* @property onToggleClick lambda be invoked when switch is been toggled
* @property onNetworkClick lambda be invoked when network item is been clicked
*/
data class ManageAccess(
data class ManageContent(
override val name: String,
override val protocolName: String,
override val iconResId: Int,
override val iconResId: MutableState<Int>,
override val isMainNetwork: Boolean,
val isAdded: Boolean,
val networkId: String,
val contractAddress: ContractAddress?,
val onToggleClick: (String, String) -> Unit,
val isAdded: MutableState<Boolean>,
val id: String,
val address: ContractAddress?,
val decimalCount: Int?,
val blockchain: Blockchain,
val onToggleClick: (TokenItemState.ManageContent, ManageContent) -> Unit,
val onNetworkClick: () -> Unit,
) : NetworkItemState
) : NetworkItemState {
/**
* Change toggle state [isAdded].
*
* It is a hack that helps us to change element of flow
*/
fun changeToggleState() {
val reverseState = !isAdded.value
isAdded.value = reverseState
iconResId.value = if (reverseState) getActiveIconRes(blockchain.id) else blockchain.getGreyedOutIconRes()
}
}
}

View file

@ -25,24 +25,26 @@ sealed interface TokenItemState {
* @property iconUrl token icon url
* @property networks list of networks that is available for read
*/
data class ReadAccess(
data class ReadContent(
override val name: String,
override val iconUrl: String,
override val networks: ImmutableList<NetworkItemState.ReadAccess>,
override val networks: ImmutableList<NetworkItemState.ReadContent>,
) : TokenItemState
/**
* Token item state that is available for read and edit
* Token item state that is available for read and manage
*
* @property name token name
* @property iconUrl token icon url
* @property networks list of networks is available for read and edit
* @property id token id
* @property symbol token brief name
*/
data class ManageAccess(
data class ManageContent(
override val name: String,
override val iconUrl: String,
override val networks: ImmutableList<NetworkItemState.ManageAccess>,
override val networks: ImmutableList<NetworkItemState.ManageContent>,
val id: String,
val symbol: String,
) : TokenItemState
}

View file

@ -1,6 +1,8 @@
package com.tangem.tap.features.tokens.presentation.states
import kotlinx.collections.immutable.ImmutableList
import androidx.paging.LoadState
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
/**
* State holder for screen with list of tokens
@ -12,47 +14,69 @@ internal sealed interface TokensListStateHolder {
/** Toolbar state */
val toolbarState: TokensListToolbarState
/** Tokens list */
val tokens: Flow<PagingData<TokenItemState>>
/** Callback to be invoked when [tokens] loading state is been changed */
val onTokensLoadStateChanged: (LoadState) -> Unit
/**
* Util function that allow to make a copy
*
* @param toolbarState toolbar state
* @param toolbarState toolbar state
* @param tokens tokens list
* @param onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
*/
fun copySealed(toolbarState: TokensListToolbarState): TokensListStateHolder {
fun copySealed(
toolbarState: TokensListToolbarState = this.toolbarState,
tokens: Flow<PagingData<TokenItemState>> = this.tokens,
onTokensLoadStateChanged: (LoadState) -> Unit = this.onTokensLoadStateChanged,
): TokensListStateHolder {
return when (this) {
is ManageAccess -> copy(toolbarState = toolbarState)
is Loading -> copy(toolbarState = toolbarState)
is ReadAccess -> copy(toolbarState = toolbarState)
is ManageContent -> copy(toolbarState, tokens, onTokensLoadStateChanged)
is Loading -> copy(toolbarState, tokens, onTokensLoadStateChanged)
is ReadContent -> copy(toolbarState, tokens, onTokensLoadStateChanged)
}
}
/**
* Loading state
*
* @property toolbarState toolbar state
* @property toolbarState toolbar state
* @property tokens tokens list
* @property onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
*/
data class Loading(override val toolbarState: TokensListToolbarState) : TokensListStateHolder
data class Loading(
override val toolbarState: TokensListToolbarState,
override val tokens: Flow<PagingData<TokenItemState>>,
override val onTokensLoadStateChanged: (LoadState) -> Unit,
) : TokensListStateHolder
/**
* State screen that is available only for read
*
* @property toolbarState toolbar state
* @property tokens tokens list
* @property toolbarState toolbar state
* @property tokens tokens list
* @property onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
*/
data class ReadAccess(
data class ReadContent(
override val toolbarState: TokensListToolbarState,
override val tokens: ImmutableList<TokenItemState.ReadAccess>,
) : TokensListStateHolder, TokensListVisibility
override val tokens: Flow<PagingData<TokenItemState>>,
override val onTokensLoadStateChanged: (LoadState) -> Unit,
) : TokensListStateHolder
/**
* State screen that is available for read and edit
* State screen that is available for read and manage
*
* @property toolbarState toolbar state
* @property tokens tokens list
* @property onSaveButtonClick callback to be invoked when SaveButton is being clicked
* @property toolbarState toolbar state
* @property tokens tokens list
* @property onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
* @property onSaveButtonClick callback to be invoked when SaveButton is being clicked
*/
data class ManageAccess(
data class ManageContent(
override val toolbarState: TokensListToolbarState,
override val tokens: ImmutableList<TokenItemState.ManageAccess>,
override val tokens: Flow<PagingData<TokenItemState>>,
override val onTokensLoadStateChanged: (LoadState) -> Unit,
val onSaveButtonClick: () -> Unit,
) : TokensListStateHolder, TokensListVisibility
) : TokensListStateHolder
}

View file

@ -6,39 +6,39 @@ sealed interface TokensListToolbarState {
/** Callback to be invoked when BackButton is being clicked */
val onBackButtonClick: () -> Unit
/** Callback to be invoked when SearchButton is being clicked */
val onSearchButtonClick: () -> Unit
/** Toolbar state as title */
sealed interface Title : TokensListToolbarState {
/** Toolbar title id from resources */
val titleResId: Int
/** Callback to be invoked when SearchButton is being clicked */
val onSearchButtonClick: () -> Unit
/**
* Title state that is available only for read
*
* @property titleResId toolbar title id from resources
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property titleResId toolbar title id from resources
* @property onSearchButtonClick callback to be invoked when SearchButton is being clicked
*/
data class ReadAccess(
override val titleResId: Int,
data class Read(
override val onBackButtonClick: () -> Unit,
override val titleResId: Int,
override val onSearchButtonClick: () -> Unit,
) : Title
/**
* Title state that is available for read and edit
* Title state that is available for read and manage
*
* @property titleResId toolbar title id from resources
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property titleResId toolbar title id from resources
* @property onSearchButtonClick callback to be invoked when SearchButton is being clicked
* @property onAddCustomTokenClick callback to be invoked when AddCustomTokenButton is being clicked
*/
data class ManageAccess(
override val titleResId: Int,
data class Manage(
override val onBackButtonClick: () -> Unit,
override val titleResId: Int,
override val onSearchButtonClick: () -> Unit,
val onAddCustomTokenClick: () -> Unit,
) : Title
@ -47,15 +47,13 @@ sealed interface TokensListToolbarState {
/**
* Toolbar state as input field
*
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property onSearchButtonClick callback to be invoked when SearchButton is being clicked
* @property value input value
* @property onValueChange lambda to be invoked when search value is being changed
* @property onCleanButtonClick callback to be invoked when CleanButton is being clicked
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property value input value
* @property onValueChange lambda to be invoked when search value is being changed
* @property onCleanButtonClick callback to be invoked when CleanButton is being clicked
*/
data class SearchInputField(
data class InputField(
override val onBackButtonClick: () -> Unit,
override val onSearchButtonClick: () -> Unit,
val value: String,
val onValueChange: (String) -> Unit,
val onCleanButtonClick: () -> Unit,

View file

@ -1,10 +0,0 @@
package com.tangem.tap.features.tokens.presentation.states
import kotlinx.collections.immutable.ImmutableList
/** Marker interface for states that have a list of tokens */
sealed interface TokensListVisibility {
/** Tokens list */
val tokens: ImmutableList<TokenItemState>
}

View file

@ -53,7 +53,7 @@ internal fun BriefNetworksList(
internal fun BriefNetworkItem(model: NetworkItemState) {
Box(modifier = Modifier.size(size = TangemTheme.dimens.size20)) {
Image(
painter = painterResource(id = model.iconResId),
painter = painterResource(id = model.iconResId.value),
contentDescription = null,
modifier = Modifier.size(size = TangemTheme.dimens.size20),
)

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
import com.tangem.tap.features.tokens.presentation.states.TokenItemState
import kotlinx.collections.immutable.ImmutableCollection
/**
@ -43,7 +44,7 @@ import kotlinx.collections.immutable.ImmutableCollection
@Composable
internal fun DetailedNetworksList(
isExpanded: Boolean,
tokenId: String?,
token: TokenItemState,
networks: ImmutableCollection<NetworkItemState>,
modifier: Modifier = Modifier,
) {
@ -56,7 +57,7 @@ internal fun DetailedNetworksList(
Column {
networks.forEachIndexed { index, network ->
key(network.name) {
DetailedNetworkItem(model = network, tokenId = tokenId, isLastItem = networks.size - 1 == index)
DetailedNetworkItem(token = token, network = network, isLastItem = networks.size - 1 == index)
}
}
}
@ -65,18 +66,18 @@ internal fun DetailedNetworksList(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DetailedNetworkItem(model: NetworkItemState, tokenId: String?, isLastItem: Boolean) {
private fun DetailedNetworkItem(token: TokenItemState, network: NetworkItemState, isLastItem: Boolean) {
val clipboardManager = LocalClipboardManager.current
val itemHeight = TangemTheme.dimens.size50
Row(
modifier = Modifier
.combinedClickable(
enabled = model is NetworkItemState.ManageAccess,
enabled = network is NetworkItemState.ManageContent,
onLongClick = {
if (model is NetworkItemState.ManageAccess && model.contractAddress != null) {
clipboardManager.setText(AnnotatedString(text = model.contractAddress))
model.onNetworkClick()
if (network is NetworkItemState.ManageContent && network.address != null) {
clipboardManager.setText(AnnotatedString(text = network.address))
network.onNetworkClick()
}
},
onClick = {},
@ -90,14 +91,19 @@ private fun DetailedNetworkItem(model: NetworkItemState, tokenId: String?, isLas
) {
NetworkItemArrow(itemHeight = itemHeight, isLastItem = isLastItem)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing16))
BriefNetworkItem(model = model)
BriefNetworkItem(model = network)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing6))
NetworkTitle(model = model)
NetworkTitle(model = network)
if (model is NetworkItemState.ManageAccess) {
if (network is NetworkItemState.ManageContent) {
Switch(
checked = model.isAdded,
onCheckedChange = { model.onToggleClick(requireNotNull(tokenId), model.networkId) },
checked = network.isAdded.value,
onCheckedChange = {
network.onToggleClick(
requireNotNull(token as? TokenItemState.ManageContent),
network,
)
},
modifier = Modifier.padding(start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing8),
colors = SwitchDefaults.colors(
checkedThumbColor = TangemColorPalette.Meadow,
@ -125,7 +131,7 @@ private fun RowScope.NetworkTitle(model: NetworkItemState) {
},
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = if (model is NetworkItemState.ManageAccess && model.isAdded) {
color = if (model is NetworkItemState.ManageContent && model.isAdded.value) {
TangemColorPalette.Black
} else {
TangemColorPalette.Dark2
@ -139,7 +145,7 @@ private fun Preview_DetailedNetworksList_ManageAccess() {
TangemTheme {
DetailedNetworksList(
isExpanded = true,
tokenId = null,
token = TokenListPreviewData.createManageToken(),
networks = TokenListPreviewData.createManageNetworksList(),
)
}
@ -151,7 +157,7 @@ private fun Preview_DetailedNetworksList_ReadAccess() {
TangemTheme {
DetailedNetworksList(
isExpanded = true,
tokenId = null,
token = TokenListPreviewData.createReadToken(),
networks = TokenListPreviewData.createReadNetworksList(),
)
}

View file

@ -46,7 +46,7 @@ import com.tangem.wallet.R
*/
@Composable
internal fun TokenItem(model: TokenItemState) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
ConstraintLayout(
modifier = Modifier
@ -101,7 +101,7 @@ internal fun TokenItem(model: TokenItemState) {
DetailedNetworksList(
isExpanded = isExpanded,
tokenId = (model as? TokenItemState.ManageAccess)?.id,
token = model,
networks = model.networks,
modifier = Modifier.constrainAs(detailedNetworksList) {
top.linkTo(anchor = availableNetworksText.bottom, margin = spacing16)

View file

@ -1,5 +1,7 @@
package com.tangem.tap.features.tokens.presentation.ui
import androidx.compose.runtime.mutableStateOf
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
import com.tangem.tap.features.tokens.presentation.states.TokenItemState
import com.tangem.wallet.R
@ -11,62 +13,67 @@ import kotlinx.collections.immutable.persistentListOf
*/
object TokenListPreviewData {
fun createManageToken(): TokenItemState.ManageAccess {
return TokenItemState.ManageAccess(
fun createManageToken(): TokenItemState.ManageContent {
return TokenItemState.ManageContent(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = createManageNetworksList(),
id = "",
symbol = "",
)
}
fun createReadToken(): TokenItemState.ReadAccess {
return TokenItemState.ReadAccess(
fun createReadToken(): TokenItemState.ReadContent {
return TokenItemState.ReadContent(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = createReadNetworksList(),
)
}
fun createManageNetworksList(): ImmutableList<NetworkItemState.ManageAccess> {
fun createManageNetworksList(): ImmutableList<NetworkItemState.ManageContent> {
return persistentListOf(
NetworkItemState.ManageAccess(
NetworkItemState.ManageContent(
name = "Ethereum",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
iconResId = mutableStateOf(R.drawable.ic_eth_no_color),
isMainNetwork = true,
isAdded = true,
networkId = "",
contractAddress = null,
isAdded = mutableStateOf(true),
id = "",
address = null,
onToggleClick = { _, _ -> },
onNetworkClick = {},
decimalCount = null,
blockchain = Blockchain.Ethereum,
),
NetworkItemState.ManageAccess(
NetworkItemState.ManageContent(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
iconResId = mutableStateOf(R.drawable.ic_bsc_no_color),
isMainNetwork = false,
isAdded = false,
networkId = "",
contractAddress = null,
isAdded = mutableStateOf(false),
id = "",
address = null,
onToggleClick = { _, _ -> },
onNetworkClick = {},
decimalCount = null,
blockchain = Blockchain.BSC,
),
)
}
fun createReadNetworksList(): ImmutableList<NetworkItemState.ReadAccess> {
fun createReadNetworksList(): ImmutableList<NetworkItemState.ReadContent> {
return persistentListOf(
NetworkItemState.ReadAccess(
NetworkItemState.ReadContent(
name = "Ethereum",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
iconResId = mutableStateOf(R.drawable.ic_eth_no_color),
isMainNetwork = true,
),
NetworkItemState.ReadAccess(
NetworkItemState.ReadContent(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
iconResId = mutableStateOf(R.drawable.ic_bsc_no_color),
isMainNetwork = false,
),
)

View file

@ -12,58 +12,55 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.FabPosition
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.compose.extensions.OnBottomReached
import com.tangem.tap.features.tokens.presentation.states.TokenItemState
import com.tangem.tap.features.tokens.presentation.states.TokensListStateHolder
import com.tangem.tap.features.tokens.presentation.states.TokensListToolbarState
import com.tangem.tap.features.tokens.presentation.states.TokensListVisibility
import com.tangem.tap.features.tokens.redux.TokensAction
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddTokensScreen(stateHolder: TokensListStateHolder) {
internal fun TokensListScreen(stateHolder: TokensListStateHolder) {
BackHandler(onBack = stateHolder.toolbarState.onBackButtonClick)
Scaffold(
topBar = { AddTokensToolbar(state = stateHolder.toolbarState) },
topBar = { TokensListToolbar(state = stateHolder.toolbarState) },
floatingActionButton = {
if (stateHolder is TokensListStateHolder.ManageAccess) {
if (stateHolder is TokensListStateHolder.ManageContent) {
SaveChangesButton(onClick = stateHolder.onSaveButtonClick)
}
},
floatingActionButtonPosition = FabPosition.Center,
) { scaffoldPadding ->
// This is a hack because AnimatedContent trigger recomposition if stateHolder content is changed
AnimatedVisibility(
visible = stateHolder is TokensListVisibility,
enter = fadeIn(),
exit = fadeOut(),
) {
if (stateHolder is TokensListVisibility) {
TokensListContent(
stateHolder = stateHolder,
scaffoldPadding = scaffoldPadding,
)
}
}
val tokens = stateHolder.tokens.collectAsLazyPagingItems()
TokensListContent(
tokens = tokens,
scaffoldPadding = scaffoldPadding,
)
stateHolder.onTokensLoadStateChanged(tokens.loadState.refresh)
AnimatedVisibility(
visible = stateHolder is TokensListStateHolder.Loading,
@ -87,24 +84,24 @@ private fun LoadingContent() {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun TokensListContent(stateHolder: TokensListVisibility, scaffoldPadding: PaddingValues) {
val state = rememberLazyListState().apply {
OnBottomReached(loadMoreThreshold = 40) {
store.dispatch(TokensAction.LoadMore(scanResponse = store.state.globalState.scanResponse))
}
private fun TokensListContent(tokens: LazyPagingItems<TokenItemState>, scaffoldPadding: PaddingValues) {
val state = rememberLazyListState()
if (state.isScrollInProgress) {
LocalSoftwareKeyboardController.current?.hide()
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(scaffoldPadding),
state,
state = state,
) {
items(
items = stateHolder.tokens,
key = TokenItemState::name,
itemContent = { TokenItem(model = it) },
)
items(items = tokens, key = TokenItemState::name) {
it?.let { TokenItem(model = it) }
}
}
}
@ -122,16 +119,18 @@ private fun SaveChangesButton(onClick: () -> Unit) {
@Preview
@Composable
private fun Preview_AddTokensScreen_Loading() {
private fun Preview_TokensListScreen_Loading() {
TangemTheme {
AddTokensScreen(
TokensListScreen(
stateHolder = TokensListStateHolder.Loading(
toolbarState = TokensListToolbarState.Title.ManageAccess(
toolbarState = TokensListToolbarState.Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
tokens = emptyFlow(),
onTokensLoadStateChanged = {},
),
)
}
@ -139,21 +138,28 @@ private fun Preview_AddTokensScreen_Loading() {
@Preview
@Composable
private fun Preview_AddTokensScreen_ManageAccess() {
private fun Preview_TokensListScreen_Manage() {
TangemTheme {
AddTokensScreen(
stateHolder = TokensListStateHolder.ManageAccess(
toolbarState = TokensListToolbarState.Title.ManageAccess(
TokensListScreen(
stateHolder = TokensListStateHolder.ManageContent(
toolbarState = TokensListToolbarState.Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
tokens = persistentListOf(
TokenListPreviewData.createManageToken(),
TokenListPreviewData.createManageToken(),
),
tokens = flow {
emit(
PagingData.from(
listOf(
TokenListPreviewData.createManageToken(),
TokenListPreviewData.createManageToken(),
),
),
)
},
onSaveButtonClick = {},
onTokensLoadStateChanged = {},
),
)
}
@ -161,19 +167,26 @@ private fun Preview_AddTokensScreen_ManageAccess() {
@Preview
@Composable
private fun Preview_AddTokensScreen_ReadAccess() {
private fun Preview_TokensListScreen_Read() {
TangemTheme {
AddTokensScreen(
stateHolder = TokensListStateHolder.ReadAccess(
toolbarState = TokensListToolbarState.Title.ReadAccess(
TokensListScreen(
stateHolder = TokensListStateHolder.ReadContent(
toolbarState = TokensListToolbarState.Title.Read(
titleResId = R.string.search_tokens_title,
onBackButtonClick = {},
onSearchButtonClick = {},
),
tokens = persistentListOf(
TokenListPreviewData.createReadToken(),
TokenListPreviewData.createReadToken(),
),
tokens = flow {
emit(
PagingData.from(
listOf(
TokenListPreviewData.createManageToken(),
TokenListPreviewData.createManageToken(),
),
),
)
},
onTokensLoadStateChanged = {},
),
)
}

View file

@ -34,7 +34,7 @@ import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.presentation.states.TokensListToolbarState
import com.tangem.tap.features.tokens.presentation.states.TokensListToolbarState.SearchInputField
import com.tangem.tap.features.tokens.presentation.states.TokensListToolbarState.InputField
import com.tangem.tap.features.tokens.presentation.states.TokensListToolbarState.Title
import com.tangem.wallet.R
import kotlinx.coroutines.delay
@ -43,7 +43,7 @@ import kotlinx.coroutines.delay
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddTokensToolbar(state: TokensListToolbarState) {
internal fun TokensListToolbar(state: TokensListToolbarState) {
TopAppBar(backgroundColor = TangemTheme.colors.background.secondary) {
IconButton(onClick = state.onBackButtonClick) {
Icon(
@ -55,7 +55,7 @@ internal fun AddTokensToolbar(state: TokensListToolbarState) {
when (state) {
is Title -> TitleContent(state = state, modifier = Modifier.weight(1f))
is SearchInputField -> InputContent(state = state, modifier = Modifier.weight(1f))
is InputField -> InputContent(state = state, modifier = Modifier.weight(1f))
}
}
}
@ -78,7 +78,7 @@ private fun TitleContent(state: Title, modifier: Modifier = Modifier) {
)
}
if (state is Title.ManageAccess) {
if (state is Title.Manage) {
IconButton(onClick = state.onAddCustomTokenClick) {
Icon(
painter = painterResource(id = R.drawable.ic_plus_24),
@ -91,8 +91,9 @@ private fun TitleContent(state: Title, modifier: Modifier = Modifier) {
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun InputContent(state: SearchInputField, modifier: Modifier = Modifier) {
private fun InputContent(state: InputField, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
BasicTextField(
value = state.value,
@ -100,7 +101,7 @@ private fun InputContent(state: SearchInputField, modifier: Modifier = Modifier)
modifier = modifier.focusRequester(focusRequester),
textStyle = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.Normal),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { state.onSearchButtonClick() }),
keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
singleLine = true,
maxLines = 1,
cursorBrush = SolidColor(value = TangemTheme.colors.stroke.secondary),
@ -168,8 +169,8 @@ private fun Hint(value: String) {
@Composable
private fun Preview_AddTokensToolbar_EditAccess() {
TangemTheme {
AddTokensToolbar(
state = Title.ManageAccess(
TokensListToolbar(
state = Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
@ -183,8 +184,8 @@ private fun Preview_AddTokensToolbar_EditAccess() {
@Composable
private fun Preview_AddTokensToolbar_ReadAccess() {
TangemTheme {
AddTokensToolbar(
state = Title.ReadAccess(
TokensListToolbar(
state = Title.Read(
titleResId = R.string.search_tokens_title,
onBackButtonClick = {},
onSearchButtonClick = {},
@ -199,10 +200,9 @@ private fun Preview_AddTokensToolbar_SearchInputField() {
var value by remember { mutableStateOf("") }
TangemTheme {
AddTokensToolbar(
state = SearchInputField(
TokensListToolbar(
state = InputField(
onBackButtonClick = {},
onSearchButtonClick = {},
value = value,
onValueChange = { value = it },
onCleanButtonClick = { value = "" },

View file

@ -0,0 +1,59 @@
package com.tangem.tap.features.tokens.presentation.viewmodels
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.ManageTokens
/** Analytics sender for tokens list screen */
class TokensListAnalyticsSender(private val analyticsEventHandler: AnalyticsEventHandler) {
fun sendWhenTokenAdded(token: Token) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.On,
),
)
}
fun sendWhenBlockchainAdded(blockchain: Blockchain) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.On,
),
)
}
fun sendWhenTokenRemoved(token: Token) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.Off,
),
)
}
fun sendWhenBlockchainRemoved(blockchain: Blockchain) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.Off,
),
)
}
fun sendWhenSaveButtonClicked() {
analyticsEventHandler.send(ManageTokens.ButtonSaveChanges())
}
fun sendWhenTokenSearched() {
analyticsEventHandler.send(ManageTokens.TokenSearched())
}
fun sendWhenAddCustomTokenClicked() {
analyticsEventHandler.send(ManageTokens.ButtonCustomToken())
}
}

View file

@ -3,27 +3,26 @@ package com.tangem.tap.features.tokens.presentation.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.map
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchNotification
import com.tangem.tap.common.extensions.fullNameWithoutTestnet
import com.tangem.tap.common.extensions.getGreyedOutIconRes
import com.tangem.tap.common.extensions.getNetworkName
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.tokens.Contract
import com.tangem.tap.domain.tokens.Currency
import com.tangem.tap.features.tokens.domain.TokensListInteractor
import com.tangem.tap.features.tokens.domain.models.Token
import com.tangem.tap.features.tokens.domain.models.Token.Network
import com.tangem.tap.features.tokens.presentation.models.TokensListArgs
import com.tangem.tap.features.tokens.presentation.router.TokensListRouter
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
import com.tangem.tap.features.tokens.presentation.states.TokenItemState
@ -31,317 +30,328 @@ import com.tangem.tap.features.tokens.presentation.states.TokensListStateHolder
import com.tangem.tap.features.tokens.presentation.states.TokensListToolbarState
import com.tangem.tap.features.tokens.redux.TokenWithBlockchain
import com.tangem.tap.features.tokens.redux.TokensAction
import com.tangem.tap.features.tokens.redux.TokensState
import com.tangem.tap.features.tokens.ui.compose.fullName
import com.tangem.tap.features.wallet.redux.models.WalletDialog
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.store
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.wallet.R
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.rekotlin.StoreSubscriber
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
import com.tangem.blockchain.common.Token as BlockchainToken
/**
* ViewModel for add tokens screen
* ViewModel for tokens list screen
*
* @property router feature router
* @property dispatchers coroutine dispatchers provider
* @property interactor feature interactor
* @property router feature router
* @property dispatchers coroutine dispatchers provider
* @property reduxStateHolder redux state holder
* @param analyticsEventHandler analytics event handler
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class TokensListViewModel @Inject constructor(
private val interactor: TokensListInteractor,
private val router: TokensListRouter,
private val dispatchers: AppCoroutineDispatcherProvider,
private val reduxStateHolder: AppStateHolder,
analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel() {
private val uiStateBuilder = UiStateBuilder()
private val args = TokensListArgs()
private val analyticsSender = TokensListAnalyticsSender(analyticsEventHandler)
private val actionsHandler = ActionsHandler(router = router, debouncer = Debouncer())
var uiState by mutableStateOf(uiStateBuilder.getInitialUiState())
/** Screen state */
var uiState by mutableStateOf(value = getInitialUiState())
private set
private val reduxSubscriber = ReduxSubscriber()
private var debounceJob: Job? = null
private val addedTokenList: MutableList<TokenWithBlockchain> = args.mainScreenTokenList.toMutableList()
private val addedBlockchainList: MutableList<Blockchain> = args.mainScreenBlockchainList.toMutableList()
private var originalAddedTokenList: List<TokenWithBlockchain>? = null
private var originalAddedBlockchainList: List<Blockchain>? = null
private var addedTokenList: List<TokenWithBlockchain>? = null
private var addedBlockchainList: List<Blockchain>? = null
fun subscribeOnReduxEvents(lifecycle: Lifecycle) {
lifecycle.addObserver(reduxSubscriber)
private fun getInitialUiState(): TokensListStateHolder {
return TokensListStateHolder.Loading(
toolbarState = getInitialToolbarState(),
tokens = getInitialTokensList(),
onTokensLoadStateChanged = actionsHandler::onTokensLoadStateChanged,
)
}
private object AnalyticsSender {
fun sendWhenTokenAdded(token: Token) {
Analytics.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.On,
),
private fun getInitialToolbarState(): TokensListToolbarState {
return if (args.isManageAccess) {
TokensListToolbarState.Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = actionsHandler::onBackButtonClick,
onSearchButtonClick = actionsHandler::onSearchButtonClick,
onAddCustomTokenClick = actionsHandler::onAddCustomTokenClick,
)
}
fun sendWhenBlockchainAdded(blockchain: Blockchain) {
Analytics.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.On,
),
} else {
TokensListToolbarState.Title.Read(
titleResId = R.string.search_tokens_title,
onBackButtonClick = actionsHandler::onBackButtonClick,
onSearchButtonClick = actionsHandler::onSearchButtonClick,
)
}
fun sendWhenTokenRemoved(token: Token) {
Analytics.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.Off,
),
)
}
fun sendWhenBlockchainRemoved(blockchain: Blockchain) {
Analytics.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.Off,
),
)
}
fun sendWhenSaveButtonClicked() {
Analytics.send(ManageTokens.ButtonSaveChanges())
}
}
private inner class ReduxSubscriber : DefaultLifecycleObserver, StoreSubscriber<TokensState> {
override fun onStart(owner: LifecycleOwner) {
store.subscribe(
subscriber = this,
transform = { state ->
state
.skipRepeats { old, new -> old.tokensState == new.tokensState }
.select(AppState::tokensState)
},
)
}
override fun onStop(owner: LifecycleOwner) {
store.unsubscribe(subscriber = this)
}
override fun newState(state: TokensState) {
val isNotOriginalListsInitialized = originalAddedTokenList == null || originalAddedBlockchainList == null
val isNotCurrentListsInitialized = addedTokenList == null || addedBlockchainList == null
if (isNotOriginalListsInitialized || isNotCurrentListsInitialized) {
originalAddedTokenList = state.addedTokens
originalAddedBlockchainList = state.addedBlockchains
addedTokenList = state.addedTokens
addedBlockchainList = state.addedBlockchains
private fun getInitialTokensList(searchText: String = ""): Flow<PagingData<TokenItemState>> {
return interactor.getTokensList(searchText = searchText).map {
it.map { token ->
if (args.isManageAccess) createManageTokenContent(token) else createReadTokenContent(token)
}
uiStateBuilder.updateStateByReduxState(state)
}
}
private inner class UiStateBuilder {
private fun createManageTokenContent(token: Token): TokenItemState.ManageContent {
return TokenItemState.ManageContent(
name = getTokenName(token),
iconUrl = token.iconUrl,
networks = token.networks.map(::createManageNetworkContent).toImmutableList(),
id = token.id,
symbol = token.symbol,
)
}
fun getInitialUiState(): TokensListStateHolder {
return TokensListStateHolder.Loading(toolbarState = getToolbarState())
private fun createManageNetworkContent(network: Network): NetworkItemState.ManageContent {
return NetworkItemState.ManageContent(
name = network.blockchain.fullNameWithoutTestnet.uppercase(),
protocolName = getNetworkProtocolName(network),
iconResId = mutableStateOf(
getNetworkIconResId(network.address, network.blockchain),
),
isMainNetwork = isMainNetwork(network),
isAdded = mutableStateOf(
isAdded(address = network.address, blockchain = network.blockchain),
),
id = network.id,
address = network.address,
decimalCount = network.decimalCount,
blockchain = network.blockchain,
onToggleClick = actionsHandler::onToggleClick,
onNetworkClick = actionsHandler::onNetworkClick,
)
}
private fun createReadTokenContent(token: Token): TokenItemState.ReadContent {
return TokenItemState.ReadContent(
name = getTokenName(token),
iconUrl = token.iconUrl,
networks = token.networks.map(::createReadNetworkContent).toImmutableList(),
)
}
private fun createReadNetworkContent(network: Network): NetworkItemState.ReadContent {
return NetworkItemState.ReadContent(
name = network.blockchain.fullNameWithoutTestnet.uppercase(),
protocolName = getNetworkProtocolName(network),
iconResId = mutableStateOf(getNetworkIconResId(network.address, network.blockchain)),
isMainNetwork = isMainNetwork(network),
)
}
private fun getTokenName(token: Token) = "${token.name} (${token.symbol})"
private fun getNetworkProtocolName(network: Network): String {
return if (network.address == null) {
MAIN_NETWORK_LABEL
} else {
network.blockchain.getNetworkName().uppercase()
}
}
private fun getNetworkIconResId(address: String?, blockchain: Blockchain): Int {
return if (isAdded(address = address, blockchain = blockchain)) {
getActiveIconRes(blockchain.id)
} else {
blockchain.getGreyedOutIconRes()
}
}
private fun isMainNetwork(network: Network) = network.address == null
private fun isAdded(address: String?, blockchain: Blockchain?): Boolean {
return if (address != null) {
addedTokenList.any { addedToken ->
address == addedToken.token.contractAddress && blockchain == addedToken.blockchain
}
} else {
addedBlockchainList.contains(blockchain)
}
}
private inner class ActionsHandler(
private val router: TokensListRouter,
private val debouncer: Debouncer,
) {
fun onBackButtonClick() {
router.popBackStack()
}
fun updateStateByReduxState(state: TokensState) {
uiState = if (state.currencies.isEmpty()) {
TokensListStateHolder.Loading(toolbarState = getToolbarState(reduxState = state))
} else if (state.allowToAdd) {
TokensListStateHolder.ManageAccess(
toolbarState = getToolbarState(reduxState = state),
tokens = state.currencies.map(::toManageTokenItemState).toImmutableList(),
onSaveButtonClick = {
val notNullAddedTokens = addedTokenList ?: return@ManageAccess
val notNullAddedBlockchains = addedBlockchainList ?: return@ManageAccess
fun onSearchButtonClick() {
uiState = uiState.copySealed(
toolbarState = TokensListToolbarState.InputField(
onBackButtonClick = this::onBackButtonClick,
value = "",
onValueChange = this::onSearchValueChange,
onCleanButtonClick = this::onCleanButtonClick,
),
)
}
AnalyticsSender.sendWhenSaveButtonClicked()
store.dispatch(TokensAction.SaveChanges(notNullAddedTokens, notNullAddedBlockchains))
},
)
fun onAddCustomTokenClick() {
analyticsSender.sendWhenAddCustomTokenClicked()
router.openAddCustomTokenScreen()
}
fun onToggleClick(toggledToken: TokenItemState.ManageContent, toggledNetwork: NetworkItemState.ManageContent) {
val blockchain = Blockchain.fromNetworkId(
networkId = if (toggledNetwork.address == null) toggledNetwork.id else toggledToken.id,
)
if (toggledNetwork.address == null && blockchain != null) {
updateBlockchainItem(toggledNetwork, blockchain)
} else {
TokensListStateHolder.ReadAccess(
toolbarState = getToolbarState(state),
tokens = state.currencies.map(::toReadTokenItemState).toImmutableList(),
)
updateTokenItem(toggledToken, toggledNetwork)
}
}
private fun getToolbarState(reduxState: TokensState = store.state.tokensState): TokensListToolbarState {
val toolbarState = uiState.toolbarState
return if (reduxState.searchInput != null && toolbarState is TokensListToolbarState.SearchInputField) {
TokensListToolbarState.SearchInputField(
onBackButtonClick = router::popBackStack,
onSearchButtonClick = ::onSearchButtonClick,
value = toolbarState.value,
onValueChange = ::onSearchValueChange,
onCleanButtonClick = ::onCleanButtonClick,
)
} else if (reduxState.allowToAdd) {
TokensListToolbarState.Title.ManageAccess(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = router::popBackStack,
onSearchButtonClick = ::onSearchButtonClick,
onAddCustomTokenClick = router::openAddCustomTokenScreen,
)
} else {
TokensListToolbarState.Title.ReadAccess(
titleResId = R.string.search_tokens_title,
onBackButtonClick = router::popBackStack,
onSearchButtonClick = ::onSearchButtonClick,
)
}
// FIXME("Necessary to avoid using redux actions")
fun onNetworkClick() {
store.dispatchNotification(R.string.contract_address_copied_message)
}
private fun onSearchButtonClick() {
uiState = when (val state = uiState.toolbarState) {
is TokensListToolbarState.Title -> {
uiState.copySealed(
toolbarState = TokensListToolbarState.SearchInputField(
onBackButtonClick = uiState.toolbarState.onBackButtonClick,
onSearchButtonClick = uiState.toolbarState.onSearchButtonClick,
value = "",
onValueChange = ::onSearchValueChange,
onCleanButtonClick = ::onCleanButtonClick,
),
)
fun onTokensLoadStateChanged(state: LoadState) {
uiState = when (state) {
is LoadState.NotLoading -> {
analyticsSender.sendWhenTokenSearched()
if (args.isManageAccess) {
TokensListStateHolder.ManageContent(
toolbarState = uiState.toolbarState,
tokens = uiState.tokens,
onTokensLoadStateChanged = uiState.onTokensLoadStateChanged,
onSaveButtonClick = this::onSaveButtonClick,
)
} else {
TokensListStateHolder.ReadContent(
toolbarState = uiState.toolbarState,
tokens = uiState.tokens,
onTokensLoadStateChanged = uiState.onTokensLoadStateChanged,
)
}
}
is TokensListToolbarState.SearchInputField -> {
store.dispatch(TokensAction.SetSearchInput(searchInput = state.value))
uiState.copySealed(toolbarState = getToolbarState())
else -> {
TokensListStateHolder.Loading(
toolbarState = uiState.toolbarState,
tokens = uiState.tokens,
onTokensLoadStateChanged = uiState.onTokensLoadStateChanged,
)
}
}
}
private fun onSearchValueChange(newValue: String) {
(uiState.toolbarState as? TokensListToolbarState.SearchInputField)?.let { state ->
uiState = uiState.copySealed(toolbarState = state.copy(value = newValue))
}
val state = requireNotNull(uiState.toolbarState as? TokensListToolbarState.InputField)
uiState = uiState.copySealed(toolbarState = state.copy(value = newValue))
debounceJob?.cancel()
debounceJob = viewModelScope.launch(dispatchers.io) {
delay(timeMillis = 800L)
store.dispatch(TokensAction.SetSearchInput(searchInput = newValue))
debouncer.debounce(waitMs = 800L, coroutineScope = viewModelScope + dispatchers.io) {
uiState = uiState.copySealed(tokens = getInitialTokensList(newValue))
}
}
private fun onCleanButtonClick() {
(uiState.toolbarState as? TokensListToolbarState.SearchInputField)?.let { state ->
if (state.value.isEmpty()) {
uiState = uiState.copySealed(toolbarState = getToolbarState())
} else {
onSearchValueChange(newValue = "")
}
val state = requireNotNull(uiState.toolbarState as? TokensListToolbarState.InputField)
if (state.value.isEmpty()) {
uiState = uiState.copySealed(toolbarState = getInitialToolbarState())
} else {
onSearchValueChange(newValue = "")
}
}
private fun onToggleClick(tokenId: String, networkId: String) {
(uiState as? TokensListStateHolder.ManageAccess)?.let { state ->
val switchedToken = state.tokens.firstOrNull { it.id == tokenId }
val contractAddress = switchedToken?.networks
?.firstOrNull { it.networkId == networkId }
?.contractAddress
private fun updateBlockchainItem(toggledNetwork: NetworkItemState.ManageContent, blockchain: Blockchain) {
val isRemoveAction = addedBlockchainList.contains(blockchain)
val blockchain = Blockchain.fromNetworkId(
networkId = if (contractAddress == null) networkId else tokenId,
)
if (isRemoveAction) {
val isTokenWithSameBlockchainFound = addedTokenList.any { it.blockchain == blockchain }
val isAddedOnMainScreen = args.mainScreenBlockchainList.contains(blockchain)
if (blockchain != null && contractAddress == null) {
onBlockchainToggleClick(blockchain)
} else if (contractAddress != null) {
onTokenToggleClick(tokenId, networkId, switchedToken, contractAddress)
}
updateTokensList()
}
}
private fun onBlockchainToggleClick(blockchain: Blockchain) {
val isRemoveAction = addedBlockchainList?.contains(blockchain)
if (isRemoveAction == true) {
val isTokenWithSameBlockchainFound = addedTokenList?.any { it.blockchain == blockchain }
val isAddedOnMainScreen = originalAddedBlockchainList?.contains(blockchain)
if (isTokenWithSameBlockchainFound == true) {
if (isTokenWithSameBlockchainFound) {
store.dispatchDialogShow(
WalletDialog.TokensAreLinkedDialog(
currencyTitle = blockchain.name,
currencySymbol = blockchain.currency,
),
)
} else if (isAddedOnMainScreen == true) {
} else if (isAddedOnMainScreen) {
store.dispatchDialogShow(
WalletDialog.RemoveWalletDialog(
currencyTitle = blockchain.name,
onOk = {
AnalyticsSender.sendWhenBlockchainAdded(blockchain)
addedBlockchainList = addedBlockchainList?.minus(blockchain)
updateTokensList()
analyticsSender.sendWhenBlockchainAdded(blockchain)
addedBlockchainList.remove(blockchain)
toggledNetwork.changeToggleState()
},
),
)
} else {
AnalyticsSender.sendWhenBlockchainRemoved(blockchain)
addedBlockchainList = addedBlockchainList?.minus(blockchain)
analyticsSender.sendWhenBlockchainRemoved(blockchain)
addedBlockchainList.remove(blockchain)
toggledNetwork.changeToggleState()
}
} else {
AnalyticsSender.sendWhenBlockchainAdded(blockchain)
addedBlockchainList = addedBlockchainList?.plus(blockchain)
analyticsSender.sendWhenBlockchainAdded(blockchain)
addedBlockchainList.add(blockchain)
toggledNetwork.changeToggleState()
}
}
private fun onTokenToggleClick(
tokenId: String,
networkId: String,
switchedToken: TokenItemState.ManageAccess,
contractAddress: String,
private fun updateTokenItem(
toggledToken: TokenItemState.ManageContent,
toggledNetwork: NetworkItemState.ManageContent,
) {
val currency = store.state.tokensState.currencies.firstOrNull { it.id == tokenId } ?: return
val contract = currency.contracts.firstOrNull { it.networkId == networkId } ?: return
val token = TokenWithBlockchain(
token = Token(
id = switchedToken.id,
name = switchedToken.name,
symbol = currency.symbol,
contractAddress = contractAddress,
decimals = requireNotNull(contract.decimalCount),
token = BlockchainToken(
id = toggledToken.id,
name = toggledToken.name,
symbol = toggledToken.symbol,
contractAddress = requireNotNull(toggledNetwork.address),
decimals = requireNotNull(toggledNetwork.decimalCount),
),
blockchain = contract.blockchain,
blockchain = toggledNetwork.blockchain,
)
val isUnsupportedToken = !store.state.tokensState.canHandleToken(token)
val isRemoveAction = addedTokenList?.contains(token)
val isAddedOnMainScreen = originalAddedTokenList?.contains(token)
if (isRemoveAction == true) {
if (isAddedOnMainScreen == true) {
val isRemoveAction = addedTokenList.contains(token)
if (isRemoveAction) {
val isAddedOnMainScreen = args.mainScreenTokenList.contains(token)
if (isAddedOnMainScreen) {
store.dispatchDialogShow(
WalletDialog.RemoveWalletDialog(
currencyTitle = token.token.name,
onOk = {
AnalyticsSender.sendWhenTokenRemoved(token.token)
addedTokenList = addedTokenList?.minus(token)
updateTokensList()
analyticsSender.sendWhenTokenRemoved(token.token)
addedTokenList.remove(token)
toggledNetwork.changeToggleState()
},
),
)
} else {
AnalyticsSender.sendWhenTokenRemoved(token.token)
addedTokenList = addedTokenList?.minus(token)
analyticsSender.sendWhenTokenRemoved(token.token)
addedTokenList.remove(token)
toggledNetwork.changeToggleState()
}
} else {
val isUnsupportedToken =
!(reduxStateHolder.scanResponse?.card?.canHandleToken(token.blockchain) ?: false)
if (isUnsupportedToken) {
store.dispatchDialogShow(
AppDialog.SimpleOkDialogRes(
@ -350,100 +360,16 @@ internal class TokensListViewModel @Inject constructor(
),
)
} else {
AnalyticsSender.sendWhenTokenAdded(token.token)
addedTokenList = addedTokenList?.plus(token)
analyticsSender.sendWhenTokenAdded(token.token)
addedTokenList.add(token)
toggledNetwork.changeToggleState()
}
}
}
private fun updateTokensList() {
(uiState as? TokensListStateHolder.ManageAccess)?.let { state ->
val tokens = store.state.tokensState.currencies.mapNotNull { currency ->
state.tokens.firstOrNull { it.id == currency.id }?.copy(
networks = currency.contracts.mapNotNull { contract ->
state.tokens.firstOrNull { it.id == currency.id }
?.networks?.firstOrNull { it.networkId == contract.networkId }
?.copy(
isAdded = contract.isAdded(),
iconResId = if (contract.isAdded()) {
getActiveIconRes(contract.blockchain.id)
} else {
contract.blockchain.getGreyedOutIconRes()
},
)
}.toImmutableList(),
)
}.toImmutableList()
uiState = state.copy(tokens = tokens)
}
}
private fun toManageTokenItemState(currency: Currency): TokenItemState.ManageAccess {
return TokenItemState.ManageAccess(
name = currency.fullName,
iconUrl = currency.iconUrl,
networks = currency.contracts.map(::toManageNetworkItemState).toImmutableList(),
id = currency.id,
)
}
private fun toManageNetworkItemState(contract: Contract): NetworkItemState.ManageAccess {
val isAdded = contract.isAdded()
return NetworkItemState.ManageAccess(
name = contract.blockchain.fullNameWithoutTestnet.uppercase(),
protocolName = if (contract.address == null) {
MAIN_NETWORK_LABEL
} else {
contract.blockchain.getNetworkName().uppercase()
},
iconResId = if (isAdded) {
getActiveIconRes(contract.blockchain.id)
} else {
contract.blockchain.getGreyedOutIconRes()
},
isMainNetwork = contract.address == null,
isAdded = isAdded,
networkId = contract.networkId,
contractAddress = contract.address,
onToggleClick = ::onToggleClick,
onNetworkClick = { store.dispatchNotification(R.string.contract_address_copied_message) },
)
}
private fun toReadTokenItemState(currency: Currency): TokenItemState.ReadAccess {
return TokenItemState.ReadAccess(
name = currency.fullName,
iconUrl = currency.iconUrl,
networks = currency.contracts.map(::toReadNetworkItemState).toImmutableList(),
)
}
private fun toReadNetworkItemState(contract: Contract): NetworkItemState.ReadAccess {
return NetworkItemState.ReadAccess(
name = contract.blockchain.fullNameWithoutTestnet.uppercase(),
protocolName = if (contract.address == null) {
MAIN_NETWORK_LABEL
} else {
contract.blockchain.getNetworkName().uppercase()
},
iconResId = if (contract.isAdded()) {
getActiveIconRes(contract.blockchain.id)
} else {
contract.blockchain.getGreyedOutIconRes()
},
isMainNetwork = contract.address == null,
)
}
private fun Contract.isAdded(): Boolean {
return if (address != null) {
addedTokenList?.any { addedToken ->
address == addedToken.token.contractAddress && blockchain == addedToken.blockchain
}
} else {
addedBlockchainList?.contains(blockchain)
} ?: false
private fun onSaveButtonClick() {
analyticsSender.sendWhenSaveButtonClicked()
store.dispatch(TokensAction.SaveChanges(addedTokenList, addedBlockchainList))
}
}

View file

@ -113,35 +113,30 @@ class TokensMiddleware {
val scanResponse = store.state.globalState.scanResponse ?: return@launch
val currentTokens = store.state.tokensState.addedWallets.toNonCustomTokensWithBlockchains(
scanResponse.card.derivationStyle,
derivationStyle = scanResponse.card.derivationStyle,
)
val currentBlockchains = store.state.tokensState.addedWallets.toNonCustomBlockchains(
scanResponse.card.derivationStyle,
derivationStyle = scanResponse.card.derivationStyle,
)
val blockchainsToAdd = action.addedBlockchains.filter { !currentBlockchains.contains(it) }
val blockchainsToRemove =
currentBlockchains.filter { !action.addedBlockchains.contains(it) }
val blockchainsToAdd = action.addedBlockchains.filterNot(currentBlockchains::contains)
val blockchainsToRemove = currentBlockchains.filterNot(action.addedBlockchains::contains)
val tokensToAdd = action.addedTokens.filter { !currentTokens.contains(it) }
val tokensToRemove = currentTokens.filter { token ->
!action.addedTokens.any { it.token == token.token }
}
val derivationStyle = scanResponse.card.derivationStyle
val tokensToAdd = action.addedTokens.filterNot(currentTokens::contains)
val tokensToRemove = currentTokens.filterNot { token -> action.addedTokens.any { it.token == token.token } }
removeCurrenciesIfNeeded(
convertToCurrencies(
currencies = convertToCurrencies(
blockchains = blockchainsToRemove,
tokens = tokensToRemove,
derivationStyle = derivationStyle,
derivationStyle = scanResponse.card.derivationStyle,
),
)
@Suppress("ComplexCondition")
if (tokensToAdd.isEmpty() && tokensToRemove.isEmpty() &&
blockchainsToAdd.isEmpty() && blockchainsToRemove.isEmpty()
) {
store.dispatchDebugErrorNotification("Nothing to save")
val isNothingToDoWithTokens = tokensToAdd.isEmpty() && tokensToRemove.isEmpty()
val isNothingToDoWithBlockchain = blockchainsToAdd.isEmpty() && blockchainsToRemove.isEmpty()
if (isNothingToDoWithTokens && isNothingToDoWithBlockchain) {
store.dispatchDebugErrorNotification(message = "Nothing to save")
store.dispatchOnMain(NavigationAction.PopBackTo())
return@launch
}
@ -149,8 +144,9 @@ class TokensMiddleware {
val currencyList = convertToCurrencies(
blockchains = blockchainsToAdd,
tokens = tokensToAdd,
derivationStyle = derivationStyle,
derivationStyle = scanResponse.card.derivationStyle,
)
if (scanResponse.supportsHdWallet()) {
deriveMissingBlockchains(scanResponse, currencyList) {
submitAdd(it, currencyList)