Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-07 12:53:30 +05:00
parent 75e794abdf
commit 342b6a2f50
20 changed files with 254 additions and 60 deletions

View file

@ -1,7 +1,6 @@
package com.tangem.tap.di.domain
import com.tangem.domain.onramp.GetOnrampCurrenciesUseCase
import com.tangem.domain.onramp.OnrampSaveDefaultCurrencyUseCase
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.OnrampRepository
import dagger.Module
import dagger.Provides
@ -24,4 +23,22 @@ internal object OnrampDomainModule {
fun provideOnrampSaveDefaultCurrencyUseCase(onrampRepository: OnrampRepository): OnrampSaveDefaultCurrencyUseCase {
return OnrampSaveDefaultCurrencyUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideGetOnrampCountriesUseCase(onrampRepository: OnrampRepository): GetOnrampCountriesUseCase {
return GetOnrampCountriesUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideGetOnrampCountryUseCase(onrampRepository: OnrampRepository): GetOnrampCountryUseCase {
return GetOnrampCountryUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideOnrampSaveDefaultCountryUseCase(onrampRepository: OnrampRepository): OnrampSaveDefaultCountryUseCase {
return OnrampSaveDefaultCountryUseCase(onrampRepository)
}
}

View file

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

View file

@ -4,6 +4,7 @@ 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.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
@ -25,7 +26,7 @@ internal class DefaultOnrampRepository(
) : OnrampRepository {
private val currencyConverter = CurrencyConverter()
private val countryConverter = CountryConverter()
private val countryConverter = CountryConverter(currencyConverter)
override suspend fun getCurrencies(): List<OnrampCurrency> = withContext(dispatchers.io) {
onrampApi.getCurrencies()
@ -39,15 +40,21 @@ internal class DefaultOnrampRepository(
.map(countryConverter::convert)
}
override suspend fun saveDefaultCurrency(currency: OnrampCurrency) {
override suspend fun getCountryByIp(): OnrampCountry = withContext(dispatchers.io) {
onrampApi.getCountryByIp()
.getOrThrow()
.let(countryConverter::convert)
}
override suspend fun saveDefaultCurrency(currency: OnrampCurrency) = withContext(dispatchers.io) {
appPreferencesStore.storeObject<OnrampCurrencyDTO>(
key = PreferencesKeys.ONRAMP_DEFAULT_CURRENCY,
value = currencyConverter.convertBack(currency),
)
}
override suspend fun getDefaultCurrencySync(): OnrampCurrency? {
return appPreferencesStore
override suspend fun getDefaultCurrencySync(): OnrampCurrency? = withContext(dispatchers.io) {
appPreferencesStore
.getObjectSyncOrNull<OnrampCurrencyDTO>(PreferencesKeys.ONRAMP_DEFAULT_CURRENCY)
?.let(currencyConverter::convert)
}
@ -57,4 +64,23 @@ internal class DefaultOnrampRepository(
.getObject<OnrampCurrencyDTO>(PreferencesKeys.ONRAMP_DEFAULT_CURRENCY)
.map { it?.let(currencyConverter::convert) }
}
override suspend fun saveDefaultCountry(country: OnrampCountry) = withContext(dispatchers.io) {
appPreferencesStore.storeObject<OnrampCountryDTO>(
key = PreferencesKeys.ONRAMP_DEFAULT_COUNTRY,
value = countryConverter.convertBack(country),
)
}
override suspend fun getDefaultCountrySync(): OnrampCountry? = withContext(dispatchers.io) {
appPreferencesStore
.getObjectSyncOrNull<OnrampCountryDTO>(PreferencesKeys.ONRAMP_DEFAULT_COUNTRY)
?.let(countryConverter::convert)
}
override fun getDefaultCountry(): Flow<OnrampCountry?> {
return appPreferencesStore
.getObject<OnrampCountryDTO>(PreferencesKeys.ONRAMP_DEFAULT_COUNTRY)
.map { it?.let(countryConverter::convert) }
}
}

View file

@ -2,13 +2,14 @@ package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.utils.converter.Converter
import com.tangem.utils.converter.TwoWayConverter
internal class CountryConverter : Converter<OnrampCountryDTO, OnrampCountry> {
private val currencyConverter = CurrencyConverter()
internal class CountryConverter(
private val currencyConverter: CurrencyConverter,
) : TwoWayConverter<OnrampCountryDTO, OnrampCountry> {
override fun convert(value: OnrampCountryDTO) = OnrampCountry(
id = "${value.alpha3}-${value.name}",
name = value.name,
code = value.code,
image = value.image,
@ -17,4 +18,14 @@ internal class CountryConverter : Converter<OnrampCountryDTO, OnrampCountry> {
defaultCurrency = currencyConverter.convert(value.defaultCurrency),
onrampAvailable = value.onrampAvailable,
)
override fun convertBack(value: OnrampCountry): OnrampCountryDTO = OnrampCountryDTO(
name = value.name,
code = value.code,
image = value.image,
alpha3 = value.alpha3,
continent = value.continent,
defaultCurrency = currencyConverter.convertBack(value.defaultCurrency),
onrampAvailable = value.onrampAvailable,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.onramp.model
data class OnrampCountry(
val id: String,
val name: String,
val code: String,
val image: String,

View file

@ -0,0 +1,12 @@
package com.tangem.domain.onramp
import arrow.core.Either
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.repositories.OnrampRepository
class GetOnrampCountriesUseCase(private val onrampRepository: OnrampRepository) {
suspend operator fun invoke(): Either<Throwable, List<OnrampCountry>> {
return Either.catch { onrampRepository.getCountries() }
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.onramp
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.repositories.OnrampRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
class GetOnrampCountryUseCase(private val repository: OnrampRepository) {
operator fun invoke(): Flow<Either<Throwable, OnrampCountry?>> {
return repository.getDefaultCountry()
.map<OnrampCountry?, Either<Throwable, OnrampCountry?>> { it.right() }
.catch { emit(it.left()) }
}
suspend fun invokeSync(): Either<Throwable, OnrampCountry> {
return Either.catch { repository.getDefaultCountrySync() ?: repository.getCountryByIp() }
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.onramp
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.repositories.OnrampRepository
class OnrampSaveDefaultCountryUseCase(private val repository: OnrampRepository) {
suspend operator fun invoke(country: OnrampCountry) {
repository.saveDefaultCountry(country)
}
}

View file

@ -3,9 +3,9 @@ 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) {
class OnrampSaveDefaultCurrencyUseCase(private val repository: OnrampRepository) {
suspend operator fun invoke(currency: OnrampCurrency) {
onrampRepository.saveDefaultCurrency(currency)
repository.saveDefaultCurrency(currency)
}
}

View file

@ -7,7 +7,11 @@ import kotlinx.coroutines.flow.Flow
interface OnrampRepository {
suspend fun getCurrencies(): List<OnrampCurrency>
suspend fun getCountries(): List<OnrampCountry>
suspend fun getCountryByIp(): OnrampCountry
suspend fun saveDefaultCurrency(currency: OnrampCurrency)
suspend fun getDefaultCurrencySync(): OnrampCurrency?
fun getDefaultCurrency(): Flow<OnrampCurrency?>
suspend fun saveDefaultCountry(country: OnrampCountry)
suspend fun getDefaultCountrySync(): OnrampCountry?
fun getDefaultCountry(): Flow<OnrampCountry?>
}

View file

@ -19,13 +19,13 @@ import dagger.assisted.AssistedInject
internal class DefaultSelectCountryComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SelectCountryComponent.Params,
@Assisted params: SelectCountryComponent.Params,
) : SelectCountryComponent, AppComponentContext by appComponentContext {
private val model: OnrampSelectCountryModel = getOrCreateModel(params)
override fun dismiss() {
params.onDismiss()
model.dismiss()
}
@Composable

View file

@ -11,7 +11,7 @@ internal data class CountryListUM(val items: ImmutableList<CountriesListItemUM>)
}
/** Get tokens */
fun getTokens(): ImmutableList<CountriesListItemUM> {
fun getCountries(): ImmutableList<CountriesListItemUM> {
if (getSearchBar() == null) return items
return if (items.size > 1) {

View file

@ -1,7 +1,6 @@
package com.tangem.features.onramp.selectcountry.entity
import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsTransformer
import com.tangem.features.onramp.selectcountry.model.MockedCountriesData
import com.tangem.features.onramp.utils.SearchBarUMTransformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -11,12 +10,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
private const val LOADING_ITEMS_COUNT = 5
internal class CountryListUMController @Inject constructor() {
val state: StateFlow<CountryListUM> get() = _state.asStateFlow()
private val _state: MutableStateFlow<CountryListUM> = MutableStateFlow(
value = CountryListUM(
items = MockedCountriesData.getLoadingItems().map(CountriesListItemUM::Country).toImmutableList(),
items = getLoadingItems().map(CountriesListItemUM::Country).toImmutableList(),
),
)
@ -35,7 +36,7 @@ internal class CountryListUMController @Inject constructor() {
prevState.copy(
items = persistentListOf(
updatedSearchBar,
*prevState.getTokens().toTypedArray(),
*prevState.getCountries().toTypedArray(),
),
)
} else {
@ -48,4 +49,7 @@ internal class CountryListUMController @Inject constructor() {
fun getSearchBar(): CountriesListItemUM.SearchBar? {
return _state.value.getSearchBar()
}
private fun getLoadingItems(): List<CountryItemState> =
MutableList(LOADING_ITEMS_COUNT) { CountryItemState.Loading("Loading #$it") }
}

View file

@ -1,7 +1,9 @@
package com.tangem.features.onramp.selectcountry.entity.transformer
import arrow.core.Either
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.selectcountry.entity.CountriesListItemUM
import com.tangem.features.onramp.selectcountry.entity.CountryItemState
@ -10,22 +12,48 @@ import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class UpdateCountryItemsTransformer(
private val countries: List<CountryItemState>,
private val maybeCountries: Either<Throwable, List<OnrampCountry>>,
private val defaultCountry: OnrampCountry?,
private val query: String,
private val onQueryChange: (String) -> Unit,
private val onActiveChange: (Boolean) -> Unit,
private val onCountryClick: (OnrampCountry) -> Unit,
) : Transformer<CountryListUM> {
override fun transform(prevState: CountryListUM): CountryListUM {
val searchBarItem = prevState.getSearchBar() ?: createSearchBarItem()
return prevState.copy(
items = (listOfNotNull(searchBarItem) + countries.map(::convertCountry)).toImmutableList(),
return maybeCountries.fold(
ifLeft = { prevState }, // TODO: [REDACTED_JIRA]
ifRight = { countries ->
val countriesListItems = countries.filterByQuery().toUiModels()
prevState.copy(items = (listOf(searchBarItem) + countriesListItems).toImmutableList())
},
)
}
// TODO: Temporarily. Will be refactored after implement domain
private fun convertCountry(state: CountryItemState): CountriesListItemUM.Country =
CountriesListItemUM.Country(state)
private fun List<OnrampCountry>.toUiModels(): List<CountriesListItemUM.Country> {
return map { country ->
val countryItemState = if (country.onrampAvailable) {
CountryItemState.Content(
id = country.id,
flagUrl = country.image,
countryName = country.name,
isSelected = defaultCountry?.id?.equals(country.id) == true,
onClick = { onCountryClick(country) },
)
} else {
CountryItemState.Unavailable(id = country.id, flagUrl = country.image, countryName = country.name)
}
CountriesListItemUM.Country(countryItemState)
}
}
private fun List<OnrampCountry>.filterByQuery(): List<OnrampCountry> {
return filter { country ->
country.alpha3.lowercase().contains(query.lowercase()) ||
country.name.lowercase().contains(query.lowercase())
}
}
private fun createSearchBarItem(): CountriesListItemUM.SearchBar {
return CountriesListItemUM.SearchBar(

View file

@ -13,9 +13,6 @@ internal object MockedCountriesData {
delay(3_000) // simulate network call
emit(getMockedCountries())
}
fun getLoadingItems(): List<CountryItemState> = MutableList(5) {
CountryItemState.Loading("Loading #$it")
}
private fun getMockedCountries(): List<CountryItemState> = MutableList(50) { index ->
if (index % 5 == 0) {

View file

@ -2,9 +2,14 @@ package com.tangem.features.onramp.selectcountry.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.extensions.resourceReference
import com.tangem.domain.onramp.GetOnrampCountriesUseCase
import com.tangem.domain.onramp.GetOnrampCountryUseCase
import com.tangem.domain.onramp.OnrampSaveDefaultCountryUseCase
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.selectcountry.entity.CountryItemState
import com.tangem.features.onramp.selectcountry.SelectCountryComponent
import com.tangem.features.onramp.selectcountry.entity.CountryListUM
import com.tangem.features.onramp.selectcountry.entity.CountryListUMController
import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsTransformer
@ -17,36 +22,54 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@ComponentScoped
@Suppress("LongParameterList")
internal class OnrampSelectCountryModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val searchManager: SearchManager,
private val countryListUMController: CountryListUMController,
private val getOnrampCountriesUseCase: GetOnrampCountriesUseCase,
private val saveDefaultCountryUseCase: OnrampSaveDefaultCountryUseCase,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<CountryListUM> = countryListUMController.state
private val params: SelectCountryComponent.Params = paramsContainer.require()
init {
subscribeOnUpdateState()
modelScope.launch { subscribeOnUpdateState() }
}
private fun subscribeOnUpdateState() {
combine(
flow = MockedCountriesData.getCountryItems(),
flow2 = searchManager.query,
) { countryItems, query ->
val filteredCountryItems = countryItems.filterByQuery(query = query)
fun dismiss() {
params.onDismiss()
}
private suspend fun subscribeOnUpdateState() {
combine(
flow = flowOf(getOnrampCountriesUseCase.invoke()),
flow2 = getOnrampCountryUseCase.invoke(),
flow3 = searchManager.query,
) { maybeCountries, maybeCountry, query ->
UpdateCountryItemsTransformer(
countries = filteredCountryItems,
maybeCountries = maybeCountries,
defaultCountry = maybeCountry.getOrNull(),
query = query,
onQueryChange = ::onSearchQueryChange,
onActiveChange = ::onSearchBarActiveChange,
onCountryClick = ::saveCountry,
)
}
.onEach(countryListUMController::update)
.flowOn(dispatchers.main)
.launchIn(modelScope)
}
private fun saveCountry(country: OnrampCountry) {
modelScope.launch {
saveDefaultCountryUseCase.invoke(country)
dismiss()
}
}
private fun onSearchQueryChange(newQuery: String) {
val searchBar = countryListUMController.getSearchBar()
if (searchBar?.searchBarUM?.query == newQuery) return
@ -66,12 +89,4 @@ internal class OnrampSelectCountryModel @Inject constructor(
),
)
}
// TODO: Temporarily. Will be refactored after implement domain
private fun List<CountryItemState>.filterByQuery(query: String): List<CountryItemState> {
return filter {
(it as? CountryItemState.Content)?.countryName?.contains(query) == true ||
(it as? CountryItemState.Unavailable)?.countryName?.contains(query) == true
}
}
}

View file

@ -29,7 +29,9 @@ internal fun CountryListItem(state: CountriesListItemUM, modifier: Modifier = Mo
when (state) {
is CountriesListItemUM.Country -> CountryItem(
state = state.state,
modifier = modifier.countryClickable(state.state).padding(all = TangemTheme.dimens.spacing16),
modifier = modifier
.countryClickable(state.state)
.padding(all = TangemTheme.dimens.spacing16),
)
is CountriesListItemUM.SearchBar -> SearchBar(
state = state.searchBarUM,

View file

@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
@ -36,8 +37,9 @@ internal class DefaultOnrampSettingsComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
BackHandler(onBack = params.onBack)
OnrampSettingsContent(modifier = modifier, state = model.state)
OnrampSettingsContent(modifier = modifier, state = state)
val bottomSheet by bottomSheetSlot.subscribeAsState()
bottomSheet.child?.instance?.BottomSheet()

View file

@ -1,5 +1,9 @@
package com.tangem.features.onramp.settings.entity
sealed class OnrampSettingsItemUM {
data class Residence(val countryName: String, val flagUrl: String, val onClick: () -> Unit) : OnrampSettingsItemUM()
data class Residence(
val countryName: String = "",
val flagUrl: String = "",
val onClick: () -> Unit,
) : OnrampSettingsItemUM()
}

View file

@ -1,34 +1,69 @@
package com.tangem.features.onramp.settings.model
import arrow.core.Either
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.onramp.GetOnrampCountryUseCase
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.features.onramp.settings.OnrampSettingsComponent
import com.tangem.features.onramp.settings.entity.OnrampSettingsConfig
import com.tangem.features.onramp.settings.entity.OnrampSettingsItemUM
import com.tangem.features.onramp.settings.entity.OnrampSettingsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ComponentScoped
internal class OnrampSettingsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnrampSettingsComponent.Params = paramsContainer.require()
val state: StateFlow<OnrampSettingsUM> get() = _state
val bottomSheetNavigation: SlotNavigation<OnrampSettingsConfig> = SlotNavigation()
val state = OnrampSettingsUM(
onBack = params.onBack,
items = listOf(
OnrampSettingsItemUM.Residence(
countryName = "United States of America",
flagUrl = "",
onClick = { bottomSheetNavigation.activate(OnrampSettingsConfig.SelectCountry) },
),
).toImmutableList(),
)
private val params: OnrampSettingsComponent.Params = paramsContainer.require()
private val _state: MutableStateFlow<OnrampSettingsUM> = MutableStateFlow(getInitialState())
init {
subscribeOnUpdateState()
}
private fun subscribeOnUpdateState() {
getOnrampCountryUseCase.invoke()
.onEach(::updateResidenceState)
.launchIn(modelScope)
}
private fun updateResidenceState(maybeCountry: Either<Throwable, OnrampCountry?>) {
maybeCountry.onRight { country ->
_state.update { state ->
state.copy(
items = listOf(
OnrampSettingsItemUM.Residence(
countryName = country?.name.orEmpty(),
flagUrl = country?.image.orEmpty(),
onClick = ::openSelectCountryBottomSheet,
),
).toImmutableList(),
)
}
}
}
private fun openSelectCountryBottomSheet() {
bottomSheetNavigation.activate(OnrampSettingsConfig.SelectCountry)
}
private fun getInitialState(): OnrampSettingsUM {
return OnrampSettingsUM(
onBack = params.onBack,
items = listOf(OnrampSettingsItemUM.Residence(onClick = ::openSelectCountryBottomSheet)).toImmutableList(),
)
}
}