From 8f09a5e87f1213540f84fc5fa182c33c32482c0a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Jun 2026 12:38:40 +0100 Subject: [PATCH 1/7] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + features/address-book/impl/build.gradle.kts | 6 + .../addressbook/component/AddressBookRoute.kt | 8 + .../component/DefaultAddressBookComponent.kt | 18 +- .../di/AddressBookComponentModule.kt | 6 + .../addressbook/di/AddressBookModelModule.kt | 6 + .../DefaultEditContactComponent.kt | 40 ++++ .../editcontact/EditContactComponent.kt | 15 ++ .../editcontact/contract/EditContactUM.kt | 26 +++ .../editcontact/model/EditContactModel.kt | 68 +++++++ .../editcontact/ui/EditContactContent.kt | 185 ++++++++++++++++++ .../list/DefaultAddressBookListComponent.kt | 6 +- .../list/contract/AddressBookListUM.kt | 5 +- .../list/model/AddressBookListModel.kt | 26 ++- .../list/ui/AddressBookEmptyScreen.kt | 27 ++- .../editcontact/model/EditContactModelTest.kt | 113 +++++++++++ 16 files changed, 539 insertions(+), 17 deletions(-) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 57b95dc184..0c188411c0 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -102,6 +102,7 @@ %d address %d addresses + Contact Contact name Copy address We couldn’t create contact. Please try again later. diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 0e9ca8c98a..4711602801 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -19,6 +19,9 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.addressBook) + /** Common */ + implementation(projects.common.ui) + /** Core modules */ implementation(projects.core.configToggles) implementation(projects.core.decompose) @@ -41,4 +44,7 @@ dependencies { /** Other */ implementation(deps.kotlin.immutable.collections) + + /** Tests */ + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt index baa9ee7021..49e12ab00f 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt @@ -7,4 +7,12 @@ internal sealed class AddressBookRoute { @Serializable data object List : AddressBookRoute() + + /** + * if [contactId] is not null we should fetch existing contact + */ + @Serializable + data class EditContact( + val contactId: String? = null, + ) : AddressBookRoute() } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt index 78c5312faa..4b4ee9140a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt @@ -9,11 +9,15 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.pushNew import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.addressbook.list.AddressBookListComponent +import com.tangem.features.addressbook.editcontact.EditContactComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,6 +26,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: AddressBookComponent.Params, private val addressBookListComponentFactory: AddressBookListComponent.Factory, + private val editContactComponentFactory: EditContactComponent.Factory, ) : AddressBookComponent, AppComponentContext by context { private val navigation = StackNavigation() @@ -51,11 +56,16 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( context = childByContext(componentContext), params = AddressBookListComponent.Params( onContactClick = { contactId -> - // TODO [REDACTED_TASK_KEY] router.push(EditContact(contactId)) - }, - onAddContactClick = { - // TODO [REDACTED_TASK_KEY] router.push(AddContact) + navigation.pushNew(AddressBookRoute.EditContact(contactId)) }, + onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) }, + ), + ) + is AddressBookRoute.EditContact -> editContactComponentFactory.create( + context = childByContext(componentContext), + params = EditContactComponent.Params( + contactId = config.contactId?.let(::ContactId), + onBackClick = { navigation.pop() }, ), ) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt index 00e407405f..188884bab3 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt @@ -4,6 +4,8 @@ import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.addressbook.component.DefaultAddressBookComponent import com.tangem.features.addressbook.list.AddressBookListComponent import com.tangem.features.addressbook.list.DefaultAddressBookListComponent +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.EditContactComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -23,4 +25,8 @@ internal interface AddressBookComponentModule { fun bindAddressBookListComponentFactory( factory: DefaultAddressBookListComponent.Factory, ): AddressBookListComponent.Factory + + @Binds + @Singleton + fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt index 7c666b0b6d..0fb085f06d 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.addressbook.list.model.AddressBookListModel +import com.tangem.features.addressbook.editcontact.model.EditContactModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +18,9 @@ internal interface AddressBookModelModule { @IntoMap @ClassKey(AddressBookListModel::class) fun bindAddressBookModel(model: AddressBookListModel): Model + + @Binds + @IntoMap + @ClassKey(EditContactModel::class) + fun bindEditContactModel(model: EditContactModel): Model } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt new file mode 100644 index 0000000000..8f83105d52 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.addressbook.editcontact + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.addressbook.editcontact.model.EditContactModel +import com.tangem.features.addressbook.editcontact.ui.EditContactContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultEditContactComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: EditContactComponent.Params, +) : EditContactComponent, AppComponentContext by context { + + private val model: EditContactModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + EditContactContent( + state = state, + modifier = modifier, + ) + BackHandler(onBack = state.onCloseClick) + } + + @AssistedFactory + interface Factory : EditContactComponent.Factory { + override fun create( + context: AppComponentContext, + params: EditContactComponent.Params, + ): DefaultEditContactComponent + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt new file mode 100644 index 0000000000..ede28263b7 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt @@ -0,0 +1,15 @@ +package com.tangem.features.addressbook.editcontact + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId + +internal interface EditContactComponent : ComposableContentComponent { + + interface Factory : ComponentFactory + + data class Params( + val contactId: ContactId?, + val onBackClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt new file mode 100644 index 0000000000..2a54242a81 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.editcontact.contract + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class EditContactUM( + val title: TextReference, + val name: String, + val namePlaceholder: TextReference, + val portfolioIcon: AccountIconUM.CryptoPortfolio, + val colors: Colors, + val onNameChange: (String) -> Unit, + val onCloseClick: () -> Unit, +) { + + @Immutable + data class Colors( + val selected: CryptoPortfolioIcon.Color, + val list: ImmutableList, + val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt new file mode 100644 index 0000000000..1f1035618b --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -0,0 +1,68 @@ +package com.tangem.features.addressbook.editcontact.model + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.EditContactComponent +import com.tangem.features.addressbook.editcontact.contract.EditContactUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class EditContactModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params: EditContactComponent.Params = paramsContainer.require() + + val state: StateFlow + field = MutableStateFlow(getInitialState()) + + private fun onNameChange(name: String) { + state.update { it.copy(name = name) } + } + + private fun onColorSelect(color: CryptoPortfolioIcon.Color) { + state.update { oldState -> + oldState.copy( + colors = oldState.colors.copy(selected = color), + portfolioIcon = oldState.portfolioIcon.copy(color = color), + ) + } + } + + private fun getInitialState(): EditContactUM { + val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() + val selectedColor = colors.first() + val titleResId = if (params.contactId == null) { + R.string.address_book_new_contact + } else { + R.string.address_book_contact + } + return EditContactUM( + title = resourceReference(titleResId), + name = "", + namePlaceholder = resourceReference(R.string.address_book_new_contact), + portfolioIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = selectedColor, + ), + colors = EditContactUM.Colors( + selected = selectedColor, + list = colors, + onColorSelect = ::onColorSelect, + ), + onNameChange = ::onNameChange, + onCloseClick = params.onBackClick, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt new file mode 100644 index 0000000000..2183d33c43 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt @@ -0,0 +1,185 @@ +package com.tangem.features.addressbook.editcontact.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.account.AccountIcon +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.common.ui.account.getUiColor +import com.tangem.core.ui.R +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.fields.AutoSizeTextField +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.contract.EditContactUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = TangemTheme.colors3.bg.primary) + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemTopBar( + modifier = Modifier.statusBarsPadding(), + title = state.title, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24), + onClick = state.onCloseClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ContactSummary(state = state) + ContactColor(colors = state.colors) + } + } +} + +@Composable +private fun ContactSummary(state: EditContactUM) { + val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() } + Column( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(TangemTheme.colors3.bg.secondary), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + AccountIcon( + name = stringReference(avatarName), + icon = state.portfolioIcon, + size = AccountIconSize.Large, + ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResourceSafe(R.string.address_book_contact_name), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.tertiary, + ) + Spacer(modifier = Modifier.height(2.dp)) + + AutoSizeTextField( + value = state.name, + onValueChange = state.onNameChange, + centered = true, + singleLine = true, + placeholder = state.namePlaceholder, + textStyle = TangemTheme.typography3.heading.medium, + color = TangemTheme.colors3.text.primary, + placeholderColor = TangemTheme.colors3.text.tertiary, + ) + Spacer(modifier = Modifier.height(20.dp)) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Suppress("MagicNumber") +@Composable +private fun ContactColor(colors: EditContactUM.Colors) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(TangemTheme.colors3.bg.secondary), + ) { + FlowRow( + maxItemsInEachRow = 6, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + ) { + colors.list.fastForEach { color -> + val isSelected = color == colors.selected + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = { colors.onColorSelect(color) }) + .size(48.dp), + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(47.dp) + .border(2.dp, color.getUiColor(), shape = CircleShape), + ) + Box( + modifier = Modifier + .size(36.dp) + .background(color = color.getUiColor(), shape = CircleShape), + ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .background(color = color.getUiColor(), shape = CircleShape), + ) + } + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_EditContactContent() { + val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() + TangemThemePreview { + EditContactContent( + state = EditContactUM( + title = stringReference("New contact"), + name = "", + namePlaceholder = stringReference("New contact"), + portfolioIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = colors.first(), + ), + colors = EditContactUM.Colors( + selected = colors.first(), + list = colors, + onColorSelect = {}, + ), + onNameChange = {}, + onCloseClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 416edbd08c..63733b90f9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -23,9 +23,9 @@ internal class DefaultAddressBookListComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - when (state) { - AddressBookListUM.Empty -> AddressBookEmptyScreen( - onAddContactClick = params.onAddContactClick, + when (val addressBookListUM = state) { + is AddressBookListUM.Empty -> AddressBookEmptyScreen( + tangemButtonUM = addressBookListUM.tangemButtonUM, onBackClick = router::pop, modifier = modifier, ) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt index f5cf907646..4c0c74bab4 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt @@ -1,12 +1,15 @@ package com.tangem.features.addressbook.list.contract import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.domain.addressbook.model.Contact import kotlinx.collections.immutable.ImmutableList @Immutable internal sealed class AddressBookListUM { - data object Empty : AddressBookListUM() + data class Empty( + val tangemButtonUM: TangemButtonUM, + ) : AddressBookListUM() data class AddressList(val contacts: ImmutableList) : AddressBookListUM() } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 224fa6edd0..27039d82aa 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -2,6 +2,16 @@ package com.tangem.features.addressbook.list.model import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.R +import com.tangem.core.ui.R.drawable.ic_plus_24 +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.addressbook.list.AddressBookListComponent import com.tangem.features.addressbook.list.contract.AddressBookListUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -10,10 +20,24 @@ import javax.inject.Inject @ModelScoped internal class AddressBookListModel @Inject constructor( + paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, ) : Model() { + private val params = paramsContainer.require() + val state: StateFlow = MutableStateFlow( - AddressBookListUM.Empty, + AddressBookListUM.Empty( + tangemButtonUM = TangemButtonUM( + text = TextReference.Res(R.string.address_book_new_contact), + tangemIconUM = TangemIconUM.Icon( + iconRes = ic_plus_24, + tintReference = { TangemTheme.colors3.text.inverse.primary }, + ), + iconPosition = TangemButtonIconPosition.End, + type = TangemButtonType.Primary, + onClick = params.onAddContactClick, + ), + ), ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt index 8c258eb5eb..b1045b5cc2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt @@ -14,17 +14,21 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.ds.button.PrimaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @Composable internal fun AddressBookEmptyScreen( - onAddContactClick: () -> Unit, + tangemButtonUM: TangemButtonUM, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -40,19 +44,17 @@ internal fun AddressBookEmptyScreen( iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24), onClick = onBackClick, size = TangemButton.Size.X11, - variant = TangemButton.Variant.Secondary, + variant = TangemButton.Variant.Material, ) }, ) NoContactInfo() - PrimaryButtonIconEnd( + PrimaryTangemButton( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - text = stringResourceSafe(R.string.address_book_add_contact), - iconResId = R.drawable.ic_plus_24, - onClick = onAddContactClick, + buttonUM = tangemButtonUM, ) } } @@ -104,5 +106,14 @@ private fun ContactImage() { @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun Preview_AddressBookEmptyScreen() { - AddressBookEmptyScreen(onAddContactClick = {}, onBackClick = {}) + AddressBookEmptyScreen( + tangemButtonUM = TangemButtonUM( + text = TextReference.Res(R.string.address_book_new_contact), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24), + iconPosition = TangemButtonIconPosition.End, + type = TangemButtonType.Secondary, + onClick = {}, + ), + onBackClick = {}, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt new file mode 100644 index 0000000000..d44b437435 --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -0,0 +1,113 @@ +package com.tangem.features.addressbook.editcontact.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.EditContactComponent +import com.tangem.features.addressbook.editcontact.contract.EditContactUM +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class EditContactModelTest { + + @Test + fun `WHEN model created THEN initial state is correct`() = runTest { + val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList() + val expectedSelectedColor = expectedColors.first() + + val model = createModel(testScope = this) + val state = model.state.value + + val expected = EditContactUM( + title = resourceReference(R.string.address_book_new_contact), + name = "", + namePlaceholder = resourceReference(R.string.address_book_new_contact), + portfolioIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = expectedSelectedColor, + ), + colors = EditContactUM.Colors( + selected = expectedSelectedColor, + list = expectedColors, + onColorSelect = state.colors.onColorSelect, + ), + onNameChange = state.onNameChange, + onCloseClick = state.onCloseClick, + ) + assertThat(state).isEqualTo(expected) + } + + @Test + fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest { + // Arrange + val params = EditContactComponent.Params( + contactId = ContactId(value = "contact-id"), + onBackClick = {}, + ) + + // Act + val model = createModel(testScope = this, params = params) + val state = model.state.value + + // Assert + assertThat(state.title).isEqualTo(resourceReference(R.string.address_book_contact)) + } + + @Test + fun `GIVEN initial state WHEN onNameChange THEN name updated`() = runTest { + val model = createModel(testScope = this) + val newName = "Satoshi" + + model.state.value.onNameChange(newName) + + assertThat(model.state.value.name).isEqualTo(newName) + } + + @Test + fun `GIVEN initial state WHEN onColorSelect THEN selected color and portfolio icon updated`() = runTest { + val model = createModel(testScope = this) + val newColor = CryptoPortfolioIcon.Color.entries.last() + + model.state.value.colors.onColorSelect(newColor) + + val state = model.state.value + assertThat(state.colors.selected).isEqualTo(newColor) + assertThat(state.portfolioIcon.color).isEqualTo(newColor) + } + + private fun createModel( + testScope: TestScope, + params: EditContactComponent.Params = EditContactComponent.Params( + contactId = null, + onBackClick = {}, + ), + paramsContainer: ParamsContainer = MutableParamsContainer(value = params), + ): EditContactModel { + return EditContactModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file From b3640235eff73d92ade519e4500db972340a094b Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 00:30:26 -0700 Subject: [PATCH 2/7] Updated on 2026-08-14 --- .../entity/PaymentAccountStatusValueDM.kt | 3 + .../PaymentAccountStatusValueDMConverter.kt | 31 +++-- .../DefaultPaymentAccountStatusFetcher.kt | 21 ++- .../data/pay/util/CustomerInfoConverter.kt | 1 + ...aymentAccountStatusValueDMConverterTest.kt | 22 ++- .../account/PaymentAccountStatusValue.kt | 50 ++++--- .../tangem/domain/pay/model/CustomerInfo.kt | 1 + .../destination/model/SendDestinationModel.kt | 2 +- .../setup/TangemPayCardLimitSetupModel.kt | 2 +- .../tangempay/model/TangemPayCardPageModel.kt | 5 +- .../tangempay/model/TangemPayDetailsModel.kt | 129 ++++++++---------- .../transformers/DetailsBalanceTransformer.kt | 35 ++--- .../utils/PaymentAccountStatusExt.kt | 6 + .../setup/TangemPayCardLimitSetupModelTest.kt | 6 +- .../converter/TangemPayMainBlockConverter.kt | 8 +- 15 files changed, 175 insertions(+), 147 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 71bce0a129..41b174cf8d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -40,6 +40,7 @@ sealed interface PaymentAccountStatusValueDM { @NameLabel("active_account") data class ActiveAccount( + @Json(name = "active_account") val marker: Boolean = true, @Json(name = "customer_id") val customerId: String, @Json(name = "currency_code") val currencyCode: String, @Json(name = "deposit_address") val depositAddress: String?, @@ -59,9 +60,11 @@ sealed interface PaymentAccountStatusValueDM { @NameLabel("deactivated_account") data class DeactivatedAccount( @Json(name = "deactivated_account") val marker: Boolean = true, + @Json(name = "customer_id") val customerId: String, @Json(name = "fiat_rate") val fiatRate: BigDecimal?, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal, ) : PaymentAccountStatusValueDM @JsonClass(generateAdapter = true) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index aab76125e5..c586313c67 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -39,11 +39,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard() is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount( customerId = value.customerId, - currencyCode = value.currencyCode, + currencyCode = value.balance.fiatBalance.currency, depositAddress = value.depositAddress, - fiatBalance = value.fiatBalance.toDM(), - cryptoBalance = value.cryptoBalance.toDM(), - availableForWithdrawal = value.availableForWithdrawal, + fiatBalance = value.balance.fiatBalance.toDM(), + cryptoBalance = value.balance.cryptoBalance.toDM(), + availableForWithdrawal = value.balance.availableForWithdrawal, fiatRate = value.fiatRate, cards = value.cards.map { card -> PaymentAccountStatusValueDM.TangemPayCard( @@ -63,9 +63,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ) is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty() is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount( + customerId = value.customerId, fiatRate = value.fiatRate, - fiatBalance = value.fiatBalance.toDM(), - cryptoBalance = value.cryptoBalance.toDM(), + fiatBalance = value.balance.fiatBalance.toDM(), + cryptoBalance = value.balance.cryptoBalance.toDM(), + availableForWithdrawal = value.balance.availableForWithdrawal, ) // Transient statuses are not persisted is PaymentAccountStatusValue.Loading, @@ -90,11 +92,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded( source = StatusSource.CACHE, customerId = value.customerId, - currencyCode = value.currencyCode, depositAddress = value.depositAddress, - fiatBalance = value.fiatBalance.toDomain(), - cryptoBalance = value.cryptoBalance.toDomain(), - availableForWithdrawal = value.availableForWithdrawal, + balance = PaymentAccountStatusValue.Balance( + fiatBalance = value.fiatBalance.toDomain(), + cryptoBalance = value.cryptoBalance.toDomain(), + availableForWithdrawal = value.availableForWithdrawal, + ), cryptoCurrency = cryptoCurrency, fiatRate = value.fiatRate, cards = value.cards.map { card -> @@ -123,8 +126,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ) is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated( source = StatusSource.CACHE, - fiatBalance = value.fiatBalance.toDomain(), - cryptoBalance = value.cryptoBalance.toDomain(), + customerId = value.customerId, + balance = PaymentAccountStatusValue.Balance( + fiatBalance = value.fiatBalance.toDomain(), + cryptoBalance = value.cryptoBalance.toDomain(), + availableForWithdrawal = value.availableForWithdrawal, + ), cryptoCurrency = cryptoCurrency, fiatRate = value.fiatRate, ) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 746221090a..7c205fffd6 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -33,6 +33,7 @@ import com.tangem.domain.pay.repository.TangemPayCloseCardRepository import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.security.isSecurityExposed import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -287,11 +288,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( customerId = requireNotNull(customerId) { "CustomerId must not be null" }, ) } - fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> { + fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() && + (isDeactivated || isFormer) -> { PaymentAccountStatusValue.Deactivated( source = StatusSource.ACTUAL, - fiatBalance = fiatBalance, - cryptoBalance = cryptoBalance, + customerId = requireNotNull(customerId) { "CustomerId must not be null" }, + balance = PaymentAccountStatusValue.Balance( + fiatBalance = fiatBalance, + cryptoBalance = cryptoBalance, + availableForWithdrawal = availableForWithdrawal.orZero(), + ), cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), fiatRate = quotesData?.fiatRate, ) @@ -321,11 +327,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, customerId = customerId, - currencyCode = cardInfo.currencyCode, depositAddress = cardInfo.depositAddress, - fiatBalance = cardInfo.fiatBalance, - cryptoBalance = cardInfo.cryptoBalance, - availableForWithdrawal = cardInfo.availableForWithdrawal, + balance = PaymentAccountStatusValue.Balance( + fiatBalance = cardInfo.fiatBalance, + cryptoBalance = cardInfo.cryptoBalance, + availableForWithdrawal = cardInfo.availableForWithdrawal, + ), cryptoCurrency = cryptoCurrency, fiatRate = fiatRate, cards = listOf( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index 746b701bc8..95f70c31bc 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -65,6 +65,7 @@ internal object CustomerInfoConverter : Converter TotalFiatBalance.Loading is Loaded -> { val rate = this.fiatRate ?: return TotalFiatBalance.Failed - TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source) + TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source) } is Deactivated -> { val rate = this.fiatRate ?: return TotalFiatBalance.Failed - TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source) + TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source) } } @@ -104,8 +104,8 @@ sealed class PaymentAccountStatusValue { * Represents a state where the account is deactivated. * * @property source The source of the status information. - * @property fiatBalance The fiat balance details. - * @property cryptoBalance The crypto balance details. + * @property customerId The unique identifier of the customer. + * @property balance The balance details (fiat, crypto and amount available for withdrawal). * @property cryptoCurrency The crypto currency held by the deactivated account. * @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency, * or `null` if the quote is not yet available. When `null`, @@ -114,18 +114,18 @@ sealed class PaymentAccountStatusValue { @Serializable data class Deactivated( override val source: StatusSource, - val fiatBalance: FiatBalance, - val cryptoBalance: CryptoBalance, + val customerId: String, + val balance: Balance, val cryptoCurrency: CryptoCurrency.Token, val fiatRate: SerializedBigDecimal?, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, value = buildCryptoCurrencyStatusValue( - amount = cryptoBalance.balance, - fiatAmount = fiatBalance.availableBalance, + amount = balance.cryptoBalance.balance, + fiatAmount = balance.fiatBalance.availableBalance, fiatRate = fiatRate, - depositAddress = cryptoBalance.depositAddress, + depositAddress = balance.cryptoBalance.depositAddress, ), ) } @@ -135,11 +135,9 @@ sealed class PaymentAccountStatusValue { * * @property source The source of the status information. * @property customerId The unique identifier of the customer. - * @property currencyCode The code of the currency. * @property depositAddress The address for deposits, if available. - * @property fiatBalance The fiat balance details. - * @property cryptoBalance The crypto balance details. - * @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds). + * @property balance The balance details (fiat, crypto and amount available for withdrawal). + * The fiat currency code is available via [Balance.fiatBalance]. * @property cryptoCurrency The crypto currency held by the account. * @property cards The list of user's cards. * @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency, @@ -150,11 +148,8 @@ sealed class PaymentAccountStatusValue { data class Loaded( override val source: StatusSource, val customerId: String, - val currencyCode: String, val depositAddress: String?, - val fiatBalance: FiatBalance, - val cryptoBalance: CryptoBalance, - val availableForWithdrawal: SerializedBigDecimal, + val balance: Balance, val cryptoCurrency: CryptoCurrency.Token, val cards: List, val fiatRate: SerializedBigDecimal?, @@ -162,10 +157,10 @@ sealed class PaymentAccountStatusValue { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, value = buildCryptoCurrencyStatusValue( - amount = availableForWithdrawal, - fiatAmount = fiatBalance.availableBalance, + amount = balance.availableForWithdrawal, + fiatAmount = balance.fiatBalance.availableBalance, fiatRate = fiatRate, - depositAddress = cryptoBalance.depositAddress, + depositAddress = balance.cryptoBalance.depositAddress, ), ) } @@ -202,6 +197,21 @@ sealed class PaymentAccountStatusValue { } } + /** + * Aggregates all balance data of a payment account, as returned by the `customer/me` endpoint. + * + * @property fiatBalance The fiat balance details. + * @property cryptoBalance The crypto balance details. + * @property availableForWithdrawal The crypto amount currently available for withdrawal/swap + * (excludes pending/locked funds). + */ + @Serializable + data class Balance( + val fiatBalance: FiatBalance, + val cryptoBalance: CryptoBalance, + val availableForWithdrawal: SerializedBigDecimal, + ) + /** * Represents the fiat balance of the payment account. * diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 952e701938..fbc32175d5 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -28,6 +28,7 @@ data class CustomerInfo( val state: State, val fiatBalance: PaymentAccountStatusValue.FiatBalance?, val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?, + val availableForWithdrawal: BigDecimal?, ) { enum class State { NEW, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 86a15ad9e0..419e8a6b2b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -271,7 +271,7 @@ internal class SendDestinationModel @Inject constructor( private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? { val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null val (paymentAccountAddress, currency) = when (val status = this.value) { - is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency + is PaymentAccountStatusValue.Loaded -> status.balance.cryptoBalance.depositAddress to status.cryptoCurrency else -> return null } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index a3b81fab45..9b33e3f33a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -97,7 +97,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } ?.amount - val currency = getJavaCurrencyByCode(status.currencyCode) + val currency = getJavaCurrencyByCode(status.balance.fiatBalance.currency) uiState.update { state -> val amount = if (index == 0) { currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 9acc3c9982..31c439172e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -118,8 +118,9 @@ internal class TangemPayCardPageModel @Inject constructor( val dailyLimitState = if (limit != null) { TangemPayDailyLimitBlockState.Content( limit = limit.amount.format { - val symbol = getJavaCurrencyByCode(status.currencyCode).symbol - fiat(status.currencyCode, symbol).optionalDecimals() + val currencyCode = status.balance.fiatBalance.currency + val symbol = getJavaCurrencyByCode(currencyCode).symbol + fiat(currencyCode, symbol).optionalDecimals() }, onChangeClick = ::onClickLimitChange, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 5e47660a9b..67e7bc30fe 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -25,8 +25,8 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier -import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository @@ -40,8 +40,6 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.features.tangempay.model.listener.CardDetailsEvent -import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.transformers.* import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.utils.* @@ -52,7 +50,6 @@ import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -70,33 +67,36 @@ internal class TangemPayDetailsModel @Inject constructor( private val cardDetailsRepository: TangemPayCardDetailsRepository, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val uiMessageSender: UiMessageSender, - private val cardDetailsEventListener: CardDetailsEventListener, private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener, private val tangemPayWithdrawRepository: TangemPayWithdrawRepository, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val expressTransactionsEventListener: ExpressTransactionsEventListener, private val tangemPayFeatureToggles: TangemPayFeatureToggles, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, ) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() - private val userWalletId = params.initialStatus.userWalletId private val isTangemPayDeactivated = params.initialStatus.isDeactivated - private val loaded: PaymentAccountStatusValue.Loaded? = - params.initialStatus.value as? PaymentAccountStatusValue.Loaded - private val firstCard = loaded?.cards?.firstOrNull() - val cryptoCurrency: CryptoCurrency = params.initialStatus.cryptoCurrency - private val initialCardFrozenState: TangemPayCardFrozenState = when { - firstCard == null -> TangemPayCardFrozenState.Unfrozen - else -> firstCard.frozenState - } + private val initialCard = params.initialStatus.ifLoadedOrNull { it.cards.firstOrNull() } + + private val currentStatus = MutableStateFlow(params.initialStatus) + + private val userWalletId + get() = currentStatus.value.userWalletId + + val cryptoCurrency + get() = currentStatus.value.cryptoCurrency private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, onOpenMenu = ::onOpenMenu, intents = this, - cardFrozenState = initialCardFrozenState, + cardFrozenState = when { + initialCard == null -> TangemPayCardFrozenState.Unfrozen + else -> initialCard.frozenState + }, isRedesignEnabled = isRedesignEnabled(), ) @@ -104,39 +104,49 @@ internal class TangemPayDetailsModel @Inject constructor( field = MutableStateFlow( stateFactory.getInitialState( isTangemPayDeactivated = isTangemPayDeactivated, - cardNumberEnd = firstCard?.lastDigits.orEmpty(), - isReissuing = firstCard == null || firstCard.state != TangemPayCardState.Active, + cardNumberEnd = initialCard?.lastDigits.orEmpty(), + isReissuing = initialCard == null || initialCard.state != TangemPayCardState.Active, ), ) private val refreshStateJobHolder = JobHolder() - private val fetchBalanceJobHolder = JobHolder() private val addToWalletBannerJobHolder = JobHolder() - private var balance: TangemPayCardBalance? = null - val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { analytics.send(TangemPayAnalyticsEvents.MainScreenOpened()) handleBalanceHiding() - fetchBalance() - if (!isTangemPayDeactivated && firstCard != null) { - subscribeToCardFrozenState(firstCard.id) - fetchAddToWalletBanner() - paymentAccountStatusSupplier.invoke(userWalletId) - .map { it.value } + val statusFlow = paymentAccountStatusSupplier.invoke(userWalletId) + .onEach { status -> currentStatus.update { status } } + .map { it.value } + + if (isTangemPayDeactivated) { + statusFlow + .filterIsInstance() + .onEach { state -> + uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) + } + .launchIn(modelScope) + } else { + if (initialCard != null) { + subscribeToCardFrozenState(initialCard.id) + } + fetchAddToWalletBanner() + statusFlow .filterIsInstance() .filter { it.source == StatusSource.ACTUAL } .onEach { state -> - val card = state.cards.firstOrNull() ?: return@onEach - uiState.update( - TangemPayCardDataTransformer( - card = card, - onCardClick = { onCardClick() }, - ), - ) + uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) + state.cards.firstOrNull()?.let { card -> + uiState.update( + TangemPayCardDataTransformer( + card = card, + onCardClick = { onCardClick() }, + ), + ) + } } .launchIn(modelScope) } @@ -165,17 +175,16 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickAddFunds() { analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) - val currentBalance = balance - val depositAddress = currentBalance?.depositAddress - if (currentBalance == null || depositAddress == null) { + val balance = currentStatus.value.balanceOrNull() + if (balance == null) { showBottomSheetError(TangemPayDetailsErrorType.Receive) } else { bottomSheetNavigation.activate( TangemPayDetailsNavigation.AddFunds( walletId = userWalletId, - fiatBalance = currentBalance.availableForWithdrawal, - cryptoBalance = currentBalance.availableForWithdrawal, - depositAddress = depositAddress, + fiatBalance = balance.availableForWithdrawal, + cryptoBalance = balance.availableForWithdrawal, + depositAddress = balance.cryptoBalance.depositAddress, cryptoCurrency = cryptoCurrency, ), ) @@ -184,12 +193,6 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickWithdraw() { analytics.send(TangemPayAnalyticsEvents.WithdrawClicked()) - val currentBalance = balance - val depositAddress = currentBalance?.depositAddress - if (currentBalance == null || depositAddress == null) { - showBottomSheetError(TangemPayDetailsErrorType.Withdraw) - return - } modelScope.launch { val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId) if (hasActiveWithdrawal) { @@ -197,18 +200,19 @@ internal class TangemPayDetailsModel @Inject constructor( } else { uiMessageSender.send( message = TangemPayMessagesFactory.createWithdrawWarning( - onGotItClick = { onConfirmWithdrawal(cryptoCurrency, currentBalance, depositAddress) }, + onGotItClick = { onConfirmWithdrawal(cryptoCurrency) }, ), ) } } } - private fun onConfirmWithdrawal( - currency: CryptoCurrency, - currentBalance: TangemPayCardBalance, - depositAddress: String, - ) { + private fun onConfirmWithdrawal(currency: CryptoCurrency) { + val balance = currentStatus.value.balanceOrNull() + if (balance == null) { + showBottomSheetError(TangemPayDetailsErrorType.Withdraw) + return + } router.push( AppRoute.Swap( cryptoCurrency = currency, @@ -216,27 +220,15 @@ internal class TangemPayDetailsModel @Inject constructor( screenSource = AnalyticsParam.ScreensSources.TangemPay.value, currencyPosition = AppRoute.Swap.CurrencyPosition.FROM, tangemPayInput = AppRoute.Swap.TangemPayInput( - cryptoAmount = currentBalance.availableForWithdrawal, - fiatAmount = currentBalance.availableForWithdrawal, - depositAddress = depositAddress, + cryptoAmount = balance.availableForWithdrawal, + fiatAmount = balance.availableForWithdrawal, + depositAddress = balance.cryptoBalance.depositAddress, isWithdrawal = true, ), ), ) } - private fun fetchBalance(): Job { - return modelScope.launch { - val result = try { - cardDetailsRepository.getCardBalance(userWalletId).onRight { balance = it } - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - uiState.update(transformer = DetailsBalanceTransformer(balance = result)) - }.saveIn(fetchBalanceJobHolder) - } - private fun fetchAddToWalletBanner() { modelScope.launch { val isDone = try { @@ -263,7 +255,7 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onContactSupportClicked() { analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) - val customerId = loaded?.customerId ?: return + val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return modelScope.launch { sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.Visa.FeatureIsBeta( @@ -277,10 +269,9 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onRefreshSwipe(refreshState: ShowRefreshState) { modelScope.launch { uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) - cardDetailsEventListener.send(CardDetailsEvent.Hide) + paymentAccountStatusFetcher.invoke(userWalletId) expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) txHistoryUpdateListener.triggerUpdate() - fetchBalance().join() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) }.saveIn(refreshStateJobHolder) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index eca90fdbb4..5cbd4bdb05 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -1,45 +1,32 @@ package com.tangem.features.tangempay.model.transformers -import arrow.core.Either -import com.tangem.core.error.UniversalError import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.formatStyled import com.tangem.core.ui.res.TangemTheme -import com.tangem.domain.pay.model.TangemPayCardBalance +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.persistentListOf import java.util.Currency internal class DetailsBalanceTransformer( - private val balance: Either, + private val fiatBalance: PaymentAccountStatusValue.FiatBalance, ) : Transformer { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val balance = when (balance) { - is Either.Left -> { - TangemPayDetailsBalanceBlockState.Error( - actionButtons = persistentListOf(), - cardsBlockState = prevState.balanceBlockState.cardsBlockState, - ) - } - is Either.Right -> { - TangemPayDetailsBalanceBlockState.Content( - isBalanceFlickering = false, - fiatBalance = getFiatBalanceText(balance.value), - actionButtons = prevState.balanceBlockState.actionButtons, - cardsBlockState = prevState.balanceBlockState.cardsBlockState, - ) - } - } + val balance = TangemPayDetailsBalanceBlockState.Content( + isBalanceFlickering = false, + fiatBalance = getFiatBalanceText(fiatBalance), + actionButtons = prevState.balanceBlockState.actionButtons, + cardsBlockState = prevState.balanceBlockState.cardsBlockState, + ) return prevState.copy(balanceBlockState = balance) } - private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference { - val currency = Currency.getInstance(balance.currencyCode) - return balance.fiatBalance.formatStyled { + private fun getFiatBalanceText(fiatBalance: PaymentAccountStatusValue.FiatBalance): TextReference { + val currency = Currency.getInstance(fiatBalance.currency) + return fiatBalance.availableBalance.formatStyled { fiat( fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 29ab477218..56139acdaa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -36,6 +36,12 @@ internal inline fun AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccou } } +internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Balance? = when (val v = value) { + is PaymentAccountStatusValue.Loaded -> v.balance + is PaymentAccountStatusValue.Deactivated -> v.balance + else -> null +} + internal fun AccountStatus.Payment.findCard( initialCardId: String, initialStatus: AccountStatus.Payment, diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index 9808c4f907..e192e54e5f 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -85,7 +85,11 @@ internal class TangemPayCardLimitSetupModelTest { val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { every { source } returns StatusSource.ACTUAL every { cards } returns listOf(cardWithLimit) - every { currencyCode } returns "USD" + every { balance } returns mockk(relaxed = true) { + every { fiatBalance } returns mockk(relaxed = true) { + every { currency } returns "USD" + } + } } val paymentStatusWithLimit: AccountStatus.Payment = mockk(relaxed = true) { every { value } returns statusWithLimit diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index c771ddd2c9..773354e3fe 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -58,8 +58,8 @@ internal class TangemPayMainBlockConverter( subtitle = TextReference.Res(R.string.tangempay_status_deactivated), isBalanceFlickering = statusValue.source == StatusSource.CACHE, balance = getBalanceText( - currencyCode = statusValue.fiatBalance.currency, - balance = statusValue.fiatBalance.availableBalance, + currencyCode = statusValue.balance.fiatBalance.currency, + balance = statusValue.balance.fiatBalance.availableBalance, ), balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol), shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, @@ -75,8 +75,8 @@ internal class TangemPayMainBlockConverter( }, isBalanceFlickering = statusValue.source == StatusSource.CACHE, balance = getBalanceText( - currencyCode = statusValue.currencyCode, - balance = statusValue.fiatBalance.availableBalance, + currencyCode = statusValue.balance.fiatBalance.currency, + balance = statusValue.balance.fiatBalance.availableBalance, ), balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol), shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, From e8cd53de9cc3499a11cf1e0afba7619bffabc068 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 00:30:35 -0700 Subject: [PATCH 3/7] Updated on 2026-08-14 --- .../component/impl/DefaultRoutingComponent.kt | 38 ++++++++------- .../TangemPayHotWalletOnboardingModel.kt | 5 +- .../TangemPayHotWalletOnboardingScreen.kt | 46 +++++++++++++------ .../TangemPayHotWalletOnboardingModelTest.kt | 8 +--- 4 files changed, 53 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 6be9860e0c..00c73a1027 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -214,33 +214,18 @@ internal class DefaultRoutingComponent @AssistedInject constructor( FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, ) TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled") - if (isHotWalletOnboardingEnabled) { + val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) { val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) { appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}") if (tangemPayHotWalletOnboardingDeepLink != null) { - val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding - val shouldShowTos = !cardRepository.isTangemTOSAccepted() - val route = if (shouldShowTos) "Disclaimer" else "HotWalletOnboarding" - TangemLogger.i("[TangemPay][HWO] TOS accepted=${!shouldShowTos}, navigating to $route") - return if (shouldShowTos) { - AppRoute.Disclaimer(isTosAccepted = false, nextRoute = hotWalletRoute) - } else { - hotWalletRoute - } + AppRoute.TangemPayHotWalletOnboarding + } else { + getDefaultRoute() } - } - - val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled( - FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED, - ) - // Referral users skip the Home stories screen and land directly on the - // mobile wallet creation flow. - val afterEmptyRoute: AppRoute = if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) { - AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet) } else { - AppRoute.Home(launchMode = launchMode) + getDefaultRoute() } val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull() @@ -261,6 +246,19 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } } + private suspend fun getDefaultRoute(): AppRoute { + val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED, + ) + // Referral users skip the Home stories screen and land directly on the + // mobile wallet creation flow. + return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) { + AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet) + } else { + AppRoute.Home(launchMode = launchMode) + } + } + @Composable override fun Content(modifier: Modifier) { RootContent( diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt index f0585fb4f5..acead48f67 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt @@ -7,7 +7,6 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.dialog.Dialogs @@ -16,7 +15,6 @@ import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase import com.tangem.domain.hotwallet.IsHotWalletCreationSupported import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase -import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.onboarding.api.R import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.MnemonicType @@ -38,7 +36,6 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor( private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase, private val router: Router, private val uiMessageSender: UiMessageSender, - private val urlOpener: UrlOpener, ) : Model() { val uiState: StateFlow @@ -51,7 +48,7 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor( ) private fun onTermsClick() { - urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) + router.push(AppRoute.Disclaimer(isTosAccepted = true)) } private fun onGetCardClick() { diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingScreen.kt index 6435e64bb2..3b769db3ba 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingScreen.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingScreen.kt @@ -14,17 +14,16 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.R -import com.tangem.core.ui.components.TextButton -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero @@ -80,10 +79,11 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier = .fillMaxWidth() .padding(horizontal = 40.dp), ) + SpacerH16() Spacer(Modifier.weight(1f)) Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { NavigationPrimaryButton( primaryButton = NavigationButton( @@ -94,18 +94,36 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier = onClick = state.onGetCardClick, ), ) - TextButton( - modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits), - onClick = state.onTermsClick, - colors = TangemButtonsDefaults.defaultTextButtonColors.copy( - contentColor = TangemTheme.colors.text.primary1, - ), - ) + TosText(onClick = state.onTermsClick) } } } +@Composable +private fun TosText(onClick: () -> Unit, modifier: Modifier = Modifier) { + val termsTemplate = stringResourceSafe(R.string.onboarding_create_wallet_term_of_conditions_text) + val termsLinkText = stringResourceSafe(R.string.disclaimer_title) + val termsLinkColor = TangemTheme.colors.text.accent + Text( + modifier = modifier.fillMaxWidth(), + text = buildAnnotatedString { + appendWithStyledPlaceholder(template = termsTemplate) { + withLink( + LinkAnnotation.Clickable( + tag = "tos_link", + styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.None)), + ) { onClick() }, + ) { + appendColored(text = termsLinkText, color = termsLinkColor) + } + } + }, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) +} + @Composable private fun Features(modifier: Modifier = Modifier) { Column( diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt index a2d4591f4d..cb1af5576e 100644 --- a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt +++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt @@ -6,7 +6,6 @@ import com.google.common.truth.Truth.assertThat import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase @@ -14,7 +13,6 @@ import com.tangem.domain.hotwallet.IsHotWalletCreationSupported import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase -import com.tangem.features.tangempay.TangemPayConstants import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.MnemonicType import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -32,7 +30,6 @@ internal class TangemPayHotWalletOnboardingModelTest { private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk() private val router: Router = mockk(relaxed = true) private val uiMessageSender: UiMessageSender = mockk(relaxed = true) - private val urlOpener: UrlOpener = mockk(relaxed = true) private val testUserWalletId = UserWalletId("1234567890ABCDEF") private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) { @@ -43,12 +40,12 @@ internal class TangemPayHotWalletOnboardingModelTest { inner class OnTermsClick { @Test - fun `WHEN onTermsClick THEN urlOpener called with terms link`() = runTest { + fun `WHEN onTermsClick THEN navigate to Disclaimer`() = runTest { val model = createModel() model.uiState.value.onTermsClick.invoke() - verify { urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } + verify { router.push(AppRoute.Disclaimer(isTosAccepted = true)) } } } @@ -118,7 +115,6 @@ internal class TangemPayHotWalletOnboardingModelTest { clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase, router = router, uiMessageSender = uiMessageSender, - urlOpener = urlOpener, ) } } \ No newline at end of file From 6c41eefe0bbcd2b5aec4846a6d5c2f4119f8c8f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 09:33:22 +0200 Subject: [PATCH 4/7] Updated on 2026-08-14 --- .../tangempay/ui/TangemPayCardDetailsBlock.kt | 25 ++++++------------- .../ui/TangemPayChangePinScreenV2.kt | 20 ++++++++------- .../ui/TangemPayEditDisplayNameScreen.kt | 2 +- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 24f15fce6d..4b87b1f576 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -108,14 +108,11 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M @Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") @Composable private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { - val isRenaming = state.displayNameState is DisplayNameState.Editing - Box(modifier = modifier.fillMaxSize()) { TangemPayCardBackground( modifier = Modifier .fillMaxSize() .zIndex(0f), - isRenaming = isRenaming, cardFrozenState = state.cardFrozenState, ) @@ -147,6 +144,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif ) } CardNumberBlock( + isRenaming = state.displayNameState is DisplayNameState.Editing, numberShort = state.numberShort, cardNumberRef = cardNumberRef, ) @@ -203,11 +201,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif } @Composable -private fun TangemPayCardBackground( - isRenaming: Boolean, - cardFrozenState: TangemPayCardFrozenState, - modifier: Modifier = Modifier, -) { +private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, modifier: Modifier = Modifier) { val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen val freezeProgress by animateFloatAsState( targetValue = if (isFrozen) 1f else 0f, @@ -225,14 +219,6 @@ private fun TangemPayCardBackground( contentDescription = null, ) - if (isRenaming && LocalVisaRedesignEnabled.current) { - Box( - modifier = Modifier - .fillMaxSize() - .background(CardBackgroundColor.copy(alpha = 0.8f)), - ) - } - if (isFrozen || freezeProgress > 0f) { Image( modifier = Modifier @@ -344,6 +330,7 @@ private fun CardTopBlock(modifier: Modifier = Modifier) { @Composable private fun ConstraintLayoutScope.CardNumberBlock( + isRenaming: Boolean, numberShort: String, cardNumberRef: ConstrainedLayoutReference, modifier: Modifier = Modifier, @@ -352,7 +339,11 @@ private fun ConstraintLayoutScope.CardNumberBlock( Text( text = numberShort, style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.staticDark.primary, + color = if (isRenaming) { + TangemTheme.colors3.text.staticDark.secondary + } else { + TangemTheme.colors3.text.staticDark.primary + }, modifier = modifier .constrainAs(cardNumberRef) { start.linkTo(parent.start) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreenV2.kt index 26c4737f33..40e461ac4e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreenV2.kt @@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_cross_20 import com.tangem.core.ui.test.TangemPayTestTags @@ -54,7 +54,7 @@ internal fun TangemPayChangePinScreenV2( .statusBarsPadding(), ) { TangemTopBar( - title = resourceReference(R.string.tangempay_set_pin_title), + title = resourceReference(R.string.visa_onboarding_pin_code_title), endContent = { TangemButton( iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_20), @@ -73,7 +73,7 @@ internal fun TangemPayChangePinScreenV2( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResourceSafe(R.string.tangempay_set_pin_header), + text = stringResourceSafe(R.string.visa_onboarding_pin_code_description), style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, textAlign = TextAlign.Center, @@ -94,6 +94,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod val focusRequester = remember { FocusRequester() } Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { PinCode( + isError = state.error != null, value = state.pinCode, onValueChange = state.onPinCodeChange, focusRequester = focusRequester, @@ -109,7 +110,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod Text( text = error.resolveReference(), style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.status.warning, + color = TangemTheme.colors3.text.status.error, textAlign = TextAlign.Center, modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE), ) @@ -125,6 +126,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod @Composable private fun PinCode( + isError: Boolean, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, @@ -180,10 +182,10 @@ private fun PinCode( ), digit = digit, backgroundColor = TangemTheme.colors3.bg.opaque.primary, - borderColor = if (isActive) { - TangemTheme.colors3.border.status.info - } else { - TangemTheme.colors3.border.secondary + borderColor = when { + isError -> TangemTheme.colors3.border.status.error + isActive -> TangemTheme.colors3.border.status.info + else -> TangemTheme.colors3.border.secondary }, textColor = TangemTheme.colors3.text.primary, textStyle = TangemTheme.typography3.heading.medium, @@ -201,7 +203,7 @@ private fun PinCode( private fun TangemPayChangePinScreenV2Preview( @PreviewParameter(TangemPayChangePinUMPreviewProvider::class) state: TangemPayChangePinUM, ) { - TangemThemePreview { + TangemThemePreviewRedesign { TangemPayChangePinScreenV2( state = state, onBackClick = {}, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt index f543b7507d..817eaffea4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt @@ -144,7 +144,7 @@ internal fun TangemPayEditDisplayNameScreenV2( .fillMaxWidth() .padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4) .imePadding(), - text = resourceReference(R.string.common_save), + text = resourceReference(R.string.common_done), onClick = state.onDoneClick, isLoading = state.isLoading, isEnabled = !state.isLoading && state.isDoneEnabled, From 044efe36c5eddb7a1d16356ac60f1c8ffcd50855 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 13:30:48 +0300 Subject: [PATCH 5/7] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 9 ++ .../com/tangem/common/routing/AppRoute.kt | 5 + .../ic_push_notification_settings_24.xml | 10 ++ .../impl/build.gradle.kts | 3 +- ...efaultPushNotificationSettingsComponent.kt | 96 +++++++++++++++++++ ...PushNotificationSettingsComponentModule.kt | 20 ++++ .../impl/entity/PushNotificationSettingsUM.kt | 2 - .../model/PushNotificationSettingsModel.kt | 26 +++-- .../impl/ui/AllowPushNotificationsBanner.kt | 28 ++++++ .../impl/ui/NotificationSettingRow.kt | 92 ++++++++++++++++++ .../ui/PushNotificationSettingsContent.kt | 68 +++++++++++++ .../impl/ui/PushNotificationSettingsError.kt | 19 ++++ .../ui/PushNotificationSettingsLoading.kt | 80 ++++++++++++++++ .../impl/ui/PushNotificationSettingsScreen.kt | 46 +++++++++ .../PushNotificationSettingsModelTest.kt | 26 ++--- .../wallet-settings/impl/build.gradle.kts | 1 + .../preview/PreviewWalletSettingsComponent.kt | 2 + .../model/WalletSettingsModel.kt | 10 ++ .../walletsettings/utils/ItemsBuilder.kt | 32 +++++-- 19 files changed, 540 insertions(+), 35 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_push_notification_settings_24.xml create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/component/DefaultPushNotificationSettingsComponent.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/di/PushNotificationSettingsComponentModule.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/AllowPushNotificationsBanner.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/NotificationSettingRow.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsContent.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsError.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsLoading.kt create mode 100644 features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsScreen.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 5ef88d9866..6400bc4ab8 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -32,6 +32,7 @@ import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub import com.tangem.features.pushnotifications.api.PushNotificationsParams +import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendEntryPointComponent @@ -87,6 +88,7 @@ internal class ChildFactory @Inject constructor( private val resetCardComponentFactory: ResetCardComponent.Factory, private val referralComponentFactory: ReferralComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, + private val pushNotificationSettingsComponentFactory: PushNotificationSettingsComponent.Factory, private val walletComponentFactory: WalletEntryComponent.Factory, private val sendComponentFactoryV2: SendComponent.Factory, private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory, @@ -172,6 +174,13 @@ internal class ChildFactory @Inject constructor( componentFactory = walletSettingsComponentFactory, ) } + is AppRoute.PushNotificationSettings -> { + createComponentChild( + context = context, + params = PushNotificationSettingsComponent.Params(route.userWalletId), + componentFactory = pushNotificationSettingsComponentFactory, + ) + } is AppRoute.WalletBackup -> { createComponentChild( context = context, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 73d0e6bebb..5ae1808b7e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -257,6 +257,11 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, ) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}") + @Serializable + data class PushNotificationSettings( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/push_notification_settings/${userWalletId.stringValue}") + @Serializable data class WalletBackup( val userWalletId: UserWalletId, diff --git a/core/ui/src/main/res/drawable/ic_push_notification_settings_24.xml b/core/ui/src/main/res/drawable/ic_push_notification_settings_24.xml new file mode 100644 index 0000000000..8cbc8e1bcf --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_push_notification_settings_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/features/push-notification-settings/impl/build.gradle.kts b/features/push-notification-settings/impl/build.gradle.kts index bed7c654b6..b69c5cd2a8 100644 --- a/features/push-notification-settings/impl/build.gradle.kts +++ b/features/push-notification-settings/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.domain.pushNotificationPreferences) /* AndroidX */ + implementation(deps.androidx.activity.compose) implementation(deps.lifecycle.compose) /* Compose */ @@ -50,7 +51,7 @@ dependencies { implementation(deps.kotlin.immutable.collections) /* Tests */ - testImplementation(deps.test.junit) + testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) testImplementation(deps.test.coroutine) testImplementation(deps.test.truth) diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/component/DefaultPushNotificationSettingsComponent.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/component/DefaultPushNotificationSettingsComponent.kt new file mode 100644 index 0000000000..491b9dcb17 --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/component/DefaultPushNotificationSettingsComponent.kt @@ -0,0 +1,96 @@ +package com.tangem.features.pushnotificationsettings.impl.component + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.essenty.lifecycle.doOnResume +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.feature.walletsettings.component.NetworksAvailableForNotificationsComponent +import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull +import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent +import com.tangem.features.pushnotificationsettings.impl.entity.NetworksAvailableForNotificationBSConfig +import com.tangem.features.pushnotificationsettings.impl.model.PushNotificationSettingsModel +import com.tangem.features.pushnotificationsettings.impl.ui.PushNotificationSettingsScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultPushNotificationSettingsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: PushNotificationSettingsComponent.Params, + private val networksAvailableForNotificationsComponentFactory: NetworksAvailableForNotificationsComponent.Factory, +) : PushNotificationSettingsComponent, AppComponentContext by context { + + private val model: PushNotificationSettingsModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + key = "moreInfoBottomSheet", + childFactory = ::bottomSheetChild, + ) + + init { + lifecycle.doOnResume { model.onResume() } + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = model::onPermissionResult, + ) + + LaunchedEffect(Unit) { + model.requestPushPermission.collect { + val permission = getPushPermissionOrNull() + if (permission != null) { + permissionLauncher.launch(permission) + } else { + model.onPermissionResult(isGranted = false) + } + } + } + + PushNotificationSettingsScreen( + modifier = modifier, + state = state, + onBackClick = router::pop, + ) + + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + @Suppress("UNUSED_PARAMETER") config: NetworksAvailableForNotificationBSConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = networksAvailableForNotificationsComponentFactory.create( + context = childByContext(componentContext), + params = NetworksAvailableForNotificationsComponent.Params( + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + + @AssistedFactory + interface Factory : PushNotificationSettingsComponent.Factory { + override fun create( + context: AppComponentContext, + params: PushNotificationSettingsComponent.Params, + ): DefaultPushNotificationSettingsComponent + } +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/di/PushNotificationSettingsComponentModule.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/di/PushNotificationSettingsComponentModule.kt new file mode 100644 index 0000000000..b6ecab63ea --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/di/PushNotificationSettingsComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.pushnotificationsettings.impl.di + +import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent +import com.tangem.features.pushnotificationsettings.impl.component.DefaultPushNotificationSettingsComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface PushNotificationSettingsComponentModule { + + @Binds + @Singleton + fun bindPushNotificationSettingsComponentFactory( + factory: DefaultPushNotificationSettingsComponent.Factory, + ): PushNotificationSettingsComponent.Factory +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/entity/PushNotificationSettingsUM.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/entity/PushNotificationSettingsUM.kt index f31df5f7cf..b24b6ed544 100644 --- a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/entity/PushNotificationSettingsUM.kt +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/entity/PushNotificationSettingsUM.kt @@ -1,7 +1,6 @@ package com.tangem.features.pushnotificationsettings.impl.entity import androidx.compose.runtime.Immutable -import com.tangem.core.ui.event.StateEvent import kotlinx.collections.immutable.PersistentList @Immutable @@ -12,7 +11,6 @@ internal sealed interface PushNotificationSettingsUM { data class Content( val banner: AllowPushNotificationsBannerUM?, val toggles: PersistentList, - val requestPermissionEvent: StateEvent, val onMoreInfoClick: () -> Unit, ) : PushNotificationSettingsUM diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt index f27b886fbb..cbc841a27c 100644 --- a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt @@ -9,9 +9,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.ui.event.StateEvent -import com.tangem.core.ui.event.consumedEvent -import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage @@ -38,6 +35,8 @@ import com.tangem.utils.coroutines.saveIn import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -45,6 +44,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -69,7 +69,6 @@ internal class PushNotificationSettingsModel @Inject constructor( private val loadState = MutableStateFlow(LoadState.Loading) private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled()) - private val pendingRequest = MutableStateFlow>(consumedEvent()) private var pendingPermissionToggle: ToggleSpec? = null private val preferencesJobHolder = JobHolder() @@ -77,15 +76,19 @@ internal class PushNotificationSettingsModel @Inject constructor( private val cachedPrefs: WalletPushNotificationPreferences? get() = (loadState.value as? LoadState.Content)?.prefs + private val requestPushPermissionChannel = Channel(Channel.BUFFERED) + + /** One-shot requests to launch the system push permission prompt, consumed by the component. */ + val requestPushPermission: Flow = requestPushPermissionChannel.receiveAsFlow() + val uiState: StateFlow = combine( loadState, osNotificationsEnabled, - pendingRequest, - ) { load, osEnabled, request -> + ) { load, osEnabled -> when (load) { is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry) is LoadState.Loading -> PushNotificationSettingsUM.Loading - is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled, request = request) + is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled) } }.stateIn( scope = modelScope, @@ -117,7 +120,6 @@ internal class PushNotificationSettingsModel @Inject constructor( } fun onPermissionResult(isGranted: Boolean) { - pendingRequest.value = consumedEvent() val tapped = pendingPermissionToggle pendingPermissionToggle = null modelScope.launch { @@ -145,12 +147,10 @@ internal class PushNotificationSettingsModel @Inject constructor( private fun buildContent( prefs: WalletPushNotificationPreferences, osEnabled: Boolean, - request: StateEvent, ): PushNotificationSettingsUM.Content { return PushNotificationSettingsUM.Content( banner = buildBanner(prefs = prefs, osEnabled = osEnabled), toggles = buildToggles(prefs), - requestPermissionEvent = request, onMoreInfoClick = ::onMoreInfoClick, ) } @@ -190,7 +190,7 @@ internal class PushNotificationSettingsModel @Inject constructor( private fun requestPermission(tapped: ToggleSpec? = null) { pendingPermissionToggle = tapped - pendingRequest.value = triggeredEvent(data = Unit, onConsume = ::onPermissionEventConsumed) + requestPushPermissionChannel.trySend(Unit) } private fun onBannerCtaClick() { @@ -219,10 +219,6 @@ internal class PushNotificationSettingsModel @Inject constructor( applyOptimisticToggle(spec, newValue) } - private fun onPermissionEventConsumed() { - pendingRequest.value = consumedEvent() - } - private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) { val current = cachedPrefs ?: return loadState.value = LoadState.Content(current.withCategory(spec.category, newValue)) diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/AllowPushNotificationsBanner.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/AllowPushNotificationsBanner.kt new file mode 100644 index 0000000000..da20ec497a --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/AllowPushNotificationsBanner.kt @@ -0,0 +1,28 @@ +package com.tangem.features.pushnotificationsettings.impl.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.R as CoreUiR +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.pushnotificationsettings.impl.R +import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM + +@Composable +internal fun AllowPushNotificationsBanner(state: AllowPushNotificationsBannerUM, modifier: Modifier = Modifier) { + Notification( + modifier = modifier.fillMaxWidth(), + config = NotificationConfig( + title = resourceReference(R.string.push_notification_settings_banner_title), + subtitle = resourceReference(R.string.push_notification_settings_banner_description), + iconResId = CoreUiR.drawable.ic_alert_circle_24, + iconTint = NotificationConfig.IconTint.Warning, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.common_open_settings_button_title), + onClick = state.onOpenSettingsClick, + ), + ), + ) +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/NotificationSettingRow.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/NotificationSettingRow.kt new file mode 100644 index 0000000000..751b904642 --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/NotificationSettingRow.kt @@ -0,0 +1,92 @@ +package com.tangem.features.pushnotificationsettings.impl.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.TangemSwitch +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.styledResourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.pushnotificationsettings.impl.R +import com.tangem.features.pushnotificationsettings.impl.entity.ToggleUM + +@Composable +internal fun NotificationSettingRow( + toggle: ToggleUM, + showInlineMoreInfoLink: Boolean, + onMoreInfoClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + BlockCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(id = toggle.titleRes), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + TangemSwitch( + checked = toggle.isOn, + onCheckedChange = toggle.onCheckedChange, + ) + } + } + + SubtitleText( + base = toggle.subtitle, + showMoreInfo = showInlineMoreInfoLink, + onMoreInfoClick = onMoreInfoClick, + ) + } +} + +@Composable +private fun SubtitleText(base: TextReference, showMoreInfo: Boolean, onMoreInfoClick: () -> Unit) { + val reference: TextReference = if (showMoreInfo) { + combinedReference( + base, + stringReference(" "), + styledResourceReference( + id = R.string.push_notifications_more_info, + spanStyleReference = { + TangemTheme.typography.caption2 + .copy(color = TangemTheme.colors.text.accent) + .toSpanStyle() + }, + onClick = onMoreInfoClick, + ), + ) + } else { + base + } + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + text = reference.resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsContent.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsContent.kt new file mode 100644 index 0000000000..391208437f --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsContent.kt @@ -0,0 +1,68 @@ +package com.tangem.features.pushnotificationsettings.impl.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.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM +import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM +import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId + +@Composable +internal fun PushNotificationSettingsContent( + state: PushNotificationSettingsUM.Content, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + item(key = "banner") { + AnimatedBannerSlot(banner = state.banner) + } + + items(items = state.toggles, key = { it.id.name }) { toggle -> + NotificationSettingRow( + modifier = Modifier.animateItem(), + toggle = toggle, + showInlineMoreInfoLink = toggle.id == ToggleId.TransactionAlerts, + onMoreInfoClick = state.onMoreInfoClick, + ) + } + } +} + +@Composable +private fun AnimatedBannerSlot(banner: AllowPushNotificationsBannerUM?) { + var lastVisible by remember { mutableStateOf(banner) } + if (banner != null) { + lastVisible = banner + } + AnimatedVisibility( + visible = banner != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + lastVisible?.let { AllowPushNotificationsBanner(state = it) } + } +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsError.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsError.kt new file mode 100644 index 0000000000..46c152f6f0 --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsError.kt @@ -0,0 +1,19 @@ +package com.tangem.features.pushnotificationsettings.impl.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM + +@Composable +internal fun PushNotificationSettingsError(state: PushNotificationSettingsUM.Error, modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = state.onRetryClick) + } +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsLoading.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsLoading.kt new file mode 100644 index 0000000000..bc2bcf7d3d --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsLoading.kt @@ -0,0 +1,80 @@ +package com.tangem.features.pushnotificationsettings.impl.ui + +import androidx.compose.foundation.layout.Arrangement +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.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun PushNotificationSettingsLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + repeat(SHIMMER_ROW_COUNT) { + ShimmerRow() + } + } +} + +@Composable +private fun ShimmerRow(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + BlockCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + RectangleShimmer( + modifier = Modifier + .width(SHIMMER_TITLE_WIDTH) + .height(SHIMMER_TITLE_HEIGHT), + ) + RectangleShimmer( + modifier = Modifier + .width(SHIMMER_SWITCH_WIDTH) + .height(SHIMMER_SWITCH_HEIGHT), + radius = TangemTheme.dimens.radius12, + ) + } + } + RectangleShimmer( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .width(SHIMMER_SUBTITLE_WIDTH) + .height(SHIMMER_SUBTITLE_HEIGHT), + ) + } +} + +private const val SHIMMER_ROW_COUNT = 3 +private val SHIMMER_TITLE_WIDTH = 160.dp +private val SHIMMER_TITLE_HEIGHT = 18.dp +private val SHIMMER_SWITCH_WIDTH = 40.dp +private val SHIMMER_SWITCH_HEIGHT = 22.dp +private val SHIMMER_SUBTITLE_WIDTH = 220.dp +private val SHIMMER_SUBTITLE_HEIGHT = 14.dp \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsScreen.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsScreen.kt new file mode 100644 index 0000000000..586d61f56a --- /dev/null +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/ui/PushNotificationSettingsScreen.kt @@ -0,0 +1,46 @@ +package com.tangem.features.pushnotificationsettings.impl.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.pushnotificationsettings.impl.R +import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM + +@Composable +internal fun PushNotificationSettingsScreen( + state: PushNotificationSettingsUM, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = TangemTheme.colors.background.secondary, + topBar = { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = resourceReference(R.string.push_notification_settings_title), + startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick), + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + when (state) { + is PushNotificationSettingsUM.Loading -> PushNotificationSettingsLoading() + is PushNotificationSettingsUM.Content -> PushNotificationSettingsContent(state = state) + is PushNotificationSettingsUM.Error -> PushNotificationSettingsError(state = state) + } + } + } +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt b/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt index 152d433421..500cfcf9b6 100644 --- a/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt +++ b/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Test +import org.junit.jupiter.api.Test @Suppress("LongParameterList") class PushNotificationSettingsModelTest { @@ -204,13 +204,15 @@ class PushNotificationSettingsModelTest { val model = model(osEnabled = false, preferencesFlow = flow) advanceUntilIdle() - val offers = (model.uiState.value as PushNotificationSettingsUM.Content) - .toggles.first { it.id == ToggleId.OffersUpdates } - offers.onCheckedChange(true) - advanceUntilIdle() + model.requestPushPermission.test { + val offers = (model.uiState.value as PushNotificationSettingsUM.Content) + .toggles.first { it.id == ToggleId.OffersUpdates } + offers.onCheckedChange(true) + advanceUntilIdle() - val content = model.uiState.value as PushNotificationSettingsUM.Content - assertThat(content.requestPermissionEvent.javaClass.simpleName).isEqualTo("Triggered") + awaitItem() + expectNoEvents() + } coVerify(exactly = 0) { updatePreference(any(), any(), any()) } } @@ -224,12 +226,14 @@ class PushNotificationSettingsModelTest { val banner = requireNotNull( (model.uiState.value as PushNotificationSettingsUM.Content).banner, ) - banner.onOpenSettingsClick() - advanceUntilIdle() + model.requestPushPermission.test { + banner.onOpenSettingsClick() + advanceUntilIdle() + // The banner CTA opens system settings directly and never asks for the permission. + expectNoEvents() + } verify(exactly = 1) { settingsManager.openAppNotificationSettings() } - val refreshed = model.uiState.value as PushNotificationSettingsUM.Content - assertThat(refreshed.requestPermissionEvent.javaClass.simpleName).isEqualTo("Consumed") } @Test diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index 073dc92b0f..58b07a4853 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.features.nft.api) implementation(projects.features.onboardingV2.api) implementation(projects.features.pushNotifications.api) + implementation(projects.features.pushNotificationSettings.api) implementation(projects.features.hotWallet.api) implementation(projects.features.wallet.api) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 4fa7a53f60..23e9499009 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -63,6 +63,8 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { isNotificationsEnabled = true, onCheckedNotificationsChanged = {}, onNotificationsDescriptionClick = {}, + isPushNotificationSettingsEnabled = false, + onNotificationSettingsClick = {}, isNotificationsPermissionGranted = false, onAccessCodeClick = {}, onBackupClick = {}, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 8f236ca63a..6741173d06 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -51,6 +51,7 @@ import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents +import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList @@ -92,6 +93,7 @@ internal class WalletSettingsModel @Inject constructor( private val accountListSortingSaver: AccountListSortingSaver, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() @@ -203,6 +205,8 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Cold -> userWallet.isMultiCurrency is UserWallet.Hot -> true } + val isPushNotificationSettingsEnabled = + pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled return itemsBuilder.buildItems( userWallet = userWallet, cardItem = cardItem, @@ -245,6 +249,8 @@ internal class WalletSettingsModel @Inject constructor( isNotificationsPermissionGranted = isNotificationsPermissionGranted, onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, + isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled, + onNotificationSettingsClick = ::onNotificationSettingsClick, onAccessCodeClick = { onAccessCodeClick(userWallet) }, onBackupClick = ::onBackupClick, onCardSettingsClick = ::onCardSettingsClick, @@ -252,6 +258,10 @@ internal class WalletSettingsModel @Inject constructor( ) } + private fun onNotificationSettingsClick() { + router.push(AppRoute.PushNotificationSettings(userWalletId = params.userWalletId)) + } + private fun forgetWallet() = modelScope.launch { val userWallet = getUserWalletUseCase(params.userWalletId) .getOrNull() diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 8bde456990..21dfa146a5 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -33,6 +33,8 @@ internal class ItemsBuilder @Inject constructor() { isNotificationsPermissionGranted: Boolean, onCheckedNotificationsChanged: (Boolean) -> Unit, onNotificationsDescriptionClick: () -> Unit, + isPushNotificationSettingsEnabled: Boolean, + onNotificationSettingsClick: () -> Unit, forgetWallet: () -> Unit, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, @@ -50,20 +52,26 @@ internal class ItemsBuilder @Inject constructor() { isLinkMoreCardsAvailable = isLinkMoreCardsAvailable, isReferralAvailable = isReferralAvailable, isManageTokensAvailable = isManageTokensAvailable, + isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled, onLinkMoreCardsClick = onLinkMoreCardsClick, onReferralClick = onReferralClick, onManageTokensClick = onManageTokensClick, onBackupClick = onBackupClick, onCardSettingsClick = onCardSettingsClick, + onNotificationSettingsClick = onNotificationSettingsClick, ), ) .addAll( - buildNotificationItems( - isNotificationsPermissionGranted = isNotificationsPermissionGranted, - isNotificationsEnabled = isNotificationsEnabled, - onCheckedNotificationsChanged = onCheckedNotificationsChanged, - onNotificationsDescriptionClick = onNotificationsDescriptionClick, - ), + if (isPushNotificationSettingsEnabled) { + emptyList() + } else { + buildNotificationItems( + isNotificationsPermissionGranted = isNotificationsPermissionGranted, + isNotificationsEnabled = isNotificationsEnabled, + onCheckedNotificationsChanged = onCheckedNotificationsChanged, + onNotificationsDescriptionClick = onNotificationsDescriptionClick, + ) + }, ) .addAll( buildNFTItems( @@ -137,11 +145,13 @@ internal class ItemsBuilder @Inject constructor() { isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, isManageTokensAvailable: Boolean, + isPushNotificationSettingsEnabled: Boolean, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, onManageTokensClick: () -> Unit, onBackupClick: () -> Unit, onCardSettingsClick: () -> Unit, + onNotificationSettingsClick: () -> Unit, ) = WalletSettingsItemUM.WithItems( id = "card", description = null, @@ -207,6 +217,16 @@ internal class ItemsBuilder @Inject constructor() { add(referralBlock) } + + if (isPushNotificationSettingsEnabled) { + val notificationSettingsBlock = BlockUM( + text = resourceReference(R.string.push_notification_settings_title), + iconRes = R.drawable.ic_push_notification_settings_24, + onClick = onNotificationSettingsClick, + ) + + add(notificationSettingsBlock) + } }.toImmutableList(), ) From 4d6afd7afc9fc67b6f780f1b9d4127b4a84d104a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Jun 2026 16:41:06 +0300 Subject: [PATCH 6/7] Updated on 2026-08-14 --- .../tokendetails/model/TokenDetailsModel.kt | 3 + .../state/TokenDetailsBalanceBlockUM.kt | 2 + .../transformer/SetBalanceTransformer.kt | 14 ++ .../SetYieldSupplyBalanceTransformer.kt | 27 ++++ .../ui/components/TokenDetailsBalanceBlock.kt | 55 ++++++- .../transformer/SetBalanceTransformerTest.kt | 83 +++++++++- .../SetYieldSupplyBalanceTransformerTest.kt | 146 ++++++++++++++++++ 7 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformer.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformerTest.kt diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index ac85882198..5aa1becb9c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -109,6 +109,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.Q import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetYieldSupplyBalanceTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer @@ -470,6 +471,7 @@ internal class TokenDetailsModel @Inject constructor( yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value) .onEach { formatted -> uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted) + redesignStateController.update(SetYieldSupplyBalanceTransformer(formatted)) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -479,6 +481,7 @@ internal class TokenDetailsModel @Inject constructor( uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance( YieldSupplyRewardBalance.empty(), ) + redesignStateController.update(SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty())) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt index 406042f571..8a1b6d4902 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt @@ -37,6 +37,8 @@ internal sealed class TokenDetailsBalanceBlockUM { val displayFiatBalanceAvailable: TextReference?, val isBalanceFlickering: Boolean, val isBalanceZero: Boolean, + val displayYieldSupplyFiatBalance: String? = null, + val displayYieldSupplyCryptoBalance: String? = null, ) : TokenDetailsBalanceBlockUM() { val displayCryptoBalance: TextReference diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt index e2f6f7f828..99bb251bdb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt @@ -86,6 +86,9 @@ internal class SetBalanceTransformer( val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount) + val isYieldSupplyActive = status.value.yieldSupplyStatus?.isActive == true + val prevContent = prev as? TokenDetailsBalanceBlockUM.Content + return TokenDetailsBalanceBlockUM.Content( addFundsButton = prev.addFundsButton, swapButton = prev.swapButton, @@ -108,6 +111,17 @@ internal class SetBalanceTransformer( }, isBalanceFlickering = status.value.sources.total == StatusSource.CACHE, isBalanceZero = totalCryptoAmount.isNullOrZero(), + // Preserve the ticking yield balance produced by SetYieldSupplyBalanceTransformer across status updates + displayYieldSupplyFiatBalance = if (isYieldSupplyActive) { + prevContent?.displayYieldSupplyFiatBalance + } else { + null + }, + displayYieldSupplyCryptoBalance = if (isYieldSupplyActive) { + prevContent?.displayYieldSupplyCryptoBalance + } else { + null + }, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformer.kt new file mode 100644 index 0000000000..e924a6a7b6 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformer.kt @@ -0,0 +1,27 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +/** + * Pushes the ticking yield supply balance (emitted every tick by [YieldSupplyGetRewardsBalanceUseCase]) + * into the redesign [TokenDetailsBalanceBlockUM.Content], so the balance increments in real time. + */ +internal class SetYieldSupplyBalanceTransformer( + private val yieldSupplyRewardBalance: YieldSupplyRewardBalance, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val balanceBlockUM = prevState.balanceBlockUM + if (balanceBlockUM !is TokenDetailsBalanceBlockUM.Content) return prevState + + return prevState.copy( + balanceBlockUM = balanceBlockUM.copy( + displayYieldSupplyFiatBalance = yieldSupplyRewardBalance.fiatBalance, + displayYieldSupplyCryptoBalance = yieldSupplyRewardBalance.cryptoBalance, + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index 6592a5416a..56957faa3b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -11,7 +11,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview @@ -23,10 +25,12 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.text.TextAnimatedCounter import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.button.action.ActionButtons import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference @@ -42,6 +46,9 @@ import kotlinx.collections.immutable.persistentListOf private val CurrencyIconSize: Dp = 70.dp private val NetworkBadgeSize: Dp = 24.dp +/** OpenType "tabular figures" feature — makes every digit the same width to prevent horizontal jitter. */ +private const val TABULAR_FIGURES_FEATURE = "tnum" + @Composable internal fun TokenDetailsBalanceBlock( balanceBlockUM: TokenDetailsBalanceBlockUM, @@ -124,20 +131,60 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd } } SpacerH(TangemTheme.dimens2.x2) - Text( + AnimatedBalance( modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT), - text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + yieldBalance = state.displayYieldSupplyFiatBalance, + fallbackBalance = state.displayFiatBalance, style = TangemTheme.typography2.titleRegular44, color = TangemTheme.colors2.text.neutral.primary, + isBalanceHidden = isBalanceHidden, ) SpacerH(TangemTheme.dimens2.x2_5) - Text( - text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + AnimatedBalance( + yieldBalance = state.displayYieldSupplyCryptoBalance, + fallbackBalance = state.displayCryptoBalance, style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.secondary, + isBalanceHidden = isBalanceHidden, ) } +/** + * Renders a balance that animates digit-by-digit ([TextAnimatedCounter]) while a ticking yield supply + * value is present, and falls back to a plain [Text] otherwise (or when the balance is hidden). + */ +@Composable +private fun AnimatedBalance( + yieldBalance: String?, + fallbackBalance: TextReference, + style: TextStyle, + color: Color, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x6), + contentAlignment = Alignment.Center, + ) { + if (yieldBalance != null && !isBalanceHidden) { + TextAnimatedCounter( + text = yieldBalance, + // Tabular figures keep every digit the same width, so the centered balance doesn't + // jitter horizontally as digits roll during the increment animation. + style = style.copy(color = color, fontFeatureSettings = TABULAR_FIGURES_FEATURE), + ) + } else { + Text( + text = fallbackBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = style, + color = color, + ) + } + } +} + @Composable private fun LoadingBody() { Text( diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt index bba4ce1980..7cd9696901 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM @@ -434,6 +435,59 @@ class SetBalanceTransformerTest { // endregion + // region Yield supply balance + + @Test + fun `GIVEN yield active AND prev has yield balances WHEN transform THEN yield balances preserved`() { + // GIVEN + val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus())) + val transformer = createTransformer(status) + val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH") + val state = initialState().copy(balanceBlockUM = prev) + + // WHEN + val result = transformer.transform(state) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12") + assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH") + } + + @Test + fun `GIVEN yield inactive AND prev has yield balances WHEN transform THEN yield balances are cleared`() { + // GIVEN + val status = createStatus(loadedValue(yieldSupplyStatus = null)) + val transformer = createTransformer(status) + val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH") + val state = initialState().copy(balanceBlockUM = prev) + + // WHEN + val result = transformer.transform(state) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayYieldSupplyFiatBalance).isNull() + assertThat(content.displayYieldSupplyCryptoBalance).isNull() + } + + @Test + fun `GIVEN yield active AND prev is not Content WHEN transform THEN yield balances are null`() { + // GIVEN — prev is the default Loading block, so there are no yield balances to preserve yet + val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus())) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayYieldSupplyFiatBalance).isNull() + assertThat(content.displayYieldSupplyCryptoBalance).isNull() + } + + // endregion + // region Unrelated fields preserved @Test @@ -473,6 +527,7 @@ class SetBalanceTransformerTest { fiatAmount: BigDecimal = BigDecimal("21000"), fiatRate: BigDecimal = BigDecimal("2000"), stakingBalance: StakingBalance? = null, + yieldSupplyStatus: YieldSupplyStatus? = null, sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(), ): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded( amount = amount, @@ -480,13 +535,39 @@ class SetBalanceTransformerTest { fiatRate = fiatRate, priceChange = BigDecimal("2.5"), stakingBalance = stakingBalance, - yieldSupplyStatus = null, + yieldSupplyStatus = yieldSupplyStatus, hasCurrentNetworkTransactions = false, pendingTransactions = emptySet(), networkAddress = mockk(relaxed = true), sources = sources, ) + private fun activeYieldSupplyStatus(): YieldSupplyStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = null, + ) + + private fun contentWithYieldBalances( + fiat: String?, + crypto: String?, + ): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content( + addFundsButton = placeholderButton(), + swapButton = placeholderButton(), + transferButton = placeholderButton(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference(""), + displayFiatBalanceAll = stringReference(""), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + isBalanceZero = false, + displayYieldSupplyFiatBalance = fiat, + displayYieldSupplyCryptoBalance = crypto, + ) + private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote( amount = BigDecimal("5.0"), stakingBalance = null, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformerTest.kt new file mode 100644 index 0000000000..c8e7c4a92a --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetYieldSupplyBalanceTransformerTest.kt @@ -0,0 +1,146 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SetYieldSupplyBalanceTransformerTest { + + @Test + fun `GIVEN Content state WHEN transform THEN yield balances are set from reward balance`() { + // GIVEN + val state = stateWith(contentBlock()) + val transformer = SetYieldSupplyBalanceTransformer( + YieldSupplyRewardBalance(fiatBalance = "$21,000.12", cryptoBalance = "10.500001 ETH"), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12") + assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH") + } + + @Test + fun `GIVEN empty reward balance WHEN transform on Content THEN yield balances are nulled`() { + // GIVEN + val state = stateWith( + contentBlock().copy( + displayYieldSupplyFiatBalance = "$21,000.12", + displayYieldSupplyCryptoBalance = "10.500001 ETH", + ), + ) + val transformer = SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty()) + + // WHEN + val result = transformer.transform(state) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayYieldSupplyFiatBalance).isNull() + assertThat(content.displayYieldSupplyCryptoBalance).isNull() + } + + @Test + fun `GIVEN Loading block WHEN transform THEN state is unchanged`() { + // GIVEN + val state = stateWith(loadingBlock()) + val transformer = SetYieldSupplyBalanceTransformer( + YieldSupplyRewardBalance(fiatBalance = "$1", cryptoBalance = "1 ETH"), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + @Test + fun `GIVEN Error block WHEN transform THEN state is unchanged`() { + // GIVEN + val state = stateWith(errorBlock()) + val transformer = SetYieldSupplyBalanceTransformer( + YieldSupplyRewardBalance(fiatBalance = "$1", cryptoBalance = "1 ETH"), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + private fun contentBlock(): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content( + addFundsButton = placeholderButton(), + swapButton = placeholderButton(), + transferButton = placeholderButton(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("10.5 ETH"), + displayFiatBalanceAll = stringReference("$21,000.00"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + isBalanceZero = false, + ) + + private fun loadingBlock(): TokenDetailsBalanceBlockUM.Loading = TokenDetailsBalanceBlockUM.Loading( + addFundsButton = placeholderButton(), + swapButton = placeholderButton(), + transferButton = placeholderButton(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ) + + private fun errorBlock(): TokenDetailsBalanceBlockUM.Error = TokenDetailsBalanceBlockUM.Error( + addFundsButton = placeholderButton(), + swapButton = placeholderButton(), + transferButton = placeholderButton(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ) + + private fun stateWith(balanceBlockUM: TokenDetailsBalanceBlockUM): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = balanceBlockUM, + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + addFundsUM = AddFundsUM.Loading, + transferUM = TransferUM.Loading, + zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading, + ) + + private fun placeholderButton(): TangemButtonUM = TangemButtonUM( + text = stringReference(""), + type = TangemButtonType.Secondary, + onClick = {}, + ) +} \ No newline at end of file From 87819454186e4058f49c72781a45db35a9a6d706 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 15:15:17 +0300 Subject: [PATCH 7/7] Updated on 2026-08-14 --- .../com/tangem/scenarios/GaslessScenarios.kt | 49 ++++ .../sendViaSwap/GaslessSendViaSwapTest.kt | 254 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt index 7617efa34f..90ef47eb41 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt @@ -87,4 +87,53 @@ fun BaseTestCase.openGaslessSendScreenWithHotWallet( step("Click on 'Send' button in bottom sheet") { onTransferBottomSheet { sendButton.clickWithAssertion() } } +} + +/** + * Open an existing hot wallet, select the token to send and choose the swap target token/network — + * the shared entry into the gasless send-via-swap flow. Scenario states stay in the test body. + */ +fun BaseTestCase.openSendViaSwapScreenWithHotWallet( + seedPhrase: String, + tokenName: String, + swapTokenName: String, + networkName: String, + networkType: String? = null, +) { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select '$swapTokenName' as the token to receive via swap") { + selectTokenToSendViaSwap( + swapTokenName = swapTokenName, + networkName = networkName, + networkType = networkType, + ) + } +} + +/** + * Send-via-swap amount entry: type the amount, advance past the quote-gated 'Next' button (waiting + * until it becomes enabled once the swap quote loads), then fill the recipient and open the + * 'Send confirm' screen. Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in + * extensions on [BaseTestCase]. + */ +fun BaseTestCase.enterSwapAmountAndOpenSendConfirm(amount: String, recipientAddress: String) { + step("Type amount '$amount' in input field") { + onSendScreen { amountInputTextField.performTextReplacement(amount) } + } + step("Click on 'Next' button") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + }.isSuccess + } + } + enterRecipientAndOpenSendConfirm(recipientAddress) } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt new file mode 100644 index 0000000000..582555f882 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt @@ -0,0 +1,254 @@ +package com.tangem.tests.send.sendViaSwap + +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless send-via-swap: paying the network fee with the stablecoin while converting it through an + * express swap. Covers the fee-token selection on the swap summary, the stablecoin balance validation + * against the gasless fee, and the full signed swap-and-send on a hot wallet. + */ +@HiltAndroidTest +class GaslessSendViaSwapTest : BaseTestCase() { + + private val tokenName = "USDC" + private val currencySymbol = "USDC" + private val nativeTokenName = "Polygon" + private val swapTokenName = "Bitcoin" + private val mainNetwork = "MAIN" + private val providerName = "Changelly" + private val tokenAmount = "1" + private val hotWalletTokensState = "PolygonUSDCHotWallet" + private val quotesState = "PolygonUSDC" + private val assetsScenarioName = "express_api_assets" + private val assetsExchangeEnabledState = "BitcoinExchangeEnabled" + private val providersState = "HotWalletSvS" + + @AllureId("5120") + @DisplayName("Gasless Send via Swap: the network fee is selectable and payable with the stablecoin") + @Test + fun checkFeeTokenSelectionForSwapTest() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") { + setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState) + } + + step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") { + openSendViaSwapScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + swapTokenName = swapTokenName, + networkName = swapTokenName, + networkType = mainNetwork, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) + } + step("Assert 'Network fee' block with token selection is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeSelectorTitle.assertIsDisplayed() + selectFeeIcon.assertIsDisplayed() + } + } + } + step("Click on 'Network fee' block") { + onSendConfirmScreen { feeSelectorBlock.performClick() } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' bottom sheet is displayed") { + onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() } + } + step("Assert '$tokenName' is available for the fee payment") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the network fee is calculated in '$currencySymbol' on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + } + } + } + + @AllureId("5121") + @DisplayName("Gasless Send via Swap: insufficient stablecoin balance to cover the fee blocks the swap") + @Test + fun checkBalanceValidationForFeeTest() { + val usdcBalanceScenario = "polygon_usdc_balance" + val lowBalanceState = "LowBalance" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(usdcBalanceScenario) + } + ).run { + step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") { + setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState) + } + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") { + setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState) + } + + step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") { + openSendViaSwapScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + swapTokenName = swapTokenName, + networkName = swapTokenName, + networkType = mainNetwork, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Assert 'Not enough funds' error is displayed in the fee selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() } + } + } + step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") { + onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() } + } + } + } + + @AllureId("5122") + @DisplayName("Gasless Send via Swap: sign and send a swap paying the fee with the stablecoin") + @Test + fun checkSendViaSwapFinalScreenAndSendTest() { + val exchangeStatusScenario = "exchange_status_provider" + val changellyStatusState = "Changelly" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(exchangeStatusScenario) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") { + setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState) + } + step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") { + setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState) + } + + step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") { + openSendViaSwapScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + swapTokenName = swapTokenName, + networkName = swapTokenName, + networkType = mainNetwork, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the sent '$tokenName' amount is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { primaryAmount.assertIsDisplayed() } + } + } + step("Assert the recipient address is displayed") { + onSendConfirmScreen { recipientAddress(BITCOIN_RECIPIENT_ADDRESS).assertIsDisplayed() } + } + step("Assert the amount to receive after the swap is displayed") { + onSendConfirmScreen { secondaryAmount.assertIsDisplayed() } + } + step("Assert the network fee is paid in '$currencySymbol'") { + onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + step("Sign, send and open the 'Transaction sent' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' success screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } +} \ No newline at end of file