Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-06 17:20:12 +05:00
parent bed70c4dc8
commit 8966448261
30 changed files with 667 additions and 211 deletions

View file

@ -74,6 +74,7 @@ dependencies {
implementation(projects.domain.walletConnect)
implementation(projects.domain.markets)
implementation(projects.domain.manageTokens)
implementation(projects.domain.onramp)
implementation(projects.common)
implementation(projects.common.routing)
@ -112,6 +113,7 @@ dependencies {
implementation(projects.data.walletConnect)
implementation(projects.data.markets)
implementation(projects.data.manageTokens)
implementation(projects.data.onramp)
/** Features */
implementation(projects.features.onboarding)

View file

@ -0,0 +1,27 @@
package com.tangem.tap.di.domain
import com.tangem.domain.onramp.GetOnrampCurrenciesUseCase
import com.tangem.domain.onramp.OnrampSaveDefaultCurrencyUseCase
import com.tangem.domain.onramp.repositories.OnrampRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object OnrampDomainModule {
@Provides
@Singleton
fun provideGetOnrampCurrenciesUseCase(onrampRepository: OnrampRepository): GetOnrampCurrenciesUseCase {
return GetOnrampCurrenciesUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideOnrampSaveDefaultCurrencyUseCase(onrampRepository: OnrampRepository): OnrampSaveDefaultCurrencyUseCase {
return OnrampSaveDefaultCurrencyUseCase(onrampRepository)
}
}

View file

@ -1,178 +0,0 @@
package com.tangem.datasource.api.onramp
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.onramp.models.common.OnrampDestinationDTO
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse
import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
internal class MockedOnrampApi : OnrampApi {
override suspend fun getCurrencies(): ApiResponse<List<OnrampCurrencyDTO>> = ApiResponse.Success(
COUNTRIES.map(OnrampCountryDTO::defaultCurrency),
)
override suspend fun getCountries(): ApiResponse<List<OnrampCountryDTO>> = ApiResponse.Success(COUNTRIES + RUSSIA)
override suspend fun getCountryByIp(): ApiResponse<OnrampCountryDTO> = ApiResponse.Success(RUSSIA)
override suspend fun getPaymentMethods(): ApiResponse<List<PaymentMethodDTO>> = ApiResponse.Success(
listOf(
PaymentMethodDTO(id = "google", name = "Google Play", image = ""),
PaymentMethodDTO(id = "apple", name = "Apple Pay", image = ""),
PaymentMethodDTO(id = "card", name = "Card", image = ""),
),
)
override suspend fun getPairs(body: OnrampPairsRequest): ApiResponse<List<OnrampPairDTO>> = ApiResponse.Success(
listOf(
OnrampPairDTO(
fromCurrencyCode = "USD",
to = OnrampDestinationDTO(contractAddress = "0xcontract_address", network = "ethereum"),
providers = listOf(),
),
),
)
override suspend fun getQuote(
fromCurrencyCode: String,
toContractAddress: String,
toNetwork: String,
paymentMethod: String,
countryCode: String,
fromAmount: String,
toDecimals: Int,
providerId: String,
): ApiResponse<OnrampQuoteResponse> {
TODO("Not yet implemented")
}
override suspend fun getData(
fromCurrencyCode: String,
toContractAddress: String,
toNetwork: String,
paymentMethod: String,
countryCode: String,
fromAmount: String,
toDecimals: Int,
providerId: String,
toAddress: String,
redirectUrl: String,
language: String?,
theme: String?,
requestId: String,
): ApiResponse<OnrampDataResponse> {
TODO("Not yet implemented")
}
override suspend fun getStatus(txId: String): ApiResponse<OnrampStatusResponse> {
TODO("Not yet implemented")
}
private companion object {
private val RUSSIA = OnrampCountryDTO(
name = "Russia",
code = "RU",
image = "https://hatscripts.github.io/circle-flags/flags/ru.svg",
alpha3 = "RUS",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Russian ruble",
code = "RUB",
image = "https://hatscripts.github.io/circle-flags/flags/ru.svg",
precision = 2,
),
onrampAvailable = false,
)
private val COUNTRIES = listOf(
OnrampCountryDTO(
name = "United States of America",
code = "USA",
image = "https://hatscripts.github.io/circle-flags/flags/us.svg",
alpha3 = "USA",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "US Dollar",
code = "USD",
image = "https://hatscripts.github.io/circle-flags/flags/us.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Europe Union",
code = "EU",
image = "https://hatscripts.github.io/circle-flags/flags/eu.svg",
alpha3 = "EUR",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Euro",
code = "EUR",
image = "https://hatscripts.github.io/circle-flags/flags/eu.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Great Britain",
code = "GB",
image = "https://hatscripts.github.io/circle-flags/flags/gb.svg",
alpha3 = "GB",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "British Pound Sterling",
code = "GBP",
image = "https://hatscripts.github.io/circle-flags/flags/gb.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "CANADA",
code = "CA",
image = "https://hatscripts.github.io/circle-flags/flags/ca.svg",
alpha3 = "CA",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Canadian Dollar",
code = "CAD",
image = "https://hatscripts.github.io/circle-flags/flags/ca.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Hon Kong",
code = "HK",
image = "https://hatscripts.github.io/circle-flags/flags/hk.svg",
alpha3 = "HK",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Hon Kong Dollar",
code = "HKD",
image = "https://hatscripts.github.io/circle-flags/flags/hk.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Australia",
code = "AU",
image = "https://hatscripts.github.io/circle-flags/flags/au.svg",
alpha3 = "AU",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Australian Dollar",
code = "AUD",
image = "https://hatscripts.github.io/circle-flags/flags/au.svg",
precision = 2,
),
onrampAvailable = true,
),
)
}
}

View file

@ -11,7 +11,6 @@ import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.onramp.MockedOnrampApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
@ -99,24 +98,22 @@ internal object NetworkModule {
@Provides
@Singleton
fun provideOnrampApi(
// @NetworkMoshi moshi: Moshi,
// @ApplicationContext context: Context,
// apiConfigsManager: ApiConfigsManager,
// appLogsStore: AppLogsStore,
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): OnrampApi {
// TODO: Remove when backend will be ready - [REDACTED_TASK_KEY]
return MockedOnrampApi()
// return createApi(
// id = ApiConfig.ID.Express,
// moshi = moshi,
// context = context,
// apiConfigsManager = apiConfigsManager,
// clientBuilder = {
// addInterceptor(
// NetworkLogsSaveInterceptor(appLogsStore),
// )
// },
// )
return createApi(
id = ApiConfig.ID.Express,
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
)
},
)
}
@Provides

View file

@ -103,6 +103,8 @@ object PreferencesKeys {
val SHOULD_SHOW_RING_PROMO_KEY by lazy { booleanPreferencesKey(name = "shouldShowRingPromo") }
val ONRAMP_DEFAULT_CURRENCY by lazy { stringPreferencesKey(name = "onrampDefaultCurrency") }
// region Permission
fun getShouldShowPermission(permission: String) = booleanPreferencesKey("shouldShowPushPermission_$permission")

View file

@ -4,15 +4,24 @@ import com.tangem.data.onramp.converters.CountryConverter
import com.tangem.data.onramp.converters.CurrencyConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
internal class DefaultOnrampRepository(
private val onrampApi: OnrampApi,
private val dispatchers: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
) : OnrampRepository {
private val currencyConverter = CurrencyConverter()
@ -29,4 +38,23 @@ internal class DefaultOnrampRepository(
.getOrThrow()
.map(countryConverter::convert)
}
override suspend fun saveDefaultCurrency(currency: OnrampCurrency) {
appPreferencesStore.storeObject<OnrampCurrencyDTO>(
key = PreferencesKeys.ONRAMP_DEFAULT_CURRENCY,
value = currencyConverter.convertBack(currency),
)
}
override suspend fun getDefaultCurrencySync(): OnrampCurrency? {
return appPreferencesStore
.getObjectSyncOrNull<OnrampCurrencyDTO>(PreferencesKeys.ONRAMP_DEFAULT_CURRENCY)
?.let(currencyConverter::convert)
}
override fun getDefaultCurrency(): Flow<OnrampCurrency?> {
return appPreferencesStore
.getObject<OnrampCurrencyDTO>(PreferencesKeys.ONRAMP_DEFAULT_CURRENCY)
.map { it?.let(currencyConverter::convert) }
}
}

View file

@ -2,9 +2,9 @@ package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.utils.converter.Converter
import com.tangem.utils.converter.TwoWayConverter
internal class CurrencyConverter : Converter<OnrampCurrencyDTO, OnrampCurrency> {
internal class CurrencyConverter : TwoWayConverter<OnrampCurrencyDTO, OnrampCurrency> {
override fun convert(value: OnrampCurrencyDTO) = OnrampCurrency(
name = value.name,
@ -12,4 +12,11 @@ internal class CurrencyConverter : Converter<OnrampCurrencyDTO, OnrampCurrency>
image = value.image,
precision = value.precision,
)
override fun convertBack(value: OnrampCurrency): OnrampCurrencyDTO = OnrampCurrencyDTO(
name = value.name,
code = value.code,
image = value.image,
precision = value.precision,
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.onramp.di
import com.tangem.data.onramp.DefaultOnrampRepository
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -16,10 +17,15 @@ internal object OnrampDataModule {
@Provides
@Singleton
fun provideOnrampRepository(onrampApi: OnrampApi, dispatchers: CoroutineDispatcherProvider): OnrampRepository {
fun provideOnrampRepository(
onrampApi: OnrampApi,
dispatchers: CoroutineDispatcherProvider,
appPreferencesStore: AppPreferencesStore,
): OnrampRepository {
return DefaultOnrampRepository(
onrampApi = onrampApi,
dispatchers = dispatchers,
appPreferencesStore = appPreferencesStore,
)
}
}

View file

@ -0,0 +1,3 @@
package com.tangem.domain.onramp.model
data class OnrampCurrencies(val populars: List<OnrampCurrency>, val others: List<OnrampCurrency>)

View file

@ -0,0 +1,23 @@
package com.tangem.domain.onramp
import arrow.core.Either
import com.tangem.domain.onramp.model.OnrampCurrencies
import com.tangem.domain.onramp.repositories.OnrampRepository
class GetOnrampCurrenciesUseCase(
private val onrampRepository: OnrampRepository,
) {
suspend operator fun invoke(): Either<Throwable, OnrampCurrencies> {
return Either.catch {
val currenciesList = onrampRepository.getCurrencies()
val (populars, others) = currenciesList.toSet()
.partition { popularFiatCodes.contains(it.code.uppercase()) }
OnrampCurrencies(populars = populars, others = others)
}
}
private companion object {
val popularFiatCodes = setOf("USD", "EUR", "GBP", "CAD", "AUD", "HKD")
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.onramp
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.repositories.OnrampRepository
class OnrampSaveDefaultCurrencyUseCase(private val onrampRepository: OnrampRepository) {
suspend operator fun invoke(currency: OnrampCurrency) {
onrampRepository.saveDefaultCurrency(currency)
}
}

View file

@ -2,8 +2,12 @@ package com.tangem.domain.onramp.repositories
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import kotlinx.coroutines.flow.Flow
interface OnrampRepository {
suspend fun getCurrencies(): List<OnrampCurrency>
suspend fun getCountries(): List<OnrampCountry>
suspend fun saveDefaultCurrency(currency: OnrampCurrency)
suspend fun getDefaultCurrencySync(): OnrampCurrency?
fun getDefaultCurrency(): Flow<OnrampCurrency?>
}

View file

@ -30,6 +30,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.onramp)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)

View file

@ -46,7 +46,7 @@ internal class DefaultConfirmResidencyComponent @AssistedInject constructor(
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val bottomSheetConfig = remember {
val bottomSheetConfig = remember(key1 = this) {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,

View file

@ -2,6 +2,7 @@ package com.tangem.features.onramp.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@ -31,11 +32,13 @@ internal class PreviewConfirmResidencyComponent(
@Composable
override fun BottomSheet() {
val state by previewState.collectAsStateWithLifecycle()
val bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
val bottomSheetConfig = remember(key1 = this) {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
ConfirmResidencyBottomSheet(
config = bottomSheetConfig,
content = { modifier ->

View file

@ -31,7 +31,7 @@ internal class DefaultSelectCountryComponent @AssistedInject constructor(
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheetConfig = remember {
val bottomSheetConfig = remember(key1 = this) {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,

View file

@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
internal class CountryListUMController @Inject constructor() {
@ -22,12 +21,10 @@ internal class CountryListUMController @Inject constructor() {
)
fun update(transformer: UpdateCountryItemsTransformer) {
Timber.d("Applying ${transformer::class.simpleName}")
_state.update(transformer::transform)
}
fun update(transformer: SearchBarUMTransformer) {
Timber.d("Applying ${transformer::class.simpleName}")
_state.update { prevState ->
val searchBarItem = prevState.getSearchBar()

View file

@ -6,15 +6,16 @@ import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.selectcountry.entity.CountriesListItemUM
import com.tangem.features.onramp.selectcountry.entity.CountryItemState
import com.tangem.features.onramp.selectcountry.entity.CountryListUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class UpdateCountryItemsTransformer(
private val countries: List<CountryItemState>,
private val onQueryChange: (String) -> Unit,
private val onActiveChange: (Boolean) -> Unit,
) {
) : Transformer<CountryListUM> {
fun transform(prevState: CountryListUM): CountryListUM {
override fun transform(prevState: CountryListUM): CountryListUM {
val searchBarItem = prevState.getSearchBar() ?: createSearchBarItem()
return prevState.copy(

View file

@ -0,0 +1,56 @@
package com.tangem.features.onramp.selectcurrency
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.onramp.selectcurrency.model.OnrampSelectCurrencyModel
import com.tangem.features.onramp.selectcurrency.ui.OnrampCurrencyList
import com.tangem.features.onramp.selectcurrency.ui.SelectCurrencyBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSelectCurrencyComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: SelectCurrencyComponent.Params,
) : SelectCurrencyComponent, AppComponentContext by context {
private val model: OnrampSelectCurrencyModel = getOrCreateModel(params)
override fun dismiss() {
model.dismiss()
}
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheetConfig = remember(key1 = this) {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
SelectCurrencyBottomSheet(
config = bottomSheetConfig,
content = {
OnrampCurrencyList(modifier = Modifier.fillMaxSize(), state = state)
},
)
}
@AssistedFactory
interface Factory : SelectCurrencyComponent.Factory {
override fun create(
context: AppComponentContext,
params: SelectCurrencyComponent.Params,
): DefaultSelectCurrencyComponent
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.onramp.selectcurrency
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
internal interface SelectCurrencyComponent : ComposableBottomSheetComponent {
data class Params(val onDismiss: () -> Unit)
interface Factory : ComponentFactory<Params, SelectCurrencyComponent>
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.selectcurrency.di
import com.tangem.features.onramp.selectcurrency.DefaultSelectCurrencyComponent
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
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 OnrampSelectCurrencyComponentModule {
@Binds
@Singleton
fun bindSelectCurrencyComponentFactory(
factory: DefaultSelectCurrencyComponent.Factory,
): SelectCurrencyComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.selectcurrency.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.selectcurrency.model.OnrampSelectCurrencyModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface OnrampSelectCurrencyModelModule {
@Binds
@IntoMap
@ClassKey(OnrampSelectCurrencyModel::class)
fun bindOnrampSelectCurrencyModel(model: OnrampSelectCurrencyModel): Model
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.onramp.selectcurrency.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface CurrenciesListUM {
val searchBarUM: SearchBarUM
data class Loading(
override val searchBarUM: SearchBarUM,
val sections: ImmutableList<CurrenciesSection<CurrencyItemState.Loading>>,
) : CurrenciesListUM
data class Error(override val searchBarUM: SearchBarUM, val onRetry: () -> Unit) : CurrenciesListUM
data class Content(
override val searchBarUM: SearchBarUM,
val sections: ImmutableList<CurrenciesSection<CurrencyItemState.Content>>,
) : CurrenciesListUM
fun copySealed(searchBarUM: SearchBarUM = this.searchBarUM): CurrenciesListUM {
return when (this) {
is Loading -> this.copy(searchBarUM = searchBarUM)
is Error -> this.copy(searchBarUM = searchBarUM)
is Content -> this.copy(searchBarUM = searchBarUM)
}
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.onramp.selectcurrency.entity
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class CurrenciesSection<T : CurrencyItemState>(val title: TextReference, val items: ImmutableList<T>)

View file

@ -0,0 +1,19 @@
package com.tangem.features.onramp.selectcurrency.entity
import androidx.compose.runtime.Immutable
import com.tangem.domain.onramp.model.OnrampCurrency
@Immutable
internal sealed interface CurrencyItemState {
/** Unique ID */
val id: String
data class Loading(override val id: String) : CurrencyItemState
data class Content(
override val id: String,
val onrampCurrency: OnrampCurrency,
val onClick: () -> Unit,
) : CurrencyItemState
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.onramp.selectcurrency.entity
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsTransformer
import com.tangem.features.onramp.utils.SearchBarUMTransformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
internal class CurrencyListController(private val currencySearchBarUM: SearchBarUM) {
val state: StateFlow<CurrenciesListUM> get() = _state.asStateFlow()
private val _state: MutableStateFlow<CurrenciesListUM> = MutableStateFlow(value = getInitialState())
fun update(transformer: UpdateCurrencyItemsTransformer) {
_state.update(transformer::transform)
}
fun update(transformer: SearchBarUMTransformer) {
_state.update { prevState ->
val searchBarUM = transformer.transform(prevState.searchBarUM)
prevState.copySealed(searchBarUM)
}
}
private fun getInitialState(): CurrenciesListUM {
return CurrenciesListUM.Loading(
searchBarUM = currencySearchBarUM,
sections = listOf(
CurrenciesSection(stringReference("Popular fiats"), items = createLoadingItems("popular")),
CurrenciesSection(stringReference("Other currencies"), items = createLoadingItems("other")),
).toImmutableList(),
)
}
private fun createLoadingItems(prefix: String, size: Int = 5): ImmutableList<CurrencyItemState.Loading> =
MutableList(size) { index -> CurrencyItemState.Loading("$prefix#$index") }.toImmutableList()
}

View file

@ -0,0 +1,65 @@
package com.tangem.features.onramp.selectcurrency.entity.transformer
import arrow.core.Either
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.onramp.model.OnrampCurrencies
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.features.onramp.selectcurrency.entity.CurrenciesListUM
import com.tangem.features.onramp.selectcurrency.entity.CurrenciesSection
import com.tangem.features.onramp.selectcurrency.entity.CurrencyItemState
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class UpdateCurrencyItemsTransformer(
private val maybeCurrencies: Either<Throwable, OnrampCurrencies>,
private val query: String,
private val onRetry: () -> Unit,
private val onCurrencyClick: (OnrampCurrency) -> Unit,
) : Transformer<CurrenciesListUM> {
override fun transform(prevState: CurrenciesListUM): CurrenciesListUM {
return maybeCurrencies.fold(
ifLeft = { CurrenciesListUM.Error(searchBarUM = prevState.searchBarUM, onRetry = onRetry) },
ifRight = { currencies ->
val populars = currencies.populars.filterByQuery()
val others = currencies.others.filterByQuery()
val sections = buildList {
if (populars.isNotEmpty()) {
add(
CurrenciesSection(
title = stringReference("Popular fiats"),
items = populars.map(::convertCurrencyToUiModel).toImmutableList(),
),
)
}
if (others.isNotEmpty()) {
add(
CurrenciesSection(
title = stringReference("Other currencies"),
items = others.map(::convertCurrencyToUiModel).toImmutableList(),
),
)
}
}
CurrenciesListUM.Content(
searchBarUM = prevState.searchBarUM,
sections = sections.toImmutableList(),
)
},
)
}
private fun List<OnrampCurrency>.filterByQuery(): List<OnrampCurrency> {
return filter {
it.code.lowercase().contains(query.lowercase()) || it.name.lowercase().contains(query.lowercase())
}
}
private fun convertCurrencyToUiModel(onrampCurrency: OnrampCurrency): CurrencyItemState.Content {
return CurrencyItemState.Content(
id = onrampCurrency.code,
onrampCurrency = onrampCurrency,
onClick = { onCurrencyClick(onrampCurrency) },
)
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.features.onramp.selectcurrency.model
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.onramp.GetOnrampCurrenciesUseCase
import com.tangem.domain.onramp.OnrampSaveDefaultCurrencyUseCase
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import com.tangem.features.onramp.selectcurrency.entity.CurrenciesListUM
import com.tangem.features.onramp.selectcurrency.entity.CurrencyListController
import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsTransformer
import com.tangem.features.onramp.utils.SearchManager
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ComponentScoped
internal class OnrampSelectCurrencyModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val searchManager: SearchManager,
private val getOnrampCurrenciesUseCase: GetOnrampCurrenciesUseCase,
private val saveDefaultCurrencyUseCase: OnrampSaveDefaultCurrencyUseCase,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<CurrenciesListUM> get() = controller.state
private val params: SelectCurrencyComponent.Params = paramsContainer.require()
private val controller = CurrencyListController(currencySearchBarUM = createSearchBarUM())
private val refreshTrigger = MutableSharedFlow<Unit>()
init {
subscribeOnUpdateState()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun subscribeOnUpdateState() {
combine(
flow = refreshTrigger.onStart { emit(Unit) }.flatMapLatest { flowOf(getOnrampCurrenciesUseCase.invoke()) },
flow2 = searchManager.query,
) { maybeCurrencies, query ->
UpdateCurrencyItemsTransformer(
maybeCurrencies = maybeCurrencies,
query = query,
onRetry = ::onRetry,
onCurrencyClick = ::saveDefaultCurrency,
)
}
.onEach(controller::update)
.launchIn(modelScope)
}
fun dismiss() {
params.onDismiss()
}
private fun saveDefaultCurrency(currency: OnrampCurrency) {
modelScope.launch {
saveDefaultCurrencyUseCase.invoke(currency)
dismiss()
}
}
private fun onRetry() {
modelScope.launch {
refreshTrigger.emit(Unit)
}
}
private fun onSearchQueryChange(newQuery: String) {
val searchBarUM = controller.state.value.searchBarUM
if (searchBarUM.query == newQuery) return
modelScope.launch {
controller.update(transformer = UpdateSearchQueryTransformer(newQuery))
searchManager.update(newQuery)
}
}
private fun onSearchBarActiveChange(isActive: Boolean) {
controller.update(
transformer = UpdateSearchBarActiveStateTransformer(
isActive = isActive,
placeHolder = resourceReference(id = R.string.common_search),
),
)
}
private fun createSearchBarUM(): SearchBarUM {
return SearchBarUM(
placeholderText = resourceReference(R.string.onramp_currency_search),
query = "",
onQueryChange = ::onSearchQueryChange,
isActive = false,
onActiveChange = ::onSearchBarActiveChange,
)
}
}

View file

@ -0,0 +1,145 @@
package com.tangem.features.onramp.selectcurrency.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.util.fastForEach
import coil.compose.AsyncImage
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.selectcurrency.entity.CurrenciesListUM
import com.tangem.features.onramp.selectcurrency.entity.CurrencyItemState
@Composable
internal fun SelectCurrencyBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
content = { content() },
)
}
@Composable
internal fun OnrampCurrencyList(state: CurrenciesListUM, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing16),
) {
item(key = "search_bar") {
SearchBar(
state = state.searchBarUM,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing20),
)
}
when (state) {
is CurrenciesListUM.Content -> currencyListContent(state = state)
is CurrenciesListUM.Error -> TODO()
is CurrenciesListUM.Loading -> currencyListLoading(state = state)
}
}
}
private fun LazyListScope.currencyListLoading(state: CurrenciesListUM.Loading) {
state.sections.fastForEach { section ->
item(key = section.title.toString()) { GroupTitle(title = section.title.resolveReference()) }
items(
items = section.items,
key = { item -> item.id },
contentType = { item -> item::class.java },
itemContent = { CurrencyLoading(modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16)) },
)
}
}
private fun LazyListScope.currencyListContent(state: CurrenciesListUM.Content) {
state.sections.fastForEach { section ->
item(key = section.title.toString()) { GroupTitle(title = section.title.resolveReference()) }
items(
items = section.items,
key = { item -> item.id },
contentType = { item -> item::class.java },
itemContent = { item ->
CurrencyContent(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = item.onClick)
.padding(vertical = TangemTheme.dimens.spacing16),
currency = item,
)
},
)
}
}
@Composable
private fun GroupTitle(title: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun CurrencyContent(currency: CurrencyItemState.Content, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(CircleShape),
model = currency.onrampCurrency.image,
contentDescription = null,
)
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2)) {
Text(
text = currency.onrampCurrency.code,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = currency.onrampCurrency.name,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun CurrencyLoading(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
CircleShimmer(modifier = Modifier.size(TangemTheme.dimens.size36))
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6)) {
TextShimmer(
modifier = Modifier.width(width = TangemTheme.dimens.size70),
style = TangemTheme.typography.subtitle2,
)
TextShimmer(
modifier = Modifier.width(width = TangemTheme.dimens.size52),
style = TangemTheme.typography.caption2,
)
}
}
}

View file

@ -24,6 +24,7 @@ internal class SearchManager @Inject constructor() {
suspend fun update(value: String) {
coroutineScope {
if (value.isEmpty()) {
jobHolder.cancel()
_query.value = value
} else {
withDebounce(jobHolder) { _query.value = value }