Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-19 10:20:09 +03:00
commit f0e4aec9a4
858 changed files with 13724 additions and 5705 deletions

View file

@ -0,0 +1,17 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:TechAnalyticsEvent.kt$TechAnalyticsEvent.KeyboardIdentifier${ put("Package", it) put("GPUrl", "https://play.google.com/store/apps/details?id=$packageName") }</ID>
<ID>UseEmptyCounterpart:AnalyticsEvent.kt$AnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:Basic.kt$Basic$mapOf()</ID>
<ID>UseEmptyCounterpart:ExceptionAnalyticsEvent.kt$ExceptionAnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:MainScreenAnalyticsEvent.kt$MainScreenAnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:OnboardingAnalyticsEvent.kt$OnboardingAnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:OnboardingAnalyticsEvent.kt$OnboardingAnalyticsEvent.CreateWallet$mapOf()</ID>
<ID>UseEmptyCounterpart:OnboardingAnalyticsEvent.kt$OnboardingAnalyticsEvent.Error$mapOf()</ID>
<ID>UseEmptyCounterpart:OnboardingAnalyticsEvent.kt$OnboardingAnalyticsEvent.Onboarding$mapOf()</ID>
<ID>UseEmptyCounterpart:OnboardingAnalyticsEvent.kt$OnboardingAnalyticsEvent.SeedPhrase$mapOf()</ID>
<ID>UseEmptyCounterpart:TechAnalyticsEvent.kt$TechAnalyticsEvent$mapOf()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -11,11 +11,6 @@ sealed class AnalyticsParam {
companion object
}
sealed class TokenBalanceState(val value: String) {
data object Empty : TokenBalanceState("Empty")
data object Full : TokenBalanceState("Full")
}
sealed class RateApp(val value: String) {
data object Liked : RateApp("Liked")
data object Disliked : RateApp("Disliked")
@ -83,12 +78,14 @@ sealed class AnalyticsParam {
data object Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets")
data object HotWallet : ScreensSources("Hot Wallet")
data object TangemPay : ScreensSources("Tangem Pay")
data object WalletSettings : ScreensSources("Wallet Settings")
data object Upgrade : ScreensSources("Upgrade")
data object HardwareWallet : ScreensSources("Hardware Wallet")
data object ImportWallet : ScreensSources("Import Wallet")
data object CreateWalletIntro : ScreensSources("Create Wallet Intro")
data object AddNewWallet : ScreensSources("Add New Wallet")
data object CreateWallet : ScreensSources("Create Wallet")
}
sealed class TxSentFrom(val value: String) {
@ -203,8 +200,9 @@ sealed class AnalyticsParam {
Pending(value = "Pending"),
}
enum class EnsStatus(val value: String) {
EMPTY("Empty"), FULL("Full")
enum class EmptyFull(val value: String) {
Empty("Empty"),
Full("Full"),
}
enum class ProductType(val value: String) {
@ -268,5 +266,6 @@ sealed class AnalyticsParam {
const val CHOSEN_TOKEN = "Token Chosen"
const val ENS = "ENS"
const val ENS_ADDRESS = "ENS Address"
const val ACCOUNT_DERIVATION_FROM = "Account Derivation (from)"
}
}

View file

@ -10,11 +10,11 @@ sealed class Basic(
) : Basic(
event = "Card Was Scanned",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
AnalyticsParam.Key.SOURCE to source.value,
),
)
class SignedIn(
class SignedInLegacy(
currency: AnalyticsParam.WalletType,
batch: String,
signInType: SignInType,
@ -24,8 +24,8 @@ sealed class Basic(
) : Basic(
event = "Signed in",
params = buildMap {
put(AnalyticsParam.CURRENCY, currency.value)
put(AnalyticsParam.BATCH, batch)
put(AnalyticsParam.Key.CURRENCY, currency.value)
put(AnalyticsParam.Key.BATCH, batch)
put("Wallet Type", if (isImported) "Seed Phrase" else "Seedless")
put("Sign in type", signInType.name)
put("Wallets Count", walletsCount)
@ -39,10 +39,37 @@ sealed class Basic(
}
}
class SignedIn(
signInType: SignInType,
walletsCount: Int,
) : Basic(
event = "Signed in",
params = buildMap {
put("Sign in type", signInType.value)
put("Wallets Count", walletsCount.toString())
},
) {
enum class SignInType(val value: String) {
Card("Card"),
Biometric("Biometric"),
NoSecurity("No Security"),
AccessCode("Access Code"),
}
}
class ButtonBuy(
source: AnalyticsParam.ScreensSources,
) : Basic(
event = "Button - Buy",
params = buildMap {
put(AnalyticsParam.Key.SOURCE, source.value)
},
)
class ToppedUp(userWalletId: String, currency: AnalyticsParam.WalletType) :
Basic(
event = "Topped up",
params = mapOf(AnalyticsParam.CURRENCY to currency.value),
params = mapOf(AnalyticsParam.Key.CURRENCY to currency.value),
),
OneTimeAnalyticsEvent {
@ -53,16 +80,16 @@ sealed class Basic(
Basic(
event = "Transaction sent",
params = buildMap {
this[AnalyticsParam.SOURCE] = sentFrom.value
this[AnalyticsParam.Key.SOURCE] = sentFrom.value
if (sentFrom is AnalyticsParam.TxData) {
this[AnalyticsParam.BLOCKCHAIN] = sentFrom.blockchain
this[AnalyticsParam.TOKEN_PARAM] = sentFrom.token
this[AnalyticsParam.Key.BLOCKCHAIN] = sentFrom.blockchain
this[AnalyticsParam.Key.TOKEN_PARAM] = sentFrom.token
sentFrom.feeType?.value?.let {
this[AnalyticsParam.FEE_TYPE] = it
this[AnalyticsParam.Key.FEE_TYPE] = it
}
}
if (sentFrom is AnalyticsParam.TxSentFrom.Approve) {
this[AnalyticsParam.PERMISSION_TYPE] = sentFrom.permissionType
this[AnalyticsParam.Key.PERMISSION_TYPE] = sentFrom.permissionType
}
this["Memo"] = memoType.name
},
@ -79,7 +106,7 @@ sealed class Basic(
class ButtonSupport(source: AnalyticsParam.ScreensSources) : Basic(
event = "Request Support",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
AnalyticsParam.Key.SOURCE to source.value,
),
)
@ -89,7 +116,7 @@ sealed class Basic(
) : Basic(
event = "Biometry Failed",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
AnalyticsParam.Key.SOURCE to source.value,
"Reason" to reason.value,
),
) {

View file

@ -36,26 +36,34 @@ sealed class MainScreenAnalyticsEvent(
},
)
data object ButtonReceive : MainScreenAnalyticsEvent(
class ButtonReceive : MainScreenAnalyticsEvent(
event = "Button - Receive",
)
data object LimitsClicked : MainScreenAnalyticsEvent(
class LimitsClicked : MainScreenAnalyticsEvent(
event = "Limits Clicked",
)
data object NoticeBalancesInfo : MainScreenAnalyticsEvent(
class NoticeBalancesInfo : MainScreenAnalyticsEvent(
event = "Notice - Balances Info",
)
data object NoticeLimitsInfo : MainScreenAnalyticsEvent(
class NoticeLimitsInfo : MainScreenAnalyticsEvent(
event = "Notice - Limits Info",
)
data object ButtonExplore : MainScreenAnalyticsEvent(
class ButtonExplore : MainScreenAnalyticsEvent(
event = "Button - Explore",
)
class AccountShowTokens : MainScreenAnalyticsEvent(
event = "Button - Account Show Tokens",
)
class AccountHideTokens : MainScreenAnalyticsEvent(
event = "Button - Account Hide Tokens",
)
data class ButtonSwap(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent(
event = "Button - Swap",
params = mapOf(AnalyticsParam.STATUS to status.value),
@ -66,11 +74,11 @@ sealed class MainScreenAnalyticsEvent(
params = mapOf(AnalyticsParam.STATUS to status.value),
)
data object BuyScreenOpened : MainScreenAnalyticsEvent(event = "Buy Screen Opened")
class BuyScreenOpened : MainScreenAnalyticsEvent(event = "Buy Screen Opened")
data object SwapScreenOpened : MainScreenAnalyticsEvent(event = "Swap Screen Opened")
class SwapScreenOpened : MainScreenAnalyticsEvent(event = "Swap Screen Opened")
data object SellScreenOpened : MainScreenAnalyticsEvent(event = "Sell Screen Opened")
class SellScreenOpened : MainScreenAnalyticsEvent(event = "Sell Screen Opened")
data class BuyTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Buy Token Clicked",

View file

@ -12,11 +12,92 @@ sealed class OnboardingAnalyticsEvent(
sealed class Onboarding(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingAnalyticsEvent(category = "Onboarding", event = event, params = params) {
class Started(
source: String,
) : Onboarding(
event = "Onboarding Started",
params = mapOf(
AnalyticsParam.SOURCE to source,
),
)
class Finished(
source: String,
) : Onboarding(
event = "Onboarding Finished",
params = mapOf(
AnalyticsParam.SOURCE to source,
),
)
class ButtonMobileWallet(
source: String,
) : Onboarding(
event = "Button - Mobile Wallet",
params = mapOf(
AnalyticsParam.SOURCE to source,
),
)
}
sealed class CreateWallet(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingAnalyticsEvent(category = "Onboarding / Create Wallet", event = event, params = params) {
class ButtonCreateWallet : CreateWallet("Button - Create Wallet")
class WalletCreatedSuccessfully(
source: String,
creationType: WalletCreationType = WalletCreationType.NewSeed,
seedPhraseLength: Int? = null,
passPhraseState: AnalyticsParam.EmptyFull,
) : CreateWallet(
event = "Wallet Created Successfully",
params = buildMap {
put(AnalyticsParam.SOURCE, source)
put("Creation Type", creationType.value)
put("Passphrase", passPhraseState.value)
if (seedPhraseLength != null) {
put("Seed Phrase Length", seedPhraseLength.toString())
}
},
)
sealed class WalletCreationType(val value: String) {
data object NewSeed : WalletCreationType(value = "New Seed")
data object SeedImport : WalletCreationType(value = "Seed Import")
}
}
sealed class SeedPhrase(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingAnalyticsEvent(category = "Onboarding / Seed Phrase", event = event, params = params) {
class CreateMobileScreenOpened(
source: String,
) : SeedPhrase(
event = "Create Mobile Screen Opened",
params = mapOf(
AnalyticsParam.SOURCE to source,
),
)
class ButtonImportWallet : SeedPhrase("Button - Import Wallet")
class ImportSeedPhraseScreenOpened : SeedPhrase("Import Seed Phrase Screen Opened")
class ButtonImport : SeedPhrase("Button - Import")
}
sealed class Error(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingAnalyticsEvent(category = "Error", event = event, params = params) {
data class OfflineAttestationFailed(
val source: AnalyticsParam.ScreensSources,
) : Onboarding(
) : Error(
event = "Offline Attestation Failed",
params = mapOf(AnalyticsParam.SOURCE to source.value),
)

View file

@ -0,0 +1,54 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class SignIn(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent("Sign In", event, params) {
data class ScreenOpened(
val walletsCount: Int,
) : SignIn(
event = "Sign In Screen Opened",
params = mapOf(
"Wallets Count" to walletsCount.toString(),
),
)
class ButtonBiometricSignIn : SignIn(event = "Button - Biometric Sign In")
class ButtonUnlockAllWithBiometric : SignIn(event = "Button - Unlock All With Biometric")
data class ErrorBiometricUpdated(
val isFromUnlockAll: Boolean,
) : SignIn(event = "Error - Biometric Updated")
class ButtonWallet(
signInType: SignInType,
walletsCount: Int,
) : SignIn(
event = "Button - Wallet",
params = buildMap {
put("Wallets Count", walletsCount.toString())
put("Sign in type", signInType.value)
},
) {
enum class SignInType(val value: String) {
Card("Card"),
Biometric("Biometric"),
NoSecurity("No Security"),
AccessCode("Access Code"),
}
}
data class ButtonAddWallet(
val sources: AnalyticsParam.ScreensSources,
) : SignIn(
event = "Button - Add Wallet",
params = mapOf(
AnalyticsParam.SOURCE to sources.value,
),
)
}

View file

@ -33,7 +33,7 @@
},
{
"name": "HOT_WALLET_ENABLED",
"version": "undefined"
"version": "5.32.0"
},
{
"name": "TANGEM_PAY_ENABLED",

View file

@ -22,6 +22,9 @@ enum class ApiEnvironment {
@Json(name = "STAGE")
STAGE,
@Json(name = "STAGE_2")
STAGE_2,
@Json(name = "MOCK")
MOCK,

View file

@ -30,6 +30,7 @@ internal class Express(
createDev2Environment(),
createDev3Environment(),
createStageEnvironment(),
createStage2Environment(),
createMockedEnvironment(),
createProdEnvironment(),
)
@ -73,6 +74,12 @@ internal class Express(
headers = createHeaders(isProd = false),
)
private fun createStage2Environment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE_2,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend
@ -22,12 +23,7 @@ internal class P2PEthPool(
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
else -> if (P2PStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD
}
}

View file

@ -31,7 +31,7 @@ internal class TangemPay(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/bff/",
baseUrl = "https://api.dev.us.paera.com/bff-v2/",
headers = createHeaders(),
)
@ -43,7 +43,7 @@ internal class TangemPay(
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/bff/",
baseUrl = "https://api.us.paera.com/bff-v2/",
headers = createHeaders(),
)

View file

@ -77,6 +77,7 @@ internal class YieldSupply(
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
-> environmentConfigStorage.getConfigSync().yieldModuleApiKeyDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().yieldModuleApiKey
} ?: error("No tangem tech api config provided")

View file

@ -23,9 +23,7 @@ interface P2PEthPoolApi {
* @param network Ethereum pool network: "mainnet" or "hoodi" (testnet)
*/
@GET("api/v1/staking/pool/{network}/vaults")
suspend fun getVaults(
@Path("network") network: String = "mainnet",
): ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>>
suspend fun getVaults(@Path("network") network: String): ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>>
/**
* Prepare deposit transaction

View file

@ -26,6 +26,7 @@ data class TokenMarketListResponse(
@Json(name = "market_cap") val marketCap: BigDecimal?,
@Json(name = "is_under_market_cap_limit") val isUnderMarketCapLimit: Boolean?,
@Json(name = "staking_opportunities") val stakingOpportunities: List<StakingOpportunities>?,
@Json(name = "max_yield_apy") val maxYieldApy: BigDecimal?,
) {
@JsonClass(generateAdapter = true)

View file

@ -38,6 +38,6 @@ interface NewsApi {
private companion object {
private const val NEWS_PATH = "api/v1/news"
private const val NEWS_PATH = "v1/news"
}
}

View file

@ -5,11 +5,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsTrendingResponse(
@Json(name = "meta") val meta: NewsTrendingMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsTrendingMetaDto(
@Json(name = "limit") val limit: Int,
)

View file

@ -3,109 +3,13 @@ package com.tangem.datasource.api.pay
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.pay.models.request.*
import com.tangem.datasource.api.pay.models.response.*
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.*
private const val TX_HISTORY_PAGING_DEFAULT_LIMIT = 20
@Suppress("TooManyFunctions")
interface TangemPayApi {
// region: auth
@POST("v1/auth/challenge")
suspend fun generateNonceByCardId(@Body request: GenerateNoneByCardIdRequest): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/challenge")
suspend fun generateNonceByCardWallet(
@Body request: GenerateNoneByCardWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token")
suspend fun getTokenByCustomerWallet(@Body request: GetTokenByCustomerWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCustomerWalletAccessToken(
@Body request: RefreshCustomerWalletAccessTokenRequest,
): ApiResponse<JWTResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCardIdAccessToken(@Body request: RefreshTokenByCardIdRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCardWalletAccessToken(@Body request: RefreshTokenByCardWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/exchange")
suspend fun exchangeAccessToken(@Body request: ExchangeAccessTokenRequest): ApiResponse<JWTResponse>
// endregion
// region: activation
@POST("v1/activation/status")
suspend fun getRemoteActivationStatus(
@Header("Authorization") authHeader: String,
@Body request: ActivationStatusRequest,
): ApiResponse<CardActivationRemoteStateResponse>
@POST("v1/activation/acceptance/message")
suspend fun getCardWalletAcceptance(
@Header("Authorization") authHeader: String,
@Body request: GetCardWalletAcceptanceRequest,
): ApiResponse<VisaDataToSignResponse>
@POST("v1/activation/acceptance/message")
suspend fun getCustomerWalletAcceptance(
@Header("Authorization") authHeader: String,
@Body request: GetCustomerWalletAcceptanceRequest,
): ApiResponse<VisaDataToSignResponse>
@POST("v1/activation/data")
suspend fun activateByCardWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCardWalletRequest,
): ApiResponse<Unit>
@POST("v1/activation/data")
suspend fun activateByCustomerWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCustomerWalletRequest,
): ApiResponse<Unit>
@POST("v1/activation/pin")
suspend fun setPinCode(
@Header("Authorization") authHeader: String,
@Body body: SetPinCodeRequest,
): ApiResponse<Unit>
// endregion
@GET("customer/info")
suspend fun getCustomerInfo(
@Header("Authorization") authHeader: String,
@Query("card_id") cardId: String,
): ApiResponse<VisaCustomerInfo>
@GET("product_instance/transactions")
suspend fun getTxHistory(
@Header("Authorization") authHeader: String,
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): ApiResponse<VisaTxHistoryResponse>
@GET("v1/customer/transactions")
suspend fun getTangemPayTxHistory(
@Header("Authorization") authHeader: String,
@ -128,6 +32,9 @@ interface TangemPayApi {
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
@GET("v1/customer/eligibility")
suspend fun checkCustomerEligibility(): ApiResponse<CustomerEligibilityResponse>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,

View file

@ -5,5 +5,10 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CheckCustomerWalletResponse(
@Json(name = "id") val id: String?,
)
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
data class Result(
@Json(name = "id") val id: String?,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerEligibilityResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "is_tangem_pay_available") val isTangemPayAvailable: Boolean,
)
}

View file

@ -12,11 +12,11 @@ data class CustomerMeResponse(
data class Result(
@Json(name = "id") val id: String,
@Json(name = "state") val state: String,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "created_at") val createdAt: String,
@Json(name = "product_instance") val productInstance: ProductInstance?,
@Json(name = "payment_account") val paymentAccount: PaymentAccount?,
@Json(name = "kyc") val kyc: Kyc?,
@Json(name = "depositAddress") val depositAddress: String?,
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: BalanceResponse?,
)
@ -24,49 +24,49 @@ data class CustomerMeResponse(
@JsonClass(generateAdapter = true)
data class ProductInstance(
@Json(name = "id") val id: String,
@Json(name = "cid") val cid: String,
@Json(name = "cid") val cid: String?,
@Json(name = "card_id") val cardId: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String?,
@Json(name = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
) {
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "new")
@Json(name = "NEW")
NEW,
@Json(name = "ready_for_manufacturing")
@Json(name = "READY_FOR_MANUFACTURING")
READY_FOR_MANUFACTURING,
@Json(name = "manufacturing")
@Json(name = "MANUFACTURING")
MANUFACTURING,
@Json(name = "sent_to_delivery")
@Json(name = "SENT_TO_DELIVERY")
SENT_TO_DELIVERY,
@Json(name = "delivered")
@Json(name = "DELIVERED")
DELIVERED,
@Json(name = "activating")
@Json(name = "ACTIVATING")
ACTIVATING,
@Json(name = "active")
@Json(name = "ACTIVE")
ACTIVE,
@Json(name = "blocked")
@Json(name = "BLOCKED")
BLOCKED,
@Json(name = "deactivating")
@Json(name = "DEACTIVATING")
DEACTIVATING,
@Json(name = "deactivated")
@Json(name = "DEACTIVATED")
DEACTIVATED,
@Json(name = "canceled")
@Json(name = "CANCELED")
CANCELED,
@Json(name = "unknown")
@Json(name = "UNKNOWN")
UNKNOWN,
}
}
@ -91,8 +91,8 @@ data class CustomerMeResponse(
@JsonClass(generateAdapter = true)
data class Card(
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: Int,
@Json(name = "expiration_year") val expirationYear: Int,
@Json(name = "expiration_month") val expirationMonth: String,
@Json(name = "expiration_year") val expirationYear: String,
@Json(name = "emboss_name") val embossName: String,
@Json(name = "card_type") val cardType: String,
@Json(name = "card_status") val cardStatus: String,

View file

@ -4,7 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class VisaErrorResponse(
data class TangemPayErrorResponse(
@Json(name = "error") val error: Error,
) {
@JsonClass(generateAdapter = true)

View file

@ -50,6 +50,12 @@ interface TangemTechApi {
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@PUT("/v1/wallets/{walletId}/tokens")
suspend fun saveTokens(
@Path(value = "walletId") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
/** Returns referral status by [walletId] */
@GET("v1/referral/{walletId}")
suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse<ReferralResponse>
@ -129,6 +135,12 @@ interface TangemTechApi {
@Body body: List<WalletIdBody>,
): ApiResponse<Unit>
@PUT("/v1/user-wallets/applications/{application_id}/wallets")
suspend fun associateApplicationIdWithWalletsV2(
@Path("application_id") applicationId: String,
@Body body: AssociateApplicationIdWithWalletsBody,
): ApiResponse<Unit>
@GET("v1/user-wallets/wallets/{wallet_id}")
suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse<WalletResponse>

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssociateAppWithWalletsErrorResponse(
@Json(name = "missingWalletIds") val missingWalletIds: List<String>,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssociateApplicationIdWithWalletsBody(
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -19,6 +19,7 @@ data class GetWalletAccountsResponse(
@Json(name = "group") val group: GroupType?,
@Json(name = "sort") val sort: SortType?,
@Json(name = "totalAccounts") val totalAccounts: Int,
@Json(name = "totalArchivedAccounts") val totalArchivedAccounts: Int,
)
}

View file

@ -0,0 +1,104 @@
package com.tangem.datasource.api.visa
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardIdRequest
import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardWalletRequest
import com.tangem.datasource.api.pay.models.request.SetPinCodeRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationStatusRequest
import com.tangem.datasource.api.visa.models.request.ExchangeAccessTokenRequest
import com.tangem.datasource.api.visa.models.request.GenerateNoneByCardIdRequest
import com.tangem.datasource.api.visa.models.request.GenerateNoneByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.GetAccessTokenByCardIdRequest
import com.tangem.datasource.api.visa.models.request.GetAccessTokenByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.GetCardWalletAcceptanceRequest
import com.tangem.datasource.api.visa.models.request.GetCustomerWalletAcceptanceRequest
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.datasource.api.visa.models.response.GenerateNonceResponse
import com.tangem.datasource.api.visa.models.response.JWTResponse
import com.tangem.datasource.api.visa.models.response.VisaCustomerInfo
import com.tangem.datasource.api.visa.models.response.VisaDataToSignResponse
import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
interface VisaApi {
@POST("v1/auth/token/refresh")
suspend fun refreshCardWalletAccessToken(@Body request: RefreshTokenByCardWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/challenge")
suspend fun generateNonceByCardId(@Body request: GenerateNoneByCardIdRequest): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/challenge")
suspend fun generateNonceByCardWallet(
@Body request: GenerateNoneByCardWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCardIdAccessToken(@Body request: RefreshTokenByCardIdRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/exchange")
suspend fun exchangeAccessToken(@Body request: ExchangeAccessTokenRequest): ApiResponse<JWTResponse>
@POST("v1/activation/status")
suspend fun getRemoteActivationStatus(
@Header("Authorization") authHeader: String,
@Body request: ActivationStatusRequest,
): ApiResponse<CardActivationRemoteStateResponse>
@POST("v1/activation/acceptance/message")
suspend fun getCardWalletAcceptance(
@Header("Authorization") authHeader: String,
@Body request: GetCardWalletAcceptanceRequest,
): ApiResponse<VisaDataToSignResponse>
@POST("v1/activation/acceptance/message")
suspend fun getCustomerWalletAcceptance(
@Header("Authorization") authHeader: String,
@Body request: GetCustomerWalletAcceptanceRequest,
): ApiResponse<VisaDataToSignResponse>
@POST("v1/activation/data")
suspend fun activateByCardWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCardWalletRequest,
): ApiResponse<Unit>
@POST("v1/activation/data")
suspend fun activateByCustomerWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCustomerWalletRequest,
): ApiResponse<Unit>
@POST("v1/activation/pin")
suspend fun setPinCode(
@Header("Authorization") authHeader: String,
@Body body: SetPinCodeRequest,
): ApiResponse<Unit>
@GET("customer/info")
suspend fun getCustomerInfo(
@Header("Authorization") authHeader: String,
@Query("card_id") cardId: String,
): ApiResponse<VisaCustomerInfo>
@GET("product_instance/transactions")
suspend fun getTxHistory(
@Header("Authorization") authHeader: String,
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): ApiResponse<VisaTxHistoryResponse>
}

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.request
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.response
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.response
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.response
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.response
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.response
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.pay.models.response
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View file

@ -15,4 +15,7 @@ interface AppCurrencyResponseStore {
/** Get [CurrenciesResponse.Currency] synchronously or null */
suspend fun getSyncOrNull(): CurrenciesResponse.Currency?
/** Store [CurrenciesResponse.Currency] */
suspend fun store(currency: CurrenciesResponse.Currency)
}

View file

@ -5,6 +5,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import kotlinx.coroutines.flow.Flow
/**
@ -25,4 +26,11 @@ internal class DefaultAppCurrencyResponseStore(
PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
)
}
override suspend fun store(currency: CurrenciesResponse.Currency) {
appPreferencesStore.storeObject(
PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
currency,
)
}
}

View file

@ -5,7 +5,6 @@ import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.MoonPay
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MockApiConfigsManager
@ -21,6 +20,7 @@ import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.api.visa.VisaApi
import com.tangem.datasource.di.utils.RetrofitApiBuilder
import com.tangem.datasource.di.utils.RetrofitApiBuilder.Timeouts
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -129,7 +129,16 @@ internal object NetworkModule {
@Provides
@Singleton
fun provideTangemVisaApi(retrofitApiBuilder: RetrofitApiBuilder): TangemPayApi {
fun provideTangemPayApi(retrofitApiBuilder: RetrofitApiBuilder): TangemPayApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideVisaApi(retrofitApiBuilder: RetrofitApiBuilder): VisaApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.datastore.RuntimeDataStore
@ -77,6 +78,24 @@ internal object StakingStoreModule {
return DefaultStakingActionsStore(dataStore = RuntimeDataStore())
}
@Provides
@Singleton
fun provideP2PBalancesPersistenceStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): DataStore<Map<String, Set<P2PEthPoolAccountResponse>>> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes(valueTypes = setTypes<P2PEthPoolAccountResponse>()),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "p2p_balances") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
@Provides
@Singleton
fun provideP2PEthPoolVaultsStore(

View file

@ -1,25 +1,25 @@
package com.tangem.datasource.local.news.trending
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private typealias TrendingCache = Map<String, List<ShortArticle>>
private typealias TrendingCache = Map<String, TrendingNews>
internal class DefaultTrendingNewsStore(
private val store: RuntimeSharedStore<TrendingCache>,
) : TrendingNewsStore {
override fun get(key: String): Flow<List<ShortArticle>> {
return store.get().map { it[key].orEmpty() }
override fun get(key: String): Flow<TrendingNews> {
return store.get().map { it[key] ?: TrendingNews.Data(emptyList()) }
}
override suspend fun getSyncOrNull(key: String): List<ShortArticle>? {
override suspend fun getSyncOrNull(key: String): TrendingNews? {
return store.getSyncOrNull()?.get(key)
}
override suspend fun store(key: String, value: List<ShortArticle>) {
override suspend fun store(key: String, value: TrendingNews) {
store.update(emptyMap()) { current ->
current + (key to value)
}

View file

@ -1,15 +1,15 @@
package com.tangem.datasource.local.news.trending
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import kotlinx.coroutines.flow.Flow
interface TrendingNewsStore {
fun get(key: String): Flow<List<ShortArticle>>
fun get(key: String): Flow<TrendingNews>
suspend fun getSyncOrNull(key: String): List<ShortArticle>?
suspend fun getSyncOrNull(key: String): TrendingNews?
suspend fun store(key: String, value: List<ShortArticle>)
suspend fun store(key: String, value: TrendingNews)
suspend fun clear()
}

View file

@ -27,6 +27,8 @@ object PreferencesKeys {
val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") }
val ROOT_DETECTED_WARNING_SHOWN_KEY by lazy { booleanPreferencesKey(name = "rootDetectedWarningShown") }
val SHOULD_SHOW_ASK_BIOMETRY_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
@ -77,8 +79,8 @@ object PreferencesKeys {
val SHOULD_SHOW_MARKETS_TOOLTIP_KEY by lazy { booleanPreferencesKey(name = "shouldShowMarketsTooltip") }
val MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY by lazy {
booleanPreferencesKey(name = "marketsStakingNotificationHideClicked")
val MARKETS_YIELD_SUPPLY_NOTIFICATION_HIDE_CLICKED_KEY by lazy {
booleanPreferencesKey(name = "marketsYieldSupplyNotificationHideClicked")
}
val WALLET_FIRST_USAGE_DATE_KEY by lazy { longPreferencesKey(name = "walletFirstUsageDate") }
@ -108,6 +110,8 @@ object PreferencesKeys {
val ONRAMP_TRANSACTIONS_STATUSES_KEY by lazy { stringPreferencesKey(name = "onrampTransactionsStatuses") }
val ONRAMP_HANDLED_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "onrampHandledTransactions") }
val ONBOARDING_FINALIZE_SCAN_RESPONSE_KEY by lazy { stringPreferencesKey(name = "onboardingFinalizeScanResponse") }
val IS_GOOGLE_SERVICES_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGoogleServicesAvailable") }
@ -192,6 +196,9 @@ object PreferencesKeys {
fun getTangemPayCheckCustomerByWalletId(userWalletId: UserWalletId) =
booleanPreferencesKey("tangem_pay_check_customer_by_wallet_id_$userWalletId")
fun getTangemPayHideOnboardingKey(userWalletId: UserWalletId) =
booleanPreferencesKey("tangem_pay_hide_onboarding_key_$userWalletId")
// endregion
}

View file

@ -4,28 +4,32 @@ import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.staking.YieldBalanceItem
import com.tangem.utils.converter.Converter
import kotlinx.datetime.Instant
class YieldBalanceConverter(
/**
* Converts StakeKit DTO to [StakingBalance].
* Returns [StakingBalance.Data.StakeKit] for non-empty balances, [StakingBalance.Empty] otherwise.
*/
class StakingBalanceConverter(
private val source: StatusSource,
) : Converter<YieldBalanceWrapperDTO, YieldBalance?> {
) : Converter<YieldBalanceWrapperDTO, StakingBalance?> {
constructor(isCached: Boolean) : this(source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL)
override fun convert(value: YieldBalanceWrapperDTO): YieldBalance? {
override fun convert(value: YieldBalanceWrapperDTO): StakingBalance? {
val stakingId = StakingID(
integrationId = value.integrationId ?: return null,
address = value.addresses.address,
)
return if (value.balances.isEmpty()) {
YieldBalance.Empty(stakingId = stakingId, source = source)
StakingBalance.Empty(stakingId = stakingId, source = source)
} else {
YieldBalance.Data(
StakingBalance.Data.StakeKit(
stakingId = stakingId,
balance = YieldBalanceItem(
items = value.balances

View file

@ -30,5 +30,9 @@ interface TangemPayStorage {
suspend fun deleteWithdrawOrder(userWalletId: UserWalletId)
suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean
suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean)
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
}

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
@ -250,7 +251,7 @@ internal class ProdApiConfigsManagerTest {
id = ApiConfig.ID.TangemPay,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/bff/",
baseUrl = "https://api.dev.us.paera.com/bff-v2/",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
@ -299,11 +300,17 @@ internal class ProdApiConfigsManagerTest {
}
private fun createP2PModel(): TestModel {
val (environment, baseUrl) = if (P2PStakingConfig.USE_TESTNET) {
ApiEnvironment.DEV to "https://api-test.p2p.org/"
} else {
ApiEnvironment.PROD to "https://api.p2p.org/"
}
return TestModel(
id = ApiConfig.ID.P2PEthPool,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.p2p.org/",
environment = environment,
baseUrl = baseUrl,
headers = mapOf(
"Authorization" to ProviderSuspend { "Bearer $P2P_API_KEY" },
"accept" to ProviderSuspend { "application/json" },

View file

@ -0,0 +1,22 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:BatchAction.kt$BatchAction.UpdateBatches$val async: Boolean = false</ID>
<ID>BooleanPropertyNaming:BatchFetchResult.kt$BatchFetchResult.Success$val empty: Boolean</ID>
<ID>BooleanPropertyNaming:BatchFetchResult.kt$BatchFetchResult.Success$val last: Boolean</ID>
<ID>BooleanPropertyNaming:BatchListSource.kt$DefaultBatchListSource$val started = job.start()</ID>
<ID>MultilineLambdaItParameter:BatchListSource.kt$DefaultBatchListSource${ currentCoroutineContext().ensureActive() BatchFetchResult.Error(it) }</ID>
<ID>MultilineLambdaItParameter:BatchListSource.kt$DefaultBatchListSource${ if (predicate(it.first)) { it.second.cancel() null } else { it } }</ID>
<ID>MultilineLambdaItParameter:CursorBatchFetcher.kt$CursorBatchFetcher${ currentCoroutineContext().ensureActive() return BatchFetchResult.Error(it) }</ID>
<ID>MultilineLambdaItParameter:LimitOffsetBatchFetcher.kt$LimitOffsetBatchFetcher${ currentCoroutineContext().ensureActive() BatchFetchResult.Error(it) }</ID>
<ID>NamedArguments:BatchListSource.kt$DefaultBatchListSource(fetchDispatcher, context, generateNewKey, batchFetcher, null)</ID>
<ID>NamedArguments:BatchListSource.kt$DefaultBatchListSource(fetchDispatcher, context, generateNewKey, batchFetcher, updateFetcher)</ID>
<ID>NestedScopeFunctions:BatchListSource.kt$DefaultBatchListSource$also { currentCoroutineContext().ensureActive() }</ID>
<ID>SuspendFunSwallowedCancellation:BatchListSource.kt$DefaultBatchListSource$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:CursorBatchFetcher.kt$CursorBatchFetcher$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:LimitOffsetBatchFetcher.kt$LimitOffsetBatchFetcher$runCatching</ID>
<ID>UseEmptyCounterpart:BatchListSource.kt$DefaultBatchListSource$listOf()</ID>
<ID>UseOrEmpty:BatchListSource.kt$DefaultBatchListSource$batch?.let { listOf(it) } ?: emptyList()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -2017,7 +2017,7 @@
<string name="yield_module_transaction_exit_subtitle">%1$s выведено из Aave</string>
<string name="yield_module_transaction_initialize">Режим доходности инициализирован</string>
<string name="yield_module_transaction_reactivate">Режим доходности реактивирован</string>
<string name="yield_module_transaction_topup">Перевод средств в Aave</string>
<string name="yield_module_transaction_topup">Перевод в Aave</string>
<string name="yield_module_transaction_topup_subtitle">%1$s отправлено в Aave</string>
<string name="yield_module_transaction_withdraw">Вывод из Aave</string>
<string name="yield_module_transfer_mode_automatic">Автоматически</string>

View file

@ -103,10 +103,10 @@
<string name="app_settings_enable_biometrics_description">Go to settings to enable biometric authentication in the Tangem App</string>
<string name="app_settings_enable_biometrics_title">Enable biometric authentication</string>
<string name="app_settings_off_biometrics_alert_message">Disabling %1$s will require you to enter your passcode to unlock the app and to interact with your wallet.</string>
<string name="app_settings_off_require_access_code_alert_message">Youll be asked for your wallets access code later so we can securely store it for future use</string>
<string name="app_settings_off_saved_access_code_alert_message">This will delete all the saved wallet access codes. Any further operation with the wallet will require submitting the access code.</string>
<string name="app_settings_off_require_access_code_alert_message">You\'ll be asked for your access code later for secure storage.</string>
<string name="app_settings_off_saved_access_code_alert_message">This will delete all saved wallet access codes. You\'ll need to enter the access code again to use the wallet.</string>
<string name="app_settings_off_saved_wallet_alert_message">Removing the saved devices deletes all the saved wallets and their access codes from the app.</string>
<string name="app_settings_on_require_access_code_alert_message">This will delete all the saved wallet access codes. Any further interaction with the wallet will require submitting the access code.</string>
<string name="app_settings_on_require_access_code_alert_message">This will delete all saved wallet access codes. Youll need to enter the access code again to use the wallet.</string>
<string name="app_settings_require_access_code">Require Access Code</string>
<string name="app_settings_require_access_code_footer">This option turns off biometrics for sensitive actions. Youll need to enter your access code each time you sign a transaction.</string>
<string name="app_settings_saved_access_codes">Save Access Code</string>
@ -145,10 +145,10 @@
<string name="balance_hidden_title">Balances are hidden</string>
<string name="beta_mode_warning_message">According to the blockchain developers, Kaspa tokens are currently in beta. Stay tuned for updates!</string>
<string name="beta_mode_warning_title">Beta Mode</string>
<string name="biometric_disabled_warning_description">Biometrics are turned off on your device, so you cant use them to unlock your wallets. Enable biometrics in your device settings to use this method again.</string>
<string name="biometric_disabled_warning_description">Biometrics are turned off on your device, so you can\'t use them to unlock your wallets. Enable biometrics in your device settings to use this method again.</string>
<string name="biometric_disabled_warning_title">Biometric authentication disabled</string>
<string name="biometric_lockout_permanent_warning_description">Please scan the card or ring</string>
<string name="biometric_lockout_permanent_warning_description_2">Youve reached the limit of biometric attempts. Please unlock your wallet with a device tap or enter your access code.</string>
<string name="biometric_lockout_permanent_warning_description_2">You\'ve reached the limit of biometric attempts. Please unlock your wallet with a card/ring or enter your access code.</string>
<string name="biometric_lockout_permanent_warning_title">Biometric authentication locked</string>
<string name="biometric_lockout_warning_description">Please try again in 30 seconds or scan the card or ring</string>
<string name="biometric_lockout_warning_description_2">Biometric login is temporarily locked. Please try again in 30 seconds, or unlock your wallet with a device tap or access code.</string>
@ -182,7 +182,7 @@
<string name="card_reset_alert_finish_ok_button">Upgrade again</string>
<string name="card_reset_alert_finish_title">Reset complete</string>
<string name="card_reset_alert_incomplete_message">We recommend completing the reset process for all Tangem devices in this wallet.</string>
<string name="card_reset_alert_incomplete_title">You havent reset all your Tangem devices</string>
<string name="card_reset_alert_incomplete_title">Some Tangem devices still need to be reset.</string>
<string name="card_settings_access_code_recovery_disabled_description">Disable this option if you don\'t want this card to be used to reset access codes on other cards or rings in this wallet. Please note that this will also prevent you from resetting the access code on this card.</string>
<string name="card_settings_access_code_recovery_enabled_description">Allows you to use this card to reset access code on other cards in this wallet</string>
<string name="card_settings_access_code_recovery_title">Access code recovery</string>
@ -384,8 +384,9 @@
<string name="common_transaction_status">Transaction status</string>
<string name="common_transactions">Transactions</string>
<string name="common_transfer">Transfer</string>
<string name="common_unable_to_load">Unable to load the data…</string>
<string name="common_unable_to_load">Unable to load data…</string>
<string name="common_understand">I understand</string>
<string name="common_understand_continue">I understand, continue</string>
<string name="common_unknown_error">There was an error. Please try again.</string>
<string name="common_unreachable">Unreachable</string>
<string name="common_unstake">Unstake</string>
@ -575,7 +576,7 @@
<string name="home_button_create_new_wallet">Create New Wallet</string>
<string name="home_button_order">Order Tangem</string>
<string name="home_button_scan">Scan Tangem</string>
<string name="hot_access_code_set_biometric_ask">Do you want to allow “Tangem” to use biometric authentication? To confirm your identity and open the app</string>
<string name="hot_access_code_set_biometric_ask">Do you want to allow \"Tangem\" to use biometric authentication to confirm your identity and open the app?</string>
<string name="hot_crypto_add_token_subtitle">to %s</string>
<string name="hot_crypto_token_network">On %s network</string>
<string name="hw_access_code_create_alert_title">Are you sure you want to cancel access code setup?</string>
@ -799,7 +800,7 @@
<string name="markets_tooltip_title">Add tokens</string>
<string name="markets_yield_supply_banner_description">Power up your assets while supplying them with instant access. %s</string>
<string name="markets_yield_supply_banner_title">Activate Yield Mode</string>
<string name="mobile_wallet_requires_min_os_warning_body">You must update to %1$s in order to create mobile wallet</string>
<string name="mobile_wallet_requires_min_os_warning_body">You must update to %1$s before creating a mobile wallet</string>
<string name="mobile_wallet_requires_min_os_warning_title">Mobile Wallet requires %1$s or later</string>
<string name="news_all_news">All news</string>
<plurals name="news_published_hours_ago">
@ -1090,6 +1091,8 @@
<string name="reset_cards_dialog_next_device_description">Please reset the next device to continue</string>
<string name="ring_promo_text">Ring owners get 3 commission-free swaps on Changelly until 15.11!</string>
<string name="ring_promo_title">Swap With 0% Fees Now!</string>
<string name="root_detected_warning_description">Devices with root access are considered less secure. Your data may be exposed to additional risks.</string>
<string name="root_detected_warning_title">Root access detected</string>
<string name="save_user_wallet_agreement_access_description">Log into the app and check your balance without scanning the card or ring</string>
<string name="save_user_wallet_agreement_access_title">Access the app</string>
<string name="save_user_wallet_agreement_allow_biometrics">Allow to use biometrics</string>
@ -1426,6 +1429,7 @@
<string name="tangem_pay_freeze_card_success">Your card is frozen.</string>
<string name="tangem_pay_get_help">Get Help</string>
<string name="tangem_pay_other">Other</string>
<string name="tangem_pay_rooted_device_subtitle">Unable to use on rooted devices</string>
<string name="tangem_pay_status_completed">Completed</string>
<string name="tangem_pay_status_declined">Declined</string>
<string name="tangem_pay_status_pending">Pending</string>
@ -1598,7 +1602,7 @@
<string name="user_push_notification_agreement_header">Would you like to use\nPush-notifications?</string>
<string name="user_push_notification_banner_subtitle">Enable push notifications to receive alerts when funds arrive in your wallet.</string>
<string name="user_push_notification_banner_title">Don\'t Miss a Transaction</string>
<string name="user_wallet_list_add_button">Add new wallet</string>
<string name="user_wallet_list_add_button">Add Wallet</string>
<string name="user_wallet_list_delete_hw_prompt">If you delete this wallet without a backup, you will permanently lose access to your funds.</string>
<string name="user_wallet_list_delete_prompt">Are you sure you want to forget this wallet?</string>
<string name="user_wallet_list_error_unable_to_unlock">An error has occurred, please scan your card or ring to log in</string>
@ -1759,7 +1763,7 @@
<string name="wallet_settings_change_access_code_title">Change access code</string>
<string name="wallet_settings_push_notifications_description">Stay notified on wallet incoming transactions and Tangem updates.</string>
<string name="wallet_settings_push_notifications_huawei_warning">Push notifications may currently not work on Huawei devices. We\'re actively working on a solution and will release a fix in an upcoming update. Thank you for your understanding!</string>
<string name="wallet_settings_push_notifications_title">Transaction Notifications</string>
<string name="wallet_settings_push_notifications_title">Transaction notifications</string>
<string name="wallet_settings_set_access_code_title">Set access code</string>
<string name="wallet_settings_title">Wallet settings</string>
<string name="wallet_title">Tangem</string>
@ -1952,7 +1956,7 @@
<string name="wc_warning_transaction">Suspicious transaction</string>
<string name="welcome_create_wallet_already_have">Already have Tangem Wallet?</string>
<string name="welcome_create_wallet_feature_assets">Thousands of assets</string>
<string name="welcome_create_wallet_feature_class">Best in class hardware wallet</string>
<string name="welcome_create_wallet_feature_class">Top-tier hardware wallet</string>
<string name="welcome_create_wallet_feature_delivery">Fast delivery</string>
<string name="welcome_create_wallet_feature_one_tap">Start in one tap</string>
<string name="welcome_create_wallet_feature_seamless">Seamless and secure</string>
@ -1961,7 +1965,7 @@
<string name="welcome_create_wallet_mobile_description">Create or import a software wallet</string>
<string name="welcome_create_wallet_mobile_description_full">Create or import a software wallet on your phone.</string>
<string name="welcome_create_wallet_mobile_title">Start with Mobile Wallet</string>
<string name="welcome_create_wallet_other_method">Other method</string>
<string name="welcome_create_wallet_other_method">Other methods</string>
<string name="welcome_create_wallet_use_hardware_description">Use a Tangem hardware wallet</string>
<string name="welcome_create_wallet_use_hardware_title">Learn more &amp; buy</string>
<string name="welcome_interrupted_backup_alert_discard">Discard</string>

1
core/security/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,4 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}

View file

@ -0,0 +1,9 @@
package com.tangem.security
interface DeviceSecurityInfoProvider {
val isRooted: Boolean
val isBootloaderUnlocked: Boolean
val isXposed: Boolean
}
fun DeviceSecurityInfoProvider.isSecurityExposed(): Boolean = isRooted || isBootloaderUnlocked || isXposed

View file

@ -66,4 +66,6 @@ dependencies {
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -24,7 +24,6 @@
<ID>NoNameShadowing:TextAnimatedCounter.kt$char</ID>
<ID>PropertyUsedBeforeDeclaration:InputManager.kt$InputManager$_query</ID>
<ID>ReusedModifierInstance:EllipsisText.kt$Text( text = layoutText, color = color, style = style, fontStyle = fontStyle, textDecoration = textDecoration, textAlign = textAlign, softWrap = softWrap, maxLines = 1, onTextLayout = { textLayoutResultState.value = it }, modifier = modifier, )</ID>
<ID>ReusedModifierInstance:Label.kt$Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .padding(horizontal = 4.dp) .clip(TangemTheme.shapes.roundedCorners8) .background(color = backgroundColor) .then( if (state.onClick != null) { Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(), onClick = state.onClick, ) } else { Modifier }, ) .padding(horizontal = 8.dp, vertical = 4.dp), ) { Text( modifier = Modifier.weight(1.0f, fill = false), text = text.resolveReference(), style = TangemTheme.typography.caption1, color = textColor, ) AnimatedVisibility(state.icon != null) { val wrappedIcon = remember(this) { requireNotNull(state.icon) } Icon( imageVector = ImageVector.vectorResource(wrappedIcon), tint = iconColor, contentDescription = null, modifier = Modifier .size(16.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = false), onClick = { state.onIconClick?.invoke() }, ), ) } }</ID>
<ID>ReusedModifierInstance:TangemRadioButton.kt$AnimatedVisibility( visible = isSelected, label = "Radio button animation", modifier = modifier .size(TangemTheme.dimens.size24), ) { Icon( painter = painterResource(id = R.drawable.ic_check_circle_24), contentDescription = null, tint = TangemTheme.colors.control.checked, ) }</ID>
<ID>ReusedModifierInstance:TokenPrice.kt$Icon( modifier = modifier, painter = painterResource( id = when (animatedType) { PriceChangeType.UP -&gt; R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -&gt; R.drawable.ic_arrow_down_8 PriceChangeType.NEUTRAL -&gt; R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -&gt; TangemTheme.colors.icon.accent PriceChangeType.DOWN -&gt; TangemTheme.colors.icon.warning PriceChangeType.NEUTRAL -&gt; TangemTheme.colors.icon.inactive }, contentDescription = null, )</ID>
<ID>UnnecessaryEventHandlerParameter:PinTextField.kt$onValueChange: (String) -&gt; Unit</ID>

View file

@ -220,6 +220,8 @@ fun SecondaryButtonIconEnd(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -230,6 +232,8 @@ fun SecondaryButtonIconEnd(
enabled = enabled,
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
@ -244,6 +248,8 @@ fun SecondaryButtonIconStart(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -254,6 +260,8 @@ fun SecondaryButtonIconStart(
enabled = enabled,
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion SecondaryButton

View file

@ -37,44 +37,46 @@ fun DialogFullScreen(
decorFitsSystemWindows = false,
),
content = {
val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow()
val parentView = LocalView.current.parent as View
SideEffect {
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams().apply {
copyFrom(activityWindow.attributes)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
val systemUiController = rememberSystemUiController(getActivityWindow())
val dialogSystemUiController = rememberSystemUiController(getDialogWindow())
ProvideSystemBarsIconsController {
val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow()
val parentView = LocalView.current.parent as View
SideEffect {
systemUiController.setSystemBarsColor(color = Color.Transparent)
dialogSystemUiController.setSystemBarsColor(color = Color.Transparent)
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams().apply {
copyFrom(activityWindow.attributes)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
}
SystemBarsIconsDisposable(darkIcons = LocalIsInDarkTheme.current.not())
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
val systemUiController = rememberSystemUiController(getActivityWindow())
val dialogSystemUiController = rememberSystemUiController(getDialogWindow())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Transparent) {
content()
SideEffect {
systemUiController.setSystemBarsColor(color = Color.Transparent)
dialogSystemUiController.setSystemBarsColor(color = Color.Transparent)
}
}
SystemBarsIconsDisposable(darkIcons = LocalIsInDarkTheme.current.not())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Transparent) {
content()
}
}
},
)

View file

@ -308,13 +308,14 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DialogButtons(
confirmButton: DialogButtonUM,
dismissButton: DialogButtonUM?,
modifier: Modifier = Modifier,
) {
Row(
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(
space = TangemTheme.dimens.spacing4,

View file

@ -2,17 +2,13 @@ package com.tangem.core.ui.components.bottomsheets.message
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -25,6 +21,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTi
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.icons.HighlightedIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -144,20 +141,11 @@ private fun BottomSheetIcon(icon: MessageBottomSheetUMV2.Icon, modifier: Modifie
MessageBottomSheetUMV2.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning
}
Box(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.1F)),
contentAlignment = Alignment.Center,
content = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size32),
painter = painterResource(icon.res),
contentDescription = null,
tint = tint,
)
},
HighlightedIcon(
modifier = modifier,
icon = icon.res,
iconTint = tint,
backgroundColor = backgroundColor,
)
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.components.bottomsheets.state
enum class BottomSheetState {
EXPANDED,
COLLAPSED,
}

View file

@ -39,8 +39,8 @@ fun PinTextField(
pinTextColor: PinTextColor,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
val focusRequester = remember { FocusRequester() }
val textFieldValue = remember(value) {
TextFieldValue(value, selection = TextRange(value.length))
}

View file

@ -0,0 +1,39 @@
package com.tangem.core.ui.components.icons
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
@Composable
fun HighlightedIcon(
@DrawableRes icon: Int,
iconTint: Color,
modifier: Modifier = Modifier,
backgroundColor: Color = iconTint,
) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.1F)),
contentAlignment = Alignment.Center,
content = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size32),
painter = painterResource(icon),
contentDescription = null,
tint = iconTint,
)
},
)
}

View file

@ -8,6 +8,7 @@ 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.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
@ -18,10 +19,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelSize
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
@ -37,6 +44,7 @@ import com.tangem.core.ui.res.TangemThemePreview
*
* @see <a href="https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=4480-1459&t=2QTpi1G7FeTexTFS-4">Figma</a>
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun Label(state: LabelUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
@ -63,12 +71,28 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
},
)
AnimatedContent(targetState = state.text) { text ->
val horizontalArrangementSize = remember {
when (state.size) {
LabelSize.REGULAR -> 4.dp
LabelSize.BIG -> 8.dp
}
}
val paddings = remember {
when (state.size) {
LabelSize.REGULAR -> PaddingValues(horizontal = 8.dp, vertical = 4.dp)
LabelSize.BIG -> PaddingValues(horizontal = 16.dp, vertical = 8.dp)
}
}
AnimatedContent(
modifier = modifier,
targetState = state.text,
) { text ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.padding(horizontal = 4.dp)
horizontalArrangement = Arrangement.spacedBy(horizontalArrangementSize),
modifier = Modifier
.clip(TangemTheme.shapes.roundedCorners8)
.background(color = backgroundColor)
.then(
@ -82,8 +106,34 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
Modifier
},
)
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(paddings),
) {
state.leadingContent.let { leadingContentUM ->
when (leadingContentUM) {
is LabelLeadingContentUM.Token -> {
SubcomposeAsyncImage(
modifier = Modifier.size(16.dp),
model = ImageRequest.Builder(context = LocalContext.current)
.data(leadingContentUM.iconUrl)
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = { CircleShimmer() },
error = {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.tertiary,
shape = CircleShape,
),
)
},
contentDescription = null,
)
}
LabelLeadingContentUM.None -> Unit
}
}
Text(
modifier = Modifier.weight(1.0f, fill = false),
text = text.resolveReference(),
@ -109,6 +159,8 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -118,47 +170,93 @@ private fun LabelPreview() {
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
),
)
Label(
state = LabelUM(
text = TextReference.Str(
"Regular long long long long long long long long long long long long Label",
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
),
style = LabelStyle.REGULAR,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
icon = R.drawable.ic_information_24,
),
)
)
Label(
state = LabelUM(
leadingContent = LabelLeadingContentUM.Token(
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/euro-coin.png",
),
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
),
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str(
"Regular long long long long long long long long long long long long Label",
),
style = LabelStyle.REGULAR,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
icon = R.drawable.ic_information_24,
),
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
size = LabelSize.BIG,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
size = LabelSize.BIG,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
size = LabelSize.BIG,
),
)
}
}
}
}

View file

@ -1,16 +1,29 @@
package com.tangem.core.ui.components.label.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
data class LabelUM(
val text: TextReference,
val style: LabelStyle,
val style: LabelStyle = LabelStyle.REGULAR,
val size: LabelSize = LabelSize.REGULAR,
val leadingContent: LabelLeadingContentUM = LabelLeadingContentUM.None,
@DrawableRes val icon: Int? = null,
val onIconClick: (() -> Unit)? = null,
val onClick: (() -> Unit)? = null,
)
@Immutable
sealed class LabelLeadingContentUM {
data object None : LabelLeadingContentUM()
data class Token(val iconUrl: String) : LabelLeadingContentUM()
}
enum class LabelStyle {
REGULAR, ACCENT, WARNING,
}
enum class LabelSize {
REGULAR, BIG,
}

View file

@ -0,0 +1,130 @@
package com.tangem.core.ui.components.pager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Horizontal pager indicator
*
* @param pagerState state of pager
* @param indicatorCount counter of visible indicator items
*/
@Composable
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorCount: Int = 5) {
val listState = rememberLazyListState()
val indicatorColor = TangemTheme.colors.control.key
val overlayColor = TangemTheme.colors.overlay.secondary
val indicatorSize = 8.dp
val spacing = 4.dp
val totalWidth: Dp = indicatorSize * indicatorCount + spacing * (indicatorCount - 1)
val widthInPx = LocalDensity.current.run { indicatorSize.toPx() }
val currentItem by remember {
derivedStateOf {
pagerState.currentPage
}
}
val itemCount = pagerState.pageCount
LaunchedEffect(key1 = currentItem) {
val viewportSize = listState.layoutInfo.viewportSize
listState.animateScrollToItem(
currentItem,
(widthInPx / 2 - viewportSize.width / 2).toInt(),
)
}
Box(
modifier = modifier
.height(32.dp)
.background(
color = overlayColor,
shape = CircleShape,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
LazyRow(
modifier = Modifier
.width(totalWidth),
state = listState,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
userScrollEnabled = false,
) {
indicatorItems(
itemCount = itemCount,
currentItem = currentItem,
indicatorShape = CircleShape,
activeColor = indicatorColor,
inActiveColor = indicatorColor.copy(alpha = 0.5f),
indicatorSize = indicatorSize,
)
}
}
}
@Suppress("LongParameterList")
private fun LazyListScope.indicatorItems(
itemCount: Int,
currentItem: Int,
indicatorShape: Shape,
activeColor: Color,
inActiveColor: Color,
indicatorSize: Dp,
) {
items(itemCount) { index ->
val isSelected = index == currentItem
Box(
modifier = Modifier
.clip(indicatorShape)
.size(indicatorSize)
.background(
if (isSelected) activeColor else inActiveColor,
indicatorShape,
),
)
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorPreviewFirstPage() {
TangemThemePreview {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(),
contentAlignment = Alignment.Center,
) {
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { 10 },
)
PagerIndicator(pagerState = pagerState)
}
}
}

View file

@ -246,7 +246,8 @@ data class EventMessageAction(
*
* @param onClick The action to perform when the button is clicked. By default, it dismisses the message.
* */
fun cancelAction(onClick: () -> Unit = onDismissRequest) = EventMessageAction(
fun cancelAction(isWarning: Boolean = false, onClick: () -> Unit = onDismissRequest) = EventMessageAction(
isWarning = isWarning,
title = resourceReference(id = R.string.common_cancel),
onClick = onClick,
)

View file

@ -39,6 +39,30 @@ object Dialogs {
)
}
/**
* Hot wallet creation not supported dialog
*
* @param leastSupportedVersion least supported OS version name ex. "Android 10"
* @param onDismiss lambda be invoked when dialog is dismissed
*/
fun hotWalletCreationNotSupportedDialog(leastSupportedVersion: String, onDismiss: () -> Unit = {}): DialogMessage {
return DialogMessage(
title = resourceReference(
id = R.string.mobile_wallet_requires_min_os_warning_title,
formatArgs = wrappedList(leastSupportedVersion),
),
message = resourceReference(
id = R.string.mobile_wallet_requires_min_os_warning_body,
formatArgs = wrappedList(leastSupportedVersion),
),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_got_it),
onClick = {},
),
onDismissRequest = onDismiss,
)
}
/**
* Universal error dialog
*/

View file

@ -3,4 +3,5 @@ package com.tangem.core.ui.test
object DetailsScreenTestTags {
const val SCREEN_CONTAINER = "DETAILS_SCREEN_CONTAINER"
const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM"
const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME"
}

View file

@ -5,6 +5,7 @@ object SendScreenTestTags {
const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE"
const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD"
const val AMOUNT_ERROR_TEXT = "SEND_SCREEN_AMOUNT_ERROR_TEXT"
const val EQUIVALENT_INPUT_AMOUNT = "SEND_SCREEN_EQUIVALENT_INPUT_AMOUNT"
const val EXCHANGE_ICON = "SEND_SCREEN_EXCHANGE_ICON"
const val TOKEN_NAME = "SEND_SCREEN_TOKEN_NAME"

View file

@ -1,7 +1,12 @@
package com.tangem.core.ui.utils
import android.text.format.DateFormat
import com.tangem.core.ui.utils.DateTimeFormatters.dateDDMMYYYY
import com.tangem.core.ui.utils.DateTimeFormatters.dateMMMdd
import com.tangem.core.ui.utils.DateTimeFormatters.dateTimeFormatter
import com.tangem.core.ui.utils.DateTimeFormatters.dateYYYY
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter
import org.joda.time.format.DateTimeFormatterBuilder
@ -80,6 +85,13 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("yyyy")
}
/**
* Example: "June 31"
*/
val dateDMMM: DateTimeFormatter by lazy {
getBestFormatterBySkeleton("d MMMM")
}
/**
* Example: "31.06.2020 12:00", "06/31/2020 12:00", "06/31/2020 12:00 PM"
*/
@ -87,6 +99,23 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("dd.MM.yyyy HH:mm")
}
/**
* Local full date formatter (e.g., "dd MMMM, HH:mm")
*/
val localFullDate: DateTimeFormatter by lazy {
DateTimeFormatterBuilder()
.appendDayOfMonth(2)
.appendLiteral(' ')
.appendMonthOfYearText()
.appendLiteral(", ")
.appendHourOfDay(2)
.appendLiteral(':')
.appendMinuteOfHour(2)
.toFormatter()
.withLocale(Locale.getDefault())
.withZone(DateTimeZone.getDefault())
}
fun formatDate(date: DateTime, formatter: DateTimeFormatter = dateFormatter): String {
return formatter.print(date)
}

View file

@ -37,4 +37,51 @@ fun Long.toTimeFormat(formatter: DateTimeFormatter = DateTimeFormatters.timeForm
*/
fun Long.formatAsDateTime(formatter: DateTimeFormatter): String {
return DateTimeFormatters.formatDate(date = DateTime(this, DateTimeZone.getDefault()), formatter = formatter)
}
/**
* Parses an ISO 8601 date string and compares it to the current UTC time.
*
* @param now The current date to compare against.
* @return A [FormattedDate] subclass.
*/
@Suppress("MagicNumber")
fun getFormattedDate(createdAt: String, now: DateTime): FormattedDate {
val pastDateUtc = try {
DateTime.parse(createdAt)
} catch (_: Exception) {
return FormattedDate.FullDate(createdAt)
}
val pastDateLocal = pastDateUtc.withZone(DateTimeZone.getDefault())
val isToday = pastDateLocal.isToday()
val diffInMillis = now.millis - pastDateUtc.millis
val diffInMinutes = diffInMillis / (1000 * 60)
val diffInHours = diffInMillis / (1000 * 60 * 60)
return when {
diffInMinutes < 1 -> FormattedDate.MinutesAgo(1)
diffInMinutes < 60 -> FormattedDate.MinutesAgo(diffInMinutes.toInt())
diffInHours < 12 && isToday -> FormattedDate.HoursAgo(diffInHours.toInt())
isToday -> {
val timeString = DateTimeFormatters.timeFormatter.print(pastDateLocal)
FormattedDate.Today(timeString)
}
else -> {
val dateString = DateTimeFormatters.localFullDate.print(pastDateLocal)
FormattedDate.FullDate(dateString)
}
}
}
/**
* Representing different formatted date representations.
*/
sealed class FormattedDate {
data class MinutesAgo(val minutes: Int) : FormattedDate()
data class HoursAgo(val hours: Int) : FormattedDate()
data class Today(val time: String) : FormattedDate()
data class FullDate(val date: String) : FormattedDate()
}

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="40" android:viewportWidth="40" android:width="24dp">
<path android:fillColor="#000000" android:pathData="M15.849,26.896C16.954,26.896 17.849,27.728 17.85,28.753V33.615C17.849,34.641 16.954,35.473 15.849,35.473C14.745,35.473 13.85,34.641 13.849,33.615V28.753C13.85,27.727 14.745,26.896 15.849,26.896Z"/>
<path android:fillColor="#000000" android:pathData="M32.454,20.958C33.558,20.958 34.454,21.819 34.454,22.879V33.552C34.454,34.612 33.558,35.473 32.454,35.473C31.35,35.473 30.454,34.612 30.454,33.552V22.879C30.454,21.818 31.349,20.958 32.454,20.958Z"/>
<path android:fillColor="#000000" android:pathData="M7.547,29.467C8.651,29.467 9.547,30.362 9.547,31.467V33.472C9.547,34.576 8.651,35.472 7.547,35.472C6.442,35.472 5.547,34.576 5.547,33.472V31.467C5.547,30.362 6.442,29.467 7.547,29.467Z"/>
<path android:fillColor="#000000" android:pathData="M24.151,23.976C25.256,23.976 26.151,24.755 26.151,25.716V33.732C26.151,34.693 25.256,35.472 24.151,35.472C23.047,35.472 22.151,34.693 22.151,33.732V25.716C22.152,24.755 23.047,23.976 24.151,23.976Z"/>
<path android:fillColor="#000000" android:pathData="M29.46,4.062C29.927,4.036 30.392,4.174 30.77,4.457C31.203,4.781 31.486,5.266 31.556,5.801L32.671,14.396C32.813,15.491 32.04,16.495 30.944,16.637C29.849,16.779 28.846,16.006 28.704,14.911L28.228,11.251C26.87,12.973 25.156,14.988 23.389,16.644C19.165,20.601 14.718,22.22 9.224,21.976C8.12,21.927 7.265,20.992 7.314,19.889C7.363,18.786 8.298,17.931 9.401,17.98C13.802,18.175 17.202,16.958 20.653,13.725C22.214,12.263 23.781,10.43 25.071,8.796L20.395,9.536C19.305,9.709 18.28,8.965 18.107,7.874C17.935,6.784 18.679,5.759 19.769,5.586L29.259,4.083L29.46,4.062Z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M2.667,8C2.667,5.055 5.055,2.667 8,2.667C10.946,2.667 13.333,5.055 13.333,8C13.333,10.946 10.946,13.333 8,13.333C5.055,13.333 2.667,10.946 2.667,8ZM1.333,8C1.333,11.682 4.318,14.667 8,14.667C11.682,14.667 14.667,11.682 14.667,8C14.667,4.318 11.682,1.334 8,1.334C4.318,1.334 1.333,4.318 1.333,8Z"
android:fillColor="#919191"/>
<path
android:pathData="M5.032,11.297C4.984,11.287 4.94,11.263 4.906,11.229C4.871,11.194 4.847,11.15 4.837,11.102C4.827,11.054 4.832,11.005 4.85,10.959L6.315,7.297C6.416,7.045 6.567,6.816 6.758,6.625C6.95,6.433 7.178,6.282 7.429,6.182L11.092,4.717C11.137,4.699 11.188,4.695 11.235,4.704C11.283,4.714 11.328,4.738 11.362,4.772C11.396,4.807 11.42,4.851 11.43,4.899C11.44,4.947 11.435,4.997 11.417,5.042L9.952,8.704C9.851,8.956 9.7,9.185 9.509,9.376C9.317,9.568 9.089,9.718 8.838,9.819L5.175,11.284C5.13,11.302 5.08,11.306 5.032,11.297ZM8.28,8.736C8.425,8.707 8.559,8.636 8.664,8.531C8.768,8.426 8.84,8.292 8.869,8.147C8.898,8.001 8.884,7.85 8.827,7.713C8.77,7.576 8.673,7.46 8.55,7.377C8.427,7.295 8.282,7.251 8.134,7.251C7.935,7.251 7.744,7.33 7.603,7.47C7.463,7.611 7.384,7.802 7.384,8.001C7.384,8.149 7.427,8.294 7.51,8.417C7.592,8.54 7.709,8.637 7.846,8.694C7.983,8.75 8.134,8.765 8.28,8.736Z"
android:fillColor="#919191"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0,0h20v20h-20z"/>
<path
android:pathData="M13.75,1.75C16.73,1.75 19.083,4.093 19.084,7.083C19.084,8.9 18.257,10.495 16.961,12.081C15.673,13.657 13.843,15.315 11.713,17.247L10.505,18.347L10,18.806L9.495,18.347L8.288,17.247C6.157,15.315 4.327,13.657 3.039,12.081C1.742,10.495 0.917,8.9 0.917,7.083C0.917,4.093 3.27,1.75 6.25,1.75C7.646,1.75 8.986,2.29 10,3.174C11.013,2.29 12.353,1.75 13.75,1.75Z"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#1E1E1E"/>
</group>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M1.873,2.461C2.205,2.461 2.473,2.73 2.473,3.061V8.464C2.473,9.9 3.638,11.064 5.073,11.064H11.855V10.146C11.855,9.831 12.203,9.639 12.469,9.809L14.858,11.326C15.105,11.483 15.105,11.844 14.858,12.002L12.469,13.519C12.203,13.688 11.855,13.497 11.855,13.181V12.264H5.073C2.975,12.264 1.273,10.562 1.273,8.464V3.061C1.273,2.73 1.542,2.461 1.873,2.461Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M9.473,6.413C9.805,6.413 10.073,6.682 10.073,7.013C10.073,7.345 9.805,7.613 9.473,7.613H5.673C5.342,7.613 5.074,7.345 5.073,7.013C5.073,6.682 5.342,6.413 5.673,6.413H9.473Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M13.273,2.696C13.605,2.696 13.873,2.964 13.873,3.296C13.873,3.627 13.605,3.896 13.273,3.896H5.673C5.342,3.896 5.074,3.627 5.073,3.296C5.073,2.964 5.342,2.696 5.673,2.696H13.273Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M18.327,4.365C20.007,4.365 20.847,4.365 21.489,4.692C22.053,4.98 22.512,5.439 22.8,6.003C23.127,6.645 23.127,7.485 23.127,9.165V14.836C23.127,16.516 23.127,17.356 22.8,17.998C22.512,18.562 22.053,19.021 21.489,19.309C20.847,19.636 20.007,19.636 18.327,19.636H5.675C3.995,19.636 3.154,19.636 2.513,19.309C1.948,19.021 1.49,18.562 1.202,17.998C0.875,17.356 0.875,16.516 0.875,14.836V9.165C0.875,7.485 0.875,6.645 1.202,6.003C1.49,5.439 1.948,4.98 2.513,4.692C3.154,4.365 3.995,4.365 5.675,4.365H18.327ZM14.403,9.195C13.006,9.195 11.988,9.949 11.988,10.995C11.988,11.79 12.708,12.208 13.217,12.46C13.767,12.711 13.979,12.878 13.936,13.129C13.936,13.505 13.513,13.673 13.09,13.673C12.581,13.673 12.073,13.547 11.607,13.338L11.354,14.51C11.862,14.719 12.412,14.803 12.921,14.803C14.488,14.844 15.462,14.092 15.462,12.962C15.462,11.539 13.471,11.455 13.471,10.828C13.513,10.535 13.767,10.367 14.064,10.367C14.53,10.325 15.038,10.409 15.462,10.618L15.716,9.447C15.292,9.28 14.826,9.195 14.403,9.195ZM2.881,9.321V9.489C3.389,9.573 3.855,9.74 4.278,9.949C4.448,10.033 4.563,10.21 4.617,10.41L5.761,14.762H7.285L9.572,9.321H8.09L6.565,13.004L5.973,9.865C5.93,9.573 5.676,9.321 5.337,9.321H2.881ZM10.164,9.321L8.979,14.762H10.419L11.604,9.321H10.164ZM18.13,9.321C17.876,9.321 17.622,9.489 17.537,9.74L15.42,14.762H16.902L17.198,13.967H19.02L19.188,14.762H20.502L19.358,9.321H18.13ZM18.76,12.838H17.574L18.337,10.787L18.76,12.838Z"
android:fillColor="#656565"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,155 @@
package com.tangem.core.ui.utils
import com.google.common.truth.Truth
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DateUtilsTest {
private lateinit var defaultTimeZone: DateTimeZone
private val now = createDateTime(day = 14, hour = 12)
@BeforeEach
fun setUp() {
defaultTimeZone = DateTimeZone.getDefault()
DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow"))
}
@AfterEach
fun tearDown() {
DateTimeZone.setDefault(defaultTimeZone)
}
@Test
fun `should return MinutesAgo when difference is less than 1 minute`() {
val pastDate = now.minusSeconds(30)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should return MinutesAgo when difference is 1 minute`() {
val pastDate = now.minusMinutes(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should return MinutesAgo when difference is 30 minutes`() {
val pastDate = now.minusMinutes(30)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(30)
}
@Test
fun `should return MinutesAgo when difference is 59 minutes`() {
val pastDate = now.minusMinutes(59).minusSeconds(59)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(59)
}
@Test
fun `should return HoursAgo when difference is exactly 1 hour`() {
val pastDate = now.minusHours(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.HoursAgo::class.java)
Truth.assertThat((result as FormattedDate.HoursAgo).hours).isEqualTo(1)
}
@Test
fun `should return HoursAgo when difference is 11 hours`() {
val pastDate = now.minusHours(11)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.HoursAgo::class.java)
Truth.assertThat((result as FormattedDate.HoursAgo).hours).isEqualTo(11)
}
@Test
fun `should return Today when difference is exactly 12 hours`() {
val pastDate = createDateTime(day = 14, hour = 0)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = getFormattedDate(createdAt, nowInTest)
Truth.assertThat(result).isInstanceOf(FormattedDate.Today::class.java)
Truth.assertThat((result as FormattedDate.Today).time).isEqualTo("03:00")
}
@Test
fun `should return FullDate when difference is 18 hours and past date is another day`() {
val pastDate = createDateTime(day = 13, hour = 18)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = getFormattedDate(createdAt, nowInTest)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate when difference is exactly 24 hours`() {
val pastDate = now.minusDays(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate when difference is 2 days`() {
val pastDate = now.minusDays(2)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate with original string when format has wrong date separator`() {
val wrongFormat = "2025/10/14T12:00:00.000Z"
val result = getFormattedDate(wrongFormat, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
Truth.assertThat((result as FormattedDate.FullDate).date).isEqualTo(wrongFormat)
}
@Test
fun `should handle future dates correctly`() {
val futureDate = now.plusHours(1)
val createdAt = futureDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should handle edge case of exactly 0 milliseconds difference`() {
val createdAt = now.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
private fun createDateTime(day: Int, hour: Int): DateTime {
return DateTime(
/* year = */ 2025,
/* monthOfYear = */ 10,
/* dayOfMonth = */ day,
/* hourOfDay = */ hour,
/* minuteOfHour = */ 0,
/* secondOfMinute = */ 0,
/* millisOfSecond = */ 0,
/* zone = */ DateTimeZone.UTC,
)
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:Retryer.kt$Retryer$val result = try { block(iteration) } catch (e: CancellationException) { throw e } catch (e: Error) { throw e } catch (_: Exception) { false }</ID>
<ID>MultilineLambdaItParameter:Converter.kt$Converter${ try { convert(it) } catch (throwable: Throwable) { onError?.invoke(throwable) null } }</ID>
<ID>MultilineLambdaItParameter:PeriodicTask.kt$PeriodicTask${ if (!isActive.get()) { return@onFailure } onError.invoke(it) }</ID>
<ID>MultilineLambdaItParameter:PeriodicTask.kt$PeriodicTask${ if (!isActive.get()) { return@onSuccess } onSuccess.invoke(it) }</ID>
<ID>NullableBooleanCheck:JobHolder.kt$JobHolder$job?.isActive ?: false</ID>
<ID>PropertyUsedBeforeDeclaration:JobHolder.kt$JobHolder$job</ID>
<ID>SuspendFunSwallowedCancellation:CoroutineExt.kt$runCatching</ID>
<ID>VarCouldBeVal:PeriodicTask.kt$PeriodicTask$private var isActive: AtomicBoolean = AtomicBoolean(false)</ID>
</CurrentIssues>
</SmellBaseline>