Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-19 03:16:08 -07:00
parent e5e2f4a1e7
commit 5da87df354
18 changed files with 115 additions and 146 deletions

View file

@ -320,7 +320,7 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class Stories(
val storyId: String,
val nextScreen: AppRoute,
val nextScreen: AppRoute? = null,
val screenSource: String,
) : AppRoute(path = "/stories$storyId")

View file

@ -41,7 +41,6 @@ dependencies {
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo.models)
implementation(projects.domain.common)
implementation(tangemDeps.card.core)

View file

@ -1,48 +0,0 @@
package com.tangem.common.ui.swapStoriesScreen
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.promo.models.StoryContent
import kotlinx.collections.immutable.persistentListOf
object SwapStoriesFactory {
// WARNING! Be careful with indices. Temporary solution.
// Use all data from v1/stories api (image url, title, subtitle)
@Suppress("MagicNumber")
fun createStoriesState(swapStory: StoryContent, onStoriesClose: (Int) -> Unit): SwapStoriesUM {
val storyOrderedImageUrls = swapStory.getImageUrls()
if (storyOrderedImageUrls.size != 5) return SwapStoriesUM.Empty
return SwapStoriesUM.Content(
stories = persistentListOf(
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[0],
title = resourceReference(R.string.swap_story_first_title),
subtitle = resourceReference(R.string.swap_story_first_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[1],
title = resourceReference(R.string.swap_story_second_title),
subtitle = resourceReference(R.string.swap_story_second_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[2],
title = resourceReference(R.string.swap_story_third_title),
subtitle = resourceReference(R.string.swap_story_third_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[3],
title = resourceReference(R.string.swap_story_forth_title),
subtitle = resourceReference(R.string.swap_story_forth_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[4],
title = resourceReference(R.string.swap_story_fifth_title),
subtitle = resourceReference(R.string.swap_story_fifth_subtitle),
),
),
onClose = onStoriesClose,
)
}
}

View file

@ -15,6 +15,7 @@ dependencies {
implementation(projects.core.ui)
/* Compose */
implementation(deps.kotlin.immutable.collections)
implementation(deps.compose.runtime)
implementation(projects.common.routing)
}

View file

@ -8,7 +8,7 @@ interface StoriesComponent : ComposableContentComponent {
data class Params(
val storyId: String,
val nextScreen: AppRoute,
val nextScreen: AppRoute? = null,
val screenSource: String,
)

View file

@ -1,4 +1,4 @@
package com.tangem.common.ui.swapStoriesScreen
package com.tangem.feature.stories.api
import com.tangem.core.ui.components.stories.inner.STORY_DURATION
import com.tangem.core.ui.components.stories.model.StoriesContentConfig
@ -6,14 +6,14 @@ import com.tangem.core.ui.components.stories.model.StoryConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
sealed class SwapStoriesUM {
sealed class StoriesUM {
data object Empty : SwapStoriesUM()
data object Empty : StoriesUM()
data class Content(
override val stories: ImmutableList<Config>,
override val onClose: (Int) -> Unit,
) : SwapStoriesUM(), StoriesContentConfig<Content.Config> {
) : StoriesUM(), StoriesContentConfig<Content.Config> {
override val isRestartable: Boolean = false
data class Config(

View file

@ -13,20 +13,18 @@ android {
dependencies {
/** Feature modules */
implementation(projects.features.stories.api)
implementation(projects.features.swap.api)
/** Domain modules */
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
/** Project - Common */
implementation(projects.common.routing)
implementation(projects.common.ui)
/** Project - Core */
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.res)
implementation(projects.core.ui)
implementation(projects.core.analytics)
@ -34,7 +32,13 @@ dependencies {
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/** Compose */
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
/** Others */
implementation(deps.kotlin.immutable.collections)
implementation(deps.compose.coil)
implementation(deps.timber)
implementation(deps.arrow.core)

View file

@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.utils.ChangeRootBackgroundColorEffect
import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.stories.impl.model.StoriesModel
import com.tangem.feature.stories.impl.ui.StoriesScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -26,7 +26,7 @@ internal class DefaultStoriesComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state = model.state.collectAsStateWithLifecycle()
SwapStoriesScreen(state.value)
StoriesScreen(state.value)
ChangeRootBackgroundColorEffect(TangemColorPalette.Black)
}

View file

@ -0,0 +1,41 @@
package com.tangem.feature.stories.impl
import com.tangem.domain.promo.models.StoryContentIds
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class SlideConfig(
val titleResId: Int,
val subtitleResId: Int,
)
internal object StoriesSlideConfigs {
fun getSlides(storyId: String): ImmutableList<SlideConfig> = when (storyId) {
StoryContentIds.STORY_FIRST_TIME_SWAP.id -> swapSlides()
else -> persistentListOf()
}
private fun swapSlides(): ImmutableList<SlideConfig> = persistentListOf(
SlideConfig(
com.tangem.core.res.R.string.swap_story_first_title,
com.tangem.core.res.R.string.swap_story_first_subtitle,
),
SlideConfig(
com.tangem.core.res.R.string.swap_story_second_title,
com.tangem.core.res.R.string.swap_story_second_subtitle,
),
SlideConfig(
com.tangem.core.res.R.string.swap_story_third_title,
com.tangem.core.res.R.string.swap_story_third_subtitle,
),
SlideConfig(
com.tangem.core.res.R.string.swap_story_forth_title,
com.tangem.core.res.R.string.swap_story_forth_subtitle,
),
SlideConfig(
com.tangem.core.res.R.string.swap_story_fifth_title,
com.tangem.core.res.R.string.swap_story_fifth_subtitle,
),
)
}

View file

@ -4,7 +4,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.WATCHED
sealed class StoriesEvents(
internal sealed class StoriesEvents(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent("Stories", event, params) {

View file

@ -1,17 +1,18 @@
package com.tangem.feature.stories.impl.model
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesFactory
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.stories.api.StoriesUM
import com.tangem.feature.stories.impl.StoriesSlideConfigs
import com.tangem.feature.stories.impl.analytics.StoriesEvents
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
@ -28,8 +29,8 @@ internal class StoriesModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
val state: StateFlow<SwapStoriesUM>
field = MutableStateFlow<SwapStoriesUM>(value = SwapStoriesUM.Empty)
val state: StateFlow<StoriesUM>
field = MutableStateFlow<StoriesUM>(value = StoriesUM.Empty)
private val params = paramsContainer.require<StoriesComponent.Params>()
@ -40,10 +41,10 @@ internal class StoriesModel @Inject constructor(
private fun openScreen(hideStories: Boolean = true) {
modelScope.launch {
if (hideStories) {
shouldShowStoriesUseCase.neverToShow(StoryContentIds.STORY_FIRST_TIME_SWAP.id)
shouldShowStoriesUseCase.neverToShow(params.storyId)
}
router.pop()
router.push(params.nextScreen)
params.nextScreen?.let { router.push(it) }
}
}
@ -51,17 +52,32 @@ internal class StoriesModel @Inject constructor(
modelScope.launch {
getStoryContentUseCase.invokeSync(params.storyId).fold(
ifLeft = {
Timber.e("Unable to load stories for ${StoryContentIds.STORY_FIRST_TIME_SWAP.id}")
openScreen(hideStories = false) // Fallback to target screen
Timber.e("Unable to load stories for ${params.storyId}")
openScreen(hideStories = false)
},
ifRight = { swapStory ->
if (swapStory == null) {
openScreen(hideStories = false) // Fallback to target screen
ifRight = { story ->
if (story == null) {
openScreen(hideStories = false)
} else {
val slides = StoriesSlideConfigs.getSlides(params.storyId)
val imageUrls = story.getImageUrls()
val configs = slides.zip(imageUrls) { slide, imageUrl ->
StoriesUM.Content.Config(
imageUrl = imageUrl,
title = resourceReference(slide.titleResId),
subtitle = resourceReference(slide.subtitleResId),
)
}.toImmutableList()
if (configs.isEmpty()) {
openScreen(hideStories = false)
return@launch
}
state.update {
SwapStoriesFactory.createStoriesState(
swapStory = swapStory,
onStoriesClose = { watchCount ->
StoriesUM.Content(
stories = configs,
onClose = { watchCount ->
analyticsEventHandler.send(
StoriesEvents.SwapStories(
source = params.screenSource,

View file

@ -1,4 +1,4 @@
package com.tangem.common.ui.swapStoriesScreen
package com.tangem.feature.stories.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
@ -31,14 +31,15 @@ import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapStoriesScreenTestTags
import com.tangem.feature.stories.api.StoriesUM
import kotlinx.collections.immutable.persistentListOf
private val SubtitleColor = Color(0xFFB0B0B0)
private const val STORIES_RELATIVE_PADDING = 0.7
@Composable
fun SwapStoriesScreen(config: SwapStoriesUM) {
if (config !is SwapStoriesUM.Content) return
internal fun StoriesScreen(config: StoriesUM) {
if (config !is StoriesUM.Content) return
SystemBarsIconsDisposable(darkIcons = false)
@ -65,13 +66,13 @@ fun SwapStoriesScreen(config: SwapStoriesUM) {
error = { },
contentDescription = null,
)
SwapStoriesText(current)
StoriesText(current)
}
}
}
@Composable
private fun SwapStoriesText(current: SwapStoriesUM.Content.Config) {
private fun StoriesText(current: StoriesUM.Content.Config) {
val height = LocalWindowSize.current.height.value
val textAlign = (height.dp.value * STORIES_RELATIVE_PADDING).dp
Column(
@ -115,12 +116,12 @@ private fun SwapStoriesText(current: SwapStoriesUM.Content.Config) {
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SwapStoriesScreen_Preview() {
private fun Preview() {
TangemThemePreview {
SwapStoriesScreen(
SwapStoriesUM.Content(
StoriesScreen(
StoriesUM.Content(
stories = persistentListOf(
SwapStoriesUM.Content.Config(
StoriesUM.Content.Config(
imageUrl = "https://devweb.tangem.com/images/stories/swap/image1.png",
title = stringReference("Exchange With Us"),
subtitle = stringReference(

View file

@ -13,7 +13,6 @@ import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
@ -162,17 +161,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
label = "",
) { screen ->
when (screen) {
SwapNavScreen.PromoStories -> {
val storiesConfig = model.uiState.storiesConfig
if (storiesConfig != null) {
SwapStoriesScreen(config = storiesConfig)
} else {
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.Main -> SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,

View file

@ -11,6 +11,7 @@ import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
@ -60,8 +61,6 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
@ -70,6 +69,7 @@ import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
@ -78,7 +78,6 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
@ -145,7 +144,6 @@ internal class SwapModel @Inject constructor(
private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase,
private val getStoryContentUseCase: GetStoryContentUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
swapInteractorFactory: SwapInteractor.Factory,
@ -352,12 +350,21 @@ internal class SwapModel @Inject constructor(
var addToPortfolioManager: AddToPortfolioManager? = null
init {
modelScope.launch {
val storyId = StoryContentIds.STORY_FIRST_TIME_SWAP.id
if (shouldShowStoriesUseCase.invokeSync(storyId)) {
router.push(
AppRoute.Stories(
storyId = storyId,
nextScreen = null,
screenSource = params.screenSource,
),
)
}
}
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
modelScope.launch {
initStories()
swapRouter.openScreen(SwapNavScreen.PromoStories)
}
modelScope.launch(dispatchers.io) {
if (canUseFromAccountCurrencyStatus) {
@ -613,21 +620,6 @@ internal class SwapModel @Inject constructor(
}
}
private fun initStories() {
modelScope.launch {
getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold(
ifLeft = {
Timber.e("Unable to load stories for ${StoryContentIds.STORY_FIRST_TIME_SWAP.id}")
},
ifRight = { story ->
if (story != null) {
uiState = stateBuilder.createStoriesState(uiState, story)
}
},
)
}
}
private fun applyInitialTokenChoice(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?,
@ -1832,16 +1824,6 @@ internal class SwapModel @Inject constructor(
onSuccess = {
swapRouter.openScreen(SwapNavScreen.Success)
},
onStoriesClose = { watchCount ->
analyticsEventHandler.send(
StoriesEvents.SwapStories(
source = params.screenSource,
watchCount = watchCount.toString(),
),
)
modelScope.launch { shouldShowStoriesUseCase.neverToShow(StoryContentIds.STORY_FIRST_TIME_SWAP.id) }
swapRouter.openScreen(SwapNavScreen.Main)
},
onOpenLearnMoreAboutApproveClick = {
urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP)
},

View file

@ -5,7 +5,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -31,8 +30,6 @@ internal data class SwapStateHolder(
val successState: SwapSuccessStateHolder? = null,
val selectTokenState: SwapSelectTokenStateHolder? = null,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
val storiesConfig: SwapStoriesUM? = null,
val swapButton: SwapButton,
val shouldShowMaxAmount: Boolean,
val tosState: TosState? = null,

View file

@ -21,7 +21,6 @@ data class UiActions(
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions
val onStoriesClose: (Int) -> Unit,
val onRetryClick: () -> Unit,
val onClickFee: () -> Unit,
val onSelectFeeType: (TxFee.Legacy) -> Unit,

View file

@ -67,5 +67,5 @@ internal class SwapRouter(
}
enum class SwapNavScreen {
Main, Success, SelectToken, PromoStories
Main, Success, SelectToken
}

View file

@ -7,7 +7,6 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.bottomsheet.permission.state.*
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesFactory
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
@ -23,7 +22,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.promo.models.StoryContent
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.ExpressDataError
@ -135,15 +133,6 @@ internal class StateBuilder(
)
}
fun createStoriesState(uiStateHolder: SwapStateHolder, swapStory: StoryContent): SwapStateHolder {
return uiStateHolder.copy(
storiesConfig = SwapStoriesFactory.createStoriesState(
swapStory = swapStory,
onStoriesClose = actions.onStoriesClose,
),
)
}
fun createNoAvailableTokensToSwapState(
uiStateHolder: SwapStateHolder,
fromToken: CryptoCurrencyStatus,