Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-10 11:07:39 +05:00
parent 53e29be3c2
commit 380e5c19e0
16 changed files with 326 additions and 0 deletions

View file

@ -177,6 +177,8 @@ dependencies {
implementation(projects.features.onramp.impl)
implementation(projects.features.onboardingV2.api)
implementation(projects.features.onboardingV2.impl)
implementation(projects.features.stories.api)
implementation(projects.features.stories.impl)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)

View file

@ -4,6 +4,7 @@ import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.referral.ReferralFragment
import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.swap.presentation.SwapFragment
import com.tangem.feature.walletsettings.component.WalletSettingsComponent
import com.tangem.features.details.component.DetailsComponent
@ -56,6 +57,7 @@ internal class ChildFactory @Inject constructor(
private val swapSelectTokensComponentFactory: SwapSelectTokensComponent.Factory,
private val onboardingEntryComponentFactory: OnboardingEntryComponent.Factory,
private val welcomeComponentFactory: WelcomeComponent.Factory,
private val storiesComponentFactory: StoriesComponent.Factory,
private val sendRouter: SendRouter,
private val tokenDetailsRouter: TokenDetailsRouter,
private val walletRouter: WalletRouter,
@ -199,6 +201,16 @@ internal class ChildFactory @Inject constructor(
componentFactory = onboardingEntryComponentFactory,
)
}
is AppRoute.Stories -> {
createComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = StoriesComponent.Params(
storyId = route.storyId,
nextScreen = route.nextScreen,
),
componentFactory = storiesComponentFactory,
)
}
is AppRoute.AccessCodeRecovery,
is AppRoute.AppCurrencySelector,
is AppRoute.SaveWallet,
@ -407,6 +419,16 @@ internal class ChildFactory @Inject constructor(
componentFactory = onboardingEntryComponentFactory,
)
}
is AppRoute.Stories -> {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = StoriesComponent.Params(
storyId = route.storyId,
nextScreen = route.nextScreen,
),
componentFactory = storiesComponentFactory,
)
}
}
// endregion
}

View file

@ -341,4 +341,12 @@ sealed class AppRoute(val path: String) : Route {
AddBackup, // continue backup process for existing wallet 1
}
}
@Serializable
data class Stories(
val storyId: String,
val nextScreen: AppRoute,
) : AppRoute(path = "/stories$storyId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
}

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo.models)
implementation(deps.tangem.card.core)
implementation(deps.tangem.blockchain) {

View file

@ -0,0 +1,48 @@
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: () -> 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

@ -0,0 +1,26 @@
package com.tangem.common.ui.swapStoriesScreen
import com.tangem.core.ui.components.stories.model.StoriesContentConfig
import com.tangem.core.ui.components.stories.model.StoryConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
sealed class SwapStoriesUM {
data object Empty : SwapStoriesUM()
data class Content(
override val stories: ImmutableList<Config>,
override val onClose: () -> Unit,
) : SwapStoriesUM(), StoriesContentConfig<Content.Config> {
override val isRestartable: Boolean = false
data class Config(
val imageUrl: String,
val title: TextReference,
val subtitle: TextReference,
) : StoryConfig {
override val duration: Int = 2000
}
}
}

1
features/stories/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("kotlin-parcelize")
id("configuration")
}
android {
namespace = "com.tangem.features.stories.api"
}
dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/* Compose */
implementation(deps.compose.runtime)
implementation(projects.common.routing)
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.stories.api
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface StoriesComponent : ComposableContentComponent {
data class Params(
val storyId: String,
val nextScreen: AppRoute,
)
interface Factory : ComponentFactory<Params, StoriesComponent>
}

1
features/stories/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,43 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.stories.impl"
}
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.ui)
/** AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/** Others */
implementation(deps.timber)
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,34 @@
package com.tangem.feature.stories.impl
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.feature.stories.api.StoriesComponent
import com.tangem.feature.stories.impl.model.StoriesModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultStoriesComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: StoriesComponent.Params,
) : StoriesComponent, AppComponentContext by context {
private val model: StoriesModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state = model.state.collectAsStateWithLifecycle()
SwapStoriesScreen(state.value)
}
@AssistedFactory
interface Factory : StoriesComponent.Factory {
override fun create(context: AppComponentContext, params: StoriesComponent.Params): DefaultStoriesComponent
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.feature.stories.impl.di
import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.stories.impl.DefaultStoriesComponent
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 StoriesComponentModule {
@Binds
@Singleton
fun bindSwapStoriesComponentFactory(factory: DefaultStoriesComponent.Factory): StoriesComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.feature.stories.impl.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.feature.stories.impl.model.StoriesModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface StoriesModelModule {
@Binds
@IntoMap
@ClassKey(StoriesModel::class)
fun bindStoriesModel(model: StoriesModel): Model
}

View file

@ -0,0 +1,64 @@
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.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.ShouldShowSwapStoriesUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
internal class StoriesModel @Inject constructor(
paramsContainer: ParamsContainer,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val getStoryContentUseCase: GetStoryContentUseCase,
private val shouldShowSwapStoriesUseCase: ShouldShowSwapStoriesUseCase,
) : Model() {
val state: StateFlow<SwapStoriesUM> get() = _state
private val params = paramsContainer.require<StoriesComponent.Params>()
private val _state: MutableStateFlow<SwapStoriesUM> = MutableStateFlow(
value = SwapStoriesUM.Empty,
)
init {
initStories()
}
private fun openScreen() {
modelScope.launch {
shouldShowSwapStoriesUseCase.neverToShow()
router.pop()
router.push(params.nextScreen)
}
}
private fun initStories() {
modelScope.launch {
getStoryContentUseCase(params.storyId).fold(
ifLeft = {
Timber.e("Unable to load stories for ${StoryContentIds.STORY_FIRST_TIME_SWAP.id}")
openScreen() // Fallback to target screen
},
ifRight = { swapStory ->
_state.update {
SwapStoriesFactory.createStoriesState(
swapStory = swapStory,
onStoriesClose = ::openScreen,
)
}
},
)
}
}
}

View file

@ -205,6 +205,9 @@ include(":features:markets:impl")
include(":features:onramp:api")
include(":features:onramp:impl")
include(":features:stories:api")
include(":features:stories:impl")
// endregion Feature modules
// region Domain modules