diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9d6381ca2f..b3831aa789 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt index 4626dbbc44..372866fafc 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt @@ -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 { override fun onStart() { super.onStart() + activity?.window?.let { WindowCompat.setDecorFitsSystemWindows(it, false) } + store.subscribe(this) { state -> state.skipRepeats { oldState, newState -> oldState.homeState == newState.homeState diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/TokensListFragment.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/TokensListFragment.kt index 85bb563548..0c66bbcbea 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/TokensListFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/TokensListFragment.kt @@ -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().apply { - // TODO("[REDACTED_TASK_KEY] subscribe on Redux events") - // subscribeOnReduxEvents(lifecycle = LocalLifecycleOwner.current.lifecycle) + val viewModel = hiltViewModel().apply { + subscribeOnReduxEvents(lifecycle = LocalLifecycleOwner.current.lifecycle) } TangemTheme { diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensNetworkItemState.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/NetworkItemState.kt similarity index 54% rename from app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensNetworkItemState.kt rename to app/src/main/java/com/tangem/tap/features/tokens/presentation/states/NetworkItemState.kt index c0df838d4d..c11142b85a 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensNetworkItemState.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/NetworkItemState.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokenItemModel.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokenItemModel.kt deleted file mode 100644 index 07e0af08f0..0000000000 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokenItemModel.kt +++ /dev/null @@ -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, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokenItemState.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokenItemState.kt new file mode 100644 index 0000000000..5ce98ed4f0 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokenItemState.kt @@ -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 + + /** + * 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, + ) : 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, + val id: String, + ) : TokenItemState +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensStateHolder.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListStateHolder.kt similarity index 59% rename from app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensStateHolder.kt rename to app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListStateHolder.kt index 2db2b8bc59..0cf5c4d03a 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensStateHolder.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListStateHolder.kt @@ -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, - ) : AddTokensStateHolder, TokensListVisibility + override val toolbarState: TokensListToolbarState, + override val tokens: ImmutableList, + ) : 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, + override val toolbarState: TokensListToolbarState, + override val tokens: ImmutableList, val onSaveButtonClick: () -> Unit, - ) : AddTokensStateHolder, TokensListVisibility + ) : TokensListStateHolder, TokensListVisibility } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensToolbarState.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListToolbarState.kt similarity index 93% rename from app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensToolbarState.kt rename to app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListToolbarState.kt index 8a21094acc..532c0739cf 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/AddTokensToolbarState.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListToolbarState.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListVisibility.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListVisibility.kt index b1bd56a96e..4062066ad3 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListVisibility.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/states/TokensListVisibility.kt @@ -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 + val tokens: ImmutableList } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensScreen.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensScreen.kt index e2098944a0..a691d057e6 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensScreen.kt @@ -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(), ), ), ) diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensToolbar.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensToolbar.kt index b8c160fa6e..a79d8cb8b4 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensToolbar.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/AddTokensToolbar.kt @@ -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 = {}, diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/BriefNetworksList.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/BriefNetworksList.kt index 868395a094..e44b3def87 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/BriefNetworksList.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/BriefNetworksList.kt @@ -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, + networks: ImmutableCollection, 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), diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/DetailedNetworksList.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/DetailedNetworksList.kt index 1ad714408f..e404aec20e 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/DetailedNetworksList.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/DetailedNetworksList.kt @@ -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, + tokenId: String?, + networks: ImmutableCollection, 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(), ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenItem.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenItem.kt index cdd70f25f3..ba0b7959ec 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenItem.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenItem.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenListPreviewData.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenListPreviewData.kt new file mode 100644 index 0000000000..8eb5cd50ee --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/ui/TokenListPreviewData.kt @@ -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 { + 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 { + 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, + ), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/viewmodels/AddTokensViewModel.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/viewmodels/AddTokensViewModel.kt deleted file mode 100644 index 828ace57c6..0000000000 --- a/app/src/main/java/com/tangem/tap/features/tokens/presentation/viewmodels/AddTokensViewModel.kt +++ /dev/null @@ -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 { - - 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)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/presentation/viewmodels/TokensListViewModel.kt b/app/src/main/java/com/tangem/tap/features/tokens/presentation/viewmodels/TokensListViewModel.kt new file mode 100644 index 0000000000..1b7c1fc3c0 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/presentation/viewmodels/TokensListViewModel.kt @@ -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? = null + private var originalAddedBlockchainList: List? = null + private var addedTokenList: List? = null + private var addedBlockchainList: List? = 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 { + + 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" + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 26189619df..7a5dcfec58 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -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, diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index f61ac7485b..c39054104a 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -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" }