From df395263544d48f316feea5194ea11a8f87ba830 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 5 Feb 2025 12:00:27 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../java/com/tangem/tap/common/images/Coil.kt | 7 ++ .../common/images/DefaultImagePreloader.kt | 18 +++ .../tangem/tap/di/core/ImageLoaderModule.kt | 22 ++++ .../tangem/tap/features/main/MainViewModel.kt | 23 ++++ .../com/tangem/core/ui/coil/ImagePreloader.kt | 8 ++ .../tangem/core/ui/res/TangemColorPalette.kt | 1 + .../swap/models/SwapStoriesContentConfig.kt | 6 +- .../feature/swap/presentation/SwapFragment.kt | 1 - .../tangem/feature/swap/ui/StateBuilder.kt | 32 ++++- .../feature/swap/ui/SwapStoriesScreen.kt | 118 ++++++++++++++++-- .../feature/swap/viewmodels/SwapViewModel.kt | 25 +++- 11 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/common/images/DefaultImagePreloader.kt create mode 100644 app/src/main/java/com/tangem/tap/di/core/ImageLoaderModule.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/coil/ImagePreloader.kt diff --git a/app/src/main/java/com/tangem/tap/common/images/Coil.kt b/app/src/main/java/com/tangem/tap/common/images/Coil.kt index 0d6d4de675..cda97eebe7 100644 --- a/app/src/main/java/com/tangem/tap/common/images/Coil.kt +++ b/app/src/main/java/com/tangem/tap/common/images/Coil.kt @@ -3,12 +3,14 @@ package com.tangem.tap.common.images import android.content.Context import android.util.Log import coil.ImageLoader +import coil.memory.MemoryCache import coil.util.Logger import com.tangem.datasource.api.common.createNetworkLoggingInterceptor import okhttp3.OkHttpClient import timber.log.Timber private const val COIL_LOG_TAG = "COIL" +private const val COIL_MEMORY_CACHE_SIZE = 0.25 fun createCoilImageLoader(context: Context, logEnabled: Boolean = false): ImageLoader { return ImageLoader.Builder(context) @@ -22,6 +24,11 @@ fun createCoilImageLoader(context: Context, logEnabled: Boolean = false): ImageL .build() } } + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(COIL_MEMORY_CACHE_SIZE) + .build() + } .build() } diff --git a/app/src/main/java/com/tangem/tap/common/images/DefaultImagePreloader.kt b/app/src/main/java/com/tangem/tap/common/images/DefaultImagePreloader.kt new file mode 100644 index 0000000000..f27ffcfb95 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/images/DefaultImagePreloader.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.common.images + +import android.content.Context +import coil.imageLoader +import coil.request.ImageRequest +import com.tangem.core.ui.coil.ImagePreloader + +internal class DefaultImagePreloader( + private val appContext: Context, +) : ImagePreloader { + override fun preload(url: String) { + appContext.imageLoader.enqueue( + ImageRequest.Builder(appContext) + .data(url) + .build(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/core/ImageLoaderModule.kt b/app/src/main/java/com/tangem/tap/di/core/ImageLoaderModule.kt new file mode 100644 index 0000000000..be0b4f9c20 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/core/ImageLoaderModule.kt @@ -0,0 +1,22 @@ +package com.tangem.tap.di.core + +import android.content.Context +import com.tangem.core.ui.coil.ImagePreloader +import com.tangem.tap.common.images.DefaultImagePreloader +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object ImageLoaderModule { + + @Provides + @Singleton + fun provideImageLoader(@ApplicationContext context: Context): ImagePreloader { + return DefaultImagePreloader(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 83fb5f942c..de85fcbde1 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -10,6 +10,7 @@ import com.tangem.core.analytics.models.event.TechAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R +import com.tangem.core.ui.coil.ImagePreloader import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.BottomSheetMessage import com.tangem.core.ui.message.EventMessageAction @@ -20,6 +21,8 @@ import com.tangem.domain.balancehiding.BalanceHidingSettings import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase +import com.tangem.domain.promo.GetStoryContentUseCase +import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase @@ -50,6 +53,8 @@ internal class MainViewModel @Inject constructor( @GlobalUiMessageSender private val messageSender: UiMessageSender, private val keyboardValidator: KeyboardValidator, private val analyticsEventHandler: AnalyticsEventHandler, + private val getStoryContentUseCase: GetStoryContentUseCase, + private val imagePreloader: ImagePreloader, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel() { @@ -80,6 +85,8 @@ internal class MainViewModel @Inject constructor( deleteDeprecatedLogsUseCase() sendKeyboardIdentifierEvent() + + preloadImages() } /** Loading the resources needed to run the application */ @@ -278,4 +285,20 @@ internal class MainViewModel @Inject constructor( } } } + + // Preload stories to display on startup before navigation to targeted screen + private fun preloadImages() { + try { + viewModelScope.launch { + val swapStories = getStoryContentUseCase(StoryContentIds.STORY_FIRST_TIME_SWAP.id).getOrNull() + ?: return@launch + val storiesImages = swapStories.getImageUrls() + + // try to preload images for stories + storiesImages.forEach(imagePreloader::preload) + } + } catch (ex: Exception) { + Timber.e(ex.message) + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/coil/ImagePreloader.kt b/core/ui/src/main/java/com/tangem/core/ui/coil/ImagePreloader.kt new file mode 100644 index 0000000000..46e1e67c43 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/coil/ImagePreloader.kt @@ -0,0 +1,8 @@ +package com.tangem.core.ui.coil + +/** + * Preload image and store in runtime memory + */ +interface ImagePreloader { + fun preload(url: String) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt index 6c0bd9671f..85bd9696ad 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt @@ -7,6 +7,7 @@ object TangemColorPalette { // region Base val Black = Color(0xFF000000) val White = Color(0xFFFFFFFF) + val SoftWhite = Color(0xFFF1F1F1) // endregion Base // region Dark diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStoriesContentConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStoriesContentConfig.kt index 625eecdb62..fd2b880962 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStoriesContentConfig.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStoriesContentConfig.kt @@ -1,8 +1,8 @@ package com.tangem.feature.swap.models -import androidx.annotation.DrawableRes 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 data class SwapStoriesContentConfig( @@ -13,7 +13,9 @@ data class SwapStoriesContentConfig( } data class SwapStoryConfig( - @DrawableRes val imageRes: Int, + 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/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt index 86c77dd6e8..ff6ff1b947 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.background import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.fragment.app.viewModels - import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeFragment diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 0a7f59c547..e667603319 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.promo.models.StoryContent import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter @@ -125,19 +126,40 @@ internal class StateBuilder( ) } - fun createStoriesState(uiStateHolder: SwapStateHolder): SwapStateHolder { + // WARNING! Be careful with indices. Temporary solution. + // Use all data from v1/stories api (image url, title, subtitle) + @Suppress("MagicNumber") + fun createStoriesState(uiStateHolder: SwapStateHolder, swapStory: StoryContent): SwapStateHolder { + val storyOrderedImageUrls = swapStory.getImageUrls() + if (storyOrderedImageUrls.size != 5) return uiStateHolder + return uiStateHolder.copy( - // todo stories [REDACTED_TASK_KEY] fix when design is ready storiesConfig = SwapStoriesContentConfig( stories = persistentListOf( SwapStoryConfig( - imageRes = R.drawable.img_card_with_ring, + imageUrl = storyOrderedImageUrls[0], + title = resourceReference(R.string.swap_story_first_title), + subtitle = resourceReference(R.string.swap_story_first_subtitle), ), SwapStoryConfig( - imageRes = R.drawable.ill_businessman_3d, + imageUrl = storyOrderedImageUrls[1], + title = resourceReference(R.string.swap_story_second_title), + subtitle = resourceReference(R.string.swap_story_second_subtitle), ), SwapStoryConfig( - imageRes = R.drawable.ill_one_inch_powered, + imageUrl = storyOrderedImageUrls[2], + title = resourceReference(R.string.swap_story_third_title), + subtitle = resourceReference(R.string.swap_story_third_subtitle), + ), + SwapStoryConfig( + imageUrl = storyOrderedImageUrls[3], + title = resourceReference(R.string.swap_story_forth_title), + subtitle = resourceReference(R.string.swap_story_forth_subtitle), + ), + SwapStoryConfig( + imageUrl = storyOrderedImageUrls[4], + title = resourceReference(R.string.swap_story_fifth_title), + subtitle = resourceReference(R.string.swap_story_fifth_subtitle), ), ), onClose = actions.onStoriesClose, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapStoriesScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapStoriesScreen.kt index edb6c2d5b6..53f8089a14 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapStoriesScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapStoriesScreen.kt @@ -1,20 +1,124 @@ package com.tangem.feature.swap.ui -import androidx.compose.foundation.Image +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SystemBarsIconsDisposable import com.tangem.core.ui.components.stories.StoriesContainer +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalWindowSize +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.swap.models.SwapStoriesContentConfig +import com.tangem.feature.swap.models.SwapStoryConfig +import kotlinx.collections.immutable.persistentListOf + +private val SubtitleColor = Color(0xFF868692) +private const val STORIES_RELATIVE_PADDING = 0.7 @Composable internal fun SwapStoriesScreen(config: SwapStoriesContentConfig) { + BackHandler(onBack = config.onClose) + SystemBarsIconsDisposable(darkIcons = false) + StoriesContainer( config = config, ) { current, _ -> - // todo stories [REDACTED_TASK_KEY] fix when design is ready - Image( - painter = painterResource(current.imageRes), - contentDescription = null, + Box( + modifier = Modifier + .fillMaxSize() + .background(TangemColorPalette.Black), + ) { + SubcomposeAsyncImage( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(context = LocalContext.current) + .data(current.imageUrl) + .crossfade(enable = false) + .allowHardware(true) + .build(), + loading = { }, + error = { }, + contentDescription = null, + ) + val height = LocalWindowSize.current.height.value + val textAlign = (height.dp.value * STORIES_RELATIVE_PADDING).dp + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding( + top = textAlign, + start = 56.dp, + end = 56.dp, + ), + ) { + Text( + text = current.title.resolveReference(), + style = TangemTheme.typography.head, + color = TangemColorPalette.SoftWhite, + textAlign = TextAlign.Center, + ) + SpacerH16() + Text( + text = current.subtitle.resolveReference(), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + ), + color = SubtitleColor, + textAlign = TextAlign.Center, + ) + } + } + } +} + +// region Preview +@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() { + TangemThemePreview { + SwapStoriesScreen( + SwapStoriesContentConfig( + stories = persistentListOf( + SwapStoryConfig( + imageUrl = "https://devweb.tangem.com/images/stories/swap/image1.png", + title = stringReference("Exchange With Us"), + subtitle = stringReference( + "Trusted exchange providers let you swap assets effortlessly", + ), + ), + ), + onClose = {}, + ), ) } -} \ No newline at end of file +} +// endregion \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index 4ffd6c0123..5f87bbf478 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -27,7 +27,9 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.ShouldShowSwapStoriesUseCase +import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus @@ -89,6 +91,7 @@ internal class SwapViewModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val shouldShowSwapStoriesUseCase: ShouldShowSwapStoriesUseCase, + private val getStoryContentUseCase: GetStoryContentUseCase, private val featureToggles: SwapFeatureToggles, swapInteractorFactory: SwapInteractor.Factory, private val savedStateHandle: SavedStateHandle, @@ -168,16 +171,18 @@ internal class SwapViewModel @Inject constructor( get() = swapRouter.currentScreen init { - viewModelScope.launch(dispatchers.io) { - val fromStatus = getCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom.id).getOrNull() - val toStatus = initialCurrencyTo?.let { getCryptoCurrencyStatusUseCase(userWalletId, it.id).getOrNull() } - val wallet = getUserWalletUseCase(userWalletId).getOrNull() + viewModelScope.launch { val isShowPromoStories = shouldShowSwapStoriesUseCase.invokeSync() && featureToggles.isPromoStoriesEnabled if (isShowPromoStories) { initStories() swapRouter.openScreen(SwapNavScreen.PromoStories) } + } + viewModelScope.launch(dispatchers.io) { + val fromStatus = getCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom.id).getOrNull() + val toStatus = initialCurrencyTo?.let { getCryptoCurrencyStatusUseCase(userWalletId, it.id).getOrNull() } + val wallet = getUserWalletUseCase(userWalletId).getOrNull() if (fromStatus == null || wallet == null) { uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back) @@ -283,7 +288,16 @@ internal class SwapViewModel @Inject constructor( } private fun initStories() { - uiState = stateBuilder.createStoriesState(uiState) + viewModelScope.launch { + getStoryContentUseCase(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold( + ifLeft = { + Timber.e("Unable to load stories for ${StoryContentIds.STORY_FIRST_TIME_SWAP.id}") + }, + ifRight = { + uiState = stateBuilder.createStoriesState(uiState, it) + }, + ) + } } private fun applyInitialTokenChoice( @@ -1131,6 +1145,7 @@ internal class SwapViewModel @Inject constructor( swapRouter.openScreen(SwapNavScreen.Success) }, onStoriesClose = { + viewModelScope.launch { shouldShowSwapStoriesUseCase.neverToShow() } swapRouter.openScreen(SwapNavScreen.Main) }, )