Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-08 14:31:06 +03:00
commit 3fc0a75d40
388 changed files with 17559 additions and 3009 deletions

View file

@ -51,6 +51,7 @@ dependencies {
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.transaction)
implementation(projects.domain.analytics)
implementation(projects.common)
implementation(projects.core.analytics)
@ -74,6 +75,7 @@ dependencies {
implementation(projects.data.tokens)
implementation(projects.data.txhistory)
implementation(projects.data.wallets)
implementation(projects.data.analytics)
/** Features */
implementation(projects.features.onboarding)

View file

@ -14,6 +14,7 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.api.common.MoshiConverter
@ -216,6 +217,9 @@ internal class TapApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var sendFeatureToggles: SendFeatureToggles
@Inject
lateinit var oneTimeEventFilter: OneTimeEventFilter
// endregion Injected
override fun onCreate() {
@ -353,6 +357,8 @@ internal class TapApplication : Application(), ImageLoaderFactory {
factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder())
factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder())
factory.addFilter(oneTimeEventFilter)
val buildData = AnalyticsHandlerBuilder.Data(
application = application,
config = config,

View file

@ -65,15 +65,16 @@ class DialogManager : StoreSubscriber<GlobalState> {
messageRes = R.string.wallet_connect_scanner_error_not_valid_card,
context = context,
)
is WalletConnectDialog.AddNetwork ->
is WalletConnectDialog.AddNetwork -> {
val message = context.getString(
R.string.wallet_connect_error_missing_blockchains,
) + state.dialog.networks.joinToString()
SimpleAlertDialog.create(
titleRes = R.string.wallet_connect_title,
message = context.getString(
R.string.wallet_connect_network_not_found_format,
state.dialog.network,
),
message = message,
context = context,
)
}
is WalletConnectDialog.OpeningSessionRejected -> {
SimpleAlertDialog.create(
titleRes = R.string.wallet_connect_title,

View file

@ -2,16 +2,18 @@ package com.tangem.tap.common.analytics.converters
import com.tangem.common.Converter
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.tap.common.analytics.events.Basic
/**
[REDACTED_AUTHOR]
*/
class TopUpEventConverter : Converter<CardTypesResolver, Basic.ToppedUp?> {
class TopUpEventConverter : Converter<Pair<UserWalletId, CardTypesResolver>, Basic.ToppedUp?> {
override fun convert(value: CardTypesResolver): Basic.ToppedUp? {
val paramCardCurrency = ParamCardCurrencyConverter().convert(value) ?: return null
override fun convert(value: Pair<UserWalletId, CardTypesResolver>): Basic.ToppedUp? {
val (userWalletId, resolver) = value
val paramCardCurrency = ParamCardCurrencyConverter().convert(resolver) ?: return null
return Basic.ToppedUp(paramCardCurrency)
return Basic.ToppedUp(userWalletId, paramCardCurrency)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.common.analytics.events
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.tap.features.details.redux.SecurityOption
sealed class AnalyticsParam {
@ -14,7 +15,7 @@ sealed class AnalyticsParam {
// MultiCurrency or CurrencyType
sealed class CardCurrency(val value: String) {
object MultiCurrency : CardCurrency("Multicurrency")
object MultiCurrency : CardCurrency(value = "Multicurrency")
class SingleCurrency(type: CurrencyType) : CardCurrency(type.value)
}
@ -139,6 +140,22 @@ sealed class AnalyticsParam {
object SeedImport : WalletCreationType(value = "Seed Import")
}
sealed class AppTheme(val value: String) {
object System : AppTheme("System")
object Dark : AppTheme("Dark")
object Light : AppTheme("Light")
companion object {
fun fromAppThemeMode(mode: AppThemeMode): AppTheme {
return when (mode) {
AppThemeMode.FORCE_DARK -> Dark
AppThemeMode.FORCE_LIGHT -> Light
AppThemeMode.FOLLOW_SYSTEM -> System
}
}
}
}
companion object Key {
const val BLOCKCHAIN = "blockchain"
const val TOKEN = "Token"

View file

@ -1,6 +1,8 @@
package com.tangem.tap.common.analytics.events
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.OneTimeAnalyticsEvent
import com.tangem.domain.wallets.models.UserWalletId
/**
[REDACTED_AUTHOR]
@ -50,10 +52,15 @@ sealed class Basic(
}
}
class ToppedUp(currency: AnalyticsParam.CardCurrency) : Basic(
event = "Topped up",
params = mapOf(AnalyticsParam.CURRENCY to currency.value),
)
class ToppedUp(userWalletId: UserWalletId, currency: AnalyticsParam.CardCurrency) :
Basic(
event = "Topped up",
params = mapOf(AnalyticsParam.CURRENCY to currency.value),
),
OneTimeAnalyticsEvent {
override val oneTimeEventId: String = id + userWalletId.stringValue
}
class TransactionSent(sentFrom: AnalyticsParam.TxSentFrom, memoType: MemoType) : Basic(
event = "Transaction sent",

View file

@ -84,5 +84,10 @@ sealed class Settings(
event = "Main Currency Changed",
params = mapOf("Currency Type" to currencyType),
)
class ThemeSwitched(theme: AnalyticsParam.AppTheme) : AppSettings(
event = "App Theme Switched",
params = mapOf("State" to theme.value),
)
}
}

View file

@ -9,8 +9,8 @@ class AmplitudeAnalyticsHandler(
override fun id(): String = ID
override fun send(event: String, params: Map<String, String>) {
client.logEvent(event, params)
override fun send(eventId: String, params: Map<String, String>) {
client.logEvent(eventId, params)
}
companion object {

View file

@ -12,8 +12,8 @@ class AppsFlyerAnalyticsHandler(
override fun id(): String = ID
override fun send(event: String, params: Map<String, String>) {
client.logEvent(event, params)
override fun send(eventId: String, params: Map<String, String>) {
client.logEvent(eventId, params)
}
override fun send(event: AnalyticsEvent) {

View file

@ -14,8 +14,8 @@ class FirebaseAnalyticsHandler(
override fun id(): String = ID
override fun send(event: String, params: Map<String, String>) {
client.logEvent(event, params)
override fun send(eventId: String, params: Map<String, String>) {
client.logEvent(eventId, params)
}
override fun send(event: AnalyticsEvent) {

View file

@ -154,7 +154,7 @@ class TopUpController(
if (cardBalanceState.isToppedUp()) {
topupWalletStorage.save(topupInfo.copy(cardBalanceState = DataSourceTopupInfo.CardBalanceState.Full))
TopUpEventConverter().convert(cardTypesResolver)?.let {
TopUpEventConverter().convert(value = userWalletId to cardTypesResolver)?.let {
Analytics.send(it)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.di.domain
import com.tangem.domain.analytics.CheckIsWalletToppedUpUseCase
import com.tangem.domain.analytics.repository.AnalyticsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
@Module
@InstallIn(ViewModelComponent::class)
internal object AnalyticsDomainModule {
@Provides
fun provideCheckIsWalletToppedUpUseCase(analyticsRepository: AnalyticsRepository): CheckIsWalletToppedUpUseCase {
return CheckIsWalletToppedUpUseCase(analyticsRepository)
}
}

View file

@ -91,4 +91,16 @@ internal object SettingsDomainModule {
): UpdateBalanceHidingSettingsUseCase {
return UpdateBalanceHidingSettingsUseCase(balanceHidingRepository)
}
@Provides
@ViewModelScoped
fun provideSetWalletsScrollPreviewIsShown(settingsRepository: SettingsRepository): NeverToShowWalletsScrollPreview {
return NeverToShowWalletsScrollPreview(settingsRepository = settingsRepository)
}
@Provides
@ViewModelScoped
fun provideIsWalletsScrollPreviewEnabled(settingsRepository: SettingsRepository): IsWalletsScrollPreviewEnabled {
return IsWalletsScrollPreviewEnabled(settingsRepository = settingsRepository)
}
}

View file

@ -2,10 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.repository.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -231,4 +228,10 @@ internal object TokensDomainModule {
): GetMissedAddressesCryptoCurrenciesUseCase {
return GetMissedAddressesCryptoCurrenciesUseCase(currenciesRepository = currenciesRepository)
}
@Provides
@ViewModelScoped
fun provideGetGlobalTokenListUseCase(tokensListRepository: TokensListRepository): GetGlobalTokenListUseCase {
return GetGlobalTokenListUseCase(repository = tokensListRepository)
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.tap.di.domain
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -15,10 +18,24 @@ internal object TransactionDomainModule {
@Provides
@ViewModelScoped
fun provideGetUseCase(
fun provideGetFeeUseCase(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade, dispatchers)
}
@Provides
@ViewModelScoped
fun provideSendTransactionUseCase(
isDemoCardUseCase: IsDemoCardUseCase,
walletManagersFacade: WalletManagersFacade,
cardSdkConfigRepository: CardSdkConfigRepository,
): SendTransactionUseCase {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
)
}
}

View file

@ -67,15 +67,17 @@ internal class BiometricUserWalletsListManager(
}
}
.map {
if (throwIfNotAllWalletsUnlocked && state.value.userWallets.any(UserWallet::isLocked)) {
Timber.e("Not all user wallets have been unlocked")
val userWallets = state.value.userWallets
if (throwIfNotAllWalletsUnlocked && userWallets.any(UserWallet::isLocked)) {
Timber.e("Some user wallets remain locked")
throw UserWalletsListError.NotAllUserWalletsUnlocked
}
val selectedUserWallet = selectedUserWalletSync
if (selectedUserWallet == null || selectedUserWallet.isLocked) {
Timber.e("Unable to find selected user wallet")
throw UserWalletsListError.NoUserWalletSelected
findAndSetUnlockedUserWallet(userWallets)
?: throw UserWalletsListError.NoUserWalletSelected
} else {
selectedUserWallet
}
@ -280,13 +282,14 @@ internal class BiometricUserWalletsListManager(
prevSelectedWalletId: UserWalletId?,
userWallets: List<UserWallet>,
): UserWalletId? {
val findUnlockedAndSet = {
userWallets.firstOrNull { !it.isLocked }
?.walletId
?.also { selectedUserWalletRepository.set(it) }
}
return prevSelectedWalletId
?: (selectedUserWalletRepository.get() ?: findAndSetUnlockedUserWallet(userWallets)?.walletId)
}
return prevSelectedWalletId ?: (selectedUserWalletRepository.get() ?: findUnlockedAndSet())
private fun findAndSetUnlockedUserWallet(userWallets: List<UserWallet>): UserWallet? {
return userWallets
.firstOrNull { !it.isLocked }
?.also { selectedUserWalletRepository.set(it.walletId) }
}
private fun changeSelectedUserWalletIdIfNeeded(walletsIdsToRemove: List<UserWalletId>) {

View file

@ -75,9 +75,10 @@ interface FragmentOnBackPressedHandler {
@SuppressLint("FragmentBackPressedCallback")
fun Fragment.addBackPressHandler(handler: FragmentOnBackPressedHandler) {
requireActivity().onBackPressedDispatcher.addCallback {
handler.handleOnBackPressed()
}
requireActivity().onBackPressedDispatcher.addCallback(
owner = this,
onBackPressed = { handler.handleOnBackPressed() },
)
view?.findViewById<Toolbar>(R.id.toolbar)?.setNavigationOnClickListener {
handler.handleOnBackPressed()

View file

@ -278,7 +278,7 @@ class WalletConnectMiddleware {
).guard {
store.dispatchOnMain(
GlobalAction.ShowDialog(
WalletConnectDialog.AddNetwork(blockchain.fullName),
WalletConnectDialog.AddNetwork(listOf(blockchain.fullName)),
),
)
return@launch
@ -325,7 +325,7 @@ class WalletConnectMiddleware {
is WalletConnectError.ApprovalErrorAddNetwork -> {
store.dispatchOnMain(
GlobalAction.ShowDialog(
WalletConnectDialog.UnsupportedNetwork(action.error.networks),
WalletConnectDialog.AddNetwork(action.error.networks),
),
)
}
@ -417,7 +417,7 @@ class WalletConnectMiddleware {
store.dispatchOnMain(WalletConnectAction.FailureEstablishingSession(session.session))
store.dispatchOnMain(
GlobalAction.ShowDialog(
WalletConnectDialog.AddNetwork(blockchain.fullName),
WalletConnectDialog.AddNetwork(listOf(blockchain.fullName)),
),
)
return

View file

@ -92,7 +92,7 @@ sealed class WalletConnectDialog : StateDialog {
data class ClipboardOrScanQr(val clipboardUri: String) : WalletConnectDialog()
object UnsupportedCard : WalletConnectDialog()
data class UnsupportedNetwork(val networks: List<String>? = null) : WalletConnectDialog()
data class AddNetwork(val network: String) : WalletConnectDialog()
data class AddNetwork(val networks: List<String>) : WalletConnectDialog()
object OpeningSessionRejected : WalletConnectDialog()
object SessionTimeout : WalletConnectDialog()
data class ApproveWcSession(

View file

@ -4,10 +4,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.LifecycleCoroutineScope
import com.tangem.core.analytics.Analytics
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchWithMain
@ -121,6 +124,11 @@ internal class AppSettingsViewModel(
dialog = dialogsFactory.createThemeModeSelectorDialog(
selectedModeIndex = selectedMode.ordinal,
onSelect = { mode ->
Analytics.send(
event = Settings.AppSettings.ThemeSwitched(
theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode),
),
)
store.dispatchOnMain(DetailsAction.AppSettings.ChangeAppThemeMode(mode))
dismissDialog()
},

View file

@ -14,11 +14,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerW32
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Item
import com.tangem.core.ui.components.TangemSwitch
@Composable
internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier) {
@ -63,6 +63,7 @@ internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier
checked = item.isChecked,
enabled = item.isEnabled,
onCheckedChange = item.onCheckedChange,
checkedColor = TangemTheme.colors.icon.accent,
)
}
}

View file

@ -11,7 +11,10 @@ import com.tangem.domain.common.util.twinsIsTwinned
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.userwallets.UserWalletBuilder
import com.tangem.domain.userwallets.UserWalletIdBuilder
import com.tangem.tap.*
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.extensions.removeContext
@ -117,6 +120,15 @@ object OnboardingHelper {
Analytics.removeContext()
}
fun sendToppedUpEvent(scanResponse: ScanResponse) {
val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build()
val currency = ParamCardCurrencyConverter().convert(scanResponse.cardTypesResolver)
if (userWalletId != null && currency != null) {
Analytics.send(Basic.ToppedUp(userWalletId, currency))
}
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse, backupCardsIds: List<String>?) {
val userWallet = UserWalletBuilder(scanResponse)
.backupCardsIds(backupCardsIds?.toSet())

View file

@ -152,6 +152,8 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch
}
is OnboardingNoteAction.Balance.Set -> {
if (action.balance.balanceIsToppedUp()) {
OnboardingHelper.sendToppedUpEvent(scanResponse)
store.state.globalState.topUpController?.send(scanResponse, AnalyticsParam.CardBalanceState.Full)
store.dispatch(OnboardingNoteAction.SetStepOfScreen(OnboardingNoteStep.Done))
}

View file

@ -260,6 +260,8 @@ private fun handle(action: Action, dispatch: DispatchFunction) {
}
is TwinCardsAction.Balance.Set -> {
if (action.balance.balanceIsToppedUp()) {
OnboardingHelper.sendToppedUpEvent(getScanResponse())
store.state.globalState.topUpController?.send(getScanResponse(), AnalyticsParam.CardBalanceState.Full)
store.dispatchOnMain(TwinCardsAction.SetStepOfScreen(TwinCardsStep.Done))
}

View file

@ -20,6 +20,8 @@ import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.TokenWithBlockchain
import com.tangem.domain.tokens.TokensAction
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletconnect.WalletConnectActions
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.*
@ -90,13 +92,17 @@ object TokensMiddleware {
if (scanResponse.supportsHdWallet()) {
deriveMissingCoins(scanResponse = scanResponse, currencyList = currencyList) {
submitNewAdd(
userWalletId = action.userWallet.walletId,
userWallet = action.userWallet,
updatedScanResponse = it,
currencyList = currencyList,
)
}
} else {
submitNewAdd(userWalletId = action.userWallet.walletId, scanResponse, currencyList = currencyList)
submitNewAdd(
userWallet = action.userWallet,
updatedScanResponse = scanResponse,
currencyList = currencyList,
)
}
}
}
@ -375,16 +381,18 @@ object TokensMiddleware {
}
private fun submitNewAdd(
userWalletId: UserWalletId,
userWallet: UserWallet,
updatedScanResponse: ScanResponse,
currencyList: List<CryptoCurrency>,
) {
scope.launch {
userWalletsListManager.update(
userWalletId = userWalletId,
userWalletId = userWallet.walletId,
update = { it.copy(scanResponse = updatedScanResponse) },
).doOnSuccess {
addCryptoCurrenciesUseCase(userWalletId, currencyList)
addCryptoCurrenciesUseCase(userWallet.walletId, currencyList).onRight {
store.dispatch(action = WalletConnectActions.New.SetupUserChains(userWallet = userWallet))
}
}
}
store.dispatchOnMain(NavigationAction.PopBackTo())

View file

@ -9,11 +9,8 @@ import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.feature.swap.presentation.SwapFragment
import com.tangem.features.send.api.navigation.SendRouter
@ -26,7 +23,6 @@ import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.tokens.getIconUrl
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.send.redux.PrepareSendScreen
@ -41,9 +37,6 @@ import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import com.tangem.feature.swap.domain.models.domain.Currency as SwapCurrency
@Suppress("LargeClass")
class TradeCryptoMiddleware {
@ -58,17 +51,12 @@ class TradeCryptoMiddleware {
is TradeCryptoAction.SendCrypto -> preconfigureAndOpenSendScreen(action)
is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId)
is TradeCryptoAction.Swap -> {
openSwap(
currency = store.state.walletState.selectedWalletData?.currency?.toSwapCurrency(),
derivationPath = store.state.walletState.selectedWalletData?.currency?.derivationPath,
)
// todo remove old flow
}
is TradeCryptoAction.New.Buy -> proceedNewBuyAction(state, action)
is TradeCryptoAction.New.Sell -> proceedNewSellAction(action)
is TradeCryptoAction.New.Swap -> openSwap(
currency = action.cryptoCurrency.toSwapCurrency(),
derivationPath = action.cryptoCurrency.network.derivationPath.value,
network = action.cryptoCurrency.network,
currency = action.cryptoCurrency,
)
is TradeCryptoAction.New.SendToken -> handleNewSendToken(action = action)
is TradeCryptoAction.New.SendCoin -> handleNewSendCoin(action = action)
@ -274,70 +262,14 @@ class TradeCryptoMiddleware {
)?.let { store.dispatchOpenUrl(it) }
}
private fun openSwap(currency: SwapCurrency?, derivationPath: String?, network: Network? = null) {
private fun openSwap(currency: CryptoCurrency) {
val bundle = bundleOf(
SwapFragment.CURRENCY_BUNDLE_KEY to Json.encodeToString(currency),
SwapFragment.DERIVATION_PATH to derivationPath,
SwapFragment.NETWORK to network,
SwapFragment.CURRENCY_BUNDLE_KEY to currency,
)
store.dispatchOnMain(NavigationAction.NavigateTo(screen = AppScreen.Swap, bundle = bundle))
}
private fun CryptoCurrency.toSwapCurrency(): SwapCurrency {
val blockchain = Blockchain.fromId(network.id.value)
return when (this) {
is CryptoCurrency.Coin -> {
SwapCurrency.NativeToken(
id = blockchain.toCoinId(),
name = name,
symbol = symbol,
networkId = blockchain.toNetworkId(),
// no need to set logoUrl for blockchain cause
// error when form url with coinId, coinId of eth and arbitrum the same
logoUrl = "",
)
}
is CryptoCurrency.Token -> {
SwapCurrency.NonNativeToken(
id = id.rawCurrencyId ?: "",
name = name,
symbol = symbol,
networkId = blockchain.toNetworkId(),
logoUrl = getIconUrl(id.rawCurrencyId ?: ""),
contractAddress = contractAddress,
decimalCount = decimals,
)
}
}
}
private fun Currency.toSwapCurrency(): SwapCurrency {
return when (this) {
is Currency.Blockchain -> {
SwapCurrency.NativeToken(
id = blockchain.toCoinId(),
name = this.currencyName,
symbol = this.currencySymbol,
networkId = this.blockchain.toNetworkId(),
// no need to set logoUrl for blockchain cause
// error when form url with coinId, coinId of eth and arbitrum the same
logoUrl = "",
)
}
is Currency.Token -> SwapCurrency.NonNativeToken(
id = this.token.id ?: "",
name = this.currencyName,
symbol = this.currencySymbol,
networkId = this.blockchain.toNetworkId(),
logoUrl = getIconUrl(this.token.id ?: ""),
contractAddress = this.token.contractAddress,
decimalCount = decimals,
)
}
}
private fun handleNewSendToken(action: TradeCryptoAction.New.SendToken) {
val currency = action.tokenCurrency
val blockchain = Blockchain.fromId(currency.network.id.value)

View file

@ -0,0 +1,31 @@
package com.tangem.tap.network.auth
import com.tangem.datasource.config.ConfigManager
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.sessionId.ExpressSessionIdGenerator
import java.util.UUID
class ExpressAuthProviderImpl(
private val userWalletsStore: UserWalletsStore,
private val configManager: ConfigManager,
) : ExpressAuthProvider, ExpressSessionIdGenerator {
private var uuid = UUID.randomUUID()
override fun getApiKey(): String {
return configManager.config.tangemExpressApiKey
}
override fun getUserId(): String {
return userWalletsStore.selectedUserWalletOrNull?.walletId?.stringValue ?: ""
}
override fun getSessionId(): String {
return uuid.toString()
}
override fun generateNewSessionId() {
uuid = UUID.randomUUID()
}
}

View file

@ -224,20 +224,18 @@ class TransactionManagerImpl(
// for not EVM blockchains set gasLimit ZERO for now
when (fee.data) {
is TransactionFee.Single -> {
val fee = (fee.data as TransactionFee.Single).normal
val normalFee = (fee.data as TransactionFee.Single).normal
val singleFee = ProxyFee(
gasLimit = BigInteger.ZERO,
fee = convertToProxyAmount(amount = fee.amount),
fee = convertToProxyAmount(amount = normalFee.amount),
)
ProxyFees(
minFee = singleFee,
normalFee = singleFee,
priorityFee = singleFee,
ProxyFees.SingleFee(
singleFee = singleFee,
)
}
is TransactionFee.Choosable -> {
val choosableFee = fee.data as TransactionFee.Choosable
ProxyFees(
ProxyFees.MultipleFees(
minFee = ProxyFee(
gasLimit = BigInteger.ZERO,
fee = convertToProxyAmount(amount = choosableFee.minimum.amount),
@ -280,7 +278,7 @@ class TransactionManagerImpl(
).increaseBigIntegerByPercents(increaseBy)
return when (val gasPrice = walletManager.getGasPrice()) {
is Result.Success -> {
createProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain)
createMultipleProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain)
}
is Result.Failure -> {
error(gasPrice.error.message ?: gasPrice.error.customMessage)
@ -316,7 +314,7 @@ class TransactionManagerImpl(
fee = convertToProxyAmount(amount = choosableFee.priority.amount),
)
ProxyFees(
ProxyFees.MultipleFees(
minFee = minProxyFee,
normalFee = normalProxyFee,
priorityFee = priorityProxyFee,
@ -456,7 +454,7 @@ class TransactionManagerImpl(
* @param gasLimit
* @param blockchain
*/
private fun createProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees {
private fun createMultipleProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees {
val gasPriceNormal = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE)
val gasPricePriority = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE)
val feeMin = gasLimit.multiply(gasPrice).toBigDecimal(
@ -495,7 +493,7 @@ class TransactionManagerImpl(
decimals = blockchain.decimals(),
),
)
return ProxyFees(
return ProxyFees.MultipleFees(
minFee = minFee,
normalFee = normalFee,
priorityFee = priorityFee,

View file

@ -33,7 +33,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/send_fee_label"
android:text="@string/common_fee_label"
android:textColor="@color/text_tertiary"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"

View file

@ -21,7 +21,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/onboarding_balance_title"
android:text="@string/common_balance_title"
android:textColor="@color/text_tertiary"
android:textSize="14sp"
android:textStyle="bold"

View file

@ -12,7 +12,7 @@
android:elevation="0dp"
android:fontFamily="sans-serif-medium"
android:letterSpacing="0.036"
android:text="@string/onboarding_balance_title"
android:text="@string/common_balance_title"
android:textAllCaps="true"
android:textColor="@color/text_secondary"
android:textSize="14sp"

View file

@ -1,19 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
dependencies {
/** DI */
implementation(deps.hilt.android)
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Core shouldn't depends on core, but in case with utils and logging its necessary */
implementation(projects.core.utils)
/** Analytics - Models */
implementation(projects.core.analytics.models)
/** Domain */
implementation(projects.domain.analytics)
/** Other */
implementation(deps.kotlin.coroutines)
/** Core shouldn't depend on core, but in case with utils and logging its necessary */
implementation(projects.core.utils)
}

View file

@ -8,4 +8,7 @@ open class AnalyticsEvent(
val event: String,
var params: Map<String, String> = mapOf(),
val error: Throwable? = null,
)
) {
val id: String = "[$category] $event"
}

View file

@ -114,6 +114,11 @@ sealed class AnalyticsParam {
object SeedImport : WalletCreationType("Seed import")
}
sealed class WalletType(val value: String) {
object MultiCurrency : WalletType(value = "Multicurrency")
class SingleCurrency(currencyName: String) : WalletType(currencyName)
}
companion object Key {
const val BLOCKCHAIN = "blockchain"
const val TOKEN = "Token"

View file

@ -0,0 +1,6 @@
package com.tangem.core.analytics.models
interface OneTimeAnalyticsEvent {
val oneTimeEventId: String
}

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.core.analytics" />

View file

@ -16,7 +16,7 @@ interface AnalyticsEventFilter {
* An internal filter check that, on external or internal conditions, recognizes the possibility of
* sending an event.
*/
fun canBeSent(event: AnalyticsEvent): Boolean
suspend fun canBeSent(event: AnalyticsEvent): Boolean
/**
* Performs a check to see if the event can be dispatched by a specific handler

View file

@ -12,13 +12,11 @@ interface AnalyticsEventHandler {
interface AnalyticsHandler : AnalyticsEventHandler {
fun id(): String
fun send(event: String, params: Map<String, String> = emptyMap())
fun send(eventId: String, params: Map<String, String> = emptyMap())
override fun send(event: AnalyticsEvent) {
send(prepareEventString(event), event.params)
send(event.id, event.params)
}
fun prepareEventString(event: AnalyticsEvent): String = "[${event.category}] ${event.event}"
}
interface ErrorEventHandler {

View file

@ -2,6 +2,8 @@ package com.tangem.core.analytics.di
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.domain.analytics.repository.AnalyticsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -10,11 +12,16 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AnalyticsModule {
internal object AnalyticsModule {
@Singleton
@Provides
fun provideAnalyticsHandler(): AnalyticsEventHandler {
return Analytics // todo replace after refactoring calling Analytics in whole project
}
@Provides
fun provideOneTimeEventFilter(analyticsRepository: AnalyticsRepository): OneTimeEventFilter {
return OneTimeEventFilter(analyticsRepository)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.core.analytics.filter
import com.tangem.core.analytics.api.AnalyticsEventFilter
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.OneTimeAnalyticsEvent
import com.tangem.domain.analytics.repository.AnalyticsRepository
class OneTimeEventFilter(
private val analyticsRepository: AnalyticsRepository,
) : AnalyticsEventFilter {
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is OneTimeAnalyticsEvent
override suspend fun canBeSent(event: AnalyticsEvent): Boolean {
if (event !is OneTimeAnalyticsEvent) return true
val isSent = analyticsRepository.checkIsEventSent(event.oneTimeEventId)
if (!isSent) {
analyticsRepository.setIsEventSent(event.oneTimeEventId)
}
return !isSent
}
override fun canBeConsumedByHandler(handler: AnalyticsHandler, event: AnalyticsEvent): Boolean {
return canBeAppliedTo(event)
}
}

View file

@ -12,7 +12,11 @@ sealed class ApiResponseError : Exception() {
* @property code The HTTP status code.
* @property message A human-readable message describing the error.
*/
data class HttpException(val code: Code, override val message: String?) : ApiResponseError() {
data class HttpException(
val code: Code,
override val message: String?,
val errorBody: String?,
) : ApiResponseError() {
// region Error Codes
enum class Code(val code: Int) {

View file

@ -16,7 +16,7 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
val e = if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
ApiResponseError.HttpException(code, message())
ApiResponseError.HttpException(code, message(), errorBody()?.string())
}
apiError(e)

View file

@ -8,13 +8,12 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.math.BigDecimal
/**
* Interface of Tangem Express API (new swap mechanism)
*/
@Suppress("LongParameterList")
interface ExpressApi {
interface TangemExpressApi {
@POST("assets")
suspend fun getAssets(@Body body: AssetsRequestBody): ApiResponse<List<Asset>>
@ -31,9 +30,11 @@ interface ExpressApi {
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: BigDecimal,
@Query("providerId") providerId: Int,
@Query("rateType") rateType: RateType,
@Query("fromAmount") fromAmount: String,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,
@Query("rateType") rateType: String,
): ApiResponse<ExchangeQuoteResponse>
@GET("exchange-data")
@ -42,12 +43,14 @@ interface ExpressApi {
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: BigDecimal,
@Query("providerId") providerId: Int,
@Query("rateType") rateType: RateType,
@Query("fromAmount") fromAmount: String,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,
@Query("rateType") rateType: String,
@Query("toAddress") toAddress: String,
): ApiResponse<ExchangeDataResponse>
@GET("exchange-result")
suspend fun getExchangeResults(@Query("txId") txId: String): ApiResponse<ExchangeResultsResponse>
@GET("exchange-status")
suspend fun getExchangeStatus(@Query("txId") txId: String): ApiResponse<ExchangeStatusResponse>
}

View file

@ -0,0 +1,5 @@
package com.tangem.datasource.api.express.models
object TangemExpressValues {
const val EMPTY_CONTRACT_ADDRESS_VALUE = "0"
}

View file

@ -3,5 +3,5 @@ package com.tangem.datasource.api.express.models.request
import com.squareup.moshi.Json
data class AssetsRequestBody(
@Json(name = "filter") val filter: List<LeastTokenInfo>?,
@Json(name = "tokensList") val tokensList: List<LeastTokenInfo>?,
)

View file

@ -9,21 +9,6 @@ data class Asset(
@Json(name = "network")
val network: String,
@Json(name = "token")
val token: String,
@Json(name = "name")
val name: String,
@Json(name = "symbol")
val symbol: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "isActive")
val isActive: Boolean,
@Json(name = "exchangeAvailable")
val exchangeAvailable: Boolean,
)

View file

@ -4,8 +4,17 @@ import com.squareup.moshi.Json
import java.math.BigDecimal
data class ExchangeDataResponse(
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "toAmount")
val toAmount: BigDecimal,
val toAmount: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "txType")
val txType: TxType,

View file

@ -4,19 +4,19 @@ import com.squareup.moshi.Json
data class ExchangeProvider(
@Json(name = "id")
val id: Int,
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "id")
@Json(name = "type")
val type: ExchangeProviderType,
@Json(name = "imageLarge")
val imageLargeUrl: Int,
val imageLargeUrl: String,
@Json(name = "imageSmall")
val imageSmallUrl: Int,
val imageSmallUrl: String,
)
enum class ExchangeProviderType {

View file

@ -4,9 +4,23 @@ import com.squareup.moshi.Json
import java.math.BigDecimal
data class ExchangeQuoteResponse(
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "toAmount")
val toAmount: BigDecimal,
val toAmount: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "allowanceContract")
val allowanceContract: String?,
@Json(name = "minAmount")
val minAmount: BigDecimal,
)

View file

@ -1,42 +0,0 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
data class ExchangeResultsResponse(
@Json(name = "status")
val status: ExchangeResultsStatus,
@Json(name = "externalStatus")
val externalStatus: String,
@Json(name = "externalTxUrl")
val externalTxUrl: String,
@Json(name = "error")
val error: ExchangeResultsError?,
)
enum class ExchangeResultsStatus {
@Json(name = "processing")
PROCESSING,
@Json(name = "done")
DONE,
@Json(name = "failed")
FAILED,
@Json(name = "refunded")
REFUNDED,
@Json(name = "verificationRequired")
VERIFICATION_REQUIRED,
}
data class ExchangeResultsError(
@Json(name = "code")
val code: Int,
@Json(name = "description")
val description: String,
)

View file

@ -0,0 +1,59 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
data class ExchangeStatusResponse(
@Json(name = "providerId")
val providerId: String,
@Json(name = "externalTxId")
val externalTxId: String,
@Json(name = "externalTxStatus")
val externalStatus: ExchangeStatus,
@Json(name = "externalTxUrl")
val externalTxUrl: String,
@Json(name = "error")
val error: ExchangeStatusError?,
)
enum class ExchangeStatus {
@Json(name = "new")
NEW,
@Json(name = "waiting")
WAITING,
@Json(name = "confirming")
CONFIRMING,
@Json(name = "exchanging")
EXCHANGING,
@Json(name = "sending")
SENDING,
@Json(name = "finished")
FINISHED,
@Json(name = "failed")
FAILED,
@Json(name = "refunded")
REFUNDED,
@Json(name = "verifying")
VERIFYING,
}
data class ExchangeStatusError(
@Json(name = "code")
val code: Int,
@Json(name = "description")
val description: String,
)

View file

@ -0,0 +1,37 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import java.math.BigDecimal
data class ExpressErrorResponse(
@Json(name = "error")
val error: ExpressError,
)
data class ExpressError(
@Json(name = "code")
val code: Int,
@Json(name = "description")
val description: String?,
@Json(name = "value")
val value: ExpressErrorValue?,
)
data class ExpressErrorValue(
@Json(name = "minAmount")
val minAmount: String?,
@Json(name = "decimals")
val decimals: Int?,
@Json(name = "currentAllowance")
val currentAllowance: BigDecimal?,
@Json(name = "receivedFromDecimals")
val receivedFromDecimals: Int?,
@Json(name = "expressFromDecimals")
val expressFromDecimals: Int?,
)

View file

@ -17,10 +17,10 @@ data class SwapPair(
data class SwapPairProvider(
@Json(name = "providerId")
val providerId: Int,
val providerId: String,
@Json(name = "rateType")
val rateType: RateType,
@Json(name = "rateTypes")
val rateTypes: List<RateType>,
)
enum class RateType {

View file

@ -0,0 +1,6 @@
package com.tangem.datasource.api.express.models.response
class SwapPairsWithProviders(
val swapPair: List<SwapPair>,
val providers: List<ExchangeProvider>,
)

View file

@ -1,8 +1,8 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.token.DefaultUserMarketCoinsStore
import com.tangem.datasource.local.token.UserMarketCoinsStore
import com.tangem.datasource.local.token.DefaultAssetsStore
import com.tangem.datasource.local.token.AssetsStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -11,11 +11,11 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object MarketCoinsStoreModule {
internal object AssetsStoreModule {
@Provides
@Singleton
fun provideUserMarketCoinsStore(): UserMarketCoinsStore {
return DefaultUserMarketCoinsStore(dataStore = RuntimeDataStore())
fun provideAssetsStore(): AssetsStore {
return DefaultAssetsStore(dataStore = RuntimeDataStore())
}
}

View file

@ -2,8 +2,9 @@ package com.tangem.datasource.di
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.express.ExpressApi
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.promotion.PromotionApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.utils.RequestHeader.*
@ -32,11 +33,16 @@ class NetworkModule {
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
expressAuthProvider: ExpressAuthProvider,
): ExpressApi {
): TangemExpressApi {
val url = if (BuildConfig.ENVIRONMENT == "dev") {
DEV_EXPRESS_BASE_URL
} else {
PROD_EXPRESS_BASE_URL
}
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.baseUrl(DEV_EXPRESS_BASE_URL)
.baseUrl(url)
.client(
OkHttpClient.Builder()
.addHeaders(Express(expressAuthProvider))
@ -44,7 +50,7 @@ class NetworkModule {
.build(),
)
.build()
.create(ExpressApi::class.java)
.create(TangemExpressApi::class.java)
}
@Provides

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.swaptx.DefaultSwapTransactionStatusStore
import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SwapTransactionStatusStoreModule {
@Provides
@Singleton
fun provideSwapTransactionStatusStore(): SwapTransactionStatusStore {
return DefaultSwapTransactionStatusStore(
dataStore = RuntimeDataStore(),
)
}
}

View file

@ -44,12 +44,20 @@ class AppPreferencesStore(
return this[key]?.let(adapter::fromJson)
}
/** Get nullable list of data [T] by string [key] */
/** Get list of data [T] by string [key] */
inline fun <reified T> MutablePreferences.getObjectList(key: Preferences.Key<String>): List<T>? {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
return this[key]?.let(adapter::fromJson)
}
/** Get map with [String] key and value [V] by string [key] from [MutablePreferences] */
inline fun <reified V> MutablePreferences.getObjectMap(key: Preferences.Key<String>): Map<String, V>? {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return this[key]?.let(adapter::fromJson)
}
/**
* Set data [T] by string [key] to [MutablePreferences]
*
@ -67,4 +75,12 @@ class AppPreferencesStore(
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
this[key] = adapter.toJson(value)
}
/** Set map with [String] key and value [V] by string [key] to [MutablePreferences] */
inline fun <reified V> MutablePreferences.setObjectMap(key: Preferences.Key<String>, value: Map<String, V>) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
this[key] = adapter.toJson(value)
}
}

View file

@ -32,6 +32,16 @@ object PreferencesKeys {
val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") }
val BALANCE_HIDING_SETTINGS_KEY by lazy { stringPreferencesKey(name = "balanceHidingSettings") }
val SWAP_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "swapTransactions") }
val WALLETS_SCROLL_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "walletsScrollPreview") }
val SENT_ONE_TIME_EVENTS_KEY by lazy { stringPreferencesKey(name = "sentOneTimeEvents") }
val WALLETS_BALANCES_STATES_KEY by lazy { stringPreferencesKey(name = "walletsBalancesStates") }
val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") }
}
/** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore<Preferences> */

View file

@ -103,9 +103,31 @@ inline fun <reified T> AppPreferencesStore.getObjectList(key: Preferences.Key<St
return data.map { it[key]?.let(adapter::fromJson) }
}
/** Get nullable list of data [T] by string [key] */
/** Get list of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Store map with [String] key and value [V] by string [key] */
suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
key: Preferences.Key<String>,
value: Map<String, V>,
) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
edit { it[key] = adapter.toJson(value) }
}
/** Get map with [String] key and value [V] by string [key], or empty if data is not found */
suspend inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences.Key<String>): Map<String, V> {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.local.swaptx
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
internal class DefaultSwapTransactionStatusStore(
private val dataStore: StringKeyDataStore<ExchangeAnalyticsStatus>,
) : SwapTransactionStatusStore, StringKeyDataStore<ExchangeAnalyticsStatus> by dataStore {
override suspend fun getTransactionStatus(txId: String) = getSyncOrNull(txId)
override suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus) = store(txId, status)
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.local.swaptx
/**
* Runtime cache for storing swap transactions statuses sent to analytics
*/
interface SwapTransactionStatusStore {
suspend fun getTransactionStatus(txId: String): ExchangeAnalyticsStatus?
suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus)
}
enum class ExchangeAnalyticsStatus(val value: String) {
InProgress("In Progress"),
Done("Done"),
Fail("Fail"),
KYC("KYC"),
Refunded("Refunded"),
}

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.domain.wallets.models.UserWalletId
interface AssetsStore {
suspend fun getSyncOrNull(userWalletId: UserWalletId): List<Asset>?
suspend fun store(userWalletId: UserWalletId, item: List<Asset>)
}

View file

@ -1,18 +1,18 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.wallets.models.UserWalletId
internal class DefaultUserMarketCoinsStore(
private val dataStore: StringKeyDataStore<CoinsResponse>,
) : UserMarketCoinsStore {
internal class DefaultAssetsStore(
private val dataStore: StringKeyDataStore<List<Asset>>,
) : AssetsStore {
override suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse? {
override suspend fun getSyncOrNull(userWalletId: UserWalletId): List<Asset>? {
return dataStore.getSyncOrNull(userWalletId.stringValue)
}
override suspend fun store(userWalletId: UserWalletId, item: CoinsResponse) {
override suspend fun store(userWalletId: UserWalletId, item: List<Asset>) {
dataStore.store(userWalletId.stringValue, item)
}
}

View file

@ -1,11 +0,0 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.wallets.models.UserWalletId
interface UserMarketCoinsStore {
suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse?
suspend fun store(userWalletId: UserWalletId, item: CoinsResponse)
}

View file

@ -34,5 +34,9 @@
{
"name": "REDESIGNED_SEND_SCREEN_ENABLED",
"version": "undefined"
},
{
"name": "WALLETS_SCROLLING_PREVIEW_ENABLED",
"version": "undefined"
}
]

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="common_fee_error">Erhalt der Gebühr fehlgeschlagen</string>
<string name="no_account_generic">Laden Sie %1$s+ %2$s auf um ein Konto zu erstellen</string>
<string name="send_error_dust_amount_format">Minimaler Betrag ist %s</string>
<string name="send_error_dust_change">Restbestand zu klein</string>
<string name="send_error_invalid_fee_value">Falsche Gebühr</string>

View file

@ -13,6 +13,7 @@
<string name="common_save_changes">Änderungen speichern</string>
<string name="common_send">Absenden</string>
<string name="common_success">Erfolg</string>
<string name="common_fee_label">Gebühr</string>
<string name="details_manage_security_access_code">Zugangscode</string>
<string name="details_manage_security_access_code_description">Sie müssen den richtigen Zugangscode eingeben, bevor Sie die Karte scannen.</string>
<string name="details_manage_security_long_tap">Langes Tippen</string>
@ -40,7 +41,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">inkl. Gebühr</string>
<string name="send_fee_label">Gebühr</string>
<string name="send_fee_picker_low">Niedrig</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priorität</string>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="common_fee_error">Échec de réception des commissions</string>
<string name="no_account_generic">Pour créer un compte, téléchargez %1$s+ %2$s</string>
<string name="send_error_dust_amount_format">Le montant minimal est de %s</string>
<string name="send_error_dust_change">Le reste est trop petit</string>
<string name="send_error_invalid_fee_value">Commission non valide</string>

View file

@ -13,6 +13,7 @@
<string name="common_save_changes">Sauvegarder les modifications</string>
<string name="common_send">Envoyer</string>
<string name="common_success">Avec succès</string>
<string name="common_fee_label">Commissions</string>
<string name="details_manage_security_access_code">Code d\'accès</string>
<string name="details_manage_security_access_code_description">Vous devrez entrer le mot de passe correct avant de scanner la carte</string>
<string name="details_manage_security_long_tap">Tenez la carte fermement</string>
@ -40,7 +41,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">Inclure les commissions</string>
<string name="send_fee_label">Commissions</string>
<string name="send_fee_picker_low">Bas</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priorité</string>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="common_fee_error">Impossibile ottenere la commissione</string>
<string name="no_account_generic">Scarica %1$s+ %2$s per creare un account</string>
<string name="send_error_dust_amount_format">L\'importo minimo è di %s</string>
<string name="send_error_dust_change">L\'importo residuo è molto basso</string>
<string name="send_error_invalid_fee_value">Commissione non valida</string>

View file

@ -13,6 +13,7 @@
<string name="common_save_changes">Mantieni le modifiche</string>
<string name="common_send">Invia</string>
<string name="common_success">Con successo</string>
<string name="common_fee_label">Commissione</string>
<string name="details_manage_security_access_code">Codice di accesso</string>
<string name="details_manage_security_access_code_description">Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto</string>
<string name="details_manage_security_long_tap">Mantenimento della carta</string>
@ -40,7 +41,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">Includi commissione</string>
<string name="send_fee_label">Commissione</string>
<string name="send_fee_picker_low">Insufficiente</string>
<string name="send_fee_picker_normal">Normale</string>
<string name="send_fee_picker_priority">Prioritario</string>

View file

@ -66,6 +66,7 @@
<string name="common_approval">Одобрение</string>
<string name="common_attention">Внимание</string>
<string name="common_balance">Баланс: %s</string>
<string name="common_balance_title">Баланс</string>
<string name="common_biometric_authentication">биометрическую аутентификацию</string>
<string name="common_biometrics">биометрией</string>
<string name="common_buy">Купить</string>
@ -85,9 +86,11 @@
<string name="common_enabled">Включено</string>
<string name="common_error">Ошибка</string>
<string name="common_exchange">Обменять</string>
<string name="common_explore_transaction_history">Посмотреть историю транзакций</string>
<string name="common_explore">Обозреватель</string>
<string name="common_explore_history">Посмотреть историю</string>
<string name="common_explore_transaction_history">Посмотреть историю транзакций</string>
<string name="common_explorer">Обозреватель</string>
<string name="common_fee_label">Комиссия</string>
<string name="common_fee_selector_footer">Сетевые комиссии за транзакции используются для поддержки безопасности сети, поощрения валидаторов, выделения ресурсов и определения приоритета транзакции.</string>
<string name="common_fee_selector_option_custom">Свое</string>
<string name="common_fee_selector_option_fast">Быстро</string>
@ -191,9 +194,19 @@
<string name="exchange_tokens_available_tokens_header">Мои токены</string>
<string name="exchange_tokens_empty_tokens">У вас нет добавленных токенов. Добавьте токены для обмена</string>
<string name="exchange_tokens_unavailable_tokens_header">Недоступен для обмена с %s</string>
<string name="express_cex_status_button_title">Статус</string>
<string name="express_choose_providers_subtitle">Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами</string>
<string name="express_choose_providers_title">Выберите провайдера</string>
<string name="express_exchange_notification_failed_text">Чтобы узнать причину, посетите сайт провайдера</string>
<string name="express_exchange_notification_failed_title">Чтобы вернуть ваши деньги, посетите сайт провайдера</string>
<string name="express_exchange_notification_verification_text">Посетите сайт провайдера для проверки</string>
<string name="express_exchange_notification_verification_title">Провайдер запрашивает прохождение верификации</string>
<string name="express_exchange_token_list_subtitle">Список токенов в вашем кошельке</string>
<string name="express_fetch_best_rates">Получение наилучших курсов...</string>
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_rate">Лучший курс</string>
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="feedback_data_collection_message">Информация ниже не является обязательной. Вы можете стереть её, если хотите.</string>
<string name="feedback_preface_rate_negative">Расскажите, каких функций вам не хватает, и мы постараемся вам помочь.</string>
@ -267,7 +280,6 @@
<string name="onboarding_access_codes_doesnt_match">Введенные коды доступа не совпадают</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">Вы добавили одну резервную карту. После того, как процесс будет завершен, Вы больше не сможете добавить карт. Если у Вас есть еще одна карта, добавьте ее в резервную копию. Хотите продолжить?</string>
<string name="onboarding_backup_exit_warning">Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас.</string>
<string name="onboarding_balance_title">Баланс</string>
<string name="onboarding_button_add_backup_card">Добавить резервную карту</string>
<string name="onboarding_button_backup_card_format">Сканировать карту #%d</string>
<string name="onboarding_button_backup_now">Создать резервную копию</string>
@ -418,7 +430,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">Включая комиссию</string>
<string name="send_fee_label">Комиссия</string>
<string name="send_fee_picker_low">Низкая</string>
<string name="send_fee_picker_normal">Нормальная</string>
<string name="send_fee_picker_priority">Приоритетная</string>
@ -629,12 +640,18 @@
<string name="warning_button_like_it">Нравится</string>
<string name="warning_button_ok">Понятно!</string>
<string name="warning_button_really_cool">Очень круто!</string>
<string name="warning_button_refresh">Обновить</string>
<string name="warning_demo_mode_message">Вы находитесь в режиме демо</string>
<string name="warning_demo_mode_title">Демо режим включен</string>
<string name="warning_developer_card_message">Отсканированная вами карта является картой разработчика. Не используйте ее для создания своего кошелька.</string>
<string name="warning_developer_card_title">Не для пользователя!</string>
<string name="warning_existential_deposit_message">Cеть %1$s использует концепцию экзистенциального депозита. Если баланс вашего счета будет ниже %2$s, то он будет деактивирован, а средства на счете уничтожены.</string>
<string name="warning_existential_deposit_title">Для работы с сетью необходим депозит</string>
<string name="warning_express_no_exchangeable_coins_description">У вас в списке нет монет доступных для обмена с %s</string>
<string name="warning_express_no_exchangeable_coins_title">Нет доступных токенов для обмена</string>
<string name="warning_express_refresh_required_title">Cервис временно недоступен</string>
<string name="warning_express_too_minimal_amount_description">Пожалуйста, измените сумму для обмена</string>
<string name="warning_express_too_minimal_amount_title">Сумма для обмена должна быть не менее %s</string>
<string name="warning_failed_to_verify_card_message">Возможно, данная карта - образец или подделка</string>
<string name="warning_failed_to_verify_card_title">Ошибка проверки подлинности</string>
<string name="warning_low_signatures_message">На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства.</string>

View file

@ -4,7 +4,6 @@
<string name="address_type_legacy">遺留資產</string>
<string name="common_fee_error">獲取費用失敗</string>
<string name="kaspa_withdrawal_message_warning">由於 Kaspa 的限制,只有%1$d UTXO 可以放入單次交易中。這意味著您只能發送%2$s或更少數量。您需要減少數量。</string>
<string name="no_account_generic">加載 %1$s+ %2$s 以創建帳戶</string>
<string name="no_account_polkadot">目標帳戶未激活。發送 %s 或更多以激活帳戶</string>
<string name="send_error_dust_amount_format">最小數量是 %s</string>
<string name="send_error_dust_change">更動太小</string>

View file

@ -90,6 +90,7 @@
<string name="common_submit">提交</string>
<string name="common_success">成功</string>
<string name="common_swap">交換</string>
<string name="common_fee_label">費用</string>
<string name="common_terms_and_conditions">條款和條件</string>
<string name="common_transactions">交易</string>
<string name="common_understand">我了解</string>
@ -178,7 +179,7 @@
<string name="onboarding_access_codes_doesnt_match">輸入的訪問密碼與初始訪問密碼不匹配</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">您已添加一張備用卡。備份過程完成後,您將無法添加更多備份卡。如果您還有一張卡,請將其添加到備份中。您想繼續備份過程嗎?</string>
<string name="onboarding_backup_exit_warning">備份過程已部分完成。你現在不能退出</string>
<string name="onboarding_balance_title">餘額</string>
<string name="common_balance_title">餘額</string>
<string name="onboarding_button_add_backup_card">添加備用卡</string>
<string name="onboarding_button_backup_card_format">掃描卡片 #%d</string>
<string name="onboarding_button_backup_now">立即備份</string>
@ -294,7 +295,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">包含費用</string>
<string name="send_fee_label">費用</string>
<string name="send_fee_picker_low"></string>
<string name="send_fee_picker_normal">正常</string>
<string name="send_fee_picker_priority">優先</string>

View file

@ -64,6 +64,7 @@
<string name="common_approval">Approval</string>
<string name="common_attention">Attention</string>
<string name="common_balance">Balance: %s</string>
<string name="common_balance_title">Balance</string>
<string name="common_biometric_authentication">biometric authentication</string>
<string name="common_biometrics">biometrics</string>
<string name="common_buy">Buy</string>
@ -84,8 +85,10 @@
<string name="common_error">Error</string>
<string name="common_exchange">Exchange</string>
<string name="common_explore">Explore</string>
<string name="common_explore_history">Explore history</string>
<string name="common_explore_transaction_history">Explore transaction history</string>
<string name="common_explorer">Explorer</string>
<string name="common_fee_label">Fee</string>
<string name="common_fee_selector_footer">Network transaction fees are used to support network security, incentivize validators, allocate resources, and determine transaction priority.</string>
<string name="common_fee_selector_option_custom">Custom</string>
<string name="common_fee_selector_option_fast">Fast</string>
@ -185,14 +188,46 @@
<string name="disclaimer_title">Terms of Service</string>
<string name="error_update_app">Oops, the current version of the application is not ready to work with this card, please check for updates.</string>
<string name="error_wrong_wallet_tapped">You have used a card from another wallet. Tap the card associated with this wallet</string>
<string name="exchange_receive_view_header">You Receive</string>
<string name="exchange_send_view_header">You Send</string>
<string name="exchange_receive_view_header">You receive</string>
<string name="exchange_send_view_header">You send</string>
<string name="exchange_tokens_available_tokens_header">My tokens</string>
<string name="exchange_tokens_empty_tokens">You don\'t have any added tokens yet. Add tokens via Market to swap</string>
<string name="exchange_tokens_unavailable_tokens_header">Unavailable for swap from %s</string>
<string name="express_choose_providers_subtitle">Providers facilitate transactions, ensuring smooth and efficient token exchanges</string>
<string name="express_choose_providers_title">Choose Provider</string>
<string name="express_provider_best_rate">Best Rate</string>
<string name="exchange_tokens_empty_tokens">You haven\'t added any tokens yet. Add tokens via Market to swap</string>
<string name="exchange_tokens_unavailable_tokens_header">Cannot be swapped for %s</string>
<string name="express_cex_status_button_title">Status</string>
<string name="express_choose_providers_subtitle">Providers facilitate transactions, ensuring smooth and efficient token swaps</string>
<string name="express_choose_providers_title">Choose provider</string>
<string name="express_estimated_amount">Estimated amount</string>
<string name="express_exchange_by">Exchange by %s</string>
<string name="express_exchange_notification_failed_text">Visit providers website to refund your money</string>
<string name="express_exchange_notification_failed_title">Operation failed by provider</string>
<string name="express_exchange_notification_verification_text">Visit providers website for verification</string>
<string name="express_exchange_notification_verification_title">KYC verification required by provider</string>
<string name="express_exchange_status_confirmed">Confirmed</string>
<string name="express_exchange_status_confirming">Confirming</string>
<string name="express_exchange_status_confirming_active">Confirming…</string>
<string name="express_exchange_status_exchanged">Exchanged</string>
<string name="express_exchange_status_exchanging">Exchanging</string>
<string name="express_exchange_status_exchanging_active">Exchanging…</string>
<string name="express_exchange_status_failed">Failed</string>
<string name="express_exchange_status_received">Deposit received</string>
<string name="express_exchange_status_receiving">Awaiting deposit</string>
<string name="express_exchange_status_receiving_active">Awaiting deposit…</string>
<string name="express_exchange_status_refunded">Refunded</string>
<string name="express_exchange_status_sending">Sending to you</string>
<string name="express_exchange_status_sending_active">Sending to you…</string>
<string name="express_exchange_status_sent">Sent</string>
<string name="express_exchange_status_subtitle">Provider-sourced data. Estimated amount subject to change.</string>
<string name="express_exchange_status_title">Exchange status</string>
<string name="express_exchange_status_verified">Verified</string>
<string name="express_exchange_status_verifying">Verification required</string>
<string name="express_exchange_token_list_subtitle">List of all tokens added to your wallet</string>
<string name="express_fetch_best_rates">Fetching best rates...</string>
<string name="express_floating_rate">Floating rate</string>
<string name="express_go_to_provider">Go to provider</string>
<string name="express_provider">Provider</string>
<string name="express_provider_best_rate">Best rate</string>
<string name="express_provider_min_amount">Available from %s</string>
<string name="express_provider_not_available">Unavailable for this pair</string>
<string name="express_provider_permission_needed">Permission Needed</string>
<string name="feedback_data_collection_message">The following information is optional. You can erase it if you don\'t want to share it.</string>
<string name="feedback_preface_rate_negative">Tell us what functions you are missing, and we will try to help you.</string>
@ -267,7 +302,6 @@
<string name="onboarding_access_codes_doesnt_match">Entered access code didn\'t match the initial access code</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">You\'ve added one backup card. When backup process is finished you can\'t add more backup cards. If you have one more card, add it to backup. Do you like to continue the backup process?</string>
<string name="onboarding_backup_exit_warning">The backup process is partly complete. You can\'t exit it now.</string>
<string name="onboarding_balance_title">Balance</string>
<string name="onboarding_button_add_backup_card">Add a backup card</string>
<string name="onboarding_button_backup_card_format">Scan the card #%d</string>
<string name="onboarding_button_backup_now">Backup now</string>
@ -413,9 +447,7 @@
<string name="send_extras_error_invalid_memo">Invalid Memo. It won\'t be added to the transaction.</string>
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_insufficient_funds">Insufficient funds for transfer</string>
<string name="send_fee_include_description">Include fee</string>
<string name="send_fee_label">Fee</string>
<string name="send_fee_picker_low">Low</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priority</string>
@ -490,7 +522,7 @@
<string name="story_meet_title">Meet Tangem</string>
<string name="story_web3_description">Exchange, buy NFT\'s, make loans and deposits in more than 100 different decentralized services</string>
<string name="story_web3_title">Web 3.0 Compatible</string>
<string name="swapping_approve_information_text">Approvals are considered an industry standard across all decentralized exchanges and protect your wallet from being accessed by a smart contract without your permission. By design, smart contracts can\'t access your tokens unless you approve access from your end. By \"unlocking\" your tokens, you are give permission to the 1inch smart contract to spend your assets. The miners of the network are compensated with a gas fee (paid by you) to record this action on the blockchain. Once permission has been granted you will be able to swap your token.</string>
<string name="swapping_approve_information_text">All decentralized exchanges require approvals to prevent smart contracts from accessing your wallet without your permission. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the 1-inch smart contract to spend them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can swap your token after giving approval.</string>
<string name="swapping_approve_information_title">Approve</string>
<string name="swapping_error_wrapper">Error: %s</string>
<string name="swapping_generic_error">There was an error. Please try again.</string>
@ -498,23 +530,25 @@
<string name="swapping_high_price_impact">High price impact!</string>
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
<string name="swapping_not_enough_funds_for_fee">Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first.</string>
<string name="swapping_not_enough_funds_for_fee">Insufficient funds in your %1$s wallet to cover fees. Top up your %2$s wallet first.</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress...</string>
<string name="swapping_pending_transaction_title">Waiting</string>
<string name="swapping_permission_buttons_approve">Approve</string>
<string name="swapping_permission_current_transaction">Current transaction</string>
<string name="swapping_permission_fee_footer">The token approval network fee will be charged to confirm that you are the one allowing your token to be used for the exchange.</string>
<string name="swapping_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap.</string>
<string name="swapping_permission_header">Give Permission</string>
<string name="swapping_permission_policy_type_footer">Specify the approve limit for the selected token</string>
<string name="swapping_permission_rows_amount">Amount %s</string>
<string name="swapping_permission_rows_spender">Spender</string>
<string name="swapping_permission_rows_your_wallet">Your Wallet</string>
<string name="swapping_permission_subheader">To continue you need to allow 1inch smart contracts to use your %s</string>
<string name="swapping_permission_rows_your_wallet">Your wallet</string>
<string name="swapping_permission_subheader">To continue, grant 1inch smart contracts permission to use your %s</string>
<string name="swapping_permission_unlimited">Unlimited</string>
<string name="swapping_success_from_title">You swap</string>
<string name="swapping_success_to_title">You receive</string>
<string name="swapping_success_view_explorer_button_title">View in Explorer</string>
<string name="swapping_success_view_title">In progress</string>
<string name="swapping_swap_action">Swap</string>
<string name="swapping_swap_of_to">Swap of %s to</string>
<string name="swapping_swap_of_to">Swap %s for</string>
<string name="swapping_tangem_fee_disclaimer">Quotes include an additional Tangem commission of %s. This helps us deliver a top-of-the-line product.</string>
<string name="swapping_token_list_other_tokens">Other tokens</string>
<string name="swapping_token_list_title">Choose token</string>
@ -636,12 +670,20 @@
<string name="warning_button_like_it">Like it</string>
<string name="warning_button_ok">Ok, Got it!</string>
<string name="warning_button_really_cool">Really cool!</string>
<string name="warning_button_refresh">Refresh</string>
<string name="warning_demo_mode_message">You are currently in the Demo mode</string>
<string name="warning_demo_mode_title">Demo mode active</string>
<string name="warning_developer_card_message">The card you scanned is a developer card. Do not use it to create your wallet.</string>
<string name="warning_developer_card_title">Not for users!</string>
<string name="warning_existential_deposit_message">%1$s network requires an Existential Deposit. If your account drops below %2$s, it will be deactivated, and any remaining funds will be destroyed.</string>
<string name="warning_existential_deposit_title">Network requires Existential Deposit</string>
<string name="warning_express_no_exchangeable_coins_description">You do not have any %s exchangeable coins in your list</string>
<string name="warning_express_no_exchangeable_coins_title">No available tokens to swap</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">To make a transaction you need to deposit some %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Unable to cover %s fee</string>
<string name="warning_express_refresh_required_title">Service temporary unavailable</string>
<string name="warning_express_too_minimal_amount_description">Please change the amount to swap</string>
<string name="warning_express_too_minimal_amount_title">The amount to swap must be at least %s</string>
<string name="warning_failed_to_verify_card_message">This card might be a production sample or counterfeit</string>
<string name="warning_failed_to_verify_card_title">Authenticity check failed</string>
<string name="warning_low_signatures_message">Only %s signatures are left on this card. You must withdraw all of your funds.</string>

View file

@ -13,6 +13,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@ -162,11 +163,12 @@ fun CardWithIcon(
* >Figma component</a>
*/
@Composable
fun IconWithTitleAndDescription(
internal fun IconWithTitleAndDescription(
title: String,
description: String,
description: String?,
icon: @Composable () -> Unit,
additionalContent: @Composable () -> Unit = {},
iconBackground: Color = TangemTheme.colors.background.secondary,
) {
Row(
modifier = Modifier
@ -182,7 +184,7 @@ fun IconWithTitleAndDescription(
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.secondary,
color = iconBackground,
shape = CircleShape,
)
.height(TangemTheme.dimens.size40)
@ -205,12 +207,14 @@ fun IconWithTitleAndDescription(
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
)
SpacerH4()
Text(
text = description,
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
)
if (description != null) {
SpacerH4()
Text(
text = description,
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
)
}
}
additionalContent()

View file

@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.text.TextLayoutResult
@ -18,10 +17,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import com.tangem.core.ui.components.atoms.text.BoundCounter
/**
* https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose
*/
@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead")
@Suppress("LongMethod")
@Composable
fun MiddleEllipsisText(
@ -138,38 +139,4 @@ fun MiddleEllipsisText(
private const val ELLIPSIS_CHARACTERS_COUNT = 3
private const val ELLIPSIS_CHARACTER = '.'
private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "")
private class BoundCounter(
private val text: String,
private val textLayoutResult: TextLayoutResult,
private val charPosition: (Int) -> Int,
) {
var string = ""
private set
var width = 0f
private set
private var _nextCharWidth: Float? = null
private var invalidCharsCount = 0
fun widthWithNextChar(): Float = width + nextCharWidth()
private fun nextCharWidth(): Float = _nextCharWidth ?: run {
var boundingBox: Rect
// invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630
invalidCharsCount--
do {
boundingBox = textLayoutResult
.getBoundingBox(charPosition(string.count() + ++invalidCharsCount))
} while (boundingBox.right == 0f)
_nextCharWidth = boundingBox.width
boundingBox.width
}
fun addNextChar() {
string += text[charPosition(string.count())]
width += nextCharWidth()
_nextCharWidth = null
}
}
private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "")

View file

@ -1,181 +0,0 @@
package com.tangem.core.ui.components
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
/**
* Screen for showing result
*
* @param resultMessage message to show
* @param title title to show
* @param resultColor color which will tint the round icon of the result
* @param icon icon to show in the middle of the round icon
* @param secondaryButtonIcon icon to show in the secondary button
* @param secondaryButtonText label of the secondary button
* @param onSecondaryButtonClick action on clicking secondary button
* @param onButtonClick action on clicking "Done" button
*
* @see <a href =
* "https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?node-id=1123%3A3863&t=wwR84h5IsMaMsDhq-1"
* >Figma component</a>
*/
@Composable
fun ResultScreenContent(
resultMessage: AnnotatedString,
onButtonClick: () -> Unit,
modifier: Modifier = Modifier,
@StringRes title: Int = R.string.common_success,
resultColor: Color = TangemTheme.colors.icon.accent,
@DrawableRes icon: Int = R.drawable.ic_check_24,
@DrawableRes secondaryButtonIcon: Int? = null,
@StringRes secondaryButtonText: Int? = null,
onSecondaryButtonClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(TangemTheme.colors.background.secondary)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing32,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
SpacerHHalf()
SuccessImage(resultColor = resultColor, icon = icon)
SpacerH50()
Text(
text = stringResource(id = title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH12()
Text(
text = resultMessage,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerHHalf()
if (onSecondaryButtonClick != null && secondaryButtonText != null) {
SecondaryButtonForResultScreen(
secondaryButtonText = secondaryButtonText,
secondaryButtonIcon = secondaryButtonIcon,
onSecondaryButtonClick = onSecondaryButtonClick,
)
SpacerH12()
}
PrimaryButton(
text = stringResource(id = R.string.common_close),
modifier = Modifier
.fillMaxWidth(),
onClick = { onButtonClick() },
)
}
}
@Composable
fun SuccessImage(resultColor: Color, @DrawableRes icon: Int) {
Box(
modifier = Modifier
.background(
color = resultColor.copy(alpha = 0.2f),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.padding(TangemTheme.dimens.spacing24)
.background(
color = resultColor,
shape = CircleShape,
)
.height(TangemTheme.dimens.size93)
.width(TangemTheme.dimens.size93),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = icon),
contentDescription = null,
tint = TangemTheme.colors.icon.primary2,
modifier = Modifier.size(TangemTheme.dimens.size40),
)
}
}
}
@Composable
private fun SecondaryButtonForResultScreen(
@StringRes secondaryButtonText: Int,
onSecondaryButtonClick: () -> Unit,
@DrawableRes secondaryButtonIcon: Int? = null,
) {
if (secondaryButtonIcon != null) {
SecondaryButtonIconStart(
text = stringResource(id = secondaryButtonText),
iconResId = secondaryButtonIcon,
onClick = onSecondaryButtonClick,
modifier = Modifier.fillMaxWidth(),
)
} else {
SecondaryButton(
text = stringResource(id = secondaryButtonText),
onClick = onSecondaryButtonClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
// region preview
@Composable
private fun SuccessScreenPreview() {
ResultScreenContent(
resultMessage = AnnotatedString("Swap of 1 000 DAI to 1 131,46 MATIC"),
secondaryButtonText = R.string.swapping_success_view_explorer_button_title,
onSecondaryButtonClick = {},
onButtonClick = {},
)
}
@Preview(showBackground = true)
@Composable
private fun Preview_SuccessScreenContent_InLightTheme() {
TangemTheme(isDark = false) {
SuccessScreenPreview()
}
}
@Preview(showBackground = true)
@Composable
private fun Preview_SuccessScreenContent_InDarkTheme() {
TangemTheme(isDark = true) {
SuccessScreenPreview()
}
}
// endregion preview

View file

@ -23,7 +23,7 @@ import com.tangem.core.ui.res.TangemTheme
@Composable
fun TangemSwitch(
onCheckedChange: (Boolean) -> Unit,
checkedColor: Color = TangemTheme.colors.icon.accent,
checkedColor: Color = TangemTheme.colors.control.checked,
uncheckedColor: Color = TangemTheme.colors.icon.informative,
size: Dp = 48.dp,
checked: Boolean = false,

View file

@ -9,6 +9,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
@ -36,6 +37,28 @@ fun WarningCard(title: String, description: String, icon: @Composable (() -> Uni
)
}
/**
* A card with a warning icon to the left and title without description shown to the right of it.
*
* @param title title of the warning in bold
*
* @see <a href = "https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?node-id=290%3A217&t=yMepJZTRh5bLkOoJ-1"
* >Figma component</a>
*/
@Composable
fun WarningCardTitleOnly(title: String, icon: @Composable (() -> Unit)? = null) {
WarningCardMaterial3Style(
content = {
WarningBody(
title = title,
description = null,
icon = icon,
iconBackground = TangemTheme.colors.button.disabled,
)
},
)
}
/**
* [WarningCard], but clickable (with an 'greater then' icon to the left)
*
@ -105,7 +128,8 @@ fun RefreshableWaringCard(
@Composable
private fun WarningBody(
title: String,
description: String,
description: String?,
iconBackground: Color = TangemTheme.colors.background.secondary,
icon: @Composable (() -> Unit)? = null,
additionalContent: @Composable () -> Unit = {},
) {
@ -119,6 +143,7 @@ private fun WarningBody(
contentDescription = null,
)
},
iconBackground = iconBackground,
)
}
@ -136,6 +161,20 @@ private fun WarningCardSurface(onClick: (() -> Unit)? = null, content: @Composab
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun WarningCardMaterial3Style(onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
Card(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
backgroundColor = TangemTheme.colors.button.disabled,
elevation = TangemTheme.dimens.elevation0,
onClick = onClick ?: {},
enabled = onClick != null,
) {
content()
}
}
// endregion elements
// region Preview

View file

@ -3,14 +3,13 @@ package com.tangem.core.ui.components.appbar
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -49,7 +48,11 @@ fun AppBarWithBackButton(
contentDescription = null,
modifier = Modifier
.size(size = TangemTheme.dimens.size24)
.clickable { onBackClick() },
.clickable(
indication = rememberRipple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
onClick = onBackClick,
),
tint = TangemTheme.colors.icon.primary1,
)
if (!text.isNullOrBlank()) {

View file

@ -4,10 +4,13 @@ import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -39,7 +42,10 @@ fun AppBarWithBackButtonAndIcon(
contentDescription = null,
modifier = Modifier
.size(size = TangemTheme.dimens.size24)
.clickable { onBackClick() },
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) { onBackClick() },
tint = TangemTheme.colors.icon.primary1,
)
AnimatedContent(

View file

@ -0,0 +1,38 @@
package com.tangem.core.ui.components.atoms.text
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.TextLayoutResult
internal class BoundCounter(
private val text: String,
private val textLayoutResult: TextLayoutResult,
private val charPosition: (Int) -> Int,
) {
var string = ""
private set
var width = 0f
private set
private var _nextCharWidth: Float? = null
private var invalidCharsCount = 0
fun widthWithNextChar(): Float = width + nextCharWidth()
private fun nextCharWidth(): Float = _nextCharWidth ?: run {
var boundingBox: Rect
// invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630
invalidCharsCount--
do {
boundingBox = textLayoutResult
.getBoundingBox(charPosition(string.count() + ++invalidCharsCount))
} while (boundingBox.right == 0f)
_nextCharWidth = boundingBox.width
boundingBox.width
}
fun addNextChar() {
string += text[charPosition(string.count())]
width += nextCharWidth()
_nextCharWidth = null
}
}

View file

@ -0,0 +1,242 @@
package com.tangem.core.ui.components.atoms.text
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import com.tangem.core.ui.res.TangemTheme
sealed class TextEllipsis {
object Middle : TextEllipsis()
object End : TextEllipsis()
data class OffsetEnd(
val offsetEnd: Int = 0,
val hasSeparator: Boolean = true,
) : TextEllipsis()
}
/**
* https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose
*
* Customized Text with ellipsis. Ellipsis can be placed in: Middle, End or OffsetEnd (OffsetEnd with separator).
*
* * OffsetEnd can be useful to display big amounts with currency symbol. OffsetEnd 0 is equal to End.
*/
@Suppress("LongMethod")
@Composable
fun EllipsisText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
ellipsis: TextEllipsis = TextEllipsis.End,
) {
val ellipsisText = remember(text) {
if (ellipsis is TextEllipsis.OffsetEnd && ellipsis.hasSeparator) {
ELLIPSIS_TEXT_WITH_SEPARATOR
} else {
ELLIPSIS_TEXT
}
}
// some letters, like "r", will have less width when placed right before "."
// adding a space to prevent such case
val layoutText = remember(text) { "$text $ellipsisText" }
val textLayoutResultState = remember(layoutText) {
mutableStateOf<TextLayoutResult?>(null)
}
SubcomposeLayout(modifier) { constraints ->
// result is ignored - we only need to fill our textLayoutResult
subcompose("measure") {
Text(
text = layoutText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
maxLines = 1,
onTextLayout = { textLayoutResultState.value = it },
style = style,
)
}.first().measure(Constraints())
// to allow smart cast
val textLayoutResult = textLayoutResultState.value
?: // shouldn't happen - onTextLayout is called before subcompose finishes
return@SubcomposeLayout layout(0, 0) {}
val placeable = subcompose("visible") {
val finalText = remember(text, textLayoutResult, constraints.maxWidth) {
if (
text.isEmpty() ||
textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth
) {
// text not including ellipsis fits on the first line.
return@remember text
}
var ellipsisWidth = 0f
layoutText.indices.toList()
.takeLast(ellipsisText.length)
.forEach widthLet@{
ellipsisWidth += textLayoutResult.getBoundingBox(it).width
}
val availableWidth = constraints.maxWidth - ellipsisWidth
val startCounter = BoundCounter(text, textLayoutResult) { it }
val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it }
when (ellipsis) {
TextEllipsis.Middle -> {
middleEllipsisText(
availableWidth,
startCounter,
endCounter,
)
}
TextEllipsis.End -> {
offsetEndEllipsisText(
availableWidth = availableWidth,
startCounter = startCounter,
endCounter = endCounter,
)
}
is TextEllipsis.OffsetEnd -> {
offsetEndEllipsisText(
availableWidth = availableWidth,
startCounter = startCounter,
endCounter = endCounter,
offsetEnd = ellipsis.offsetEnd,
withSeparator = ellipsis.hasSeparator,
)
}
}
}
Text(
text = finalText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
onTextLayout = onTextLayout,
style = style,
)
}[0].measure(constraints)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
private const val ELLIPSIS_SEPARATOR = ""
private const val ELLIPSIS_TEXT = "..."
private const val ELLIPSIS_TEXT_WITH_SEPARATOR = ELLIPSIS_TEXT.plus(ELLIPSIS_SEPARATOR)
private fun middleEllipsisText(availableWidth: Float, startCounter: BoundCounter, endCounter: BoundCounter): String {
while (availableWidth - startCounter.width - endCounter.width > 0) {
val possibleEndWidth = endCounter.widthWithNextChar()
if (
startCounter.width >= possibleEndWidth &&
availableWidth - startCounter.width - possibleEndWidth >= 0
) {
endCounter.addNextChar()
} else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
startCounter.addNextChar()
} else {
break
}
}
return startCounter.string.trimEnd() + ELLIPSIS_TEXT + endCounter.string.reversed().trimStart()
}
private fun offsetEndEllipsisText(
availableWidth: Float,
startCounter: BoundCounter,
endCounter: BoundCounter,
offsetEnd: Int = 0,
withSeparator: Boolean = false,
): String {
while (availableWidth - startCounter.width - endCounter.width > 0) {
val possibleEndWidth = endCounter.widthWithNextChar()
if (
offsetEnd > endCounter.string.length &&
availableWidth - startCounter.width - possibleEndWidth >= 0
) {
endCounter.addNextChar()
} else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
startCounter.addNextChar()
} else {
break
}
}
val ellipsis = if (withSeparator) ELLIPSIS_TEXT_WITH_SEPARATOR else ELLIPSIS_TEXT
return startCounter.string.trimEnd() + ellipsis + endCounter.string.reversed().trimStart()
}
//region Preview
@Preview(widthDp = 200)
@Composable
private fun EllipsisTexPreview(@PreviewParameter(EllipsisTexPreviewParameterProvider::class) ellipsis: TextEllipsis) {
TangemTheme {
EllipsisText(
text = "11111111111111111111111111111111111111111111111111 END",
ellipsis = ellipsis,
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.fillMaxWidth(),
)
}
}
private class EllipsisTexPreviewParameterProvider : PreviewParameterProvider<TextEllipsis> {
override val values: Sequence<TextEllipsis>
get() = sequenceOf(
TextEllipsis.Middle,
TextEllipsis.End,
TextEllipsis.OffsetEnd("TEXT".length),
TextEllipsis.OffsetEnd("TEXT".length, false),
)
}
//endregion

View file

@ -6,6 +6,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.res.TangemTheme
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@ -20,6 +21,7 @@ import kotlinx.coroutines.launch
@Composable
inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet(
config: TangemBottomSheetConfig,
contentColor: Color = TangemTheme.colors.background.primary,
crossinline content: @Composable ColumnScope.(T) -> Unit,
) {
var isVisible by remember { mutableStateOf(value = config.isShow) }
@ -29,9 +31,9 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet(
ModalBottomSheet(
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = TangemTheme.colors.background.primary,
containerColor = contentColor,
shape = TangemTheme.shapes.bottomSheetLarge,
dragHandle = { TangemBottomSheetDraggableHeader() },
dragHandle = { TangemBottomSheetDraggableHeader(contentColor) },
) {
content(config.content)
}

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.components.fields
package com.tangem.core.ui.components.fields.visualtransformations
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString

View file

@ -0,0 +1,162 @@
package com.tangem.core.ui.components.inputrow
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* [Input Row Approx](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2207-810&mode=design&t=fM1ZU6zQF6g3CaTv-4)
*
* @param leftIcon left token state
* @param leftTitle left token title
* @param leftSubtitle left token subtitle
* @param rightIcon right token state
* @param rightTitle right token title
* @param rightSubtitle right token subtitle
* @param modifier composable modifier
* @param showDivider show divider
*/
@Suppress("LongParameterList")
@Composable
fun InputRowApprox(
leftIcon: TokenIconState,
leftTitle: TextReference,
leftSubtitle: TextReference,
rightIcon: TokenIconState,
rightTitle: TextReference,
rightSubtitle: TextReference,
modifier: Modifier = Modifier,
leftTitleEllipsisOffset: Int = 0,
rightTitleEllipsisOffset: Int = 0,
showDivider: Boolean = false,
) {
DividerContainer(
showDivider = showDivider,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(TangemTheme.dimens.spacing12)
.fillMaxWidth(),
) {
InputRowApproxItem(
iconState = leftIcon,
title = leftTitle,
subtitle = leftSubtitle,
titleEllipsisOffset = leftTitleEllipsisOffset,
modifier = Modifier.weight(1f),
)
Icon(
painter = painterResource(id = R.drawable.ic_approx_24),
contentDescription = null,
tint = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.spacing4,
vertical = TangemTheme.dimens.spacing10,
),
)
InputRowApproxItem(
iconState = rightIcon,
title = rightTitle,
subtitle = rightSubtitle,
titleEllipsisOffset = rightTitleEllipsisOffset,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun InputRowApproxItem(
iconState: TokenIconState,
title: TextReference,
subtitle: TextReference,
modifier: Modifier = Modifier,
titleEllipsisOffset: Int = 0,
) {
Row(
modifier = modifier,
) {
TokenIcon(
state = iconState,
modifier = Modifier
.size(TangemTheme.dimens.size36),
)
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
),
) {
EllipsisText(
text = title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
ellipsis = TextEllipsis.OffsetEnd(titleEllipsisOffset),
)
EllipsisText(
text = subtitle.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2),
)
}
}
}
//region Preview
@Preview
@Composable
private fun InputRowApproxPreview_Light() {
TangemTheme {
InputRowApprox(
leftIcon = TokenIconState.Loading,
leftTitle = TextReference.Str("Left title USD"),
leftSubtitle = TextReference.Str("Left subtitle USD"),
leftTitleEllipsisOffset = 3,
rightIcon = TokenIconState.Loading,
rightTitle = TextReference.Str("Right title Right title Right title Right title Right title USD"),
rightSubtitle = TextReference.Str("Right subtitle Right subtitle Right subtitle USD"),
rightTitleEllipsisOffset = 3,
modifier = Modifier
.background(TangemTheme.colors.background.action),
)
}
}
@Preview
@Composable
private fun InputRowApproxPreview_Dark() {
TangemTheme(isDark = true) {
InputRowApprox(
leftIcon = TokenIconState.Loading,
leftTitle = TextReference.Str("Left title Left title Left title Left title Left title USD"),
leftSubtitle = TextReference.Str("Left subtitle Left subtitle Left subtitle USD"),
leftTitleEllipsisOffset = 3,
rightIcon = TokenIconState.Loading,
rightTitle = TextReference.Str("Right title USD"),
rightSubtitle = TextReference.Str("Right subtitle USD"),
rightTitleEllipsisOffset = 3,
modifier = Modifier
.background(TangemTheme.colors.background.action),
)
}
}
//endregion

View file

@ -0,0 +1,221 @@
package com.tangem.core.ui.components.inputrow
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.currency.tokenicon.LoadingIcon
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* [Input Row Best Rate](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-889&mode=dev)
*
* @param imageUrl image source url
* @param title title
* @param titleExtra title extra
* @param subtitle subtitle
* @param modifier composable modifier
* @param showTag show tag
* @param showDivider show divider
* @param onIconClick icon click
*/
@Composable
fun InputRowBestRate(
imageUrl: String,
title: TextReference,
titleExtra: TextReference,
subtitle: TextReference,
modifier: Modifier = Modifier,
showTag: Boolean = false,
showDivider: Boolean = false,
onIconClick: (() -> Unit)? = null,
) {
DividerContainer(
showDivider = showDivider,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(TangemTheme.dimens.spacing12),
) {
InnerIcon(imageUrl = imageUrl)
Column(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12),
) {
InnerTitle(
title = title,
titleExtra = titleExtra,
showTag = showTag,
)
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
)
}
SpacerWMax()
onIconClick?.let {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing10)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = onIconClick,
),
)
}
}
}
}
@Composable
private fun InnerTitle(title: TextReference, titleExtra: TextReference, showTag: Boolean = false) {
Row {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = titleExtra.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing4),
)
if (showTag) {
Text(
text = stringResource(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing4)
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = RoundedCornerShape(TangemTheme.dimens.radius20),
)
.padding(horizontal = TangemTheme.dimens.spacing6),
)
}
}
}
@Composable
private fun InnerIcon(imageUrl: String) {
SubcomposeAsyncImage(
modifier = Modifier.size(TangemTheme.dimens.size40),
model = ImageRequest.Builder(context = LocalContext.current)
.data(imageUrl)
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = { LoadingIcon() },
error = {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.tertiary,
shape = CircleShape,
),
)
},
contentDescription = null,
)
}
//region preview
@Preview
@Composable
private fun InputRowBestRatePreview_Light(
@PreviewParameter(InputRowBestRatePreviewDataProvider::class) data: InputRowBestRatePreviewData,
) {
TangemTheme {
InputRowBestRate(
imageUrl = "",
title = data.title,
titleExtra = data.titleExtra,
subtitle = data.subtitle,
showTag = data.showTag,
onIconClick = data.iconClick,
modifier = Modifier
.background(TangemTheme.colors.background.action),
)
}
}
@Preview
@Composable
private fun InputRowBestRatePreview_Dark(
@PreviewParameter(InputRowBestRatePreviewDataProvider::class) data: InputRowBestRatePreviewData,
) {
TangemTheme(isDark = true) {
InputRowBestRate(
imageUrl = "",
title = data.title,
titleExtra = data.titleExtra,
subtitle = data.subtitle,
showTag = data.showTag,
onIconClick = data.iconClick,
modifier = Modifier
.background(TangemTheme.colors.background.action),
)
}
}
private data class InputRowBestRatePreviewData(
val title: TextReference,
val titleExtra: TextReference,
val showTag: Boolean,
val subtitle: TextReference,
val iconClick: (() -> Unit)?,
)
private class InputRowBestRatePreviewDataProvider : PreviewParameterProvider<InputRowBestRatePreviewData> {
override val values: Sequence<InputRowBestRatePreviewData>
get() = sequenceOf(
InputRowBestRatePreviewData(
title = TextReference.Str("1inch"),
titleExtra = TextReference.Str("DEX"),
subtitle = TextReference.Str("0,64554846 DAI ≈ 1 MATIC "),
showTag = true,
iconClick = {},
),
InputRowBestRatePreviewData(
title = TextReference.Str("ChangeNow"),
titleExtra = TextReference.Str("CEX"),
subtitle = TextReference.Str("0,64554846 DAI ≈ 1 MATIC "),
showTag = false,
iconClick = null,
),
)
}
//endregion

View file

@ -8,7 +8,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -42,8 +44,18 @@ import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsSta
* >Figma component</a>
*/
@Composable
fun Notification(config: NotificationConfig, modifier: Modifier = Modifier, iconTint: Color? = null) {
BaseContainer(buttonsState = config.buttonsState, onClick = config.onClick, modifier = modifier) {
fun Notification(
config: NotificationConfig,
modifier: Modifier = Modifier,
containerColor: Color? = null,
iconTint: Color? = null,
) {
BaseContainer(
buttonsState = config.buttonsState,
onClick = config.onClick,
modifier = modifier,
containerColor = containerColor,
) {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12),
@ -71,9 +83,10 @@ private fun BaseContainer(
buttonsState: NotificationConfig.ButtonsState?,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
containerColor: Color? = null,
content: @Composable BoxScope.() -> Unit,
) {
val containerColor by rememberUpdatedState(
val tempContainerColor by rememberUpdatedState(
newValue = if (buttonsState != null || onClick != null) {
TangemTheme.colors.background.primary
} else {
@ -88,7 +101,7 @@ private fun BaseContainer(
.fillMaxWidth(),
enabled = onClick != null,
shape = TangemTheme.shapes.roundedCornersXMedium,
color = containerColor,
color = containerColor ?: tempContainerColor,
) {
Box(content = content)
}

View file

@ -0,0 +1,85 @@
package com.tangem.core.ui.components.rows
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH28
import com.tangem.core.ui.res.TangemTheme
/**
* Simple clickable action row, without input and icon
*
* https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-807&mode=design&t=Ygv5sohTTHYAQcBS-4
*/
@Composable
fun SimpleActionRow(title: String, description: String, modifier: Modifier = Modifier, isClickable: Boolean = true) {
Box(
modifier = modifier
.background(color = TangemTheme.colors.background.action)
.height(TangemTheme.dimens.size44)
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing48)
.align(Alignment.CenterStart),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
AnimatedContent(targetState = title, label = "") {
Text(
text = it,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
}
AnimatedContent(targetState = description, label = "") {
Text(
text = it,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
if (isClickable) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
modifier = Modifier
.align(alignment = Alignment.CenterEnd)
.padding(end = TangemTheme.dimens.spacing12),
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Preview
@Composable
private fun SimpleActionRowPreview() {
Column {
TangemTheme(isDark = false) {
SimpleActionRow(
title = "Title",
description = "Description",
)
}
SpacerH28()
TangemTheme(isDark = false) {
SimpleActionRow(
title = "Title",
description = "Description",
)
}
}
}

View file

@ -0,0 +1,160 @@
package com.tangem.core.ui.components.rows
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@Composable
fun SelectorRowItem(
@StringRes titleRes: Int,
@DrawableRes iconRes: Int,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
preEllipsize: TextReference? = null,
postEllipsize: TextReference? = null,
isSelected: Boolean = false,
showDivider: Boolean = true,
) {
val iconTint by animateColorAsState(
targetValue = if (isSelected) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.icon.informative
},
label = "Selector icon tint change",
)
val textStyle = if (isSelected) {
TangemTheme.typography.subtitle2
} else {
TangemTheme.typography.body2
}
Box(
modifier = modifier
.fillMaxWidth()
.clickable { onSelect() },
) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
Text(
text = stringResource(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
if (preEllipsize != null && postEllipsize != null) {
SelectorValueContent(
amount = preEllipsize,
symbol = postEllipsize,
textStyle = textStyle,
)
}
}
if (showDivider) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size1)
.padding(horizontal = TangemTheme.dimens.spacing12)
.background(TangemTheme.colors.stroke.primary)
.align(Alignment.BottomCenter),
)
}
}
}
@Composable
private fun RowScope.SelectorValueContent(amount: TextReference, symbol: TextReference, textStyle: TextStyle) {
Text(
text = amount.resolveReference(),
style = textStyle,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier
.weight(1f)
.padding(
start = TangemTheme.dimens.spacing4,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
Text(
text = symbol.resolveReference(),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing1,
end = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
}
@Preview
@Composable
private fun SelectorRowItemPreview_Light() {
TangemTheme {
SelectorRowItem(
titleRes = R.string.common_fee_selector_option_slow,
iconRes = R.drawable.ic_tortoise_24,
preEllipsize = TextReference.Str("1000"),
postEllipsize = TextReference.Str("$"),
isSelected = true,
onSelect = { },
)
}
}
@Preview
@Composable
private fun SelectorRowItemPreview_Dark() {
TangemTheme(isDark = true) {
SelectorRowItem(
titleRes = R.string.common_fee_selector_option_slow,
iconRes = R.drawable.ic_tortoise_24,
preEllipsize = TextReference.Str("1000"),
postEllipsize = TextReference.Str("$"),
isSelected = true,
onSelect = { },
)
}
}

View file

@ -0,0 +1,87 @@
package com.tangem.core.ui.components.transactions
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toTimeFormat
/**
* Common transaction done screen title
*
* @param titleRes title resource
* @param date transaction timestamp in millis
*/
@Composable
fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_empty_in_process_64),
contentDescription = null,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size64),
)
Text(
text = stringResource(id = titleRes),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32),
)
Text(
text = stringResource(id = R.string.send_date_format, date.toDateFormat(), date.toTimeFormat()),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing4),
)
}
}
// region Previews
@Preview
@Composable
private fun TransactionDoneTitlePreview_Light() {
TangemTheme {
TransactionDoneTitle(
titleRes = R.string.sent_transaction_sent_title,
date = 0,
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
)
}
}
@Preview
@Composable
private fun TransactionDoneTitlePreview_Dark() {
TangemTheme(isDark = true) {
TransactionDoneTitle(
titleRes = R.string.sent_transaction_sent_title,
date = 0,
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
)
}
}
// endregion

View file

@ -7,18 +7,34 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import com.tangem.core.ui.res.TangemTheme
fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modifier = composed {
val modifierWithHorizontalPadding = this.padding(horizontal = TangemTheme.dimens.spacing16)
fun Modifier.roundedShapeItemDecoration(
currentIndex: Int,
lastIndex: Int,
addDefaultPadding: Boolean = true,
): Modifier = composed {
val modifier = if (addDefaultPadding) this.padding(horizontal = TangemTheme.dimens.spacing16) else this
val isSingleItem = currentIndex == 0 && lastIndex == 0
when {
isSingleItem -> {
modifierWithHorizontalPadding
.padding(top = TangemTheme.dimens.spacing14)
modifier
.then(
if (addDefaultPadding) {
Modifier.padding(top = TangemTheme.dimens.spacing14)
} else {
Modifier
},
)
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
}
currentIndex == 0 -> {
modifierWithHorizontalPadding
.padding(top = TangemTheme.dimens.spacing14)
modifier
.then(
if (addDefaultPadding) {
Modifier.padding(top = TangemTheme.dimens.spacing14)
} else {
Modifier
},
)
.clip(
shape = RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
@ -27,7 +43,7 @@ fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modi
)
}
currentIndex == lastIndex -> {
modifierWithHorizontalPadding
modifier
.clip(
shape = RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
@ -35,6 +51,6 @@ fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modi
),
)
}
else -> modifierWithHorizontalPadding
else -> modifier
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import kotlinx.coroutines.launch
/**
* A Composable function that reacts to a given [StateEvent], executing the provided action only once when the event
@ -17,8 +18,8 @@ import androidx.compose.runtime.NonRestartableComposable
fun <A> EventEffect(event: StateEvent<A>, onTrigger: suspend (data: A) -> Unit) {
LaunchedEffect(event) {
if (event is StateEvent.Triggered<A>) {
onTrigger(event.data)
event.onConsume()
launch { onTrigger(event.data) }
.invokeOnCompletion { event.onConsume() }
}
}
}

View file

@ -32,6 +32,16 @@ fun CryptoCurrency.Token.tryGetBackgroundForTokenIcon(
): Color {
if (isGrayscale) return TangemColorPalette.Dark2
return tryGetBackgroundForTokenIcon(contractAddress = contractAddress, fallbackColor = fallbackColor)
}
/**
* Tries to extract a background color from the contract address of a token.
*
* @param fallbackColor The color to use as a fallback.
* @return The extracted background color or the fallback color if extraction fails or if it is a test network token.
*/
fun tryGetBackgroundForTokenIcon(contractAddress: String, fallbackColor: Color = TangemColorPalette.Black): Color {
return try {
val colorHex = "#" + contractAddress.substring(range = COLOR_HEX_START_INDEX..COLOR_HEX_END_INDEX)
Color(colorHex.toColorInt())

View file

@ -104,6 +104,16 @@ fun combinedReference(refs: WrappedList<TextReference>): TextReference {
return TextReference.Combined(refs)
}
/**
* Combines multiple [TextReference] instances into a single [TextReference].
*
* @param refs Vararg of [TextReference] instances to be combined.
* @return A [TextReference] representing the combined text references.
*/
fun combinedReference(vararg refs: TextReference): TextReference {
return TextReference.Combined(WrappedList(listOf(*refs)))
}
/** Resolve [TextReference] as [String] */
@Composable
@ReadOnlyComposable

View file

@ -41,6 +41,7 @@ data class TangemDimens internal constructor(
val size0: Dp = 0.dp,
val size0_5: Dp = 0.5.dp,
val size1: Dp = 1.dp,
val size1_5: Dp = 1.5.dp,
val size2: Dp = 2.dp,
val size4: Dp = 4.dp,
val size5: Dp = 5.dp,

View file

@ -157,7 +157,7 @@ private fun darkThemeColors(): TangemColors {
),
button = TangemColors.Button(
primary = TangemColorPalette.Light4,
secondary = TangemColorPalette.Dark5,
secondary = TangemColorPalette.Dark4,
disabled = TangemColorPalette.Dark5,
positiveDisabled = TangemColorPalette.DarkGreen,
),

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.utils
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
@ -24,6 +25,10 @@ object BigDecimalFormatter {
return formatter.format(cryptoAmount) + "\u2009$cryptoCurrency"
}
fun formatCryptoAmount(cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency): String {
return formatCryptoAmount(cryptoAmount, cryptoCurrency.symbol, cryptoCurrency.decimals)
}
fun formatFiatAmount(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,

Some files were not shown because too many files have changed in this diff Show more