diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f2510944d..a990a9f3d7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) 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 276794edab..47c916c3b6 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 @@ -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 } 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 c81997cf54..28d839feba 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 @@ -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()) + } } \ No newline at end of file diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index 28e0387959..709c410a9f 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -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) { diff --git a/common/ui/src/main/java/com/tangem/common/ui/swapStoriesScreen/SwapStoriesFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/swapStoriesScreen/SwapStoriesFactory.kt new file mode 100644 index 0000000000..c9672cb67a --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/swapStoriesScreen/SwapStoriesFactory.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/swapStoriesScreen/SwapStoriesUM.kt b/common/ui/src/main/java/com/tangem/common/ui/swapStoriesScreen/SwapStoriesUM.kt new file mode 100644 index 0000000000..24fc4dbae5 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/swapStoriesScreen/SwapStoriesUM.kt @@ -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, + override val onClose: () -> Unit, + ) : SwapStoriesUM(), StoriesContentConfig { + override val isRestartable: Boolean = false + + data class Config( + val imageUrl: String, + val title: TextReference, + val subtitle: TextReference, + ) : StoryConfig { + override val duration: Int = 2000 + } + } +} \ No newline at end of file diff --git a/features/stories/api/.gitignore b/features/stories/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/stories/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/stories/api/build.gradle.kts b/features/stories/api/build.gradle.kts new file mode 100644 index 0000000000..245c2c1b18 --- /dev/null +++ b/features/stories/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt b/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt new file mode 100644 index 0000000000..5311ebc07c --- /dev/null +++ b/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt @@ -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 +} \ No newline at end of file diff --git a/features/stories/impl/.gitignore b/features/stories/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/stories/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/stories/impl/build.gradle.kts b/features/stories/impl/build.gradle.kts new file mode 100644 index 0000000000..edc8449987 --- /dev/null +++ b/features/stories/impl/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/DefaultStoriesComponent.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/DefaultStoriesComponent.kt new file mode 100644 index 0000000000..309d873b24 --- /dev/null +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/DefaultStoriesComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/di/StoriesComponentModule.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/di/StoriesComponentModule.kt new file mode 100644 index 0000000000..9702daee20 --- /dev/null +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/di/StoriesComponentModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/di/StoriesModelModule.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/di/StoriesModelModule.kt new file mode 100644 index 0000000000..de6c20e886 --- /dev/null +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/di/StoriesModelModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt new file mode 100644 index 0000000000..088ab9a799 --- /dev/null +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt @@ -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 get() = _state + + private val params = paramsContainer.require() + private val _state: MutableStateFlow = 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, + ) + } + }, + ) + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 42859fc826..627292c89e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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