Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 16:08:06 +02:00
commit cebe0dcdf1
563 changed files with 28013 additions and 2788 deletions

View file

@ -3,7 +3,6 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
@ -12,19 +11,23 @@ android {
}
dependencies {
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Other libraries */
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Core modules */
// region Other libraries
implementation(deps.amplitude.experiment)
// endregion
// region Core modules
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
/** Amplitude experiment */
implementation(deps.amplitude.experiment)
// endregion
}

View file

@ -3,28 +3,39 @@ plugins {
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
dependencies {
/** DI */
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/** Analytics - Models */
api(projects.core.analytics.models)
/** Domain */
implementation(projects.domain.analytics)
implementation(projects.domain.models)
/** Other */
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Core shouldn't depend on core, but in case with utils and logging its necessary */
// region Tangem
implementation(tangemDeps.card.core) // for calculating user id hash
// endregion
// region Core modules
api(projects.core.analytics.models)
// Core shouldn't depend on core, but with utils and logging it's necessary.
implementation(projects.core.utils)
// endregion
/** For calculating user id hash */
implementation(tangemDeps.card.core)
// region Domain
api(projects.domain.analytics)
// endregion
/** Tests */
testImplementation(projects.test.core)
// region Domain models
api(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -63,21 +63,32 @@ tasks.withType<Detekt>().configureEach {
exclude { it.file.absolutePath.contains("/build/generated/") }
}
dependencies {
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Local storages */
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region AndroidX
implementation(deps.androidx.annotation)
implementation(deps.androidx.datastore)
// endregion
/** Other libraries */
// region Other libraries
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
// endregion
/** Core modules */
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -11,10 +11,6 @@
"name": "VISA_ONBOARDING_ENABLED",
"version": "undefined"
},
{
"name": "STAKING_ETH_ENABLED",
"version": "5.39"
},
{
"name": "TWI_485_USEDESK_ENABLED",
"version": "undefined"
@ -23,10 +19,6 @@
"name": "APP_REDESIGN_ENABLED",
"version": "6.0"
},
{
"name": "GASLESS_APPROVAL_ENABLED",
"version": "5.37"
},
{
"name": "DYNAMIC_ADDRESSES_ENABLED",
"version": "5.39"
@ -51,10 +43,6 @@
"name": "HEDERA_ERC20_ENABLED",
"version": "5.37"
},
{
"name": "ADD_AND_MANAGE_TOKENS_ENABLED",
"version": "5.38"
},
{
"name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined"
@ -115,10 +103,6 @@
"name": "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED",
"version": "5.39"
},
{
"name": "AND_15154_YIELD_PROMO_ENABLED",
"version": "5.39.2"
},
{
"name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED",
"version": "5.39"
@ -147,10 +131,18 @@
"name": "AND_15741_VISA_PAY_REMOVE_ACCOUNT",
"version": "undefined"
},
{
"name": "AND_16041_VISA_TIERS_PLUS_PLAN",
"version": "undefined"
},
{
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
"version": "undefined"
},
{
"name": "AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED",
"version": "undefined"
},
{
"name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED",
"version": "6.0"
@ -170,5 +162,17 @@
{
"name": "AND_14829_WARNINGS_REFACTORING_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1469_FOR_YOU_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1367_HIGH_FEE_WARNING_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1638_VA_MVP0_ENABLED",
"version": "6.1"
}
]

View file

@ -39,16 +39,13 @@ internal class FeatureTogglesNamingConventionTest {
/** Toggles created before the AND_/TWI_ naming convention. Do NOT add new entries. */
val EXCLUDED_TOGGLES_LIST = setOf(
"ADDRESS_SYNC_ENABLED",
"ADD_AND_MANAGE_TOKENS_ENABLED",
"APP_REDESIGN_ENABLED",
"ASSETS_DISCOVERY_ENABLED",
"DYNAMIC_ADDRESSES_ENABLED",
"GASLESS_APPROVAL_ENABLED",
"HEDERA_ERC20_ENABLED",
"NEW_CARD_SCANNING_ENABLED",
"SOLANA_SCALED_UI_AMOUNT_ENABLED",
"SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED",

View file

@ -60,70 +60,79 @@ androidComponents {
}
dependencies {
/** Project */
implementation(projects.core.analytics)
implementation(projects.core.utils)
implementation(projects.core.res)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.visa.models)
/** Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
// endregion
/** Logging */
// region AndroidX
implementation(deps.androidx.core)
api(deps.androidx.datastore)
// endregion
/** Network */
// region Network
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
api(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.okio)
api(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
api(deps.retrofit.moshi)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
/** Time */
implementation(deps.jodatime)
// region Room
api(deps.room.runtime)
ksp(deps.room.compiler)
// endregion
/** Security */
implementation(deps.spongecastle.core)
// region Other libraries
api(deps.jodatime)
runtimeOnly(deps.spongecastle.core)
// endregion
/** Chucker */
// region Chucker
debugImplementation(deps.chucker)
mockedImplementation(deps.chucker)
externalImplementation(deps.chuckerStub)
internalImplementation(deps.chuckerStub)
releaseImplementation(deps.chuckerStub)
// endregion
/** Local storages */
api(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
ksp(deps.room.compiler)
// region Tangem
api(tangemDeps.blockchain)
api(tangemDeps.card.core)
// endregion
// region Core modules
api(projects.core.analytics)
implementation(projects.core.analytics.models)
api(projects.core.utils)
implementation(projects.core.res)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.nft.models)
api(projects.domain.onramp.models)
api(projects.domain.staking.models)
api(projects.domain.txhistory.models)
api(projects.domain.visa.models)
api(projects.domain.walletConnect.models)
api(projects.domain.wallets.models)
api(projects.domain.yieldSupply.models)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "55f2651d215126dd0465b9c711165cba",
"identityHash": "5d37870ed1a5b37c62e9836dc3fd061c",
"entities": [
{
"tableName": "express_provider",
@ -510,7 +510,7 @@
"notNull": true
},
{
"fieldPath": "onrampAvailable",
"fieldPath": "isOnrampAvailable",
"columnName": "onramp_available",
"affinity": "INTEGER",
"notNull": true
@ -551,11 +551,66 @@
"code"
]
}
},
{
"tableName": "token_info",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`network_id` TEXT NOT NULL, `contract_address` TEXT NOT NULL, `coin_id` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`network_id`, `contract_address`))",
"fields": [
{
"fieldPath": "networkId",
"columnName": "network_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contractAddress",
"columnName": "contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coinId",
"columnName": "coin_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "symbol",
"columnName": "symbol",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "decimals",
"columnName": "decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"network_id",
"contract_address"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '55f2651d215126dd0465b9c711165cba')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d37870ed1a5b37c62e9836dc3fd061c')"
]
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.addressbook
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
interface AddressBookApi {
@POST("v1/address-books/sync")
suspend fun syncAddressBooks(@Body body: SyncAddressBooksRequest): ApiResponse<SyncAddressBooksResponse>
@PUT("v1/address-books/{walletId}")
suspend fun updateAddressBook(
@Path("walletId") walletId: String,
@Header("If-Match") eTag: String?,
@Body body: UpdateAddressBookRequest,
): ApiResponse<UpdateAddressBookResponse>
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for `POST /address-books/sync`.
*
* Each [Wallet.etag] is optional: when it matches the backend's etag, that wallet is omitted from the
* response and the local copy is kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksRequest(
@Json(name = "wallets") val wallets: List<Wallet>,
) {
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String? = null,
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response body for `POST /address-books/sync`.
*
* [items] contains only the wallets whose backend etag differs from the one sent in the request; wallets
* with a matching etag are omitted and their local copy must be kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksResponse(
@Json(name = "items") val items: List<Item>,
) {
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "version") val version: String,
@Json(name = "updatedAt") val updatedAt: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookRequest(
@Json(name = "version") val version: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Response body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookResponse(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "updatedAt") val updatedAt: String,
)

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.gasless
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface GaslessTxServiceApiV2 {
@POST("api/v2/transaction/sign")
suspend fun signGaslessTransaction(
@Body transaction: GaslessTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
@POST("api/v2/transaction/batch-sign")
suspend fun signGaslessBatchTransaction(
@Body transaction: GaslessBatchTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for gasless batch transaction submission (v2 `POST /api/v2/transaction/batch-sign`).
* Represents a batch of transactions with fee delegation metadata.
*
* The top-level payload field is `gaslessTransaction` (shared shape with single sign see
* gasless-service `BatchSignRequestDto`), carrying `transactions[]`, `fee`, `nonce`.
*/
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionRequest(
@Json(name = "gaslessTransaction")
val gaslessTransaction: GaslessBatchTransactionDataDTO,
@Json(name = "signature")
val signature: String,
@Json(name = "userAddress")
val userAddress: String,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "eip7702auth")
val eip7702Auth: Eip7702AuthorizationDTO? = null,
)
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionDataDTO(
@Json(name = "transactions")
val transactions: List<TransactionData>,
@Json(name = "fee")
val fee: FeeData,
@Json(name = "nonce")
val nonce: String,
)

View file

@ -45,6 +45,9 @@ data class TransactionData(
@Json(name = "value")
val value: String,
@Json(name = "gasLimit")
val gasLimit: String? = null,
@Json(name = "data")
val data: String,
)

View file

@ -23,6 +23,13 @@ interface TangemPayApi {
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
/** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */
@GET("v1/account/bank-credentials/{product_instance_id}")
suspend fun getBankCredentials(
@Header("Authorization") authHeader: String,
@Path("product_instance_id") productInstanceId: String,
): ApiResponse<BankCredentialsResponse>
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Path("customer_wallet_id") customerWalletId: String,
@ -40,6 +47,12 @@ interface TangemPayApi {
@GET("v1/eligibility/channels")
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels>
/** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */
@GET("v1/eligibility/channels")
suspend fun getUserEligibilityChannels(
@Header("Authorization") authHeader: String,
): ApiResponse<TangemPayEligibilityChannels>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `bff-v2/v1/account/bank-credentials/{product_instance_id}` fiat bank requisites for the
* Virtual Account on-ramp (VA MVP0, TWI-1638).
*/
@JsonClass(generateAdapter = true)
data class BankCredentialsResponse(
@Json(name = "type") val type: String?,
@Json(name = "beneficiary_name") val beneficiaryName: String?,
@Json(name = "beneficiary_address") val beneficiaryAddress: String?,
@Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?,
@Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?,
@Json(name = "account_number") val accountNumber: String?,
@Json(name = "routing_number") val routingNumber: String?,
)

View file

@ -21,6 +21,32 @@ data class CustomerMeResponse(
@Json(name = "balance") val balance: BalanceResponse?,
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
@Json(name = "cards") val cards: List<Card>,
@Json(name = "customer_tariff_plan") val customerTariffPlan: CustomerTariffPlan? = null,
)
@JsonClass(generateAdapter = true)
data class CustomerTariffPlan(
@Json(name = "status") val status: String?,
@Json(name = "next_billing_at") val nextBillingAt: String?,
@Json(name = "pending_transition_at") val pendingTransitionAt: String?,
@Json(name = "tariff_plan") val tariffPlan: TariffPlan?,
@Json(name = "pending_tariff_plan") val pendingTariffPlan: TariffPlan?,
)
@JsonClass(generateAdapter = true)
data class TariffPlan(
@Json(name = "id") val id: String?,
@Json(name = "type") val type: String?,
@Json(name = "name") val name: String?,
@Json(name = "description_items") val descriptionItems: List<DescriptionItem>?,
)
@JsonClass(generateAdapter = true)
data class DescriptionItem(
@Json(name = "type") val type: String?,
@Json(name = "order") val order: Int?,
@Json(name = "title") val title: String?,
@Json(name = "body") val body: String?,
)
@JsonClass(generateAdapter = true)
@ -35,7 +61,17 @@ data class CustomerMeResponse(
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_card_limit") val actualCardLimit: CardLimit?,
@Json(name = "admin_card_limit") val adminCardLimit: CardLimit?,
@Json(name = "product_specification_data_type") val specificationDataType: SpecificationDataType,
) {
@JsonClass(generateAdapter = false)
enum class SpecificationDataType {
@Json(name = "ACCOUNT")
ACCOUNT,
@Json(name = "CARD")
CARD,
}
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "NEW")

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
@ -18,6 +19,7 @@ import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
@ -117,6 +119,16 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideAddressBookApi(retrofitApiBuilder: RetrofitApiBuilder): AddressBookApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi {
@ -252,4 +264,20 @@ internal object NetworkModule {
),
)
}
@Provides
@Singleton
fun provideGaslessTxServiceApiV2(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApiV2 {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
readTimeoutSeconds = TIMEOUT_60_SECONDS,
writeTimeoutSeconds = TIMEOUT_60_SECONDS,
),
)
}
}

View file

@ -5,6 +5,7 @@ import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -37,5 +38,8 @@ internal interface TxHistoryModule {
@Provides
fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao()
@Provides
fun provideTokenInfoDao(database: TxHistoryDatabase): TokenInfoDao = database.tokenInfoDao()
}
}

View file

@ -156,7 +156,7 @@ object PreferencesKeys {
val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy {
stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey")
}
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") }
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") }
fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key")
// endregion

View file

@ -4,11 +4,13 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
@Database(
version = 1,
@ -18,6 +20,7 @@ import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEnti
ExpressOnrampEntity::class,
ExpressSyncStateEntity::class,
OnrampCountryEntity::class,
TokenInfoEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
@ -25,4 +28,6 @@ abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
abstract fun syncStateDao(): ExpressSyncStateDao
abstract fun tokenInfoDao(): TokenInfoDao
}

View file

@ -0,0 +1,33 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
@Dao
interface TokenInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<TokenInfoEntity>)
/**
* Cached tokens for the given networks/contracts. Filters each column independently, so the result is a
* cross-product superset of the `(networkId, contractAddress)` pairs the caller must match exact pairs.
* Contract match is case-insensitive; pass [minUpdatedAt] = `now - ttl` to drop stale rows.
*/
@Query(
"""
SELECT * FROM token_info
WHERE network_id IN (:networkIds)
AND contract_address COLLATE NOCASE IN (:contractAddresses)
AND updated_at >= :minUpdatedAt
""",
)
suspend fun getCached(
networkIds: Collection<String>,
contractAddresses: Collection<String>,
minUpdatedAt: Long = 0,
): List<TokenInfoEntity>
}

View file

@ -0,0 +1,37 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Cached token data fetched from [TangemTechApi.getCoins], keyed by network id + contract address.
*/
@Entity(
tableName = "token_info",
primaryKeys = ["network_id", "contract_address"],
)
data class TokenInfoEntity(
@ColumnInfo(name = "network_id")
val networkId: String,
@ColumnInfo(name = "contract_address")
val contractAddress: String,
/** Coin id from the backend (used as the token's raw currency id and for the icon URL). */
@ColumnInfo(name = "coin_id")
val coinId: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "symbol")
val symbol: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
/** Last refresh timestamp in epoch milliseconds, used to evict stale entries. */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)

View file

@ -10,12 +10,25 @@ android {
}
dependencies {
api(projects.core.utils)
api(deps.decompose)
api(deps.androidx.appCompat)
implementation(deps.kotlin.coroutines)
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region AndroidX
api(deps.androidx.appCompat)
// endregion
// region Other libraries
api(deps.decompose)
// endregion
// region Core modules
api(projects.core.utils)
// endregion
}

View file

@ -9,8 +9,13 @@ android {
}
dependencies {
api(projects.core.error)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
// region Tangem
api(tangemDeps.blockchain)
api(tangemDeps.card.core)
// endregion
// region Core modules
api(projects.core.error)
// endregion
}

View file

@ -1,8 +1,6 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
@ -11,10 +9,12 @@ android {
}
dependencies {
implementation(projects.core.utils)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(deps.material)
// region AndroidX
implementation(deps.androidx.core)
// endregion
}

View file

@ -4,7 +4,7 @@ plugins {
}
dependencies {
// region Coroutines
implementation(deps.kotlin.coroutines)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
}

View file

@ -10,12 +10,17 @@ android {
dependencies {
implementation(projects.core.utils)
// region AndroidX
api(deps.androidx.annotation)
// endregion
// region Firebase libraries
// region Firebase
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.analytics)
implementation(deps.firebase.crashlytics)
// endregion
// region Core modules
implementation(projects.core.utils)
// endregion
}

View file

@ -1550,6 +1550,7 @@
<string name="staking_enabled">Staking aktiviert</string>
<string name="staking_error_no_validators_message">Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal.</string>
<string name="staking_error_no_validators_title">Staking nicht verfügbar</string>
<string name="staking_error_unavailable_region">Staking ist in Ihrer Region nicht verfügbar.</string>
<string name="staking_give_permission_fee_footer">Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst.</string>
<string name="staking_legal">Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu.</string>
<string name="staking_locked">Gesperrt</string>

View file

@ -1421,6 +1421,7 @@
<string name="staking_enabled">Staking activé</string>
<string name="staking_error_no_validators_message">Aucun validateur disponible pour le moment. Veuillez réessayer plus tard.</string>
<string name="staking_error_no_validators_title">Staking indisponible</string>
<string name="staking_error_unavailable_region">Le staking est indisponible dans votre région</string>
<string name="staking_give_permission_fee_footer">Le réseau facturera des frais dapprobation de jeton pour vérifier que vous autorisez lutilisation de votre jeton pour le jalonnement.</string>
<string name="staking_legal">En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur</string>
<string name="staking_locked">Bloqué</string>

View file

@ -1520,6 +1520,7 @@
<string name="staking_enabled">ステーキングが有効です</string>
<string name="staking_error_no_validators_message">現在、利用可能なバリデーターは見つかりません。しばらくしてからもう一度お試しください。</string>
<string name="staking_error_no_validators_title">ステーキングは利用できません</string>
<string name="staking_error_unavailable_region">お住まいの地域ではステーキングをご利用いただけません</string>
<string name="staking_give_permission_fee_footer">ネットワークは、ステーキングのためにトークンの使用を承認していることを確認するために、トークン承認手数料を請求します。</string>
<string name="staking_legal">ステーキング機能を使用すると、プロバイダーの%1$sと%2$sに同意したことになります</string>
<string name="staking_locked">ロック中</string>

View file

@ -1512,6 +1512,7 @@
<string name="staking_enabled">已启用质押</string>
<string name="staking_error_no_validators_message">目前没有可用的验证节点。请稍后再试。</string>
<string name="staking_error_no_validators_title">质押功能不可用</string>
<string name="staking_error_unavailable_region">您所在的地区暂不支持质押功能</string>
<string name="staking_give_permission_fee_footer">网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。</string>
<string name="staking_legal">使用质押功能即表示您同意提供商的 %1$s 和 %2$s</string>
<string name="staking_locked">已锁定</string>

View file

@ -115,10 +115,17 @@
<string name="address_book_enter_address">Enter address</string>
<string name="address_book_invalid_address_error">Invalid address</string>
<string name="address_book_keep_editing">Keep editing</string>
<string name="address_book_max_networks_alert_description">You can not create more than 20 addresses. Delete one to add new.</string>
<string name="address_book_max_networks_alert_title">Can\'t add new address </string>
<string name="address_book_name_empty_error">Contact name is required</string>
<string name="address_book_name_invalid_chars_error">Contact name contains invalid characters</string>
<string name="address_book_name_max_chars_error">Contact name must not exceed 50 characters</string>
<string name="address_book_name_taken_error">That name is already taken on this wallet</string>
<string name="address_book_new_contact">New contact</string>
<string name="address_book_no_contacts">No contacts yet</string>
<string name="address_book_no_contacts_description">Contacts added will appear here</string>
<string name="address_book_remove_address">Remove address</string>
<string name="address_book_save_to_wallet_title">Save to Wallet</string>
<string name="address_book_save_wallet_to_description">This contact will be linked to this wallets address book.</string>
<string name="address_book_select_network">Select network</string>
<string name="address_book_title">Address book</string>
@ -352,6 +359,7 @@
<string name="common_get_token">Get token</string>
<string name="common_go_to_provider">Go to provider</string>
<string name="common_go_to_token">Go to token</string>
<string name="common_go_to_verification">Go to verification</string>
<string name="common_got_it">Got it</string>
<string name="common_hide">Hide</string>
<string name="common_hold_to">Hold to %s</string>
@ -410,6 +418,7 @@
<string name="common_privacy_policy">Privacy Policy</string>
<string name="common_range">%1$s-%2$s</string>
<string name="common_range_with_space">%1$s — %2$s</string>
<string name="common_rate">Rate</string>
<string name="common_read_more">Read more</string>
<string name="common_receive">Receive</string>
<string name="common_received">Received</string>
@ -701,6 +710,8 @@
<string name="feedback_subject_support_tangem">Tangem feedback</string>
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
<string name="feedback_token_description_error">Coin description error</string>
<string name="for_you_description">Review portfolio and explore earn opportunities</string>
<string name="for_you_title">For You</string>
<string name="force_update_action">Update now</string>
<string name="force_update_banner_message">Update the app to its latest version to ensure proper functionality</string>
<string name="force_update_banner_title">Update needed</string>
@ -732,6 +743,8 @@
<string name="hardware_wallet_key_feature_title">Key Generation</string>
<string name="hardware_wallet_security_feature_description">All cryptographic operations happen inside the secure chip, certified against cloning and physical tampering.</string>
<string name="hardware_wallet_security_feature_title">Hardware-Level Security</string>
<string name="high_fee_warning_description">Network activity is high. You can continue now or try again later when fees may be lower.</string>
<string name="high_fee_warning_title">Network fee is higher than usual</string>
<string name="home_button_add_existing_wallet">Add Existing Wallet</string>
<string name="home_button_create_new_wallet">Create New Wallet</string>
<string name="home_button_order">Order Tangem</string>
@ -1402,6 +1415,7 @@
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_unreachable_error_text">Check your network connection</string>
<string name="send_fee_unreachable_error_title">Network fee info unreachable</string>
<string name="send_from_account_in_wallet_name">from %1$s in %2$s</string>
<string name="send_from_title">You send</string>
<string name="send_from_wallet_name">From %s</string>
<string name="send_gas_limit">Gas limit</string>
@ -1937,6 +1951,10 @@
<string name="tangempay_pay_support">Pay Support</string>
<string name="tangempay_payment_account">Payment account</string>
<string name="tangempay_payment_account_sync_needed">Session expired</string>
<string name="tangempay_current_plan_title">Current plan</string>
<string name="tangempay_current_plan_section_card">Card related</string>
<string name="tangempay_current_plan_section_plan">Plan related</string>
<string name="tangempay_current_plan_change">Change plan</string>
<string name="tangempay_pin_validation_error_message">Invalid PIN: avoid sequences or repeats</string>
<string name="tangempay_reissue_card_confirm">Replace card</string>
<string name="tangempay_reissue_card_description">This generates a new set of card details. Your old details will stop working. You can\'t undo this.</string>

View file

@ -109,51 +109,53 @@ tasks.named("preBuild") {
}
dependencies {
/** Project - Domain */
implementation(projects.domain.appTheme.models)
implementation(projects.domain.express.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
/** Project - Core */
implementation(projects.core.res)
implementation(projects.core.utils)
api(projects.core.decompose)
implementation(projects.core.error)
// region DI
implementation(deps.hilt.android)
// endregion
/** AndroidX libraries */
implementation(deps.androidx.fragment.ktx)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.immutable.collections)
api(deps.kotlin.serialization.core)
// endregion
// region Compose
implementation(deps.compose.accompanist.permission)
api(deps.compose.accompanist.systemUiController)
api(deps.compose.coil)
implementation(deps.compose.constraintLayout)
api(deps.compose.foundation)
api(deps.compose.material3)
api(deps.compose.paging)
api(deps.compose.reorderable)
api(deps.compose.shimmer)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
// endregion
// region AndroidX
api(deps.androidx.activity)
implementation(deps.androidx.activity.compose)
implementation(deps.androidx.annotation)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.core)
implementation(deps.androidx.core.ktx)
implementation(deps.androidx.paging.runtime)
implementation(deps.lifecycle.runtime.ktx)
implementation(deps.androidx.palette)
api(deps.androidx.palette)
implementation(deps.androidx.savedState)
implementation(deps.androidx.windowManager) {
exclude(
deps.kotlin.coroutines.android.get().module.group,
deps.kotlin.coroutines.android.get().module.name
deps.kotlin.coroutines.android.get().module.name,
)
}
api(deps.lifecycle.compose)
api(deps.lifecycle.runtime.ktx)
// endregion
/** Compose */
implementation(deps.compose.constraintLayout)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.paging)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.compose.coil)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
api(deps.compose.reorderable)
/** Other libraries */
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.accompanist.permission)
implementation(deps.material)
implementation(deps.compose.shimmer)
implementation(deps.kotlin.immutable.collections)
implementation(deps.zxing.qrCore)
api(deps.jodatime)
implementation(deps.markdown)
// region Other libraries
api(deps.arrow.core)
api(deps.haze) {
exclude(module = "activity-compose")
exclude(module = "activity")
@ -164,9 +166,28 @@ dependencies {
exclude(module = "activity")
exclude(module = "activity-ktx")
}
api(deps.jodatime)
api(deps.markdown)
implementation(deps.material)
implementation(deps.zxing.qrCore)
// endregion
/** Tests */
// region Core modules
api(projects.core.decompose)
api(projects.core.error)
implementation(projects.core.res)
implementation(projects.core.utils)
// endregion
// region Domain models
api(projects.domain.appTheme.models)
api(projects.domain.express.models)
api(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
// endregion
}

View file

@ -3,19 +3,22 @@ package com.tangem.core.ui.ds.button.action
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
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.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.SecondaryTangemButton
@ -32,6 +35,8 @@ import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private val ACTION_BUTTONS_SPACING = 10.dp
/**
* Action buttons row
*
@ -42,42 +47,73 @@ import kotlinx.collections.immutable.persistentListOf
*/
@Composable
fun ActionButtons(buttons: ImmutableList<TangemButtonUM>, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
val spacingPx = with(LocalDensity.current) { ACTION_BUTTONS_SPACING.roundToPx() }
Layout(
modifier = modifier,
) {
buttons.forEachIndexed { index, button ->
key(button.text to index) {
val textColor = if (button.isEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.status.disabled
}
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens2.x2_5)
.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
.semantics { if (!button.isEnabled) disabled() },
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SecondaryTangemButton(
tangemIconUM = button.tangemIconUM,
onClick = button.onClick,
isEnabled = button.isEnabled,
shape = TangemButtonShape.Rounded,
onLongClick = button.onLongClick,
)
Text(
text = button.text.orEmpty().resolveReference(),
style = TangemTheme.typography2.subheadlineMedium14,
color = textColor,
maxLines = 1,
)
content = {
buttons.forEachIndexed { index, button ->
key(button.text to index) {
ActionButton(button = button)
}
}
},
) { measurables, constraints ->
if (measurables.isEmpty()) {
return@Layout layout(constraints.minWidth, constraints.minHeight) {}
}
val cellWidth = measurables.maxOf { it.maxIntrinsicWidth(constraints.maxHeight) }
val cellConstraints = Constraints(
minWidth = cellWidth,
maxWidth = cellWidth,
minHeight = 0,
maxHeight = constraints.maxHeight,
)
val placeables = measurables.map { it.measure(cellConstraints) }
val contentWidth = cellWidth * placeables.size + spacingPx * (placeables.size - 1)
val width = if (constraints.hasBoundedWidth) maxOf(constraints.maxWidth, contentWidth) else contentWidth
val height = placeables.maxOf { it.height }
layout(width, height) {
var x = (width - contentWidth) / 2
placeables.forEach { placeable ->
placeable.place(x = x, y = (height - placeable.height) / 2)
x += cellWidth + spacingPx
}
}
}
}
@Composable
private fun ActionButton(button: TangemButtonUM, modifier: Modifier = Modifier) {
val textColor = if (button.isEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.status.disabled
}
Column(
modifier = modifier
.padding(horizontal = TangemTheme.dimens2.x2_5)
.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
.semantics { if (!button.isEnabled) disabled() },
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SecondaryTangemButton(
tangemIconUM = button.tangemIconUM,
onClick = button.onClick,
isEnabled = button.isEnabled,
shape = TangemButtonShape.Rounded,
onLongClick = button.onLongClick,
)
Text(
text = button.text.orEmpty().resolveReference(),
style = TangemTheme.typography2.subheadlineMedium14,
color = textColor,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
@ -201,6 +200,7 @@ private fun DecorationBox(
Field(
state = state,
innerTextField = innerTextField,
modifier = if (state.query.isEmpty()) Modifier else Modifier.weight(1f),
)
ClearButton(
state = state,
@ -217,58 +217,45 @@ private fun DecorationBox(
}
@Composable
private fun Field(state: SearchBarUM, innerTextField: @Composable () -> Unit) {
private fun Field(state: SearchBarUM, modifier: Modifier = Modifier, innerTextField: @Composable () -> Unit) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
modifier = modifier
.heightIn(min = TangemTheme.dimens2.x5)
.width(IntrinsicSize.Max),
.then(if (state.query.isEmpty()) Modifier.width(IntrinsicSize.Max) else Modifier),
) {
innerTextField()
val placeholderOpacity by remember(state.query) {
derivedStateOf {
if (state.query.isNotEmpty()) {
0f
} else {
1f
}
}
if (state.query.isEmpty()) {
Text(
text = state.placeholderText.resolveReference(),
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(SearchBarTestTags.PLACEHOLDER_TEXT),
)
}
Text(
text = state.placeholderText.resolveReference(),
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.testTag(SearchBarTestTags.PLACEHOLDER_TEXT)
.alpha(placeholderOpacity),
)
}
}
@Composable
private fun ClearButton(state: SearchBarUM) {
if (state.query.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterEnd)
.size(TangemTheme.dimens2.x5)
.clip(CircleShape)
.clickable(
onClick = state.onClearClick,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
)
.testTag(SearchBarTestTags.CLEAR_BUTTON),
)
}
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
modifier = Modifier
.size(TangemTheme.dimens2.x5)
.clip(CircleShape)
.clickable(
onClick = state.onClearClick,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
)
.testTag(SearchBarTestTags.CLEAR_BUTTON),
)
}
}

View file

@ -11,13 +11,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cloud_24_filled
private const val DEFAULT_DEVICE_ICON_COLOR = 0xFF595963
private const val DEFAULT_BORDER_COLOR = 0x1A1E1E1E
/**
* Composable function for displaying a wallet icon based on the provided [DeviceIconUM] state.
@ -57,9 +59,9 @@ fun TangemDeviceIcon(state: DeviceIconUM, modifier: Modifier = Modifier) {
)
DeviceIconUM.Mobile -> Icon(
modifier = modifier,
imageVector = ImageVector.vectorResource(R.drawable.ic_shield_24),
imageVector = Icons.ic_cloud_24_filled,
contentDescription = null,
tint = TangemTheme.colors2.graphic.status.attention,
tint = Color(DEFAULT_DEVICE_ICON_COLOR),
)
}
}
@ -73,10 +75,10 @@ private fun DeviceIcon(
tColor: Color?,
modifier: Modifier = Modifier,
) {
val main = mainColor.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant }
val second = secondColor?.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant }
val third = thirdColor?.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant }
val borderColor = TangemTheme.colors2.border.walletIcon
val main = mainColor.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) }
val second = secondColor?.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) }
val third = thirdColor?.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) }
val borderColor = Color(DEFAULT_BORDER_COLOR)
val imageVector = remember(isRing, main, second, third, borderColor, tColor) {
when {

View file

@ -67,7 +67,10 @@ fun TangemTab(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
)
.padding(all = TangemTheme.dimens2.x3),
.padding(
vertical = TangemTheme.dimens2.x2,
horizontal = TangemTheme.dimens2.x3,
),
) {
Text(
text = text.resolveReference(),

View file

@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -108,7 +108,7 @@ fun TangemButton(
enabled = isEnabled,
color = backgroundColor,
border = resolveBorder(isFocused = isFocused, colorTokens = colorTokens, contentAlpha = contentAlpha),
shape = RoundedCornerShape(999.dp),
shape = CircleShape,
interactionSource = interactionSource,
isMaterial = variant == TangemButton.Variant.Material,
) {

View file

@ -250,7 +250,7 @@ private fun CloseButton(onClick: () -> Unit) {
@Composable
private fun Preview(@PreviewParameter(TangemSearchStateProvider::class) state: TangemSearch.State) {
TangemThemePreviewRedesign {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.secondary)) {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.tertiary)) {
TangemSearch(
state = state,
modifier = Modifier

View file

@ -12,8 +12,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@ -113,7 +117,7 @@ fun TangemSurface(
@Composable
private fun Modifier.materialShadow(shape: Shape): Modifier = softLayerShadow(
radius = 40.dp,
color = Color.Black.copy(alpha = 0.10f),
color = Color.Black.copy(alpha = 0.12f),
shape = shape,
spread = 0.dp,
offset = DpOffset(x = 0.dp, y = 8.dp),
@ -141,19 +145,22 @@ private fun Modifier.materialBorder(shape: Shape): Modifier = border(
@Composable
private fun Modifier.materialFill(): Modifier {
val isBlurEnabled = LocalHazeState.current.blurEnabled
val material = TangemTheme.colors3.material
val hazed = hazeEffectTangem(
style = HazeStyle(
backgroundColor = TangemTheme.colors3.material.fill.blur,
backgroundColor = Color.Transparent,
blurRadius = 32.dp,
tints = emptyList(),
tints = listOf(
HazeTint(material.fill.blur),
),
),
) {
fallbackTint = HazeTint(Color.Transparent)
}
return hazed.conditionalCompose(!isBlurEnabled) {
// Paint the opaque fill first, then layer the translucent tint on top so both are visible.
background(TangemTheme.colors3.material.fill.solid)
.background(TangemTheme.colors3.material.tint.solid)
background(material.fill.solid)
.background(material.tint.solid)
}
}
@ -162,13 +169,22 @@ private fun Modifier.materialFill(): Modifier {
@ReadOnlyComposable
private fun materialBorderBrush(): Brush {
val border = TangemTheme.colors3.material.border
return Brush.linearGradient(
0f to border.start,
0.5f to border.mid,
1f to border.end,
start = Offset.Zero,
end = Offset.Infinite,
)
val startColor = border.start
val midColor = Color.Transparent
val endColor = border.end
return object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val w = size.width
val h = size.height
val k = 2f * w * h / (w * w + h * h)
return LinearGradientShader(
from = Offset.Zero,
to = Offset(x = k * h, y = k * w),
colors = listOf(startColor, midColor, midColor, endColor),
colorStops = listOf(0f, 0.40f, 0.60f, 1f),
)
}
}
}
// endregion

View file

@ -47,6 +47,9 @@ private enum class SlotId { Start, Content, Group, End }
* @param contentAlign How [contentColumn] is aligned horizontally within the bar.
* @param windowInsets Top inset applied above the row. Pass `WindowInsets(0)` inside a bottom
* sheet / modal.
* @param contentPadding Inner padding applied to the row, inside [windowInsets]. Defaults to
* [TangemTopNavigation.DefaultContentPadding] (top 8, bottom 16, horizontal 16). Override a single
* edge via [com.tangem.core.ui.extensions.copy], e.g. `DefaultContentPadding.copy(top = 16.dp)`.
* @param blurBackground Whether the fade behind the row should blur the content below.
* @param startButton Leading slot. Typically a back button (see [TangemButton.Back]).
* @param endButtonsGroup Optional pill-grouped secondary actions placed just before [endButton].
@ -59,6 +62,7 @@ fun TangemTopNavigation(
modifier: Modifier = Modifier,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
startButton: (@Composable () -> Unit)? = null,
endButtonsGroup: (@Composable RowScope.() -> Unit)? = null,
@ -89,12 +93,7 @@ fun TangemTopNavigation(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(windowInsets)
.padding(
top = 8.dp,
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
.padding(contentPadding),
content = {
val displayedStart = rememberLastNonNull(startButton)
Box(modifier = Modifier.layoutId(SlotId.Start)) {
@ -208,6 +207,7 @@ fun TangemTopNavigation(
subtitle: TextReference? = null,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
onBack: (() -> Unit)? = null,
endButtonsGroup: (@Composable RowScope.() -> Unit)? = null,
@ -217,6 +217,7 @@ fun TangemTopNavigation(
modifier = modifier,
contentAlign = contentAlign,
windowInsets = windowInsets,
contentPadding = contentPadding,
blurBackground = blurBackground,
startButton = onBack?.let { { TangemButton.Back(onClick = it) } },
endButtonsGroup = endButtonsGroup,
@ -233,6 +234,7 @@ fun TangemTopNavigation(
subtitle: TextReference? = null,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
endButtonsGroup: (@Composable RowScope.() -> Unit)? = null,
onClose: (() -> Unit)? = null,
@ -242,6 +244,7 @@ fun TangemTopNavigation(
modifier = modifier,
contentAlign = contentAlign,
windowInsets = windowInsets,
contentPadding = contentPadding,
blurBackground = blurBackground,
startButton = startButton,
endButtonsGroup = endButtonsGroup,
@ -258,6 +261,7 @@ fun TangemTopNavigation(
subtitle: TextReference? = null,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
onBack: (() -> Unit)? = null,
endButton: @Composable () -> Unit,
@ -266,6 +270,7 @@ fun TangemTopNavigation(
modifier = modifier,
contentAlign = contentAlign,
windowInsets = windowInsets,
contentPadding = contentPadding,
blurBackground = blurBackground,
startButton = onBack?.let { { TangemButton.Back(onClick = it) } },
endButton = endButton,
@ -308,7 +313,7 @@ private fun ColumnScope.TitleSubtitle(title: TextReference, subtitle: TextRefere
) {
displayedSubtitle?.let { text ->
Column {
Spacer(Modifier.height(2.dp))
Spacer(Modifier.height(4.dp))
TangemNavigationText(text = text, role = TangemNavigationText.Role.Subtitle)
}
}
@ -317,6 +322,15 @@ private fun ColumnScope.TitleSubtitle(title: TextReference, subtitle: TextRefere
object TangemTopNavigation {
/** Default inner padding of the row: top 8, bottom 16, horizontal 16. */
@Suppress("MagicNumber")
val DefaultContentPadding: PaddingValues = PaddingValues(
top = 8.dp,
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
)
/** Horizontal alignment of the center content slot. */
enum class ContentAlign {
Start, Center

View file

@ -0,0 +1,22 @@
package com.tangem.core.ui.extensions
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
/**
* Returns a copy of this [PaddingValues] with the given edges overridden, leaving the rest unchanged.
*/
@Composable
fun PaddingValues.copy(start: Dp? = null, top: Dp? = null, end: Dp? = null, bottom: Dp? = null): PaddingValues {
val layoutDirection = LocalLayoutDirection.current
return PaddingValues(
start = start ?: calculateStartPadding(layoutDirection),
top = top ?: calculateTopPadding(),
end = end ?: calculateEndPadding(layoutDirection),
bottom = bottom ?: calculateBottomPadding(),
)
}

View file

@ -52,11 +52,13 @@ open class BigDecimalCryptoFormatStyled(
fun BigDecimalFormatScope.crypto(
symbol: String,
decimals: Int,
ignoreSymbolPosition: Boolean = false,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormat {
return BigDecimalCryptoFormat(
symbol = symbol,
decimals = decimals,
shouldIgnoreSymbolPosition = ignoreSymbolPosition,
locale = locale,
)
}

View file

@ -2,4 +2,5 @@ package com.tangem.core.ui.test
object AppSettingsScreenTestTags {
const val CURRENCY_BUTTON = "APP_SETTINGS_SCREEN_CURRENCY_BUTTON"
const val BACK_BUTTON = "APP_SETTINGS_SCREEN_BACK_BUTTON"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB