Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-21 14:46:05 +03:00
commit 8f036f6002
23 changed files with 164 additions and 28 deletions

View file

@ -212,6 +212,7 @@ internal class ChildFactory @Inject constructor(
params = StoriesComponent.Params(
storyId = route.storyId,
nextScreen = route.nextScreen,
screenSource = route.screenSource,
),
componentFactory = storiesComponentFactory,
)
@ -245,6 +246,7 @@ internal class ChildFactory @Inject constructor(
currencyTo = route.currencyTo,
userWalletId = route.userWalletId,
isInitialReverseOrder = route.isInitialReverseOrder,
screenSource = route.screenSource,
),
componentFactory = swapComponentFactory,
)
@ -437,6 +439,7 @@ internal class ChildFactory @Inject constructor(
currencyTo = route.currencyTo,
userWalletId = route.userWalletId,
isInitialReverseOrder = route.isInitialReverseOrder,
screenSource = route.screenSource,
),
componentFactory = swapComponentFactory,
)
@ -564,6 +567,7 @@ internal class ChildFactory @Inject constructor(
params = StoriesComponent.Params(
storyId = route.storyId,
nextScreen = route.nextScreen,
screenSource = route.screenSource,
),
componentFactory = storiesComponentFactory,
)

View file

@ -196,6 +196,7 @@ sealed class AppRoute(val path: String) : Route {
val currencyTo: CryptoCurrency? = null,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val screenSource: String,
) : AppRoute(
path = "/swap" +
"/${currencyFrom.id.value}" +
@ -297,6 +298,7 @@ sealed class AppRoute(val path: String) : Route {
data class Stories(
val storyId: String,
val nextScreen: AppRoute,
val screenSource: String,
) : AppRoute(path = "/stories$storyId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}

View file

@ -10,7 +10,7 @@ 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: () -> Unit): SwapStoriesUM {
fun createStoriesState(swapStory: StoryContent, onStoriesClose: (Int) -> Unit): SwapStoriesUM {
val storyOrderedImageUrls = swapStory.getImageUrls()
if (storyOrderedImageUrls.size != 5) return SwapStoriesUM.Empty

View file

@ -1,7 +1,6 @@
package com.tangem.common.ui.swapStoriesScreen
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
@ -39,7 +38,6 @@ private const val STORIES_RELATIVE_PADDING = 0.7
fun SwapStoriesScreen(config: SwapStoriesUM) {
if (config !is SwapStoriesUM.Content) return
BackHandler(onBack = config.onClose)
SystemBarsIconsDisposable(darkIcons = false)
StoriesContainer(

View file

@ -12,7 +12,7 @@ sealed class SwapStoriesUM {
data class Content(
override val stories: ImmutableList<Config>,
override val onClose: () -> Unit,
override val onClose: (Int) -> Unit,
) : SwapStoriesUM(), StoriesContentConfig<Content.Config> {
override val isRestartable: Boolean = false

View file

@ -81,6 +81,8 @@ sealed class AnalyticsParam {
data object Sell : ScreensSources("Sell")
data object Backup : ScreensSources("Backup")
data object Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets")
}
sealed class TxSentFrom(val value: String) {
@ -207,5 +209,6 @@ sealed class AnalyticsParam {
const val PLACE = "Place"
const val RESIDENCE = "Residence"
const val PAYMENT_METHOD = "Payment Method"
const val WATCHED = "Watched"
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.components.stories
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -46,6 +47,7 @@ inline fun <reified T : StoryConfig> StoriesContainer(
isPauseStories: Boolean = false,
crossinline currentStoryContent: @Composable BoxScope.(T, Boolean) -> Unit,
) {
var watchedCounter by remember { mutableIntStateOf(1) }
var isPressed by remember { mutableStateOf(value = false) }
val storyState by remember(config) {
mutableStateOf(
@ -55,18 +57,23 @@ inline fun <reified T : StoryConfig> StoriesContainer(
),
)
}
BackHandler(onBack = { config.onClose(watchedCounter) })
val isPaused = isPressed || isPauseStories
val onNextClick = {
storyState.nextStory()
watchedCounter = (watchedCounter + 1).coerceAtMost(config.stories.size)
if (!storyState.hasNext()) { // Finish stories if nothing left to show
config.onClose(watchedCounter)
}
}
Box(modifier = modifier) {
StoriesClickableArea(
onPress = { isPressed = it },
onPreviousStory = storyState::prevStory,
onNextStory = {
if (storyState.nextStory()) {
config.onClose()
}
},
onNextStory = onNextClick,
)
currentStoryContent(storyState.currentStory, isPaused)
@ -79,11 +86,7 @@ inline fun <reified T : StoryConfig> StoriesContainer(
currentStep = storyState.currentIndex.intValue,
stepDuration = storyState.currentStory.duration,
paused = isPaused,
onStepFinish = {
if (storyState.nextStory()) {
config.onClose()
}
},
onStepFinish = onNextClick,
)
Icon(
painter = rememberVectorPainter(
@ -97,7 +100,7 @@ inline fun <reified T : StoryConfig> StoriesContainer(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current,
onClick = config.onClose,
onClick = { config.onClose(watchedCounter) },
),
)
}
@ -133,7 +136,7 @@ private data class StoryContainerPreviewProviderData(
StoryConfigPreviewProviderData(title = "Wallet"),
),
override val isRestartable: Boolean = true,
override val onClose: () -> Unit = {},
override val onClose: (Int) -> Unit = {},
) : StoriesContentConfig<StoryConfigPreviewProviderData>
private data class StoryConfigPreviewProviderData(

View file

@ -19,20 +19,22 @@ class StoriesStepStateMachine<T>(
val currentStory
get() = stories[currentIndex.intValue.coerceIn(FIRST_INDEX, steps)]
fun nextStory(): Boolean {
fun nextStory() {
_currentIndex.intValue = if (currentIndex.intValue == steps && isRepeatable) {
FIRST_INDEX
} else {
currentIndex.intValue.inc().coerceAtMost(stories.size)
}
return currentIndex.intValue > steps
}
fun prevStory() {
_currentIndex.intValue = currentIndex.intValue.dec().coerceAtLeast(FIRST_INDEX)
}
fun hasNext(): Boolean {
return currentIndex.intValue <= steps
}
private companion object {
const val FIRST_INDEX = 0
}

View file

@ -11,7 +11,7 @@ import kotlinx.collections.immutable.ImmutableList
interface StoriesContentConfig<T : StoryConfig> {
val stories: ImmutableList<T>
val isRestartable: Boolean
val onClose: () -> Unit
val onClose: (Int) -> Unit
}
interface StoryConfig {

View file

@ -23,6 +23,6 @@ data class StoryContent(
}
}
enum class StoryContentIds(val id: String) {
STORY_FIRST_TIME_SWAP("first-time-swap"),
enum class StoryContentIds(val id: String, val analyticType: String) {
STORY_FIRST_TIME_SWAP(id = "first-time-swap", analyticType = "Swap"),
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
@ -155,6 +156,7 @@ internal class TokenActionsHandler @AssistedInject constructor(
currencyFrom = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.Markets.value,
),
)
}

View file

@ -94,6 +94,7 @@ internal class SwapSelectTokensModel @Inject constructor(
currencyFrom = requireNotNull(fromCurrencyStatus.value).currency,
currencyTo = status.currency,
userWalletId = params.userWalletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),
onComplete = {
modelScope.launch {

View file

@ -9,6 +9,7 @@ interface StoriesComponent : ComposableContentComponent {
data class Params(
val storyId: String,
val nextScreen: AppRoute,
val screenSource: String,
)
interface Factory : ComponentFactory<Params, StoriesComponent>

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.analytics)
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -0,0 +1,33 @@
package com.tangem.feature.stories.impl.analytics
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(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Stories", event, params) {
data class SwapStories(
val source: String,
val watchCount: String,
) : StoriesEvents(
event = "Swap Stories",
params = mapOf(
AnalyticsParam.SOURCE to source,
WATCHED to watchCount,
),
)
data class Error(
val source: String,
val type: String,
) : StoriesEvents(
event = "Error",
params = mapOf(
AnalyticsParam.SOURCE to source,
AnalyticsParam.TYPE to type,
),
)
}

View file

@ -2,6 +2,7 @@ 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
@ -9,6 +10,7 @@ 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.impl.analytics.StoriesEvents
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -23,6 +25,7 @@ internal class StoriesModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val getStoryContentUseCase: GetStoryContentUseCase,
private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
val state: StateFlow<SwapStoriesUM> get() = _state
@ -50,16 +53,36 @@ internal class StoriesModel @Inject constructor(
getStoryContentUseCase.invokeSync(params.storyId).fold(
ifLeft = {
Timber.e("Unable to load stories for ${StoryContentIds.STORY_FIRST_TIME_SWAP.id}")
analyticsEventHandler.send(
StoriesEvents.Error(
source = params.screenSource,
type = StoryContentIds.STORY_FIRST_TIME_SWAP.analyticType,
),
)
openScreen(hideStories = false) // Fallback to target screen
},
ifRight = { swapStory ->
if (swapStory == null) {
analyticsEventHandler.send(
StoriesEvents.Error(
source = params.screenSource,
type = StoryContentIds.STORY_FIRST_TIME_SWAP.analyticType,
),
)
openScreen(hideStories = false) // Fallback to target screen
} else {
_state.update {
SwapStoriesFactory.createStoriesState(
swapStory = swapStory,
onStoriesClose = ::openScreen,
onStoriesClose = { watchCount ->
analyticsEventHandler.send(
StoriesEvents.SwapStories(
source = params.screenSource,
watchCount = watchCount.toString(),
),
)
openScreen()
},
)
}
}

View file

@ -12,6 +12,7 @@ interface SwapComponent : ComposableContentComponent {
val currencyTo: CryptoCurrency? = null,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val screenSource: String,
)
interface Factory : ComponentFactory<Params, SwapComponent>

View file

@ -0,0 +1,33 @@
package com.tangem.feature.swap.analytics
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(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Stories", event, params) {
data class SwapStories(
val source: String,
val watchCount: String,
) : StoriesEvents(
event = "Swap Stories",
params = mapOf(
AnalyticsParam.SOURCE to source,
WATCHED to watchCount,
),
)
data class Error(
val source: String,
val type: String,
) : StoriesEvents(
event = "Error",
params = mapOf(
AnalyticsParam.SOURCE to source,
AnalyticsParam.TYPE to type,
),
)
}

View file

@ -37,6 +37,7 @@ import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
@ -277,10 +278,23 @@ internal class SwapModel @Inject constructor(
getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold(
ifLeft = {
Timber.e("Unable to load stories for ${StoryContentIds.STORY_FIRST_TIME_SWAP.id}")
analyticsEventHandler.send(
StoriesEvents.Error(
source = params.screenSource,
type = StoryContentIds.STORY_FIRST_TIME_SWAP.analyticType,
),
)
},
ifRight = { story ->
story?.let {
if (story != null) {
uiState = stateBuilder.createStoriesState(uiState, story)
} else {
analyticsEventHandler.send(
StoriesEvents.Error(
source = params.screenSource,
type = StoryContentIds.STORY_FIRST_TIME_SWAP.analyticType,
),
)
}
},
)
@ -1131,7 +1145,13 @@ internal class SwapModel @Inject constructor(
onSuccess = {
swapRouter.openScreen(SwapNavScreen.Success)
},
onStoriesClose = {
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)
},

View file

@ -164,7 +164,8 @@ internal class SwapNotificationsFactory(
cryptoCurrencyStatus = fromCurrencyStatus,
onReduceClick = { reduceBy, reduceByDiff, _ ->
actions.onReduceByAmount(
amount.copy(value = amount.value.minus(reduceByDiff)),
// use in swap notification amountToRequest because fee is already subtracted
amountToRequest.copy(value = amountToRequest.value.minus(reduceByDiff)),
reduceBy,
)
},

View file

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

View file

@ -623,7 +623,13 @@ internal class TokenDetailsModel @Inject constructor(
return
}
appRouter.push(AppRoute.Swap(currencyFrom = cryptoCurrency, userWalletId = userWalletId))
appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),
)
}
override fun onDismissDialog() {

View file

@ -358,6 +358,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
AppRoute.Swap(
currencyFrom = cryptoCurrencyStatus.currency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.LongTap.value,
),
)
}
@ -617,6 +618,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
AppRoute.Stories(
storyId = StoryContentIds.STORY_FIRST_TIME_SWAP.id,
nextScreen = targetRoute,
screenSource = AnalyticsParam.ScreensSources.Main.value,
)
} else {
targetRoute