Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-05 20:15:56 +05:00
commit 07833b1814
11 changed files with 241 additions and 20 deletions

View file

@ -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()
}

View file

@ -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(),
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.core.ui.coil
/**
* Preload image and store in runtime memory
*/
interface ImagePreloader {
fun preload(url: String)
}

View file

@ -7,6 +7,7 @@ object TangemColorPalette {
// region Base
val Black = Color(0xFF000000)
val White = Color(0xFFFFFFFF)
val SoftWhite = Color(0xFFF1F1F1)
// endregion Base
// region Dark

View file

@ -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
}

View file

@ -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

View file

@ -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,

View file

@ -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 = {},
),
)
}
}
}
// endregion

View file

@ -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)
},
)