Updated on 2026-08-14

This commit is contained in:
Tangem 2023-04-10 18:22:31 +09:00
parent 36516c900f
commit c069df9302
19 changed files with 786 additions and 438 deletions

View file

@ -76,6 +76,7 @@ dependencies {
kapt(deps.hilt.kapt)
/** Other libraries */
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.googlePlay.core)
implementation(deps.googlePlay.core.ktx)

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import com.google.accompanist.appcompattheme.AppCompatTheme
@ -49,6 +50,8 @@ class HomeFragment : Fragment(), StoreSubscriber<HomeState> {
override fun onStart() {
super.onStart()
activity?.window?.let { WindowCompat.setDecorFitsSystemWindows(it, false) }
store.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.homeState == newState.homeState

View file

@ -5,13 +5,14 @@ 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.viewmodels.AddTokensViewModel
import com.tangem.tap.features.tokens.presentation.viewmodels.TokensListViewModel
import com.tangem.wallet.R
import dagger.hilt.android.AndroidEntryPoint
@ -35,9 +36,8 @@ internal class TokensListFragment : Fragment() {
setContent {
isTransitionGroup = true
val viewModel = hiltViewModel<AddTokensViewModel>().apply {
// TODO("[REDACTED_TASK_KEY] subscribe on Redux events")
// subscribeOnReduxEvents(lifecycle = LocalLifecycleOwner.current.lifecycle)
val viewModel = hiltViewModel<TokensListViewModel>().apply {
subscribeOnReduxEvents(lifecycle = LocalLifecycleOwner.current.lifecycle)
}
TangemTheme {

View file

@ -1,11 +1,13 @@
package com.tangem.tap.features.tokens.presentation.states
import com.tangem.tap.features.tokens.redux.ContractAddress
/**
* Network item state
*
[REDACTED_AUTHOR]
*/
sealed interface AddTokensNetworkItemState {
sealed interface NetworkItemState {
/** Network name */
val name: String
@ -23,6 +25,7 @@ sealed interface AddTokensNetworkItemState {
* Network item state that is available for read
*
* @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
*/
@ -31,25 +34,30 @@ sealed interface AddTokensNetworkItemState {
override val protocolName: String,
override val iconResId: Int,
override val isMainNetwork: Boolean,
) : AddTokensNetworkItemState
) : NetworkItemState
/**
* Network item state that is available for read and edit
*
* @property name network 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 onToggleClick lambda be invoked when switch is been toggled
* @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
*/
data class EditAccess(
data class ManageAccess(
override val name: String,
override val protocolName: String,
override val iconResId: Int,
override val isMainNetwork: Boolean,
val isAdded: Boolean,
val networkId: String,
val onToggleClick: (String) -> Unit,
) : AddTokensNetworkItemState
val contractAddress: ContractAddress?,
val onToggleClick: (String, String) -> Unit,
val onNetworkClick: () -> Unit,
) : NetworkItemState
}

View file

@ -1,14 +0,0 @@
package com.tangem.tap.features.tokens.presentation.states
/**
* Token model
*
* @property name token name
* @property iconUrl token icon url
* @property networks list of networks
*/
data class TokenItemModel(
val name: String,
val iconUrl: String,
val networks: List<AddTokensNetworkItemState>,
)

View file

@ -0,0 +1,48 @@
package com.tangem.tap.features.tokens.presentation.states
import kotlinx.collections.immutable.ImmutableList
/**
* Network item state
*
[REDACTED_AUTHOR]
*/
sealed interface TokenItemState {
/** Token name */
val name: String
/** Token icon url */
val iconUrl: String
/** List of networks */
val networks: ImmutableList<NetworkItemState>
/**
* Token item state that is available for read
*
* @property name token name
* @property iconUrl token icon url
* @property networks list of networks that is available for read
*/
data class ReadAccess(
override val name: String,
override val iconUrl: String,
override val networks: ImmutableList<NetworkItemState.ReadAccess>,
) : TokenItemState
/**
* Token item state that is available for read and edit
*
* @property name token name
* @property iconUrl token icon url
* @property networks list of networks is available for read and edit
* @property id token id
*/
data class ManageAccess(
override val name: String,
override val iconUrl: String,
override val networks: ImmutableList<NetworkItemState.ManageAccess>,
val id: String,
) : TokenItemState
}

View file

@ -1,21 +1,23 @@
package com.tangem.tap.features.tokens.presentation.states
import kotlinx.collections.immutable.ImmutableList
/**
* State holder for adding token screen
* State holder for screen with list of tokens
*
[REDACTED_AUTHOR]
*/
internal sealed interface AddTokensStateHolder {
internal sealed interface TokensListStateHolder {
/** Toolbar state */
val toolbarState: AddTokensToolbarState
val toolbarState: TokensListToolbarState
/**
* Util function that allow to make a copy
*
* @param toolbarState toolbar state
*/
fun copySealed(toolbarState: AddTokensToolbarState): AddTokensStateHolder {
fun copySealed(toolbarState: TokensListToolbarState): TokensListStateHolder {
return when (this) {
is ManageAccess -> copy(toolbarState = toolbarState)
is Loading -> copy(toolbarState = toolbarState)
@ -28,7 +30,7 @@ internal sealed interface AddTokensStateHolder {
*
* @property toolbarState toolbar state
*/
data class Loading(override val toolbarState: AddTokensToolbarState) : AddTokensStateHolder
data class Loading(override val toolbarState: TokensListToolbarState) : TokensListStateHolder
/**
* State screen that is available only for read
@ -37,9 +39,9 @@ internal sealed interface AddTokensStateHolder {
* @property tokens tokens list
*/
data class ReadAccess(
override val toolbarState: AddTokensToolbarState,
override val tokens: List<TokenItemModel>,
) : AddTokensStateHolder, TokensListVisibility
override val toolbarState: TokensListToolbarState,
override val tokens: ImmutableList<TokenItemState.ReadAccess>,
) : TokensListStateHolder, TokensListVisibility
/**
* State screen that is available for read and edit
@ -49,8 +51,8 @@ internal sealed interface AddTokensStateHolder {
* @property onSaveButtonClick callback to be invoked when SaveButton is being clicked
*/
data class ManageAccess(
override val toolbarState: AddTokensToolbarState,
override val tokens: List<TokenItemModel>,
override val toolbarState: TokensListToolbarState,
override val tokens: ImmutableList<TokenItemState.ManageAccess>,
val onSaveButtonClick: () -> Unit,
) : AddTokensStateHolder, TokensListVisibility
) : TokensListStateHolder, TokensListVisibility
}

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.tokens.presentation.states
/** Toolbar state */
sealed interface AddTokensToolbarState {
sealed interface TokensListToolbarState {
/** Callback to be invoked when BackButton is being clicked */
val onBackButtonClick: () -> Unit
@ -10,7 +10,7 @@ sealed interface AddTokensToolbarState {
val onSearchButtonClick: () -> Unit
/** Toolbar state as title */
sealed interface Title : AddTokensToolbarState {
sealed interface Title : TokensListToolbarState {
/** Toolbar title id from resources */
val titleResId: Int
@ -36,7 +36,7 @@ sealed interface AddTokensToolbarState {
* @property onSearchButtonClick callback to be invoked when SearchButton is being clicked
* @property onAddCustomTokenClick callback to be invoked when AddCustomTokenButton is being clicked
*/
data class EditAccess(
data class ManageAccess(
override val titleResId: Int,
override val onBackButtonClick: () -> Unit,
override val onSearchButtonClick: () -> Unit,
@ -59,5 +59,5 @@ sealed interface AddTokensToolbarState {
val value: String,
val onValueChange: (String) -> Unit,
val onCleanButtonClick: () -> Unit,
) : AddTokensToolbarState
) : TokensListToolbarState
}

View file

@ -1,8 +1,10 @@
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: List<TokenItemModel>
val tokens: ImmutableList<TokenItemState>
}

View file

@ -1,6 +1,9 @@
package com.tangem.tap.features.tokens.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@ -12,6 +15,7 @@ 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
@ -22,33 +26,51 @@ 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.AddTokensNetworkItemState
import com.tangem.tap.features.tokens.presentation.states.AddTokensStateHolder
import com.tangem.tap.features.tokens.presentation.states.AddTokensToolbarState
import com.tangem.tap.features.tokens.presentation.states.TokenItemModel
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
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddTokensScreen(stateHolder: AddTokensStateHolder) {
internal fun AddTokensScreen(stateHolder: TokensListStateHolder) {
BackHandler(onBack = stateHolder.toolbarState.onBackButtonClick)
Scaffold(
topBar = { AddTokensToolbar(state = stateHolder.toolbarState) },
floatingActionButton = {
if (stateHolder is AddTokensStateHolder.ManageAccess) {
if (stateHolder is TokensListStateHolder.ManageAccess) {
SaveChangesButton(onClick = stateHolder.onSaveButtonClick)
}
},
) {
when (stateHolder) {
is AddTokensStateHolder.Loading -> LoadingContent()
is TokensListVisibility -> TokensListContent(stateHolder = stateHolder, scaffoldPadding = it)
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,
)
}
}
AnimatedVisibility(
visible = stateHolder is TokensListStateHolder.Loading,
enter = fadeIn(),
exit = fadeOut(),
) {
LoadingContent()
}
}
}
@ -67,9 +89,10 @@ private fun LoadingContent() {
@Composable
private fun TokensListContent(stateHolder: TokensListVisibility, scaffoldPadding: PaddingValues) {
val state = rememberLazyListState()
state.OnBottomReached(loadMoreThreshold = 40) {
store.dispatch(TokensAction.LoadMore(scanResponse = store.state.globalState.scanResponse))
val state = rememberLazyListState().apply {
OnBottomReached(loadMoreThreshold = 40) {
store.dispatch(TokensAction.LoadMore(scanResponse = store.state.globalState.scanResponse))
}
}
LazyColumn(
modifier = Modifier
@ -79,7 +102,7 @@ private fun TokensListContent(stateHolder: TokensListVisibility, scaffoldPadding
) {
items(
items = stateHolder.tokens,
key = TokenItemModel::name,
key = TokenItemState::name,
itemContent = { TokenItem(model = it) },
)
}
@ -102,8 +125,8 @@ private fun SaveChangesButton(onClick: () -> Unit) {
private fun Preview_AddTokensScreen_Loading() {
TangemTheme {
AddTokensScreen(
stateHolder = AddTokensStateHolder.Loading(
toolbarState = AddTokensToolbarState.Title.EditAccess(
stateHolder = TokensListStateHolder.Loading(
toolbarState = TokensListToolbarState.Title.ManageAccess(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
@ -119,53 +142,16 @@ private fun Preview_AddTokensScreen_Loading() {
private fun Preview_AddTokensScreen_ManageAccess() {
TangemTheme {
AddTokensScreen(
stateHolder = AddTokensStateHolder.ManageAccess(
toolbarState = AddTokensToolbarState.Title.EditAccess(
stateHolder = TokensListStateHolder.ManageAccess(
toolbarState = TokensListToolbarState.Title.ManageAccess(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
tokens = listOf(
TokenItemModel(
name = "ETHEREUM (ETH)",
iconUrl = "",
networks = listOf(
AddTokensNetworkItemState.EditAccess(
name = "ETHEREUM",
protocolName = "MAIN",
iconResId = R.drawable.img_btc_22,
isMainNetwork = true,
isAdded = true,
networkId = "",
onToggleClick = {},
),
),
),
TokenItemModel(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = listOf(
AddTokensNetworkItemState.EditAccess(
name = "ETHEREUM",
protocolName = "MAIN",
iconResId = R.drawable.img_eth_22,
isMainNetwork = false,
isAdded = true,
networkId = "",
onToggleClick = {},
),
AddTokensNetworkItemState.EditAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
isAdded = false,
networkId = "",
onToggleClick = {},
),
),
),
tokens = persistentListOf(
TokenListPreviewData.createManageToken(),
TokenListPreviewData.createManageToken(),
),
onSaveButtonClick = {},
),
@ -178,44 +164,15 @@ private fun Preview_AddTokensScreen_ManageAccess() {
private fun Preview_AddTokensScreen_ReadAccess() {
TangemTheme {
AddTokensScreen(
stateHolder = AddTokensStateHolder.ReadAccess(
toolbarState = AddTokensToolbarState.Title.EditAccess(
stateHolder = TokensListStateHolder.ReadAccess(
toolbarState = TokensListToolbarState.Title.ReadAccess(
titleResId = R.string.search_tokens_title,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
tokens = listOf(
TokenItemModel(
name = "ETHEREUM (ETH)",
iconUrl = "",
networks = listOf(
AddTokensNetworkItemState.ReadAccess(
name = "ETHEREUM",
protocolName = "MAIN",
iconResId = R.drawable.img_btc_22,
isMainNetwork = true,
),
),
),
TokenItemModel(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = listOf(
AddTokensNetworkItemState.ReadAccess(
name = "ETHEREUM",
protocolName = "MAIN",
iconResId = R.drawable.img_eth_22,
isMainNetwork = false,
),
AddTokensNetworkItemState.ReadAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
),
),
),
tokens = persistentListOf(
TokenListPreviewData.createReadToken(),
TokenListPreviewData.createReadToken(),
),
),
)

View file

@ -1,12 +1,8 @@
package com.tangem.tap.features.tokens.presentation.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@ -16,13 +12,18 @@ import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -30,17 +31,19 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
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.AddTokensToolbarState
import com.tangem.tap.features.tokens.presentation.states.AddTokensToolbarState.SearchInputField
import com.tangem.tap.features.tokens.presentation.states.AddTokensToolbarState.Title
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.Title
import com.tangem.wallet.R
import kotlinx.coroutines.delay
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddTokensToolbar(state: AddTokensToolbarState) {
internal fun AddTokensToolbar(state: TokensListToolbarState) {
TopAppBar(backgroundColor = TangemTheme.colors.background.secondary) {
IconButton(onClick = state.onBackButtonClick) {
Icon(
@ -50,14 +53,9 @@ internal fun AddTokensToolbar(state: AddTokensToolbarState) {
)
}
val contentModifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(bottom = TangemTheme.dimens.spacing4)
when (state) {
is Title -> TitleContent(state = state, modifier = contentModifier)
is SearchInputField -> InputContent(state = state, modifier = contentModifier)
is Title -> TitleContent(state = state, modifier = Modifier.weight(1f))
is SearchInputField -> InputContent(state = state, modifier = Modifier.weight(1f))
}
}
}
@ -66,7 +64,7 @@ internal fun AddTokensToolbar(state: AddTokensToolbarState) {
private fun TitleContent(state: Title, modifier: Modifier = Modifier) {
Text(
text = stringResource(id = state.titleResId),
modifier = modifier,
modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing26),
color = TangemTheme.colors.text.primary1,
maxLines = 1,
style = TangemTheme.typography.h3,
@ -80,7 +78,7 @@ private fun TitleContent(state: Title, modifier: Modifier = Modifier) {
)
}
if (state is Title.EditAccess) {
if (state is Title.ManageAccess) {
IconButton(onClick = state.onAddCustomTokenClick) {
Icon(
painter = painterResource(id = R.drawable.ic_plus_24),
@ -91,12 +89,15 @@ private fun TitleContent(state: Title, modifier: Modifier = Modifier) {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun InputContent(state: SearchInputField, modifier: Modifier = Modifier) {
private fun InputContent(state: SearchInputField, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = state.value,
onValueChange = state.onValueChange,
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() }),
@ -104,48 +105,52 @@ fun InputContent(state: SearchInputField, modifier: Modifier = Modifier) {
maxLines = 1,
cursorBrush = SolidColor(value = TangemTheme.colors.stroke.secondary),
) { innerTextField ->
Column(modifier = Modifier.padding(top = TangemTheme.dimens.spacing6)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.weight(1f)
.padding(start = TangemTheme.dimens.spacing6),
) {
Hint(value = state.value)
innerTextField()
}
IconButton(onClick = state.onCleanButtonClick) {
Icon(
painter = painterResource(id = R.drawable.ic_cross_rounded_24),
contentDescription = "Clean entered value",
tint = TangemTheme.colors.icon.secondary,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = TangemTheme.dimens.size1)
.background(color = TangemTheme.colors.stroke.primary),
)
.weight(1f)
.padding(
start = TangemTheme.dimens.spacing28,
top = TangemTheme.dimens.spacing2,
end = TangemTheme.dimens.spacing16,
),
) {
Hint(value = state.value)
innerTextField()
}
IconButton(onClick = state.onCleanButtonClick) {
Icon(
painter = painterResource(id = R.drawable.ic_cross_rounded_24),
contentDescription = "Clean entered value",
tint = TangemTheme.colors.icon.secondary,
)
}
}
}
// This is a hack because Compose can't show the keyboard if the input field is not found.
// So first I request focus and make a delay and then show the keyboard.
val keyboardManager = LocalSoftwareKeyboardController.current
LaunchedEffect(keyboardManager) {
focusRequester.requestFocus()
delay(timeMillis = 100)
keyboardManager?.show()
}
}
@Composable
private fun Hint(value: String) {
if (value.isBlank()) {
Row(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing4),
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_search_24),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
tint = TangemColorPalette.Dark1,
)
Text(
text = stringResource(id = R.string.common_search),
@ -164,7 +169,7 @@ private fun Hint(value: String) {
private fun Preview_AddTokensToolbar_EditAccess() {
TangemTheme {
AddTokensToolbar(
state = Title.EditAccess(
state = Title.ManageAccess(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},

View file

@ -1,10 +1,8 @@
package com.tangem.tap.features.tokens.presentation.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -20,7 +18,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.presentation.states.AddTokensNetworkItemState
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
import kotlinx.collections.immutable.ImmutableCollection
/**
[REDACTED_AUTHOR]
@ -28,21 +27,19 @@ import com.tangem.tap.features.tokens.presentation.states.AddTokensNetworkItemSt
@Composable
internal fun BriefNetworksList(
isCollapsed: Boolean,
networks: List<AddTokensNetworkItemState>,
networks: ImmutableCollection<NetworkItemState>,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isCollapsed,
modifier = modifier,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
enter = fadeIn(),
exit = fadeOut(),
) {
if (isCollapsed) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
networks.forEach {
key(it.name) {
BriefNetworkItem(model = it)
}
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
networks.forEach {
key(NetworkItemState::name) {
BriefNetworkItem(model = it)
}
}
}
@ -53,7 +50,7 @@ internal fun BriefNetworksList(
[REDACTED_AUTHOR]
*/
@Composable
internal fun BriefNetworkItem(model: AddTokensNetworkItemState) {
internal fun BriefNetworkItem(model: NetworkItemState) {
Box(modifier = Modifier.size(size = TangemTheme.dimens.size20)) {
Image(
painter = painterResource(id = model.iconResId),

View file

@ -5,6 +5,9 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@ -18,8 +21,11 @@ import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
@ -28,8 +34,8 @@ import androidx.compose.ui.tooling.preview.Preview
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.AddTokensNetworkItemState
import com.tangem.wallet.R
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
import kotlinx.collections.immutable.ImmutableCollection
/**
[REDACTED_AUTHOR]
@ -37,7 +43,8 @@ import com.tangem.wallet.R
@Composable
internal fun DetailedNetworksList(
isExpanded: Boolean,
networks: List<AddTokensNetworkItemState>,
tokenId: String?,
networks: ImmutableCollection<NetworkItemState>,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@ -46,38 +53,51 @@ internal fun DetailedNetworksList(
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
) {
if (isExpanded) {
Column {
networks.forEachIndexed { index, network ->
key(network.name) {
DetailedNetworkItem(model = network, isLastItem = networks.lastIndex == index)
}
Column {
networks.forEachIndexed { index, network ->
key(network.name) {
DetailedNetworkItem(model = network, tokenId = tokenId, isLastItem = networks.size - 1 == index)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DetailedNetworkItem(model: AddTokensNetworkItemState, isLastItem: Boolean) {
private fun DetailedNetworkItem(model: NetworkItemState, tokenId: String?, isLastItem: Boolean) {
val clipboardManager = LocalClipboardManager.current
val itemHeight = TangemTheme.dimens.size50
Row(
modifier = Modifier
.combinedClickable(
enabled = model is NetworkItemState.ManageAccess,
onLongClick = {
if (model is NetworkItemState.ManageAccess && model.contractAddress != null) {
clipboardManager.setText(AnnotatedString(text = model.contractAddress))
model.onNetworkClick()
}
},
onClick = {},
indication = null,
interactionSource = remember { MutableInteractionSource() },
)
.fillMaxWidth()
.heightIn(min = itemHeight)
.padding(start = TangemTheme.dimens.spacing38),
verticalAlignment = Alignment.CenterVertically,
) {
NetworkItemArrow(itemHeight = itemHeight, isLastItem = isLastItem)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing14))
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing16))
BriefNetworkItem(model = model)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing6))
NetworkTitle(model = model)
if (model is AddTokensNetworkItemState.EditAccess) {
if (model is NetworkItemState.ManageAccess) {
Switch(
checked = model.isAdded,
onCheckedChange = { model.onToggleClick(model.networkId) },
onCheckedChange = { model.onToggleClick(requireNotNull(tokenId), model.networkId) },
modifier = Modifier.padding(start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing8),
colors = SwitchDefaults.colors(
checkedThumbColor = TangemColorPalette.Meadow,
@ -88,7 +108,7 @@ private fun DetailedNetworkItem(model: AddTokensNetworkItemState, isLastItem: Bo
}
@Composable
private fun RowScope.NetworkTitle(model: AddTokensNetworkItemState) {
private fun RowScope.NetworkTitle(model: NetworkItemState) {
Text(
modifier = Modifier.weight(1f),
text = buildAnnotatedString {
@ -105,7 +125,7 @@ private fun RowScope.NetworkTitle(model: AddTokensNetworkItemState) {
},
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = if (model is AddTokensNetworkItemState.EditAccess && model.isAdded) {
color = if (model is NetworkItemState.ManageAccess && model.isAdded) {
TangemColorPalette.Black
} else {
TangemColorPalette.Dark2
@ -115,30 +135,12 @@ private fun RowScope.NetworkTitle(model: AddTokensNetworkItemState) {
@Preview
@Composable
private fun Preview_DetailedNetworksList_EditAccess() {
private fun Preview_DetailedNetworksList_ManageAccess() {
TangemTheme {
DetailedNetworksList(
networks = listOf(
AddTokensNetworkItemState.EditAccess(
name = "ETHEREUM",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
isMainNetwork = true,
isAdded = true,
networkId = "",
onToggleClick = {},
),
AddTokensNetworkItemState.EditAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
isAdded = false,
networkId = "",
onToggleClick = {},
),
),
isExpanded = false,
isExpanded = true,
tokenId = null,
networks = TokenListPreviewData.createManageNetworksList(),
)
}
}
@ -148,21 +150,9 @@ private fun Preview_DetailedNetworksList_EditAccess() {
private fun Preview_DetailedNetworksList_ReadAccess() {
TangemTheme {
DetailedNetworksList(
networks = listOf(
AddTokensNetworkItemState.ReadAccess(
name = "ETHEREUM",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
isMainNetwork = true,
),
AddTokensNetworkItemState.ReadAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
),
),
isExpanded = false,
isExpanded = true,
tokenId = null,
networks = TokenListPreviewData.createReadNetworksList(),
)
}
}

View file

@ -22,7 +22,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -38,16 +38,15 @@ import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.compose.extensions.toPx
import com.tangem.tap.features.tokens.presentation.states.AddTokensNetworkItemState
import com.tangem.tap.features.tokens.presentation.states.TokenItemModel
import com.tangem.tap.features.tokens.presentation.states.TokenItemState
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun TokenItem(model: TokenItemModel) {
var isExpanded by remember { mutableStateOf(false) }
internal fun TokenItem(model: TokenItemState) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
ConstraintLayout(
modifier = Modifier
@ -102,6 +101,7 @@ internal fun TokenItem(model: TokenItemModel) {
DetailedNetworksList(
isExpanded = isExpanded,
tokenId = (model as? TokenItemState.ManageAccess)?.id,
networks = model.networks,
modifier = Modifier.constrainAs(detailedNetworksList) {
top.linkTo(anchor = availableNetworksText.bottom, margin = spacing16)
@ -115,7 +115,7 @@ internal fun TokenItem(model: TokenItemModel) {
onClick = { isExpanded = !isExpanded },
modifier = Modifier.constrainAs(changeNetworksViewButton) {
top.linkTo(parent.top)
end.linkTo(anchor = parent.end, margin = spacing16)
end.linkTo(anchor = parent.end, margin = spacing6)
if (!isExpanded) bottom.linkTo(parent.bottom)
},
)
@ -205,34 +205,9 @@ private fun ChangeNetworksViewButton(isExpanded: Boolean, onClick: () -> Unit, m
@Preview
@Composable
private fun Preview_TokenItem_EditAccess() {
private fun Preview_TokenItem_ManageAccess() {
TangemTheme {
TokenItem(
TokenItemModel(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = listOf(
AddTokensNetworkItemState.EditAccess(
name = "Ethereum",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
isMainNetwork = true,
isAdded = true,
networkId = "",
onToggleClick = {},
),
AddTokensNetworkItemState.EditAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
isAdded = false,
networkId = "",
onToggleClick = {},
),
),
),
)
TokenItem(model = TokenListPreviewData.createManageToken())
}
}
@ -240,49 +215,6 @@ private fun Preview_TokenItem_EditAccess() {
@Composable
private fun Preview_TokenItem_ReadAccess() {
TangemTheme {
TokenItem(
TokenItemModel(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = listOf(
AddTokensNetworkItemState.ReadAccess(
name = "Ethereum",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
isMainNetwork = true,
),
AddTokensNetworkItemState.ReadAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
),
),
),
)
}
}
@Preview
@Composable
private fun Preview_TokenItem_EditAccess_LargeNetworkList() {
TangemTheme {
TokenItem(
TokenItemModel(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = List(
size = 15,
init = {
AddTokensNetworkItemState.ReadAccess(
name = if (it % 2 != 0) "Ethereum" else "BNB SMART CHAIN",
protocolName = if (it % 2 != 0) "MAIN" else "BEP20",
iconResId = if (it % 2 != 0) R.drawable.ic_eth_no_color else R.drawable.ic_bsc_no_color,
isMainNetwork = it % 2 != 0,
)
},
),
),
)
TokenItem(model = TokenListPreviewData.createReadToken())
}
}

View file

@ -0,0 +1,74 @@
package com.tangem.tap.features.tokens.presentation.ui
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
import com.tangem.tap.features.tokens.presentation.states.TokenItemState
import com.tangem.wallet.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
[REDACTED_AUTHOR]
*/
object TokenListPreviewData {
fun createManageToken(): TokenItemState.ManageAccess {
return TokenItemState.ManageAccess(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = createManageNetworksList(),
id = "",
)
}
fun createReadToken(): TokenItemState.ReadAccess {
return TokenItemState.ReadAccess(
name = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = createReadNetworksList(),
)
}
fun createManageNetworksList(): ImmutableList<NetworkItemState.ManageAccess> {
return persistentListOf(
NetworkItemState.ManageAccess(
name = "Ethereum",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
isMainNetwork = true,
isAdded = true,
networkId = "",
contractAddress = null,
onToggleClick = { _, _ -> },
onNetworkClick = {},
),
NetworkItemState.ManageAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
isAdded = false,
networkId = "",
contractAddress = null,
onToggleClick = { _, _ -> },
onNetworkClick = {},
),
)
}
fun createReadNetworksList(): ImmutableList<NetworkItemState.ReadAccess> {
return persistentListOf(
NetworkItemState.ReadAccess(
name = "Ethereum",
protocolName = "MAIN",
iconResId = R.drawable.ic_eth_no_color,
isMainNetwork = true,
),
NetworkItemState.ReadAccess(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = R.drawable.ic_bsc_no_color,
isMainNetwork = false,
),
)
}
}

View file

@ -1,113 +0,0 @@
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.LifecycleOwner
import androidx.lifecycle.ViewModel
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.tokens.presentation.router.AddTokensRouter
import com.tangem.tap.features.tokens.presentation.states.AddTokensStateHolder
import com.tangem.tap.features.tokens.presentation.states.AddTokensToolbarState
import com.tangem.tap.features.tokens.redux.TokensAction
import com.tangem.tap.features.tokens.redux.TokensState
import com.tangem.tap.store
import com.tangem.wallet.R
import dagger.hilt.android.lifecycle.HiltViewModel
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
/**
* ViewModel for add tokens screen
*
* @property router feature router
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class AddTokensViewModel @Inject constructor(
private val router: AddTokensRouter,
) : ViewModel(), DefaultLifecycleObserver, StoreSubscriber<TokensState> {
var uiState by mutableStateOf(getInitialUiState())
private set
init {
loadContent()
}
override fun onStop(owner: LifecycleOwner) {
store.unsubscribe(subscriber = this)
}
override fun onDestroy(owner: LifecycleOwner) {
store.dispatch(TokensAction.ResetState)
}
override fun newState(state: TokensState) {
uiState = uiState.copySealed(
toolbarState = getToolbarState(hasEditAccess = state.allowToAdd),
)
}
private fun loadContent() {
store.subscribe(subscriber = this) { state ->
state
.skipRepeats { old, new -> old.tokensState == new.tokensState }
.select(AppState::tokensState)
}
}
private fun getInitialUiState(): AddTokensStateHolder {
return AddTokensStateHolder.Loading(toolbarState = getToolbarState(store.state.tokensState.allowToAdd))
}
private fun getToolbarState(hasEditAccess: Boolean): AddTokensToolbarState {
return if (hasEditAccess) {
AddTokensToolbarState.Title.EditAccess(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = router::popBackStack,
onSearchButtonClick = ::onSearchButtonClick,
onAddCustomTokenClick = router::openAddCustomTokenScreen,
)
} else {
AddTokensToolbarState.Title.ReadAccess(
titleResId = R.string.search_tokens_title,
onBackButtonClick = router::popBackStack,
onSearchButtonClick = ::onSearchButtonClick,
)
}
}
private fun onSearchButtonClick() {
uiState = when (val state = uiState.toolbarState) {
is AddTokensToolbarState.Title -> {
uiState.copySealed(
toolbarState = AddTokensToolbarState.SearchInputField(
onBackButtonClick = uiState.toolbarState.onBackButtonClick,
onSearchButtonClick = ::onSearchButtonClick,
value = "",
onValueChange = ::onValueChange,
onCleanButtonClick = { onValueChange(newValue = "") },
),
)
}
is AddTokensToolbarState.SearchInputField -> {
store.dispatch(TokensAction.SetSearchInput(state.value))
uiState.copySealed(
getToolbarState(store.state.tokensState.allowToAdd),
)
}
}
}
private fun onValueChange(newValue: String) {
val state = requireNotNull(
value = uiState.toolbarState as? AddTokensToolbarState.SearchInputField,
lazyMessage = { "Impossible to change value in another state" },
)
uiState = uiState.copySealed(toolbarState = state.copy(value = newValue))
}
}

View file

@ -0,0 +1,453 @@
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 com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.extensions.getActiveIconRes
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.presentation.router.AddTokensRouter
import com.tangem.tap.features.tokens.presentation.states.NetworkItemState
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.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.store
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
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 javax.inject.Inject
/**
* ViewModel for add tokens screen
*
* @property router feature router
* @property dispatchers coroutine dispatchers provider
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class TokensListViewModel @Inject constructor(
private val router: AddTokensRouter,
private val dispatchers: AppCoroutineDispatcherProvider,
) : ViewModel() {
private val uiStateBuilder = UiStateBuilder()
var uiState by mutableStateOf(uiStateBuilder.getInitialUiState())
private set
private val reduxSubscriber = ReduxSubscriber()
private var debounceJob: Job? = null
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 object AnalyticsSender {
fun sendWhenTokenAdded(token: Token) {
Analytics.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.On,
),
)
}
fun sendWhenBlockchainAdded(blockchain: Blockchain) {
Analytics.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.On,
),
)
}
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
}
uiStateBuilder.updateStateByReduxState(state)
}
}
private inner class UiStateBuilder {
fun getInitialUiState(): TokensListStateHolder {
return TokensListStateHolder.Loading(toolbarState = getToolbarState())
}
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
AnalyticsSender.sendWhenSaveButtonClicked()
store.dispatch(TokensAction.SaveChanges(notNullAddedTokens, notNullAddedBlockchains))
},
)
} else {
TokensListStateHolder.ReadAccess(
toolbarState = getToolbarState(state),
tokens = state.currencies.map(::toReadTokenItemState).toImmutableList(),
)
}
}
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,
)
}
}
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,
),
)
}
is TokensListToolbarState.SearchInputField -> {
store.dispatch(TokensAction.SetSearchInput(searchInput = state.value))
uiState.copySealed(toolbarState = getToolbarState())
}
}
}
private fun onSearchValueChange(newValue: String) {
(uiState.toolbarState as? TokensListToolbarState.SearchInputField)?.let { state ->
uiState = uiState.copySealed(toolbarState = state.copy(value = newValue))
}
debounceJob?.cancel()
debounceJob = viewModelScope.launch(dispatchers.io) {
delay(timeMillis = 800L)
store.dispatch(TokensAction.SetSearchInput(searchInput = newValue))
}
}
private fun onCleanButtonClick() {
(uiState.toolbarState as? TokensListToolbarState.SearchInputField)?.let { state ->
if (state.value.isEmpty()) {
uiState = uiState.copySealed(toolbarState = getToolbarState())
} 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
val blockchain = Blockchain.fromNetworkId(
networkId = if (contractAddress == null) networkId else tokenId,
)
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) {
store.dispatchDialogShow(
WalletDialog.TokensAreLinkedDialog(
currencyTitle = blockchain.name,
currencySymbol = blockchain.currency,
),
)
} else if (isAddedOnMainScreen == true) {
store.dispatchDialogShow(
WalletDialog.RemoveWalletDialog(
currencyTitle = blockchain.name,
onOk = {
AnalyticsSender.sendWhenBlockchainAdded(blockchain)
addedBlockchainList = addedBlockchainList?.minus(blockchain)
updateTokensList()
},
),
)
} else {
AnalyticsSender.sendWhenBlockchainRemoved(blockchain)
addedBlockchainList = addedBlockchainList?.minus(blockchain)
}
} else {
AnalyticsSender.sendWhenBlockchainAdded(blockchain)
addedBlockchainList = addedBlockchainList?.plus(blockchain)
}
}
private fun onTokenToggleClick(
tokenId: String,
networkId: String,
switchedToken: TokenItemState.ManageAccess,
contractAddress: String,
) {
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),
),
blockchain = contract.blockchain,
)
val isUnsupportedToken = !store.state.tokensState.canHandleToken(token)
val isRemoveAction = addedTokenList?.contains(token)
val isAddedOnMainScreen = originalAddedTokenList?.contains(token)
if (isRemoveAction == true) {
if (isAddedOnMainScreen == true) {
store.dispatchDialogShow(
WalletDialog.RemoveWalletDialog(
currencyTitle = token.token.name,
onOk = {
AnalyticsSender.sendWhenTokenRemoved(token.token)
addedTokenList = addedTokenList?.minus(token)
updateTokensList()
},
),
)
} else {
AnalyticsSender.sendWhenTokenRemoved(token.token)
addedTokenList = addedTokenList?.minus(token)
}
} else {
if (isUnsupportedToken) {
store.dispatchDialogShow(
AppDialog.SimpleOkDialogRes(
headerId = R.string.common_warning,
messageId = R.string.alert_manage_tokens_unsupported_message,
),
)
} else {
AnalyticsSender.sendWhenTokenAdded(token.token)
addedTokenList = addedTokenList?.plus(token)
}
}
}
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 companion object {
const val MAIN_NETWORK_LABEL = "MAIN"
}
}

View file

@ -80,6 +80,7 @@ data class TangemDimens internal constructor(
val spacing18: Dp = 18.dp,
val spacing20: Dp = 20.dp,
val spacing24: Dp = 24.dp,
val spacing26: Dp = 26.dp,
val spacing28: Dp = 28.dp,
val spacing32: Dp = 32.dp,
val spacing34: Dp = 34.dp,

View file

@ -46,6 +46,7 @@ hilt = "2.44"
hilt-navigation = "1.0.0"
jodatime = "2.12.1"
krateSharedPref = "2.0.0"
kotlin-immutable-collections = "0.3.5"
kotsonGsonExt = "2.5.0"
lottie = "3.4.0"
moshi = "1.12.0"
@ -168,6 +169,7 @@ appsflyer = { module = "com.appsflyer:af-android-sdk", version.ref = "appsflyer"
armadillo = { module = "at.favre.lib:armadillo", version.ref = "armadillo" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutine" }
kotlin-immutable-collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlin-immutable-collections" }
desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
googlePlay-core = { module = "com.google.android.play:core", version.ref = "googlePlayCore" }
googlePlay-core-ktx = { module = "com.google.android.play:core-ktx", version.ref = "googlePlayCoreKtx" }